狼人杀状态机与日夜循环 — 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_ASSIGNNIGHT_STARTrevealMyRole() 中 setTimeout3000ms
NIGHT_STARTWOLF_TURNstartNight() 中 setTimeout2000ms
WOLF_TURNSEER_TURNenterWolfPhase() 中 setTimeout5000ms
SEER_TURNWITCH_TURNenterSeerPhase() 中 setTimeout5000ms
WITCH_TURNGUARD_TURNenterWitchPhase() 中 setTimeout5000ms
GUARD_TURNNIGHT_RESULTenterGuardPhase() 中 setTimeout5000ms
NIGHT_RESULTDAY_DISCUSSshowNightResult() 中 setTimeout3000ms
DAY_DISCUSSDAY_VOTEspeakerOrderIndex >= alive.length发言完毕
DAY_VOTEVOTE_RESULT倒计时归零或确认投票10000ms / 手动
VOTE_RESULTGAME_OVER胜负判定:狼人=0 或 狼人≥好人3000ms
VOTE_RESULTNIGHT_START胜负未定3000ms
GAME_OVERROLE_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() 方法是日夜循环的入口点,它执行以下初始化操作:

  1. dayNumber++:天数计数器递增,从 0 变为 1
  2. phase = NIGHT_START:切换至夜晚开始阶段
  3. 设置 phaseTitle 为"第N夜"
  4. 重置 nightAction 为新的 WerewolfNightAction 实例(清空所有夜晚行动记录)
  5. 清空 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 = 25s30×8 = 240s10+3 = 13s~278s
第2轮25s30×7 = 210s13s~248s
第3轮25s30×6 = 180s13s~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_OVER
    • wolves.length >= alive.length - wolves.length → 狼人胜,进入 GAME_OVER
    • 否则 → 游戏继续,进入 NIGHT_START

4.3 触发条件的设计原则

  1. 防阻塞原则:所有玩家交互阶段均设有 setTimeout 强制超时,防止因玩家不操作导致游戏停滞
  2. 叙事节奏原则:过渡阶段(NIGHT_START、NIGHT_RESULT)使用 2-3 秒的短延迟,给予叙事节奏但不拖沓
  3. 操作窗口原则:行动阶段(WOLF/SEER/WITCH/GUARD)统一 5 秒操作窗口,讨论阶段每人口 30 秒,投票阶段 10 秒
  4. 分支确定性原则: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)

流程:

  1. 设置 phase = WOLF_TURN,phaseTitle = '狼人回合'
  2. 清空 selectedTarget(确保上一轮的选择不影响本轮)
  3. 判断当前玩家身份:
    • 若为存活狼人:phaseDesc = '选择你要击杀的目标',UI 展示可选目标列表
    • 若为其他角色:phaseDesc = '狼人正在行动...请等待',UI 仅展示等待提示
  4. 启动 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)

流程:

  1. 设置 phase = SEER_TURN,清空 selectedTarget
  2. 判断当前玩家身份:
    • 若为存活预言家:phaseDesc = '选择你要查验的对象',展示所有存活玩家(排除自身)
    • 若为其他角色:phaseDesc = '预言家正在查验...请等待'
  3. 预言家选择查验对象后:
    • 调用 selectTarget(player.id) 记录目标
    • 即时设置查验结果:若目标为狼人则 seerCheckResult = 'xxx 是 狼人!',否则 seerCheckResult = 'xxx 是 好人'
  4. 5 秒倒计时,超时检查:
    if (me !== undefined && me.role === WerewolfRole.SEER && me.isAlive && this.seerCheckResult === '') {
      this.seerCheckResult = '你未查验,时间已到'
    }
    
  5. 调用 enterWitchPhase()

查验结果的即时显示:预言家选择目标后,UI 立即显示查验结果(绿色粗体文字),这是预言家最重要的信息获取手段。查验结果仅在预言家自己的屏幕上显示,实现了信息隔离。

超时惩罚:预言家是唯一有超时提示的角色——若 5 秒内未操作,显示"你未查验,时间已到"。这是因为预言家的查验是获取关键信息的唯一途径,超时未查验对好人阵营是重大损失,需要明确的提示让玩家意识到错失了机会。

5.4 女巫回合(WITCH_TURN)

入口方法:enterWitchPhase()(WerewolfGame.ets:108-118)

流程:

  1. 设置 phase = WITCH_TURN
  2. 判断当前玩家身份:
    • 若为存活女巫:phaseDesc = '是否使用药水?',展示药水按钮
    • 若为其他角色:phaseDesc = '女巫正在决定...请等待'
  3. 女巫可看到今晚被杀的玩家信息(通过 nightAction.wolfTarget)
  4. 女巫可选择:
    • 使用解药(witchHasSave 为 true 时显示"💊 使用解药"按钮,绿色)
    • 使用毒药(witchHasPoison 为 true 时显示"🧪 使用毒药"按钮,紫色)
    • 跳过(灰色"跳过"按钮,始终显示)
  5. 5 秒后自动进入守卫回合

药水机制:

  • 解药:可救活今晚被狼人击杀的玩家,全局仅可使用一次。使用后 witchHasSave = false
  • 毒药:可毒杀任意一名存活玩家,全局仅可使用一次。使用后 witchHasPoison = false
  • 同一晚不可同时使用解药和毒药(传统规则,当前代码未强制校验此约束,需在后续版本中补充)

策略考量:女巫是否首夜自救是一个经典策略问题。在 4 人局中,女巫自救几乎是最优策略(好人数量稀缺),但在 8 人局中,女巫可能选择保留解药以待更关键时刻。NearPlay 的 UI 设计保留了这一策略空间,由玩家自主决策。

5.5 守卫回合(GUARD_TURN)

入口方法:enterGuardPhase()(WerewolfGame.ets:120-130)

流程:

  1. 设置 phase = GUARD_TURN
  2. 判断当前玩家身份:
    • 若为存活守卫:phaseDesc = '选择你要守护的对象',展示所有存活玩家
    • 若为其他角色:phaseDesc = '守卫正在行动...请等待'
  3. 守卫选择守护对象(包括自己,可通过 selectTarget(player.id) 选择)
  4. 5 秒后调用 showNightResult()

守护规则:

  • 守卫可以选择守护任意一名存活玩家,包括自己
  • 被守护的玩家若今晚被狼人击杀,则免于死亡("挡刀"效果)
  • 传统规则中,守卫不能连续两晚守护同一人。WerewolfNightAction.lastGuardTarget 字段已预留此规则的校验依据,但当前代码未实现此约束
  • 守卫与女巫解药同时作用于同一人时,传统规则为"同守同救则死"(即守卫和女巫解药同时生效时,玩家反而死亡),此规则同样需在后续版本中实现

UI 展示:守卫的目标选择 UI 以橙色高亮被选中对象(backgroundColor: '#FF9800'),与狼人的红色、预言家的蓝色形成视觉区分。

5.6 夜晚结算(NIGHT_RESULT)

入口方法:showNightResult()(WerewolfGame.ets:132-150)

流程:

  1. 设置 phase = NIGHT_RESULT,phaseTitle = '第N天 - 天亮了'
  2. 计算夜晚死亡名单:
    • 狼人视角:nightDeadNames = [](狼人看不到谁死了,信息隔离)
    • 好人视角:根据实际结算结果填充 nightDeadNames
  3. 判断死亡情况:
    • nightDeadNames.length === 0 → 平安夜,phaseDesc = '昨晚是平安夜,没有人死亡'
    • nightDeadNames.length > 0 → phaseDesc = '昨晚死亡:xxx、yyy'
  4. 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()
  }
}

初始化操作包括:

  1. 阶段切换:phase = DAY_DISCUSS
  2. 清空聊天:chatMessages = [],确保本轮讨论不受上一轮消息的干扰
  3. 重置发言者索引:speakerOrderIndex = 0,从第一位存活玩家开始
  4. 启动发言:调用 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() 清除上一轮的计时器。这一设计至关重要——它防止了以下竞态条件:

  1. 玩家 A 的 30 秒发言计时器正在运行
  2. 由于某种原因(如异常中断),startPlayerSpeech() 被意外再次调用
  3. 若不清理旧计时器,两个计时器将同时运行,导致 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')

倒计时归零时执行以下操作:

  1. clearInterval(this.speakerTimerId):停止计时器
  2. this.speakerTimerId = -1:标记无活跃计时器
  3. this.speakerOrderIndex++:推进发言者索引
  4. 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 的玩家保持原始排列顺序。这种"固定顺序"方案的优点是实现简单、行为可预测;缺点是缺乏策略性——线下狼人杀通常由"上警"(竞选警长)或遗言顺序决定发言顺序,且每轮发言顺序可以反转(顺时针/逆时针交替),增加信息博弈的复杂性。

后续版本可考虑的改进:

  1. 遗言发言:夜晚死亡的玩家发表遗言后,由其左侧或右侧的存活玩家开始发言
  2. 警长决定:若有警长角色,由警长决定发言顺序
  3. 方向交替:每轮发言方向反转,防止固定顺序带来的信息优势

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)
}

初始化操作:

  1. 阶段切换:phase = DAY_VOTE
  2. 标题设置:phaseTitle = '投票放逐'
  3. 倒计时初始化:voteTimer = 10(10 秒投票时限)
  4. 清空选择:selectedTarget = ''
  5. 启动倒计时: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 是投票阶段的核心状态变量,记录当前玩家的投票选择。其生命周期为:

  1. 初始化:enterDayVote() 中设为空字符串
  2. 更新:玩家点击目标列表中的某项,selectTarget(id) 被调用
  3. 读取: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 = '平票,无人被放逐'
  }
  // ... 胜负判定 ...
}

两种结果:

  1. 有人被放逐:selectedTarget 非空,对应玩家的 isAlive 设为 false
  2. 平票/弃票: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 判定逻辑的边界情况

  1. 守卫+女巫同时作用:若守卫守护的玩家同时被女巫解药救活,传统规则中存在"同守同救则死"的约束。当前实现未考虑此情况,可能导致本应死亡的玩家存活,从而影响胜负判定的准确性。

  2. 猎人技能:WerewolfRole.HUNTER 已在枚举中定义,Mock 数据中也有猎人玩家(u7),但当前代码未实现猎人"死亡时开枪带走一人"的技能。若猎人被投票放逐,其技能应立即触发,可能直接影响胜负判定。

  3. 连续平安夜:若每夜均无人死亡(守卫连续挡刀+女巫不毒),游戏可能陷入"白天讨论→投票放逐→夜晚平安→白天讨论…"的循环,直至白天投票产生放逐结果。当前实现中,若每轮投票均平票(所有玩家弃票),理论上游戏可以无限循环,这是一个需要处理的边界情况。

8.4 胜负判定的改进建议

  1. 夜晚结束后也判定胜负:若夜晚结束后狼人已达成屠边条件(如毒药杀死了最后一个好人),应立即结束游戏,无需等待白天讨论
  2. 最大回合数限制:防止游戏无限循环,可设置最大回合数(如 10 轮),超时则按当前存活阵营判负或平局
  3. 猎人技能触发:在放逐或夜晚死亡判定后检查是否有猎人死亡,触发其技能

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 的用途

  1. UI 显示:顶栏显示"第N天"(WerewolfGame.ets:315),夜晚标题显示"第N夜"(WerewolfGame.ets:67),天亮时显示"第N天 - 天亮了"(WerewolfGame.ets:134)
  2. 叙事标识:帮助玩家追踪游戏进程,"第 2 天的发言逻辑与第 1 天不同"是常见的推理依据
  3. 发言顺序策略:线下狼人杀中,每轮发言顺序可能根据天数交替反转,dayNumber 为此提供了判断依据

9.4 注意事项

dayNumber 不应与"回合数"混淆。一个 dayNumber 对应一个完整的"夜晚→白天"循环。角色分配阶段(ROLE_ASSIGN)不计入 dayNumber。因此,游戏的第一夜就是"第 1 天"的夜晚部分,接下来的白天就是"第 1 天"的白天部分。

当前实现中,dayNumber 没有上限检查。若游戏持续多轮(极端情况下),dayNumber 将无限递增,但在实际游戏中这不会造成问题——UI 仅显示数字,不存在溢出风险(JavaScript 的 Number 类型可安全表示至 2^53)。


10. 死亡判定

10.1 死亡判定的两个时机

在 NearPlay 狼人杀中,玩家死亡可发生在两个时机:

  1. 夜晚死亡:由狼人击杀、女巫毒杀导致,在 showNightResult() 中结算
  2. 白天放逐:由投票导致,在 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 后,会影响以下逻辑:

  1. getAlivePlayers() 过滤结果改变,影响发言顺序、投票目标列表
  2. 胜负判定的 wolves.length 和 alive.length 计算改变
  3. 角色行动阶段中 me.isAlive 检查——已死亡的角色不会进入行动阶段

10.5 死亡与遗言

传统狼人杀中,被放逐的玩家有权发表"遗言"(最后陈述),被夜晚击杀的玩家通常无遗言(首夜除外)。当前实现未包含遗言机制,被放逐玩家直接出局。后续版本可在 showVoteResult() 与胜负判定之间插入遗言阶段,给予被放逐者 30 秒的最终发言时间。

10.6 死亡判定的信息隔离

重要设计细节:狼人玩家在夜晚结算时看到的 nightDeadNames 为空数组(WerewolfGame.ets:137)。这意味着狼人无法通过 UI 确认自己的击杀是否成功(守卫可能挡刀、女巫可能救人)。这种信息隔离防止了狼人通过"击杀结果反馈"推断守卫或女巫的存在与行动,保持了游戏的推理性质。


11. 计时器管理

11.1 计时器类型概览

NearPlay 狼人杀中使用了两类计时器:

计时器类型使用场景管理方式存储变量
setTimeout阶段自动推进一次性,无需手动清理无需存储
setInterval发言倒计时、投票倒计时需手动 clearIntervalspeakerTimerId / 局部变量

11.2 setTimeout 的使用

setTimeout 用于阶段间的自动推进,共 8 处调用:

位置延迟用途
revealMyRole():613000ms角色揭示后进入夜晚
startNight():742000ms夜晚开始过渡
enterWolfPhase():875000ms狼人回合时限
enterSeerPhase():1005000ms预言家回合时限
enterWitchPhase():1175000ms女巫回合时限
enterGuardPhase():1295000ms守卫回合时限
showNightResult():1493000ms夜晚结果展示
showVoteResult():228/234/2423000ms投票结果展示

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() 方法通常执行以下操作:

  1. 停止录音:若正在录音,调用底层 API 停止音频采集
  2. 释放音频设备:释放麦克风占用,允许其他应用使用
  3. 断开服务连接:断开与语音识别服务的连接
  4. 清理回调:移除所有注册的回调函数,防止闭包持有组件引用导致 GC 无法回收

在 aboutToDisappear() 中调用 destroy() 确保了组件销毁时所有语音相关资源被正确释放,这是 ArkUI 组件资源管理的最佳实践。

11.9 计时器管理最佳实践总结

实践当前状态建议
setInterval 返回值存储speakerTimerId 已存储,voteTimer 未存储统一存储所有 setInterval 返回值
aboutToDisappear 清理仅清理 speakerTimerId清理所有活跃计时器
setTimeout 页面退出处理未处理存储返回值,aboutToDisappear 中清理
语音资源清理已实现 voiceHelper.destroy()保持当前实现
计时器 ID 初始值speakerTimerId = -1统一所有计时器 ID 初始值为 -1

附录:核心源码索引

文件核心内容
WerewolfModel.ets:1-15WerewolfPlayer 数据模型
WerewolfModel.ets:17-24WerewolfRole 角色枚举(6 种角色)
WerewolfModel.ets:26-38WerewolfPhase 阶段枚举(11 个阶段)
WerewolfModel.ets:40-50WerewolfNightAction 夜晚行动数据
WerewolfModel.ets:52-57WerewolfVoteResult 投票结果数据
WerewolfModel.ets:59-71getRoleName / getRoleIcon 工具函数
WerewolfModel.ets:73-86MockWerewolfData 测试数据
WerewolfGame.ets:10-35@State 状态变量声明
WerewolfGame.ets:37-41aboutToAppear 生命周期
WerewolfGame.ets:43-61startRoleReveal / revealMyRole
WerewolfGame.ets:64-75startNight
WerewolfGame.ets:77-130夜晚四阶段:enterWolf/Seer/Witch/GuardPhase
WerewolfGame.ets:132-150showNightResult 夜晚结算
WerewolfGame.ets:152-161enterDayDiscuss 讨论阶段初始化
WerewolfGame.ets:163-188startPlayerSpeech 发言轮转核心
WerewolfGame.ets:190-195handleVoiceResult 语音处理
WerewolfGame.ets:197-210enterDayVote 投票阶段
WerewolfGame.ets:212-244showVoteResult 投票结算与胜负判定
WerewolfGame.ets:246-248selectTarget 目标选择
WerewolfGame.ets:259-266isMyRole / getAlivePlayers 工具方法
WerewolfGame.ets:268-273aboutToDisappear 计时器与资源清理
WerewolfGame.ets:275-306build 主 UI 渲染与阶段分发
WerewolfGame.ets:308-858各阶段 UI Builder
WerewolfGame.ets:861-869ChatMessage 消息模型

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐