狼人杀状态机与日夜循环 — NearPlay 技术文档

本文档基于 WerewolfGame.etsWerewolfModel.ets 源码,系统阐述 NearPlay 狼人杀模块的有限状态机设计、日夜循环流转机制、各阶段触发条件与计时器管理等核心技术细节。


1. 游戏概述

狼人杀(Werewolf / Mafia)是一款经典的社交推理桌游,其核心机制围绕"信息不对称"与"阵营对抗"展开。在 NearPlay 的实现中,游戏将传统线下狼人杀的主持流程抽象为一套严格的有限状态机(Finite State Machine, FSM),由 ArkTS 的 @State 响应式变量驱动 UI 渲染,通过 setTimeoutsetInterval 模拟主持人的节奏控制,实现完整的一局游戏闭环。

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 类已预留了 wolfTargetseerTargetwitchSaveTarget 等字段,正是为多人同步预留的协议载体。


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 中的 witchHasSavewitchHasPoison 布尔值追踪药水剩余状态。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() 方法是日夜循环的入口点,它执行以下初始化操作:

  1. dayNumber++:天数计数器递增,从 0 变为 1
  2. phase = NIGHT_START:切换至夜晚开始阶段
  3. 设置 phaseTitle 为"第N夜"
  4. 重置 nightAction 为新的 WerewolfNightAction 实例(清空所有夜晚行动记录)
  5. 清空 selectedTargetseerCheckResultwitchDeadNamenightDeadNames

这一系列重置操作确保每个夜晚的行动数据是干净的,不会受到上一轮数据的污染。

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 === 0wolves.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-209WerewolfGame.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_TURNphaseTitle = '狼人回合'
  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_RESULTphaseTitle = '第N天 - 天亮了'
  2. 计算夜晚死亡名单:
    • 狼人视角:nightDeadNames = [](狼人看不到谁死了,信息隔离)
    • 好人视角:根据实际结算结果填充 nightDeadNames
  3. 判断死亡情况:
    • nightDeadNames.length === 0 → 平安夜,phaseDesc = '昨晚是平安夜,没有人死亡'
    • nightDeadNames.length > 0phaseDesc = '昨晚死亡: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 组件和文字输入框。GameChatPanelcanSpeak 属性与发言权限联动:

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.lengthalive.length 计算改变
  3. 角色行动阶段中 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() 方法通常执行以下操作:

  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-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 消息模型

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐