基于鸿蒙OS开发附近社交游戏平台(十一)-狼人杀部分状态机与日夜循环组件
狼人杀状态机与日夜循环 — NearPlay 技术文档
本文档基于
WerewolfGame.ets及WerewolfModel.ets源码,系统阐述 NearPlay 狼人杀模块的有限状态机设计、日夜循环流转机制、各阶段触发条件与计时器管理等核心技术细节。
1. 游戏概述
狼人杀(Werewolf / Mafia)是一款经典的社交推理桌游,其核心机制围绕"信息不对称"与"阵营对抗"展开。在 NearPlay 的实现中,游戏将传统线下狼人杀的主持流程抽象为一套严格的有限状态机(Finite State Machine, FSM),由 ArkTS 的 @State 响应式变量驱动 UI 渲染,通过 setTimeout 与 setInterval 模拟主持人的节奏控制,实现完整的一局游戏闭环。
1.1 核心机制
狼人杀的基本规则如下:所有玩家被秘密分配为两大阵营之一——好人阵营(村民及神职角色)和狼人阵营。游戏在"白天"与"黑夜"之间交替循环。每个夜晚,狼人选择击杀一名好人,神职角色依次使用特殊技能;每个白天,所有存活玩家进行公开讨论与投票放逐。好人阵营的目标是找出并消灭所有狼人,狼人阵营的目标则是让存活狼人数量不少于存活好人数量,从而"屠边"获胜。
1.2 四人局与多人局适配
当前 Mock 数据中提供了 8 人局的示例配置(2 狼人 + 1 预言家 + 1 女巫 + 1 守卫 + 1 猎人 + 2 村民),但状态机设计天然支持 4 人局的最小配置。4 人局通常配置为:1 狼人 + 1 预言家 + 1 女巫 + 1 村民(或守卫),此时狼人仅需 1 人即满足屠边条件,好人容错率极低,对预言家首夜查验和女巫用药策略要求极高。状态机对此并无特殊分支——所有阶段枚举与流转逻辑完全一致,仅在角色行动阶段(如守卫回合)自动跳过已死亡或不存在该角色的回合。
1.3 好人 vs 狼人:信息博弈
好人阵营的核心困境在于信息匮乏:村民没有任何特殊能力,只能通过白天讨论中的发言逻辑与投票行为推断狼人身份;神职角色虽拥有夜晚行动的信息优势,但过早暴露身份又可能成为狼人的优先击杀目标。狼人阵营则拥有完整的信息(互相知晓同伴身份),但必须在白天讨论中伪装为好人,制造混淆。这种信息不对称使得"白天讨论"阶段成为整个游戏的信息博弈核心,也是 startPlayerSpeech() 逐人发言机制的设计初衷——确保每位存活玩家都有等量的表达机会,防止强势玩家垄断话语权。
1.4 NearPlay 的实现定位
NearPlay 将狼人杀定位为"近场社交游戏"(Near-field Play),依托 HarmonyOS 的本地网络能力实现多设备实时连接。当前代码以单机 Mock 模式实现了完整的游戏流程闭环,为后续多设备同步提供了清晰的接口边界:所有状态变更均通过 @State 变量触发 UI 重渲染,而非直接操作 DOM,这使得状态同步协议只需在状态变更点插入分布式消息即可,无需重构 UI 层。WerewolfNightAction 类已预留了 wolfTarget、seerTarget、witchSaveTarget 等字段,正是为多人同步预留的协议载体。
2. WerewolfPhase 状态机详解
2.1 枚举定义
WerewolfPhase 枚举定义于 WerewolfModel.ets:26-38,共 11 个阶段,按游戏时间线严格排序:
export enum WerewolfPhase {
ROLE_ASSIGN = 0, // 角色分配
NIGHT_START = 1, // 夜晚开始
WOLF_TURN = 2, // 狼人回合
SEER_TURN = 3, // 预言家回合
WITCH_TURN = 4, // 女巫回合
GUARD_TURN = 5, // 守卫回合
NIGHT_RESULT = 6, // 夜晚结算
DAY_DISCUSS = 7, // 白天讨论
DAY_VOTE = 8, // 白天投票
VOTE_RESULT = 9, // 投票结果
GAME_OVER = 10, // 游戏结束
}
枚举值从 0 到 10 严格递增,反映了游戏的时间顺序。但需注意,状态机并非严格线性——从 VOTE_RESULT 存在两条分支路径:若胜负已定则跳转至 GAME_OVER(终态),否则回退至 NIGHT_START 开始新一轮日夜循环。
2.2 各阶段逐一说明

ROLE_ASSIGN(角色分配,值 = 0)
游戏的初始状态。在此阶段,系统为每位玩家随机分配角色(通过 MockWerewolfData.getPlayers() 生成,生产环境中应使用真随机数)。UI 展示"点击查看你的身份"按钮,玩家点击后调用 revealMyRole() 展示角色信息。若玩家为狼人,还会额外显示狼人同伴的昵称列表,这体现了狼人阵营的信息优势。此阶段不设超时,完全依赖玩家主动点击触发后续流程。
NIGHT_START(夜晚开始,值 = 1)
由 revealMyRole() 中的 setTimeout(() => { this.startNight() }, 3000) 延迟 3 秒触发。此阶段是夜晚的序幕,UI 显示"天黑请闭眼"与"夜幕降临,各角色依次行动…"的提示文字。背景色切换为深色 #1a1a2e,营造夜晚氛围。此阶段纯为叙事过渡,2 秒后自动进入 WOLF_TURN。
WOLF_TURN(狼人回合,值 = 2)
夜晚的第一个行动阶段。狼人玩家看到可选择击杀的目标列表(排除狼人同伴),非狼人玩家仅看到"狼人正在行动…请等待"。selectedTarget 记录狼人的击杀选择。此阶段限时 5 秒,由 setTimeout(() => { this.enterSeerPhase() }, 5000) 强制推进,无论狼人是否完成选择。若狼人未选择,selectedTarget 保持空字符串,夜晚结算时将无人被杀(平安夜的一种特殊情况)。
SEER_TURN(预言家回合,值 = 3)
预言家选择查验一名玩家的身份。选择后立即在 UI 上显示查验结果(“xxx 是 狼人!“或"xxx 是 好人”),通过 seerCheckResult 状态变量驱动。此阶段同样限时 5 秒。若预言家未在时限内选择,超时回调中将 seerCheckResult 设为"你未查验,时间已到”,这是所有夜晚阶段中唯一对超时未操作有明确提示的阶段。
WITCH_TURN(女巫回合,值 = 4)
女巫拥有两瓶药水:解药(救人)与毒药(杀人),各仅可使用一次。WerewolfNightAction 中的 witchHasSave 和 witchHasPoison 布尔值追踪药水剩余状态。UI 根据药水剩余情况动态显示对应按钮。女巫可看到今晚被狼人击杀的玩家(通过 nightAction.wolfTarget),从而决定是否使用解药。5 秒后自动进入守卫回合。
GUARD_TURN(守卫回合,值 = 5)
守卫选择守护一名玩家,被守护的玩家若今晚被狼人击杀则免于死亡。lastGuardTarget 字段记录上一晚守护的对象,实现"不能连续两晚守同一人"的规则约束(虽当前代码未完全实现此校验,但字段已预留)。5 秒后进入夜晚结算。
NIGHT_RESULT(夜晚结算,值 = 6)
综合所有夜晚行动的结果,计算最终死亡名单。核心逻辑:狼人击杀目标 - 守卫守护 - 女巫解药 + 女巫毒药 = 最终死亡名单。UI 显示"天亮了"及死亡结果,若无人死亡则显示"昨晚是平安夜"。3 秒后自动进入白天讨论阶段。此处有一个重要的信息隔离设计:狼人玩家看到的 nightDeadNames 为空数组,使其无法通过 UI 直接确认击杀是否成功。
DAY_DISCUSS(白天讨论,值 = 7)
所有存活玩家依次发言的核心社交阶段。通过 startPlayerSpeech() 实现逐人发言轮转,每名玩家拥有 30 秒发言时间。speakerOrderIndex 追踪当前发言者索引,发言完毕后自动轮转至下一位。所有玩家发言完毕后,进入投票阶段。此阶段背景色恢复为浅色 #F5F5F5,且 GameChatPanel 仅在当前发言者为本人时启用语音输入。
DAY_VOTE(白天投票,值 = 8)
所有存活玩家选择投票放逐的对象。voteTimer 从 10 秒倒计时,selectedTarget 记录当前玩家的投票选择。倒计时归零或玩家点击"确认投票"按钮均可触发投票结算。此阶段是好人阵营获取信息后的决策执行环节。
VOTE_RESULT(投票结果,值 = 9)
展示投票结果并执行放逐。若 selectedTarget 非空,对应玩家被标记为死亡(isAlive = false);否则为平票,无人被放逐。随后进行胜负判定:狼人全灭则好人胜,狼人数量 ≥ 好人数量则狼人胜;否则 3 秒后进入下一轮夜晚。此阶段是状态机唯一的分支节点。
GAME_OVER(游戏结束,值 = 10)
终态。展示胜利阵营、所有玩家的真实身份揭晓,以及"再来一局"和"返回大厅"按钮。状态机不再从此状态自动转移,除非玩家选择"再来一局"重新初始化。
2.3 ASCII 状态转换图
┌────────────────────────────────────────────────────────────────┐
│ WerewolfPhase FSM │
└───────────────────────────────────────────────────────────────┘
┌──────────────┐ 点击查看身份 ┌──────────────┐ 2s 延迟 ┌──────────────┐
│ ROLE_ASSIGN │ ──────────────────▶ │ NIGHT_START │ ──────────────▶ │ WOLF_TURN │
│ (0) │ │ (1) │ │ (2) │
└──────────────┘ └──────────────┘ └──────┬───────┘
▲ │
│ 5s 延迟│
│ ▼
│ ┌──────────────────────────────────────────────────┐ ┌──────────────┐
│ │ │ │ SEER_TURN │
│ │ 循环回退(胜负未定) │ │ (3) │
│ │ │ └──────┬───────┘
│ │ ┌──────────────┐ 3s 延迟 ┌──────────────┐ │ │5s 延迟
│ │ │ VOTE_RESULT │ ◀──────────── │ DAY_VOTE │ │ ▼
│ │ │ (9) │ │ (8) │ │ ┌──────────────┐
│ │ └──────┬───────┘ └──────▲───────┘ │ │ WITCH_TURN │
│ │ │ │ │ │ (4) │
│ │ ┌──────┴───────┐ ┌──────┴───────┐ │ └──────┬───────┘
│ │ │ 胜负判定 │ │ 讨论完毕 │ │ │5s 延迟
│ │ └──────┬───────┘ └──────────────┘ │ ▼
│ │ │ │ ┌──────────────┐
│ │ ┌──────┴───────┐ │ │ GUARD_TURN │
│ │ │ │ │ │ (5) │
│ │ ▼ ▼ │ └──────┬───────┘
│ │ 好人胜/狼人胜 胜负未定 │ │5s 延迟
│ │ │ │ │ ▼
│ │ ▼ │ │ ┌──────────────┐
│ │ ┌────────────┐ │ │ │ NIGHT_RESULT │
│ │ │ GAME_OVER │ │ │ │ (6) │
│ │ │ (10) │ │ │ └──────┬───────┘
│ │ └────────────┘ │ │ │3s 延迟
│ │ │ │ ▼
│ │ │ │ ┌──────────────┐
│ └───────────────────┴────────────────────────────────┘ │ DAY_DISCUSS │
│ 3s 后 startNight() │ (7) │
│ └──────────────┘
│ 再来一局 │
└───────────────────────────────────────────────────────────────────┘
2.4 状态转换条件汇总表
| 源状态 | 目标状态 | 触发方式 | 延迟/条件 |
|---|---|---|---|
| ROLE_ASSIGN | NIGHT_START | revealMyRole() 中 setTimeout |
3000ms |
| NIGHT_START | WOLF_TURN | startNight() 中 setTimeout |
2000ms |
| WOLF_TURN | SEER_TURN | enterWolfPhase() 中 setTimeout |
5000ms |
| SEER_TURN | WITCH_TURN | enterSeerPhase() 中 setTimeout |
5000ms |
| WITCH_TURN | GUARD_TURN | enterWitchPhase() 中 setTimeout |
5000ms |
| GUARD_TURN | NIGHT_RESULT | enterGuardPhase() 中 setTimeout |
5000ms |
| NIGHT_RESULT | DAY_DISCUSS | showNightResult() 中 setTimeout |
3000ms |
| DAY_DISCUSS | DAY_VOTE | speakerOrderIndex >= alive.length |
发言完毕 |
| DAY_VOTE | VOTE_RESULT | 倒计时归零或确认投票 | 10000ms / 手动 |
| VOTE_RESULT | GAME_OVER | 胜负判定:狼人=0 或 狼人≥好人 | 3000ms |
| VOTE_RESULT | NIGHT_START | 胜负未定 | 3000ms |
| GAME_OVER | ROLE_ASSIGN | "再来一局"按钮 | 手动 |
3. 日夜循环完整流程图
3.1 完整流程概述
狼人杀的核心循环是"夜晚→白天→夜晚→…"的交替,直至达成胜负条件。在 NearPlay 的实现中,一轮完整的日夜循环包含以下步骤:
ROLE_ASSIGN ──▶ NIGHT_START ──▶ WOLF_TURN ──▶ SEER_TURN ──▶ WITCH_TURN ──▶ GUARD_TURN
│
▼
┌─────────────────────────────────── NIGHT_RESULT ◀──┘
│ │
│ ▼
│ DAY_DISCUSS (逐人发言30s)
│ │
│ ▼
│ DAY_VOTE (10s倒计时)
│ │
│ ▼
│ VOTE_RESULT
│ ╱ ╲
│ 胜负已定 ╱ ╲ 胜负未定
│ ╱ ╲
│ ▼ ▼
│ GAME_OVER NIGHT_START (新一轮)
└────────────────────────────────────────┘
3.2 从 ROLE_ASSIGN 到 NIGHT_START:游戏初始化
游戏启动时,aboutToAppear() 生命周期回调从路由参数获取 GameRoomParams,调用 MockWerewolfData.getPlayers() 初始化玩家列表,随后进入 startRoleReveal()。此方法设置 phase = ROLE_ASSIGN,UI 展示角色分配界面。玩家点击"查看你的身份"按钮触发 revealMyRole(),3 秒延迟后调用 startNight() 进入第一个夜晚。
startNight() 方法是日夜循环的入口点,它执行以下初始化操作:
dayNumber++:天数计数器递增,从 0 变为 1phase = NIGHT_START:切换至夜晚开始阶段- 设置
phaseTitle为"第N夜" - 重置
nightAction为新的WerewolfNightAction实例(清空所有夜晚行动记录) - 清空
selectedTarget、seerCheckResult、witchDeadName、nightDeadNames
这一系列重置操作确保每个夜晚的行动数据是干净的,不会受到上一轮数据的污染。
3.3 从 NIGHT_START 到 NIGHT_RESULT:夜晚四阶段

夜晚阶段严格按固定顺序执行四个角色行动子阶段:狼人→预言家→女巫→守卫。每个子阶段限时 5 秒,由 setTimeout 强制推进。这种设计模拟了线下狼人杀中主持人依次唤醒各角色的节奏。
WOLF_TURN → SEER_TURN
狼人选择击杀目标。5 秒后 enterSeerPhase() 自动调用,无论狼人是否已选择。若狼人未操作,selectedTarget 为空,等效于"空刀"。
SEER_TURN → WITCH_TURN
预言家查验身份。5 秒后超时检查:若预言家存活但未查验,设置 seerCheckResult = '你未查验,时间已到',然后调用 enterWitchPhase()。
WITCH_TURN → GUARD_TURN
女巫决定用药。5 秒后自动进入守卫回合。当前实现中女巫的操作(解药/毒药选择)仅更新 UI 状态,实际的药水效果结算在 showNightResult() 中集中处理。
GUARD_TURN → NIGHT_RESULT
守卫选择守护对象。5 秒后调用 showNightResult(),进入夜晚结算。
3.4 从 NIGHT_RESULT 到 DAY_DISCUSS:天亮了
showNightResult() 综合计算夜晚结果。3 秒后调用 enterDayDiscuss(),初始化讨论阶段:
- 清空聊天记录
chatMessages = [] - 重置发言者索引
speakerOrderIndex = 0 - 调用
startPlayerSpeech()开始第一轮发言
3.5 从 DAY_DISCUSS 到 DAY_VOTE:发言轮转

讨论阶段的核心是 startPlayerSpeech() 的递归调用机制。每名玩家发言 30 秒,时间到后 speakerOrderIndex++ 并递归调用 startPlayerSpeech()。当 speakerOrderIndex >= alive.length 时,所有存活玩家已发言完毕,调用 enterDayVote() 进入投票。
3.6 从 DAY_VOTE 到 VOTE_RESULT:投票与放逐
投票阶段限时 10 秒。倒计时归零时自动调用 showVoteResult();玩家也可提前点击"确认投票"手动触发。showVoteResult() 执行放逐操作并判定胜负。
3.7 从 VOTE_RESULT 的两条分支
showVoteResult() 中的胜负判定产生两条分支路径:
分支一:游戏继续(wolves.length > 0 && wolves.length < alive.length - wolves.length)
setTimeout(() => { this.startNight() }, 3000)
3 秒后回退至 NIGHT_START,开始新一轮日夜循环。dayNumber 将再次递增。
分支二:游戏结束(wolves.length === 0 或 wolves.length >= alive.length - wolves.length)
setTimeout(() => {
this.phase = WerewolfPhase.GAME_OVER
this.winner = '好人阵营' // 或 '狼人阵营'
}, 3000)
进入终态 GAME_OVER,状态机停止运转。
3.8 日夜循环时序图
时间轴(秒) ────────────────────────────────────────────────────────────────▶
[0-3s] ROLE_ASSIGN: 角色分配,点击查看身份
[3-5s] NIGHT_START: 天黑请闭眼(2s过渡)
[5-10s] WOLF_TURN: 狼人选择击杀(5s)
[10-15s] SEER_TURN: 预言家查验(5s)
[15-20s] WITCH_TURN: 女巫用药(5s)
[20-25s] GUARD_TURN: 守卫守护(5s)
[25-28s] NIGHT_RESULT: 天亮了,公布死讯(3s)
[28-?] DAY_DISCUSS: 逐人发言(30s×存活人数)
[?-?+10] DAY_VOTE: 投票放逐(10s)
[?+10-?+13] VOTE_RESULT: 投票结果(3s)
│
├── 游戏结束 ──▶ GAME_OVER
│
└── 游戏继续 ──▶ NIGHT_START(新一轮)
│
▼
[0-3s] 第2夜...
3.9 一局完整游戏的典型时间线
以 8 人局为例,首夜 4 人存活(假设每轮淘汰 1 人):
| 轮次 | 夜晚时长 | 讨论时长 | 投票时长 | 合计 |
|---|---|---|---|---|
| 第1轮 | 2+5+5+5+5+3 = 25s | 30×8 = 240s | 10+3 = 13s | ~278s |
| 第2轮 | 25s | 30×7 = 210s | 13s | ~248s |
| 第3轮 | 25s | 30×6 = 180s | 13s | ~218s |
一局 8 人游戏约需 10-15 分钟(含操作延迟),4 人局则更短,约 5-8 分钟。
4. 每个阶段的触发条件
4.1 触发方式分类
NearPlay 狼人杀的阶段触发方式分为两大类:
一、自动触发(setTimeout 延迟):大多数阶段由前一个阶段的 setTimeout 回调自动推进,模拟主持人的节奏控制。这种方式确保游戏流程不会被某个玩家的不操作行为永久阻塞。
二、玩家操作触发:少数阶段依赖玩家的主动操作,如 ROLE_ASSIGN 需要点击"查看你的身份"按钮,DAY_VOTE 可通过"确认投票"按钮提前触发投票结算。
4.2 各阶段触发条件详述
ROLE_ASSIGN → NIGHT_START
- 触发方式:玩家点击按钮 + setTimeout
- 代码位置:
WerewolfGame.ets:50-62 - 详细条件:玩家点击"查看你的身份"按钮 →
revealMyRole()被调用 → 展示角色信息 →setTimeout(() => { this.startNight() }, 3000)延迟 3 秒后触发 - 设计意图:3 秒延迟给玩家阅读角色信息的时间,特别是狼人需要记忆同伴身份
NIGHT_START → WOLF_TURN
- 触发方式:setTimeout 自动
- 代码位置:
WerewolfGame.ets:74 - 延迟:2000ms
- 详细条件:无条件自动触发,纯过渡阶段
WOLF_TURN → SEER_TURN
- 触发方式:setTimeout 自动
- 代码位置:
WerewolfGame.ets:87 - 延迟:5000ms
- 详细条件:5 秒后无论狼人是否选择目标均自动推进。狼人的选择通过
selectTarget()即时更新selectedTarget,超时后selectedTarget保持最后选择的状态
SEER_TURN → WITCH_TURN
- 触发方式:setTimeout 自动 + 超时检查
- 代码位置:
WerewolfGame.ets:100-106 - 延迟:5000ms
- 详细条件:5 秒后检查预言家是否操作。若
seerCheckResult === ''(未查验),设置超时提示。然后无条件进入女巫回合
WITCH_TURN → GUARD_TURN
- 触发方式:setTimeout 自动
- 代码位置:
WerewolfGame.ets:117 - 延迟:5000ms
- 详细条件:无条件自动推进。女巫的药水使用决策在 5 秒内完成
GUARD_TURN → NIGHT_RESULT
- 触发方式:setTimeout 自动
- 代码位置:
WerewolfGame.ets:129 - 延迟:5000ms
- 详细条件:无条件自动推进。守卫的守护选择即时生效
NIGHT_RESULT → DAY_DISCUSS
- 触发方式:setTimeout 自动
- 代码位置:
WerewolfGame.ets:149 - 延迟:3000ms
- 详细条件:无条件。3 秒展示死亡信息后进入讨论
DAY_DISCUSS → DAY_VOTE
- 触发方式:条件触发(发言完毕)
- 代码位置:
WerewolfGame.ets:168-170 - 详细条件:
speakerOrderIndex >= alive.length,即所有存活玩家均已发言完毕。此条件在startPlayerSpeech()的入口处检查,当发言者索引越界时调用enterDayVote()
DAY_VOTE → VOTE_RESULT
- 触发方式:倒计时归零或手动确认
- 代码位置:
WerewolfGame.ets:203-209及WerewolfGame.ets:774 - 延迟:10000ms(倒计时)或手动触发
- 详细条件:两种触发路径——①
voteTimer倒计时至 0 时setInterval回调调用showVoteResult();② 玩家点击"确认投票"按钮直接调用showVoteResult()
VOTE_RESULT → GAME_OVER / NIGHT_START
- 触发方式:条件分支 + setTimeout
- 代码位置:
WerewolfGame.ets:225-243 - 延迟:3000ms
- 详细条件:
wolves.length === 0→ 好人胜,进入 GAME_OVERwolves.length >= alive.length - wolves.length→ 狼人胜,进入 GAME_OVER- 否则 → 游戏继续,进入 NIGHT_START
4.3 触发条件的设计原则
- 防阻塞原则:所有玩家交互阶段均设有
setTimeout强制超时,防止因玩家不操作导致游戏停滞 - 叙事节奏原则:过渡阶段(NIGHT_START、NIGHT_RESULT)使用 2-3 秒的短延迟,给予叙事节奏但不拖沓
- 操作窗口原则:行动阶段(WOLF/SEER/WITCH/GUARD)统一 5 秒操作窗口,讨论阶段每人口 30 秒,投票阶段 10 秒
- 分支确定性原则:VOTE_RESULT 的分支判断在
showVoteResult()中同步执行,不存在竞态条件
5. 夜晚阶段详细流程
5.1 夜晚阶段总览
夜晚阶段由 startNight() 方法触发入口,依次经历 NIGHT_START → WOLF_TURN → SEER_TURN → WITCH_TURN → GUARD_TURN → NIGHT_RESULT 共 6 个子阶段。每个行动阶段限 时 5 秒,整个夜晚阶段在不考虑操作延迟的情况下固定耗时 22 秒(2+5+5+5+5+0=22s,加上 NIGHT_RESULT 展示 3s 共 25s)。
5.2 狼人回合(WOLF_TURN)
入口方法:enterWolfPhase()(WerewolfGame.ets:77-88)
流程:
- 设置
phase = WOLF_TURN,phaseTitle = '狼人回合' - 清空
selectedTarget(确保上一轮的选择不影响本轮) - 判断当前玩家身份:
- 若为存活狼人:
phaseDesc = '选择你要击杀的目标',UI 展示可选目标列表 - 若为其他角色:
phaseDesc = '狼人正在行动...请等待',UI 仅展示等待提示
- 若为存活狼人:
- 启动 5 秒倒计时,到时调用
enterSeerPhase()
狼人的目标选择 UI:
- 目标列表通过
this.getAlivePlayers().filter(p => p.role !== WerewolfRole.WEREWOLF)生成,排除所有狼人同伴 - 点击目标后调用
selectTarget(player.id),更新selectedTarget - 被选中的目标以红色高亮显示(
backgroundColor: '#FF4444') - 狼人同伴信息展示在目标列表上方,使用红色文字标识
5 秒时限的意义:在线下狼人杀中,狼人讨论击杀目标需要时间沟通。5 秒的时间窗口既模拟了这一沟通需求,又防止游戏节奏拖沓。在多人在线版本中,此时间可适当延长至 15-30 秒。
5.3 预言家回合(SEER_TURN)
入口方法:enterSeerPhase()(WerewolfGame.ets:90-106)
流程:
- 设置
phase = SEER_TURN,清空selectedTarget - 判断当前玩家身份:
- 若为存活预言家:
phaseDesc = '选择你要查验的对象',展示所有存活玩家(排除自身) - 若为其他角色:
phaseDesc = '预言家正在查验...请等待'
- 若为存活预言家:
- 预言家选择查验对象后:
- 调用
selectTarget(player.id)记录目标 - 即时设置查验结果:若目标为狼人则
seerCheckResult = 'xxx 是 狼人!',否则seerCheckResult = 'xxx 是 好人'
- 调用
- 5 秒倒计时,超时检查:
if (me !== undefined && me.role === WerewolfRole.SEER && me.isAlive && this.seerCheckResult === '') { this.seerCheckResult = '你未查验,时间已到' } - 调用
enterWitchPhase()
查验结果的即时显示:预言家选择目标后,UI 立即显示查验结果(绿色粗体文字),这是预言家最重要的信息获取手段。查验结果仅在预言家自己的屏幕上显示,实现了信息隔离。
超时惩罚:预言家是唯一有超时提示的角色——若 5 秒内未操作,显示"你未查验,时间已到"。这是因为预言家的查验是获取关键信息的唯一途径,超时未查验对好人阵营是重大损失,需要明确的提示让玩家意识到错失了机会。
5.4 女巫回合(WITCH_TURN)
入口方法:enterWitchPhase()(WerewolfGame.ets:108-118)
流程:
- 设置
phase = WITCH_TURN - 判断当前玩家身份:
- 若为存活女巫:
phaseDesc = '是否使用药水?',展示药水按钮 - 若为其他角色:
phaseDesc = '女巫正在决定...请等待'
- 若为存活女巫:
- 女巫可看到今晚被杀的玩家信息(通过
nightAction.wolfTarget) - 女巫可选择:
- 使用解药(
witchHasSave为 true 时显示"💊 使用解药"按钮,绿色) - 使用毒药(
witchHasPoison为 true 时显示"🧪 使用毒药"按钮,紫色) - 跳过(灰色"跳过"按钮,始终显示)
- 使用解药(
- 5 秒后自动进入守卫回合
药水机制:
- 解药:可救活今晚被狼人击杀的玩家,全局仅可使用一次。使用后
witchHasSave = false - 毒药:可毒杀任意一名存活玩家,全局仅可使用一次。使用后
witchHasPoison = false - 同一晚不可同时使用解药和毒药(传统规则,当前代码未强制校验此约束,需在后续版本中补充)
策略考量:女巫是否首夜自救是一个经典策略问题。在 4 人局中,女巫自救几乎是最优策略(好人数量稀缺),但在 8 人局中,女巫可能选择保留解药以待更关键时刻。NearPlay 的 UI 设计保留了这一策略空间,由玩家自主决策。
5.5 守卫回合(GUARD_TURN)
入口方法:enterGuardPhase()(WerewolfGame.ets:120-130)
流程:
- 设置
phase = GUARD_TURN - 判断当前玩家身份:
- 若为存活守卫:
phaseDesc = '选择你要守护的对象',展示所有存活玩家 - 若为其他角色:
phaseDesc = '守卫正在行动...请等待'
- 若为存活守卫:
- 守卫选择守护对象(包括自己,可通过
selectTarget(player.id)选择) - 5 秒后调用
showNightResult()
守护规则:
- 守卫可以选择守护任意一名存活玩家,包括自己
- 被守护的玩家若今晚被狼人击杀,则免于死亡("挡刀"效果)
- 传统规则中,守卫不能连续两晚守护同一人。
WerewolfNightAction.lastGuardTarget字段已预留此规则的校验依据,但当前代码未实现此约束 - 守卫与女巫解药同时作用于同一人时,传统规则为"同守同救则死"(即守卫和女巫解药同时生效时,玩家反而死亡),此规则同样需在后续版本中实现
UI 展示:守卫的目标选择 UI 以橙色高亮被选中对象(backgroundColor: '#FF9800'),与狼人的红色、预言家的蓝色形成视觉区分。
5.6 夜晚结算(NIGHT_RESULT)
入口方法:showNightResult()(WerewolfGame.ets:132-150)
流程:
- 设置
phase = NIGHT_RESULT,phaseTitle = '第N天 - 天亮了' - 计算夜晚死亡名单:
- 狼人视角:
nightDeadNames = [](狼人看不到谁死了,信息隔离) - 好人视角:根据实际结算结果填充
nightDeadNames
- 狼人视角:
- 判断死亡情况:
nightDeadNames.length === 0→ 平安夜,phaseDesc = '昨晚是平安夜,没有人死亡'nightDeadNames.length > 0→phaseDesc = '昨晚死亡:xxx、yyy'
- 3 秒后进入白天讨论
结算逻辑的关键细节:当前 Mock 实现中,夜晚死亡结果使用硬编码逻辑(检查 u3 玩家是否存活),这是开发阶段的临时方案。生产版本的结算逻辑应为:
最终死亡名单 = []
if (wolfTarget !== '' && wolfTarget !== guardTarget && wolfTarget !== witchSaveTarget):
最终死亡名单.add(wolfTarget) // 狼人击杀生效
if (witchPoisonTarget !== ''):
最终死亡名单.add(witchPoisonTarget) // 毒药击杀生效
5.7 夜晚阶段时序详图
┌────── NIGHT_START ──────┐
│ dayNumber++ │
│ 重置 nightAction │
│ 清空所有临时状态 │
│ 展示"天黑请闭眼" │
│ [等待 2s] │
└────────────┬────────────┘
│
┌────── WOLF_TURN ────────┐
│ 狼人选择击杀目标 │
│ selectedTarget = xxx │
│ [等待 5s] │
└────────────┬────────────┘
│
┌────── SEER_TURN ────────┐
│ 预言家选择查验对象 │
│ seerCheckResult = ... │
│ [超时检查] │
│ [等待 5s] │
└────────────┬────────────┘
│
┌────── WITCH_TURN ───────┐
│ 女巫查看死者、决定用药 │
│ witchHasSave/Poison │
│ [等待 5s] │
└────────────┬────────────┘
│
┌────── GUARD_TURN ───────┐
│ 守卫选择守护对象 │
│ guardTarget = xxx │
│ [等待 5s] │
└────────────┬────────────┘
│
┌────── NIGHT_RESULT ─────┐
│ 计算最终死亡名单 │
│ 信息隔离:狼人看不到死讯│
│ 公布死亡/平安夜 │
│ [等待 3s] │
└────────────┬────────────┘
│
▼
DAY_DISCUSS
6. 白天讨论阶段
6.1 讨论阶段的定位
白天讨论阶段(DAY_DISCUSS)是狼人杀游戏中信息博弈的核心环节。在夜晚阶段,各角色分别获取了不同维度的不完全信息——狼人知道同伴和击杀目标、预言家知道查验结果、女巫知道死者身份并做出用药决策、守卫知道自己的守护对象。到了白天,所有存活玩家需要在有限时间内通过发言传递、验证或伪装这些信息,最终通过投票做出集体决策。
NearPlay 采用"逐人限时发言"机制实现讨论阶段,确保每位存活玩家拥有等量的发言时间和话语权,防止多人在线场景中强势玩家垄断讨论。
6.2 enterDayDiscuss() 初始化
代码位置:WerewolfGame.ets:152-161
enterDayDiscuss(): void {
this.phase = WerewolfPhase.DAY_DISCUSS
this.phaseTitle = '自由讨论'
this.chatMessages = []
this.speakerOrderIndex = 0
const alive = this.getAlivePlayers()
if (alive.length > 0) {
this.startPlayerSpeech()
}
}
初始化操作包括:
- 阶段切换:
phase = DAY_DISCUSS - 清空聊天:
chatMessages = [],确保本轮讨论不受上一轮消息的干扰 - 重置发言者索引:
speakerOrderIndex = 0,从第一位存活玩家开始 - 启动发言:调用
startPlayerSpeech()开始第一轮发言
边界条件处理:若 alive.length === 0(极端情况,理论上不会发生),不调用 startPlayerSpeech(),讨论阶段将陷入空转。这在当前实现中是一个潜在的死锁点,应在后续版本中增加保护——当存活人数为 0 时直接进入 GAME_OVER。
6.3 startPlayerSpeech() 核心机制
代码位置:WerewolfGame.ets:163-188
这是讨论阶段最核心的方法,实现了逐人发言轮转的完整逻辑:
startPlayerSpeech(): void {
if (this.speakerTimerId !== -1) {
clearInterval(this.speakerTimerId) // 清除上一轮的计时器
}
const alive = this.getAlivePlayers()
if (this.speakerOrderIndex >= alive.length) {
this.enterDayVote() // 所有人发言完毕,进入投票
return
}
const current = alive[this.speakerOrderIndex]
if (current !== undefined) {
this.currentSpeaker = current.id
this.currentSpeakerName = current.nickname
this.speakerTimer = 30
this.phaseDesc = `${current.nickname} 发言中 (30s)`
}
this.speakerTimerId = setInterval(() => {
this.speakerTimer--
if (this.speakerTimer <= 0) {
clearInterval(this.speakerTimerId)
this.speakerTimerId = -1
this.speakerOrderIndex++
this.startPlayerSpeech() // 递归调用,启动下一人发言
}
}, 1000)
}
6.3.1 计时器清理机制
方法入口首先检查 speakerTimerId !== -1,若为 true 则调用 clearInterval() 清除上一轮的计时器。这一设计至关重要——它防止了以下竞态条件:
- 玩家 A 的 30 秒发言计时器正在运行
- 由于某种原因(如异常中断),
startPlayerSpeech()被意外再次调用 - 若不清理旧计时器,两个计时器将同时运行,导致
speakerTimer被双倍递减
speakerTimerId 初始值为 -1(表示无活跃计时器),每次 setInterval 后更新为实际返回的计时器 ID,计时器清除后重置为 -1。
6.3.2 发言者遍历与终止条件
speakerOrderIndex 从 0 开始递增,对应 getAlivePlayers() 返回数组的索引。每次发言结束后(30 秒超时),speakerOrderIndex++ 并递归调用 startPlayerSpeech()。当 speakerOrderIndex >= alive.length 时,所有存活玩家均已发言完毕,调用 enterDayVote() 进入投票阶段。
需要注意的是,getAlivePlayers() 每次调用都重新过滤 isAlive === true 的玩家。这意味着如果在讨论过程中有玩家意外死亡(虽然在当前实现中讨论阶段不会发生死亡事件),发言列表会动态调整。但也引入了一个微妙的 bug:若讨论过程中某玩家死亡,alive 数组缩短,speakerOrderIndex 可能跳过后续玩家或提前触发终止条件。当前实现中此问题不会触发,但在多人在线版本中需要考虑。
6.3.3 30 秒倒计时机制
speakerTimer 从 30 开始,每秒递减 1。UI 实时显示剩余秒数,当 speakerTimer < 10 时数字变为红色(fontColor: '#F44336'),营造紧迫感。
Text(`${this.speakerTimer}s`)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.speakerTimer < 10 ? '#F44336' : '#333333')
倒计时归零时执行以下操作:
clearInterval(this.speakerTimerId):停止计时器this.speakerTimerId = -1:标记无活跃计时器this.speakerOrderIndex++:推进发言者索引this.startPlayerSpeech():递归启动下一轮发言
6.4 发言者追踪 UI
讨论阶段的 UI 包含以下关键元素:
发言者指示栏(WerewolfGame.ets:627-640):
- 左侧显示当前发言者昵称,橙色粗体
- 右侧显示剩余秒数,低于 10 秒变红
- 整行使用淡橙色背景(
#FFF3E0)
玩家状态条(WerewolfGame.ets:642-659):
- 横向展示所有存活玩家的头像和昵称
- 已发言完毕的玩家(
idx < speakerOrderIndex)以绿色背景标记 - 当前发言者(
idx === speakerOrderIndex)以橙色背景 + 2px 橙色边框标记 - 未发言的玩家以白色背景显示
这一视觉设计让所有玩家一目了然地看到发言进度,哪些人已发言、当前轮到谁、还有谁未发言。
6.5 语音输入集成
当 currentSpeaker === this.myId 时(即轮到当前设备玩家发言),UI 展示 VoiceInput 组件和文字输入框。GameChatPanel 的 canSpeak 属性与发言权限联动:
GameChatPanel({ canSpeak: this.phase === WerewolfPhase.DAY_DISCUSS && this.currentSpeaker === this.myId })
语音输入通过 VoiceInputHelper 实现。点击麦克风按钮调用:
this.voiceHelper.startListening((text: string) => {
this.handleVoiceResult(text)
})
handleVoiceResult() 将语音识别结果转化为聊天消息:
handleVoiceResult(text: string): void {
if (text.trim() !== '') {
const msg = ChatMessage.of(this.currentSpeakerName, text.trim())
this.chatMessages = [...this.chatMessages, msg]
}
}
注意消息使用 currentSpeakerName 而非玩家自行设置的昵称,确保发言归属正确。chatMessages 使用扩展运算符 [...this.chatMessages, msg] 创建新数组,触发 ArkUI 的响应式更新。
6.6 文字输入与发送
当轮到当前玩家发言时,文字输入框启用(enabled: this.currentSpeaker === this.myId),placeholder 显示"发言…“;否则禁用,placeholder 显示"未轮到你”。发送按钮仅在输入框非空且轮到当前玩家时显示。
if (this.chatInput.trim() !== '' && this.currentSpeaker === this.myId) {
Button('发送')...
}
这种设计防止了非发言者发送消息,保证了发言秩序。
6.7 讨论阶段的完整流程图
enterDayDiscuss()
│
├── chatMessages = []
├── speakerOrderIndex = 0
│
▼
startPlayerSpeech() ◀────────────────────────────┐
│ │
├── clearInterval(旧计时器) │
├── alive = getAlivePlayers() │
│ │
├── speakerOrderIndex >= alive.length? │
│ ├── YES → enterDayVote() │
│ └── NO ↓ │
│ │
├── current = alive[speakerOrderIndex] │
├── currentSpeaker = current.id │
├── currentSpeakerName = current.nickname │
├── speakerTimer = 30 │
│ │
├── setInterval(1s) ──┐ │
│ │ speakerTimer-- │
│ │ speakerTimer <= 0? │
│ │ ├── NO → 等待1s │
│ │ └── YES ↓ │
│ │ clearInterval │
│ │ speakerTimerId = -1 │
│ │ speakerOrderIndex++ │
│ └── startPlayerSpeech()─┘
│
│ [UI: 发言者指示栏、玩家状态条、语音/文字输入]
│ [30s 倒计时显示,<10s 变红]
│
▼
(所有玩家发言完毕)
│
▼
enterDayVote()
6.8 发言顺序的确定
当前实现中,发言顺序由 getAlivePlayers() 返回的数组顺序决定,即 players 数组中 isAlive === true 的玩家保持原始排列顺序。这种"固定顺序"方案的优点是实现简单、行为可预测;缺点是缺乏策略性——线下狼人杀通常由"上警"(竞选警长)或遗言顺序决定发言顺序,且每轮发言顺序可以反转(顺时针/逆时针交替),增加信息博弈的复杂性。
后续版本可考虑的改进:
- 遗言发言:夜晚死亡的玩家发表遗言后,由其左侧或右侧的存活玩家开始发言
- 警长决定:若有警长角色,由警长决定发言顺序
- 方向交替:每轮发言方向反转,防止固定顺序带来的信息优势
7. 投票阶段
7.1 投票阶段的定位
投票阶段(DAY_VOTE)是白天环节的决策执行点。经过讨论阶段的信息交换后,所有存活玩家需要投票选出一名嫌疑最大的玩家进行放逐。被放逐的玩家立即出局(isAlive = false),身份不公开(传统规则,当前实现中 GAME_OVER 阶段才会揭晓所有身份)。
7.2 enterDayVote() 初始化
代码位置:WerewolfGame.ets:197-210
enterDayVote(): void {
this.phase = WerewolfPhase.DAY_VOTE
this.phaseTitle = '投票放逐'
this.phaseDesc = '选择你要投票放逐的人'
this.voteTimer = 10
this.selectedTarget = ''
const timerId = setInterval(() => {
this.voteTimer--
if (this.voteTimer <= 0) {
clearInterval(timerId)
this.showVoteResult()
}
}, 1000)
}
初始化操作:
- 阶段切换:
phase = DAY_VOTE - 标题设置:
phaseTitle = '投票放逐' - 倒计时初始化:
voteTimer = 10(10 秒投票时限) - 清空选择:
selectedTarget = '' - 启动倒计时:
setInterval每秒递减voteTimer,归零时自动调用showVoteResult()
注意此处使用 const timerId 局部变量存储计时器 ID,而非 this.speakerTimerId 这样的成员变量。这是因为投票计时器不需要在方法间共享——它只在 enterDayVote() 中创建,在回调中清除,生命周期完全封闭。
7.3 投票 UI
代码位置:WerewolfGame.ets:723-778
投票 UI 展示所有存活玩家(排除自身)的列表,每项包含:
- 玩家头像
- 玩家昵称
- 选中标记(✓ 符号,橙色)
点击玩家条目调用 selectTarget(player.id),更新 selectedTarget。被选中的玩家以淡橙色背景高亮。底部"确认投票"按钮仅在 selectedTarget !== '' 时启用(enabled: this.selectedTarget !== '')。
投票倒计时显示在顶部右侧,低于 10 秒时数字变红(虽然初始值就是 10,实际效果是从 10 开始递减,数字始终为红色,这是一个可优化的 UI 细节——可将警告阈值设为 5 秒)。
7.4 selectedTarget 的作用
selectedTarget 是投票阶段的核心状态变量,记录当前玩家的投票选择。其生命周期为:
- 初始化:
enterDayVote()中设为空字符串 - 更新:玩家点击目标列表中的某项,
selectTarget(id)被调用 - 读取:
showVoteResult()中根据selectedTarget是否为空决定放逐结果
当前实现为单机 Mock 模式,仅记录当前玩家的投票选择。在多人在线版本中,需要收集所有玩家的投票数据,统计票数后决定放逐对象。WerewolfVoteResult 类已预留了此数据结构:
export class WerewolfVoteResult {
targetId: string = ''
targetName: string = ''
voteCounts: Record<string, number> = {}
isTie: boolean = false
}
7.5 投票结算
代码位置:WerewolfGame.ets:212-244
showVoteResult() 执行投票结算与胜负判定:
showVoteResult(): void {
this.phase = WerewolfPhase.VOTE_RESULT
this.phaseTitle = '投票结果'
if (this.selectedTarget !== '') {
const target = this.players.find(p => p.id === this.selectedTarget)
if (target !== undefined) {
this.voteResultText = `${target.nickname} 被投票放逐`
target.isAlive = false
}
} else {
this.voteResultText = '平票,无人被放逐'
}
// ... 胜负判定 ...
}
两种结果:
- 有人被放逐:
selectedTarget非空,对应玩家的isAlive设为 false - 平票/弃票:
selectedTarget为空,无人被放逐
当前 Mock 模式下,"平票"实际上是"当前玩家未投票"的同义词,因为只有当前玩家一人参与投票。在多人版本中,平票应指两名或多名候选人得票相同的情况。
7.6 投票后的胜负判定
投票结算后立即进行胜负判定(详见第 8 节)。若游戏继续,3 秒后进入下一轮夜晚;若游戏结束,3 秒后进入 GAME_OVER。
7.7 投票阶段的时序图
enterDayVote()
│
├── voteTimer = 10
├── selectedTarget = ''
├── setInterval(1s) ──── voteTimer--
│ │
│ voteTimer <= 0? ── YES ── clearInterval ── showVoteResult()
│ │
│ NO ── 继续等待
│
│ [UI: 目标列表、确认按钮、倒计时]
│ [玩家可随时点击目标切换选择]
│ [玩家可点击"确认投票"提前触发结算]
│
▼
showVoteResult()
│
├── selectedTarget !== ''?
│ ├── YES → target.isAlive = false, "xxx 被投票放逐"
│ └── NO → "平票,无人被放逐"
│
├── 胜负判定
│ ├── 狼人全灭 → 好人胜 (3s → GAME_OVER)
│ ├── 狼人≥好人 → 狼人胜 (3s → GAME_OVER)
│ └── 胜负未定 → 3s → startNight()
8. 胜负判定
8.1 判定时机
胜负判定发生在 showVoteResult() 方法中(WerewolfGame.ets:225-243),即每次投票放逐之后。当前实现中,夜晚死亡后不进行胜负判定,仅在白天投票后判定。这意味着即使夜晚结束后所有好人已死(理论上不可能,因为狼人数量会先达到屠边条件),也必须等到白天投票后才会判定游戏结束。
8.2 判定条件
const wolves = this.players.filter(p => p.role === WerewolfRole.WEREWOLF && p.isAlive)
const alive = this.players.filter(p => p.isAlive)
if (wolves.length === 0) {
// 好人阵营获胜
} else if (wolves.length >= alive.length - wolves.length) {
// 狼人阵营获胜
} else {
// 游戏继续
}
好人胜条件:wolves.length === 0,即所有狼人已被消灭(通过投票放逐或女巫毒杀)。此条件最可能在白天投票后达成——好人通过推理正确投票放逐了最后一名狼人。
狼人胜条件:wolves.length >= alive.length - wolves.length,即存活狼人数量 ≥ 存活好人数量。这涵盖了两种常见情况:
- 屠边:狼人数量 ≥ 好人数量(如 2 狼人 vs 2 好人)
- 屠城:所有好人均已死亡(极端情况,
alive.length - wolves.length === 0)
游戏继续:狼人仍存活但数量不足屠边条件,进入下一轮日夜循环。
8.3 判定逻辑的边界情况
-
守卫+女巫同时作用:若守卫守护的玩家同时被女巫解药救活,传统规则中存在"同守同救则死"的约束。当前实现未考虑此情况,可能导致本应死亡的玩家存活,从而影响胜负判定的准确性。
-
猎人技能:
WerewolfRole.HUNTER已在枚举中定义,Mock 数据中也有猎人玩家(u7),但当前代码未实现猎人"死亡时开枪带走一人"的技能。若猎人被投票放逐,其技能应立即触发,可能直接影响胜负判定。 -
连续平安夜:若每夜均无人死亡(守卫连续挡刀+女巫不毒),游戏可能陷入"白天讨论→投票放逐→夜晚平安→白天讨论…"的循环,直至白天投票产生放逐结果。当前实现中,若每轮投票均平票(所有玩家弃票),理论上游戏可以无限循环,这是一个需要处理的边界情况。
8.4 胜负判定的改进建议
- 夜晚结束后也判定胜负:若夜晚结束后狼人已达成屠边条件(如毒药杀死了最后一个好人),应立即结束游戏,无需等待白天讨论
- 最大回合数限制:防止游戏无限循环,可设置最大回合数(如 10 轮),超时则按当前存活阵营判负或平局
- 猎人技能触发:在放逐或夜晚死亡判定后检查是否有猎人死亡,触发其技能
9. dayNumber 计数
9.1 dayNumber 的定义与初始化
dayNumber 是 @State 响应式变量,初始值为 0(WerewolfGame.ets:22)。它记录当前游戏进行到第几个日夜循环,即"第几天"的概念。
9.2 递增时机
dayNumber 仅在 startNight() 方法中递增:
startNight(): void {
this.dayNumber++
// ...
}
这意味着:
- 游戏开始时
dayNumber = 0 - 第一个夜晚开始时
dayNumber = 1(第 1 夜) - 第二个夜晚开始时
dayNumber = 2(第 2 夜) - 以此类推
9.3 dayNumber 的用途
- UI 显示:顶栏显示"第N天"(
WerewolfGame.ets:315),夜晚标题显示"第N夜"(WerewolfGame.ets:67),天亮时显示"第N天 - 天亮了"(WerewolfGame.ets:134) - 叙事标识:帮助玩家追踪游戏进程,"第 2 天的发言逻辑与第 1 天不同"是常见的推理依据
- 发言顺序策略:线下狼人杀中,每轮发言顺序可能根据天数交替反转,dayNumber 为此提供了判断依据
9.4 注意事项
dayNumber 不应与"回合数"混淆。一个 dayNumber 对应一个完整的"夜晚→白天"循环。角色分配阶段(ROLE_ASSIGN)不计入 dayNumber。因此,游戏的第一夜就是"第 1 天"的夜晚部分,接下来的白天就是"第 1 天"的白天部分。
当前实现中,dayNumber 没有上限检查。若游戏持续多轮(极端情况下),dayNumber 将无限递增,但在实际游戏中这不会造成问题——UI 仅显示数字,不存在溢出风险(JavaScript 的 Number 类型可安全表示至 2^53)。
10. 死亡判定
10.1 死亡判定的两个时机
在 NearPlay 狼人杀中,玩家死亡可发生在两个时机:
- 夜晚死亡:由狼人击杀、女巫毒杀导致,在
showNightResult()中结算 - 白天放逐:由投票导致,在
showVoteResult()中直接执行
10.2 夜晚死亡判定
夜晚死亡的判定逻辑位于 showNightResult() 方法(WerewolfGame.ets:132-150)。当前实现为 Mock 硬编码:
if (me !== undefined && me.role === WerewolfRole.WEREWOLF) {
this.nightDeadNames = [] // 狼人视角不显示死讯
} else {
const target = this.players.find(p => p.id === 'u3')
if (target !== undefined && target.isAlive) {
this.nightDeadNames = [target.nickname]
}
}
生产版本的完整判定逻辑应为:
// 步骤1:确定狼人击杀目标
wolfKill = nightAction.wolfTarget
// 步骤2:守卫守护判定
if (guardTarget === wolfKill) {
wolfKill = '' // 被守护,击杀无效
}
// 步骤3:女巫解药判定
if (witchSaveTarget === wolfKill && witchSaveTarget !== '') {
wolfKill = '' // 被救活,击杀无效
}
// 步骤4:同守同救判定(传统规则)
if (guardTarget === wolfKill_original && witchSaveTarget === wolfKill_original && guardTarget !== '') {
// 同守同救则死,玩家仍然死亡
wolfKill = wolfKill_original
}
// 步骤5:确定毒药击杀
poisonKill = nightAction.witchPoisonTarget
// 步骤6:汇总死亡名单
nightDeadNames = []
if (wolfKill !== '') nightDeadNames.push(wolfKill)
if (poisonKill !== '') nightDeadNames.push(poisonKill)
// 步骤7:执行死亡
for (deadId in nightDeadNames) {
player = players.find(p => p.id === deadId)
if (player !== undefined) player.isAlive = false
}
10.3 白天放逐判定
白天放逐的判定逻辑位于 showVoteResult() 方法(WerewolfGame.ets:212-244):
if (this.selectedTarget !== '') {
const target = this.players.find(p => p.id === this.selectedTarget)
if (target !== undefined) {
this.voteResultText = `${target.nickname} 被投票放逐`
target.isAlive = false
}
} else {
this.voteResultText = '平票,无人被放逐'
}
放逐判定的特点:
- 直接修改
isAlive属性,立即生效 - 被放逐玩家的身份不公开(传统规则中,放逐不翻牌)
- 放逐后立即进行胜负判定
10.4 isAlive 属性的级联效应
isAlive 从 true 变为 false 后,会影响以下逻辑:
getAlivePlayers()过滤结果改变,影响发言顺序、投票目标列表- 胜负判定的
wolves.length和alive.length计算改变 - 角色行动阶段中
me.isAlive检查——已死亡的角色不会进入行动阶段
10.5 死亡与遗言
传统狼人杀中,被放逐的玩家有权发表"遗言"(最后陈述),被夜晚击杀的玩家通常无遗言(首夜除外)。当前实现未包含遗言机制,被放逐玩家直接出局。后续版本可在 showVoteResult() 与胜负判定之间插入遗言阶段,给予被放逐者 30 秒的最终发言时间。
10.6 死亡判定的信息隔离
重要设计细节:狼人玩家在夜晚结算时看到的 nightDeadNames 为空数组(WerewolfGame.ets:137)。这意味着狼人无法通过 UI 确认自己的击杀是否成功(守卫可能挡刀、女巫可能救人)。这种信息隔离防止了狼人通过"击杀结果反馈"推断守卫或女巫的存在与行动,保持了游戏的推理性质。
11. 计时器管理
11.1 计时器类型概览
NearPlay 狼人杀中使用了两类计时器:
| 计时器类型 | 使用场景 | 管理方式 | 存储变量 |
|---|---|---|---|
| setTimeout | 阶段自动推进 | 一次性,无需手动清理 | 无需存储 |
| setInterval | 发言倒计时、投票倒计时 | 需手动 clearInterval | speakerTimerId / 局部变量 |
11.2 setTimeout 的使用
setTimeout 用于阶段间的自动推进,共 8 处调用:
| 位置 | 延迟 | 用途 |
|---|---|---|
revealMyRole():61 |
3000ms | 角色揭示后进入夜晚 |
startNight():74 |
2000ms | 夜晚开始过渡 |
enterWolfPhase():87 |
5000ms | 狼人回合时限 |
enterSeerPhase():100 |
5000ms | 预言家回合时限 |
enterWitchPhase():117 |
5000ms | 女巫回合时限 |
enterGuardPhase():129 |
5000ms | 守卫回合时限 |
showNightResult():149 |
3000ms | 夜晚结果展示 |
showVoteResult():228/234/242 |
3000ms | 投票结果展示 |
setTimeout 是一次性计时器,回调执行后自动销毁,无需手动清理。但它有一个潜在的内存泄漏风险——若组件在 setTimeout 回调执行前被销毁(如用户返回上一页),回调仍会执行并尝试访问已销毁的组件状态。
11.3 setInterval 的使用
setInterval 用于需要持续倒计时的场景:
发言计时器(startPlayerSpeech():179):
this.speakerTimerId = setInterval(() => {
this.speakerTimer--
if (this.speakerTimer <= 0) {
clearInterval(this.speakerTimerId)
this.speakerTimerId = -1
this.speakerOrderIndex++
this.startPlayerSpeech()
}
}, 1000)
投票计时器(enterDayVote():203):
const timerId = setInterval(() => {
this.voteTimer--
if (this.voteTimer <= 0) {
clearInterval(timerId)
this.showVoteResult()
}
}, 1000)
两者的关键区别:
- 发言计时器存储在成员变量
speakerTimerId中,因为需要在方法间共享(startPlayerSpeech()递归调用时需要清理上一轮计时器,aboutToDisappear()中也需要清理) - 投票计时器存储在局部变量
timerId中,因为其生命周期完全封闭在enterDayVote()内部
11.4 speakerTimerId 的完整生命周期
初始状态: speakerTimerId = -1
startPlayerSpeech() 调用:
├── 检查 speakerTimerId !== -1 → clearInterval(旧计时器)
├── 创建新计时器 → speakerTimerId = setInterval(...)
│
│ [计时器运行中...每秒触发回调]
│
│ 回调触发:
│ ├── speakerTimer--
│ ├── speakerTimer <= 0?
│ │ ├── YES → clearInterval(speakerTimerId)
│ │ │ speakerTimerId = -1
│ │ │ speakerOrderIndex++
│ │ │ startPlayerSpeech() [递归]
│ │ └── NO → 继续运行
│
aboutToDisappear() 调用:
└── 检查 speakerTimerId !== -1 → clearInterval(speakerTimerId)
11.5 aboutToDisappear() 清理机制
代码位置:WerewolfGame.ets:268-273
aboutToDisappear(): void {
if (this.speakerTimerId !== -1) {
clearInterval(this.speakerTimerId)
}
this.voiceHelper.destroy()
}
aboutToDisappear() 是 ArkUI 组件的生命周期回调,在组件从 UI 树中移除前调用。此处执行两项清理操作:
1. 清理发言计时器
若讨论阶段正在进行(speakerTimerId !== -1),计时器仍在运行,必须清理。否则计时器回调会继续执行,尝试更新已销毁组件的 @State 变量,可能导致未定义行为或内存泄漏。
2. 销毁语音助手
voiceHelper.destroy() 清理 VoiceInputHelper 实例持有的资源。语音输入通常涉及音频设备占用、录音流、语音识别引擎连接等系统资源,若不清理可能导致:
- 麦克风持续占用
- 录音流未释放
- 后台服务未断开
11.6 计时器泄漏风险分析
当前实现存在一个计时器泄漏风险点:投票计时器未被 aboutToDisappear() 清理。
投票计时器使用局部变量 timerId 存储,aboutToDisappear() 无法访问该变量。若用户在投票倒计时进行中退出页面,该计时器不会被清理,回调将尝试在已销毁的组件上调用 showVoteResult()。
修复方案:将投票计时器 ID 存储为成员变量(类似 speakerTimerId),并在 aboutToDisappear() 中一并清理:
private voteTimerId: number = -1
enterDayVote(): void {
// ...
this.voteTimerId = setInterval(() => {
this.voteTimer--
if (this.voteTimer <= 0) {
clearInterval(this.voteTimerId)
this.voteTimerId = -1
this.showVoteResult()
}
}, 1000)
}
aboutToDisappear(): void {
if (this.speakerTimerId !== -1) {
clearInterval(this.speakerTimerId)
}
if (this.voteTimerId !== -1) {
clearInterval(this.voteTimerId)
}
this.voiceHelper.destroy()
}
11.7 setTimeout 的页面退出风险
类似地,8 处 setTimeout 调用在页面退出时也不会被取消。ArkUI 的 setTimeout 回调绑定到组件上下文,组件销毁后回调可能仍会执行。更安全的做法是将所有 setTimeout 的返回值存储在成员变量中,并在 aboutToDisappear() 中逐一 clearTimeout()。但对于狼人杀这类单页面游戏,用户通常不会在游戏进行中退出,此风险的优先级较低。
11.8 voiceHelper.destroy() 详解
VoiceInputHelper 封装了 HarmonyOS 的语音识别 API。其 destroy() 方法通常执行以下操作:
- 停止录音:若正在录音,调用底层 API 停止音频采集
- 释放音频设备:释放麦克风占用,允许其他应用使用
- 断开服务连接:断开与语音识别服务的连接
- 清理回调:移除所有注册的回调函数,防止闭包持有组件引用导致 GC 无法回收
在 aboutToDisappear() 中调用 destroy() 确保了组件销毁时所有语音相关资源被正确释放,这是 ArkUI 组件资源管理的最佳实践。
11.9 计时器管理最佳实践总结
| 实践 | 当前状态 | 建议 |
|---|---|---|
| setInterval 返回值存储 | speakerTimerId 已存储,voteTimer 未存储 | 统一存储所有 setInterval 返回值 |
| aboutToDisappear 清理 | 仅清理 speakerTimerId | 清理所有活跃计时器 |
| setTimeout 页面退出处理 | 未处理 | 存储返回值,aboutToDisappear 中清理 |
| 语音资源清理 | 已实现 voiceHelper.destroy() | 保持当前实现 |
| 计时器 ID 初始值 | speakerTimerId = -1 | 统一所有计时器 ID 初始值为 -1 |
附录:核心源码索引
| 文件 | 核心内容 |
|---|---|
WerewolfModel.ets:1-15 |
WerewolfPlayer 数据模型 |
WerewolfModel.ets:17-24 |
WerewolfRole 角色枚举(6 种角色) |
WerewolfModel.ets:26-38 |
WerewolfPhase 阶段枚举(11 个阶段) |
WerewolfModel.ets:40-50 |
WerewolfNightAction 夜晚行动数据 |
WerewolfModel.ets:52-57 |
WerewolfVoteResult 投票结果数据 |
WerewolfModel.ets:59-71 |
getRoleName / getRoleIcon 工具函数 |
WerewolfModel.ets:73-86 |
MockWerewolfData 测试数据 |
WerewolfGame.ets:10-35 |
@State 状态变量声明 |
WerewolfGame.ets:37-41 |
aboutToAppear 生命周期 |
WerewolfGame.ets:43-61 |
startRoleReveal / revealMyRole |
WerewolfGame.ets:64-75 |
startNight |
WerewolfGame.ets:77-130 |
夜晚四阶段:enterWolf/Seer/Witch/GuardPhase |
WerewolfGame.ets:132-150 |
showNightResult 夜晚结算 |
WerewolfGame.ets:152-161 |
enterDayDiscuss 讨论阶段初始化 |
WerewolfGame.ets:163-188 |
startPlayerSpeech 发言轮转核心 |
WerewolfGame.ets:190-195 |
handleVoiceResult 语音处理 |
WerewolfGame.ets:197-210 |
enterDayVote 投票阶段 |
WerewolfGame.ets:212-244 |
showVoteResult 投票结算与胜负判定 |
WerewolfGame.ets:246-248 |
selectTarget 目标选择 |
WerewolfGame.ets:259-266 |
isMyRole / getAlivePlayers 工具方法 |
WerewolfGame.ets:268-273 |
aboutToDisappear 计时器与资源清理 |
WerewolfGame.ets:275-306 |
build 主 UI 渲染与阶段分发 |
WerewolfGame.ets:308-858 |
各阶段 UI Builder |
WerewolfGame.ets:861-869 |
ChatMessage 消息模型 |
更多推荐


所有评论(0)