狼人杀 — 角色技能与投票系统技术文档

目录

  1. WerewolfRole 六角色详解
  2. WerewolfNightAction 模型
  3. 角色信息展示
  4. 角色专属 UI
  5. 投票系统
  6. VoiceInput 语音发言
  7. 发言顺序 UI
  8. GameChatPanel canSpeak 逻辑
  9. 游戏结束展示
  10. 边界情况与未来扩展

1. WerewolfRole 六角色详解

狼人杀游戏中定义了六个核心角色,由 WerewolfRole 枚举类型统一管理。每个角色在夜晚阶段拥有不同的行动权限和技能机制,在白天阶段则平等参与讨论与投票。以下逐一详述每个角色的技能描述、夜晚行动 UI 呈现方式、操作逻辑流程以及在 ArkTS 中的代码实现细节。

1.1 狼人(WEREWOLF = 1)

狼人角色

技能描述

狼人是游戏中唯一拥有击杀能力的角色。每个夜晚,所有存活的狼人共同商议并选择一名非狼人玩家作为击杀目标。狼人阵营在游戏开始时便知晓彼此的身份,这是他们最大的信息优势。狼人的胜利条件是让存活狼人数量大于或等于好人阵营存活人数。在标准配置中,通常有两名狼人配对行动,他们需要通过沟通达成一致意见,选择最有威胁的好人角色优先击杀,以削弱好人阵营的信息获取能力。

狼人的核心策略在于:第一夜优先击杀预言家,因为预言家每晚可查验一人身份,对狼人威胁最大;第二夜考虑击杀女巫,因为女巫拥有解药和毒药两种一次性能力;守卫则需要根据白天讨论的蛛丝马迹判断守护目标。狼人在白天讨论中需要伪装自己的身份,通过混淆视听、引导投票方向来保护自己和同伴。狼人的信息优势来源于夜晚阶段的同伴互知,他们可以精确判断哪些发言者正在试图暴露狼人身份,从而在白天有针对性地反驳或转移话题。

夜晚行动 UI

当游戏进入 WerewolfPhase.WOLF_TURN 阶段时,界面呈现深蓝色背景(#1a1a2e),营造夜晚氛围。狼人玩家在屏幕上方看到"狼人回合"标题和"选择你要击杀的目标"提示文字。紧接着展示"狼人同伴"标签,以红色字体(#FF4444)标识,下方用 Row 布局水平排列所有狼人玩家的头像和昵称,帮助狼人确认队友身份。然后出现"选择击杀目标"标签,下方使用 Grid 组件以四列布局(columnsTemplate: '1fr 1fr 1fr 1fr')展示所有存活的非狼人玩家。每个可点击的目标卡片默认使用深蓝灰色背景(#333355),当被选中时背景变为红色(#FF4444),形成强烈的视觉反馈。非狼人玩家在此阶段仅看到"狼人正在行动…请等待"的灰色提示文字。

操作逻辑

狼人选择目标的操作流程如下:首先调用 isMyRole(WerewolfRole.WEREWOLF) 判断当前玩家是否为狼人且存活,如果是则展示目标选择 Grid。玩家点击某个非狼人存活玩家的卡片时,触发 selectTarget(player.id) 方法,将 selectedTarget 状态变量更新为被点击玩家的 ID。Grid 中每个卡片的背景色通过三元表达式 this.selectedTarget === player.id ? '#FF4444' : '#333355' 实时响应选中状态的变化。由于 selectedTarget@State 装饰的响应式变量,选中后 ArkUI 框架会自动重新渲染相关 UI 组件,实现即时的视觉反馈。

狼人回合有 5 秒操作时间限制(setTimeout(() => { this.enterSeerPhase() }, 5000)),超时后自动进入预言家回合。在多人在线场景中,狼人之间需要通过即时通讯达成共识,当前实现中简化为各自独立选择,服务端最终取多数意见决定击杀目标。

代码实现

狼人回合 UI 由 WolfTurnUI() Builder 方法构建。核心代码片段如下:

@Builder
WolfTurnUI() {
  Column() {
    Text(this.phaseTitle)
      .fontSize(24).fontWeight(FontWeight.Bold).fontColor(Color.White)
    Text(this.phaseDesc)
      .fontSize(16).fontColor('#CCCCCC').margin({ top: 8 })
    if (this.isMyRole(WerewolfRole.WEREWOLF)) {
      Text('狼人同伴:').fontSize(14).fontColor('#FF4444').margin({ top: 16 })
      Row() {
        ForEach(this.players.filter(
          (p: WerewolfPlayer) => p.role === WerewolfRole.WEREWOLF),
          (wolf: WerewolfPlayer) => {
            Text(`${wolf.avatar}${wolf.nickname}`)
              .fontSize(14).fontColor(Color.White).margin({ left: 8, right: 8 })
          }, (wolf: WerewolfPlayer) => wolf.id)
      }
      Text('选择击杀目标:').fontSize(14).fontColor(Color.White).margin({ top: 24 })
      Grid() {
        ForEach(this.getAlivePlayers().filter(
          (p: WerewolfPlayer) => p.role !== WerewolfRole.WEREWOLF),
          (player: WerewolfPlayer) => {
            GridItem() {
              Column() {
                Text(player.avatar).fontSize(32)
                Text(player.nickname).fontSize(12)
                  .fontColor(Color.White).margin({ top: 4 })
              }
              .padding(8).borderRadius(8)
              .backgroundColor(this.selectedTarget === player.id ? '#FF4444' : '#333355')
              .onClick(() => { this.selectTarget(player.id) })
            }
          }, (player: WerewolfPlayer) => player.id)
      }
      .columnsTemplate('1fr 1fr 1fr 1fr').width('90%').height(200).margin({ top: 8 })
    }
  }
  .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}

isMyRole() 方法的实现逻辑是先通过 this.players.find() 查找当前玩家对象,然后判断其角色是否匹配且是否存活:

isMyRole(role: WerewolfRole): boolean {
  const me = this.players.find((p: WerewolfPlayer) => p.id === this.myId)
  return me !== undefined && me.role === role && me.isAlive
}

selectTarget() 方法非常简洁,仅更新 selectedTarget 状态:

selectTarget(id: string): void {
  this.selectedTarget = id
}

1.2 预言家(SEER = 2)

技能描述

预言家是好人阵营最重要的信息角色。每个夜晚,预言家可以查验一名玩家的身份,得知该玩家是狼人还是好人。这一能力使得预言家成为好人阵营的核心信息来源,也是狼人最优先击杀的目标。预言家需要在白天发言时谨慎透露查验结果,过早暴露身份会招致狼人的针对,过晚则可能导致关键信息无法传递给其他好人玩家。

预言家的查验结果只区分"狼人"和"好人"两种,无法获知具体角色类型。这意味着被查验为好人的玩家可能是村民、女巫、守卫或猎人中的任何一种。预言家需要结合白天讨论中各玩家的发言逻辑和行为模式,综合判断哪些好人角色值得信任、哪些玩家可能在伪装。在多局游戏中,预言家通常会在第二天白天起跳(公开自己的预言家身份),以建立信息优势。

夜晚行动 UI

当游戏进入 WerewolfPhase.SEER_TURN 阶段时,预言家玩家看到"预言家回合"标题和"选择你要查验的对象"提示。下方使用 Grid 组件以四列布局展示所有存活的非自身玩家(过滤条件 p.id !== this.myId)。每个卡片默认使用深蓝灰色背景(#333355),被选中时背景变为蓝色(#2196F3),与狼人回合的红色形成视觉区分。选择目标后,立即在 Grid 下方显示查验结果:如果目标为狼人则显示"XXX 是 狼人!“(绿色字体 #4CAF50),如果为好人则显示"XXX 是 好人”。非预言家玩家仅看到"预言家正在查验…请等待"。

操作逻辑

预言家的查验逻辑在点击事件中直接实现,不同于狼人的纯选择模式。当预言家点击某个玩家卡片时,onClick 回调同时执行两个操作:调用 selectTarget(player.id) 更新选中状态,以及通过内联逻辑判断查验结果并赋值给 seerCheckResult 状态变量。查验结果判断的核心表达式为 player.role === WerewolfRole.WEREWOLF ? 'XXX 是 狼人!' : 'XXX 是 好人',其中狼人判断使用严格相等比较角色枚举值。

预言家回合同样有 5 秒时间限制。超时检查逻辑在 enterSeerPhase() 的 setTimeout 回调中实现:如果当前玩家是预言家且存活但 seerCheckResult 仍为空字符串,则显示"你未查验,时间已到"提示,确保游戏不会因玩家未操作而卡住。

代码实现

预言家回合的核心代码如下:

@Builder
SeerTurnUI() {
  Column() {
    Text(this.phaseTitle)
      .fontSize(24).fontWeight(FontWeight.Bold).fontColor(Color.White)
    Text(this.phaseDesc)
      .fontSize(16).fontColor('#CCCCCC').margin({ top: 8 })
    if (this.isMyRole(WerewolfRole.SEER)) {
      Grid() {
        ForEach(this.getAlivePlayers().filter(
          (p: WerewolfPlayer) => p.id !== this.myId),
          (player: WerewolfPlayer) => {
            GridItem() {
              Column() {
                Text(player.avatar).fontSize(32)
                Text(player.nickname).fontSize(12)
                  .fontColor(Color.White).margin({ top: 4 })
              }
              .padding(8).borderRadius(8)
              .backgroundColor(this.selectedTarget === player.id ? '#2196F3' : '#333355')
              .onClick(() => {
                this.selectTarget(player.id)
                this.seerCheckResult = player.role === WerewolfRole.WEREWOLF
                  ? `${player.nickname} 是 狼人!`
                  : `${player.nickname} 是 好人`
              })
            }
          }, (player: WerewolfPlayer) => player.id)
      }
      .columnsTemplate('1fr 1fr 1fr 1fr').width('90%').height(160).margin({ top: 16 })
      if (this.seerCheckResult !== '') {
        Text(this.seerCheckResult)
          .fontSize(16).fontColor('#4CAF50').margin({ top: 16 }).fontWeight(FontWeight.Bold)
      }
    }
  }
  .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}

1.3 女巫(WITCH = 3)

技能描述

女巫拥有两种一次性药水:解药和毒药。解药可以救活当晚被狼人杀害的玩家,毒药可以毒杀一名玩家。每种药水在整局游戏中只能使用一次,且同一晚不可同时使用两种药水(部分规则变体允许同时使用,当前实现暂未限制)。女巫是好人阵营中最具策略性的角色,需要在全局视角下权衡药水使用的时机:过早使用解药可能救活一个无关紧要的村民,过早使用毒药则可能误杀好人。

女巫的策略核心在于信息获取与风险评估。第一晚是否使用解药是一个经典难题:救活被杀者可以保留好人人数,但也可能救活一个狼人(如果守卫与狼人同时选中同一目标且规则允许守卫保护狼人)。毒药的使用更加谨慎,通常需要结合预言家的查验结果或白天讨论中的逻辑推理,确保毒杀的目标是狼人而非好人。女巫在后期如果仍然持有药水,其威慑力本身就是一种战略资源。

夜晚行动 UI

女巫回合(WerewolfPhase.WITCH_TURN)的 UI 呈现与其他角色有显著不同。女巫不使用 Grid 选择目标,而是使用按钮式的操作界面。首先,如果当晚有玩家被狼人击杀(nightAction.wolfTarget !== ''),则显示"今晚被杀的是:某位玩家"的红色提示文字(#FF4444),让女巫了解是否需要使用解药。然后,在 Row 布局中水平排列三个操作按钮:如果女巫仍持有解药(nightAction.witchHasSave === true),则显示绿色的"使用解药"按钮(#4CAF50);如果仍持有毒药(nightAction.witchHasPoison === true),则显示紫色的"使用毒药"按钮(#9C27B0);灰色"跳过"按钮(#666666)始终显示,允许女巫选择不使用任何药水。

操作逻辑

女巫的药水使用状态通过 WerewolfNightAction 中的 witchHasSavewitchHasPoison 两个布尔字段追踪。初始值均为 true,表示两种药水都可用。当女巫使用解药后,witchHasSave 被设为 false,此后所有夜晚回合的 UI 中"使用解药"按钮不再显示。毒药同理。在当前实现中,按钮的显示由条件渲染控制(if (this.nightAction.witchHasSave)if (this.nightAction.witchHasPoison)),这确保了已使用过的药水按钮不会出现在界面上。

代码实现

@Builder
WitchTurnUI() {
  Column() {
    Text(this.phaseTitle)
      .fontSize(24).fontWeight(FontWeight.Bold).fontColor(Color.White)
    Text(this.phaseDesc)
      .fontSize(16).fontColor('#CCCCCC').margin({ top: 8 })
    if (this.isMyRole(WerewolfRole.WITCH)) {
      if (this.nightAction.wolfTarget !== '') {
        Text('今晚被杀的是:某位玩家')
          .fontSize(14).fontColor('#FF4444').margin({ top: 16 })
      }
      Row() {
        if (this.nightAction.witchHasSave) {
          Button('使用解药')
            .fontSize(14).height(36).type(ButtonType.Capsule)
            .backgroundColor('#4CAF50').fontColor(Color.White).margin({ left: 8, right: 8 })
        }
        if (this.nightAction.witchHasPoison) {
          Button('使用毒药')
            .fontSize(14).height(36).type(ButtonType.Capsule)
            .backgroundColor('#9C27B0').fontColor(Color.White).margin({ left: 8, right: 8 })
        }
        Button('跳过')
          .fontSize(14).height(36).type(ButtonType.Capsule)
          .backgroundColor('#666666').fontColor(Color.White).margin({ left: 8, right: 8 })
      }.margin({ top: 24 })
    }
  }
  .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}

1.4 守卫(GUARD = 5)

技能描述

守卫每个夜晚可以守护一名玩家,被守护的玩家当晚不会被狼人击杀。守卫可以选择守护自己,但连续两晚不能守护同一个人(此限制在当前版本中通过 lastGuardTarget 字段记录,UI 层暂未实现过滤,属于未来扩展项)。守卫与女巫解药的效果叠加规则在不同版本中有所不同:标准规则下,如果守卫守护的玩家同时被女巫使用解药,则该玩家反而死亡("同守同救必死"规则),当前实现暂未包含此规则。

守卫的策略价值在于精准预判狼人的击杀目标。有经验的守卫会根据白天发言的激烈程度、各角色跳预言家的时机等信息,推断狼人可能优先击杀的目标并提前守护。守卫的存在使狼人无法确定击杀是否成功,增加了狼人策略的不确定性。守卫自我守护也是一个常用策略,尤其在守卫认为自己是当前最可能被击杀的目标时。

夜晚行动 UI

守卫回合(WerewolfPhase.GUARD_TURN)使用 Grid 组件以四列布局展示所有存活玩家(包括守卫自身)。每个卡片默认背景为深蓝灰色(#333355),被选中时背景变为橙色(#FF9800),与其他角色形成差异化配色。守卫可以点击任一存活玩家卡片进行守护选择。

操作逻辑

守卫选择守护目标的逻辑与狼人类似,通过 selectTarget(player.id) 更新 selectedTarget 状态。选中后卡片背景变为橙色。在服务端逻辑中,需要比对 guardTargetlastGuardTarget,如果两者相同则判定为无效操作(连续守护限制)。lastGuardTarget 字段在 WerewolfNightAction 模型中定义,用于记录上一晚守卫守护的目标 ID。

代码实现

@Builder
GuardTurnUI() {
  Column() {
    Text(this.phaseTitle)
      .fontSize(24).fontWeight(FontWeight.Bold).fontColor(Color.White)
    Text(this.phaseDesc)
      .fontSize(16).fontColor('#CCCCCC').margin({ top: 8 })
    if (this.isMyRole(WerewolfRole.GUARD)) {
      Grid() {
        ForEach(this.getAlivePlayers(), (player: WerewolfPlayer) => {
          GridItem() {
            Column() {
              Text(player.avatar).fontSize(32)
              Text(player.nickname).fontSize(12)
                .fontColor(Color.White).margin({ top: 4 })
            }
            .padding(8).borderRadius(8)
            .backgroundColor(this.selectedTarget === player.id ? '#FF9800' : '#333355')
            .onClick(() => { this.selectTarget(player.id) })
          }
        }, (player: WerewolfPlayer) => player.id)
      }
      .columnsTemplate('1fr 1fr 1fr 1fr').width('90%').height(160).margin({ top: 16 })
    }
  }
  .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}

1.5 猎人(HUNTER = 4)

技能描述

猎人是一个被动触发型角色,没有夜晚行动能力。猎人的特殊技能是:当猎人被狼人击杀或被投票放逐时,可以开枪带走一名玩家。这一技能使得猎人在死亡时仍能对局势产生影响,是好人阵营的重要反击手段。猎人开枪的目标选择非常关键:如果猎人能准确判断狼人身份并开枪击杀,可以瞬间扭转局势;如果误杀好人,则雪上加霜。

猎人在游戏中通常作为预言家的保镖角色存在。猎人可以主动表示自己要保护预言家,如果狼人击杀猎人,猎人开枪时可以根据白天的讨论信息选择最可疑的玩家。猎人的威慑力使得狼人不敢轻易对宣称自己身边有猎人保护的玩家下手,从而间接保护了关键角色。

夜晚行动 UI

猎人在夜晚阶段没有专属操作界面。当猎人存活时,所有夜晚回合(WOLF_TURN、SEER_TURN、WITCH_TURN、GUARD_TURN)中猎人都显示"XX正在行动…请等待"的通用等待提示。猎人的技能触发点在死亡确认环节,当前版本尚未实现猎人开枪的交互界面,属于未来扩展功能。

操作逻辑

猎人的开枪逻辑需要监听猎人的死亡事件。当 isAlivetrue 变为 false 时,需要判断死亡原因(狼人击杀或投票放逐),然后弹出目标选择界面让猎人选择开枪对象。在代码层面,这需要在 showNightResult()showVoteResult() 方法中添加猎人死亡检测逻辑,并插入一个额外的交互阶段 HUNTER_SHOOT

代码实现

当前版本中猎人的 WerewolfRole.HUNTER 枚举值已定义,但尚无专属的 Builder 方法。猎人开枪功能计划在后续版本中实现,预计代码结构如下:

@Builder
HunterShootUI() {
  Column() {
    Text('猎人开枪')
      .fontSize(24).fontWeight(FontWeight.Bold)
    Text('你已死亡!选择一名玩家带走')
      .fontSize(16).fontColor('#F44336').margin({ top: 8 })
    Grid() {
      ForEach(this.getAlivePlayers(), (player: WerewolfPlayer) => {
        GridItem() {
          Column() {
            Text(player.avatar).fontSize(32)
            Text(player.nickname).fontSize(12).margin({ top: 4 })
          }
          .padding(8).borderRadius(8)
          .backgroundColor(this.selectedTarget === player.id ? '#F44336' : '#EEEEEE')
          .onClick(() => { this.selectTarget(player.id) })
        }
      }, (player: WerewolfPlayer) => player.id)
    }
    .columnsTemplate('1fr 1fr 1fr 1fr').width('90%').height(160)
    Button('确认开枪')
      .fontSize(16).height(44).type(ButtonType.Capsule)
      .backgroundColor('#F44336').fontColor(Color.White).width('90%')
      .enabled(this.selectedTarget !== '')
  }
}

1.6 白痴(IDIOT — 计划中)

技能描述

白痴(在 WerewolfRole 枚举中尚未定义,当前以 VILLAGER 充当)是一个特殊的被动角色。白痴的技能是:当白痴被投票放逐时,可以翻开自己的身份牌,证明自己是白痴,从而免于被放逐出局。翻牌后的白痴仍然存活但失去投票权,只能参与讨论。这一机制为好人阵营提供了一个安全的"挡刀"角色,白痴可以放心地跳预言家或发表激进言论,因为即使被投票放逐也不会真正出局。

白痴的存在增加了投票阶段的不确定性。狼人在投票时需要考虑被放逐者是否可能是白痴,避免浪费投票资源。白痴玩家则可以利用这一特性,在白天主动承担争议性发言的角色,为真正的预言家等关键角色争取生存空间。

夜晚行动 UI

白痴在夜晚阶段没有操作,与普通村民相同,显示等待提示。白痴的技能触发点在投票放逐环节,需要额外的翻牌交互界面。

操作逻辑与代码实现

白痴的翻牌逻辑需要在 showVoteResult() 中检测被放逐者的角色。如果被放逐者是白痴,则弹出翻牌确认界面,确认后该玩家保留存活状态但标记投票能力为禁用(需要扩展 WerewolfPlayer 模型增加 canVote 字段)。此功能属于未来扩展,当前版本未实现。

1.7 村民(VILLAGER = 0)

村民是好人阵营的基础角色,没有任何特殊技能。村民在夜晚阶段没有操作,全程等待白天讨论和投票。村民的价值在于通过逻辑推理和发言分析判断狼人身份,在投票环节做出正确选择。村民的数量通常最多,是好人阵营投票力的主要来源。村民虽然缺乏信息获取能力,但凭借人数优势,如果能够团结投票,仍然可以有效地逐个放逐狼人。


2. WerewolfNightAction 模型

WerewolfNightAction 类是狼人杀游戏夜晚阶段的核心数据模型,负责记录所有角色在夜晚的行动信息。该模型在 WerewolfModel.ets 中定义,每个夜晚回合开始时通过 new WerewolfNightAction() 重新实例化,确保数据隔离。

2.1 类定义与字段说明

export class WerewolfNightAction {
  wolfTarget: string = ''
  seerTarget: string = ''
  seerResult: WerewolfRole = WerewolfRole.VILLAGER
  witchSaveTarget: string = ''
  witchPoisonTarget: string = ''
  guardTarget: string = ''
  lastGuardTarget: string = ''
  witchHasSave: boolean = true
  witchHasPoison: boolean = true
}

以下逐字段详细说明:

wolfTarget: string = ‘’

狼人击杀目标 ID。当狼人回合结束时,所有狼人选择的击杀目标经过服务端聚合后,将最终目标 ID 写入此字段。空字符串表示狼人尚未做出选择或当前不在夜晚阶段。在客户端本地模拟中,此字段在狼人点击选择后即被赋值。当 wolfTarget 不为空时,女巫回合 UI 会显示"今晚被杀的是:某位玩家"的提示,供女巫决策是否使用解药。

seerTarget: string = ‘’

预言家查验目标 ID。记录预言家当晚选择查验的玩家 ID。空字符串表示预言家未查验(可能是预言家已死亡或超时未操作)。此字段用于服务端验证预言家操作的合法性,以及在游戏复盘时提供完整的行动记录。

seerResult: WerewolfRole = WerewolfRole.VILLAGER

预言家查验结果。存储被查验玩家的角色枚举值,默认为 VILLAGER(0)。当预言家查验到狼人时,此值为 WEREWOLF(1);查验到其他角色时均显示为好人身份。注意在当前实现中,客户端直接读取 player.role 判断结果,而标准狼人杀规则中预言家只应得知"狼人/好人"的二元结果,因此 seerResult 理论上应该使用布尔值或枚举简化为两种状态,但使用 WerewolfRole 枚举存储为未来扩展预留了空间。

witchSaveTarget: string = ‘’

女巫解药使用目标 ID。当女巫选择使用解药时,此字段被赋值为被救玩家的 ID(通常与 wolfTarget 相同)。空字符串表示女巫未使用解药。此字段与 witchHasSave 配合使用:当 witchHasSavetrue 变为 false 时,witchSaveTarget 应同时被赋值。

witchPoisonTarget: string = ‘’

女巫毒药使用目标 ID。当女巫选择使用毒药时,此字段被赋值为被毒杀玩家的 ID。空字符串表示女巫未使用毒药。毒药目标可以是任何存活玩家,包括狼人(这是女巫反制狼人的重要手段)。毒杀效果在夜晚结算时与狼人击杀同时生效,被毒杀的玩家无法被守卫保护或女巫解药救活。

guardTarget: string = ‘’

守卫守护目标 ID。记录守卫当晚选择守护的玩家 ID。空字符串表示守卫未选择守护(可能是守卫已死亡或跳过操作)。守卫守护效果可以抵消狼人的击杀:如果 guardTarget === wolfTarget,则被攻击的玩家不会死亡。

lastGuardTarget: string = ‘’

上一晚守卫守护目标 ID。此字段用于实现守卫连续守护限制:守卫不能连续两晚守护同一个人。每次夜晚结算完成后,需要将 guardTarget 的值赋给 lastGuardTarget,以备下一晚校验使用。在 UI 层面,当守卫选择目标时,应检查 player.id === this.nightAction.lastGuardTarget,如果匹配则将该玩家卡片标记为不可选状态。

witchHasSave: boolean = true

女巫解药是否可用。初始值为 true,表示游戏开始时女巫持有一瓶解药。当女巫使用解药后,此字段变为 false,此后所有夜晚回合中"使用解药"按钮不再显示。此字段在整局游戏中只能从 true 变为 false 一次,是不可逆的状态变化。

witchHasPoison: boolean = true

女巫毒药是否可用。初始值为 true,表示游戏开始时女巫持有一瓶毒药。当女巫使用毒药后,此字段变为 false,此后所有夜晚回合中"使用毒药"按钮不再显示。与解药相同,此字段在整局游戏中只能从 true 变为 false 一次。

2.2 数据流转时序

夜晚阶段的数据流转遵循严格的时序:狼人回合 → 预言家回合 → 女巫回合 → 守卫回合 → 结算。每个回合对应的字段在前一个回合结束后才被写入。具体时序如下:

  1. NIGHT_STARTnew WerewolfNightAction() 创建空实例,所有字段为默认值
  2. WOLF_TURN:狼人选择击杀目标 → wolfTarget 被赋值
  3. SEER_TURN:预言家选择查验对象 → seerTargetseerResult 被赋值
  4. WITCH_TURN:女巫决定是否使用药水 → witchSaveTarget/witchPoisonTarget 被赋值,witchHasSave/witchHasPoison 可能变为 false
  5. GUARD_TURN:守卫选择守护对象 → guardTarget 被赋值
  6. NIGHT_RESULT:结算所有夜晚行动,计算最终死亡名单

2.3 夜晚结算算法

夜晚结算是整个游戏最复杂的逻辑环节,需要综合考虑狼人击杀、守卫守护、女巫解药和毒药四种效果的交互。结算算法的伪代码如下:

死亡名单 = []

// 1. 狼人击杀判定
if wolfTarget !== '' then
  if guardTarget === wolfTarget then
    // 守卫守护成功,狼人击杀被抵消
    pass
  else if witchSaveTarget === wolfTarget then
    // 女巫解药救活,狼人击杀被抵消
    // 注意:"同守同救必死"规则:如果 guardTarget === wolfTarget 且 witchSaveTarget === wolfTarget
    // 则该玩家反而死亡,当前版本未实现此规则
    pass
  else
    死亡名单.append(wolfTarget)
  end if
end if

// 2. 女巫毒药判定(独立于狼人击杀)
if witchPoisonTarget !== '' then
  死亡名单.append(witchPoisonTarget)
end if

// 3. 更新存活状态
for each playerId in 死亡名单 do
  player.isAlive = false
end for

// 4. 更新守卫历史
lastGuardTarget = guardTarget

2.4 NightAction 的生命周期管理

nightAction 作为 @State 装饰的状态变量,在 startNight() 方法中被重新初始化:

startNight(): void {
  this.dayNumber++
  this.phase = WerewolfPhase.NIGHT_START
  this.nightAction = new WerewolfNightAction()
  this.selectedTarget = ''
  this.seerCheckResult = ''
  this.witchDeadName = ''
  this.nightDeadNames = []
  setTimeout(() => { this.enterWolfPhase() }, 2000)
}

每次新建 WerewolfNightAction 实例时,所有字段恢复为默认值,但 witchHasSavewitchHasPoison 的默认值为 true。在实际游戏中,这两个字段需要跨夜晚保持状态(一旦使用即为 false),因此正确的做法是将药水状态存储在独立的持久化变量中,而非每次新建 NightAction 时重置。当前实现中,nightAction 每次重新实例化会导致药水状态丢失,这是一个已知的待修复问题。


3. 角色信息展示

3.1 getRoleIcon / getRoleName 映射表

角色信息展示的核心是两个映射函数:getRoleIcon()getRoleName()。它们定义在 WerewolfModel.ets 中,将 WerewolfRole 枚举值转换为用户可读的图标和名称。

export function getRoleName(role: WerewolfRole): string {
  const names: Record<string, string> = {
    '0': '村民', '1': '狼人', '2': '预言家', '3': '女巫', '4': '猎人', '5': '守卫'
  }
  return names[String(role)] ?? '未知'
}

export function getRoleIcon(role: WerewolfRole): string {
  const icons: Record<string, string> = {
    '0': '👨‍🌾', '1': '🐺', '2': '👁', '3': '🧙', '4': '🔫', '5': '🛡'
  }
  return icons[String(role)] ?? '❓'
}

两个函数的实现模式完全一致:使用 Record<string, string> 类型定义映射表,以枚举的数字值(通过 String(role) 转换)作为键,返回对应的图标或名称。当枚举值不在映射表中时,分别回退到 '未知''❓'

映射关系总结:

枚举值 数值 图标 名称
VILLAGER 0 👨‍🌾 村民
WEREWOLF 1 🐺 狼人
SEER 2 👁 预言家
WITCH 3 🧙 女巫
HUNTER 4 🔫 猎人
GUARD 5 🛡 守卫

3.2 角色揭示流程

角色揭示发生在游戏初始化阶段(WerewolfPhase.ROLE_ASSIGN),由 revealMyRole() 方法控制。流程如下:

  1. myRoleRevealed 设为 true,触发 UI 从"点击查看"按钮切换为角色信息展示
  2. 通过 players.find() 获取当前玩家对象
  3. 使用 getRoleIcon(me.role)getRoleName(me.role) 组合显示角色信息
  4. 如果是狼人角色,额外显示狼人同伴信息

3.3 狼人同伴信息

狼人角色的信息展示具有特殊性:除了显示自身角色外,还需要揭示其他狼人同伴的身份。实现逻辑在 revealMyRole() 方法中:

if (me.role === WerewolfRole.WEREWOLF) {
  const wolves = this.players.filter(
    (p: WerewolfPlayer) => p.role === WerewolfRole.WEREWOLF && p.id !== this.myId
  )
  const wolfNames = wolves.map((p: WerewolfPlayer) => p.nickname).join('、')
  this.phaseDesc = `你的身份是:${getRoleIcon(me.role)} ${getRoleName(me.role)}\n狼人同伴:${wolfNames}`
}

这段代码首先过滤出所有非自身的狼人玩家,然后使用 map().join() 将昵称列表拼接为顿号分隔的字符串,最终以换行符拼接到角色描述中。显示效果类似:“你的身份是:🐺 狼人\n狼人同伴:老王”。

3.4 角色信息在游戏各阶段的使用

角色图标和名称在游戏中有广泛的使用场景:

  • 角色分配阶段revealMyRole() 中显示玩家的初始身份
  • 夜晚各回合isMyRole() 判断当前玩家是否有操作权限
  • 预言家查验结果seerCheckResult 文字中包含角色图标
  • 游戏结束展示GameOverUI() 中显示所有玩家的真实身份,使用 getRoleIcon()getRoleName() 渲染每个玩家的角色卡片

4. 角色专属 UI

4.1 UI 架构概述

狼人杀游戏的 UI 采用阶段驱动模式:build() 方法根据 phase 状态变量决定渲染哪个 Builder。每个夜晚角色回合都有专属的 Builder 方法,在深蓝色背景(#1a1a2e)下呈现差异化的交互界面。白天阶段则使用浅色背景(#F5F5F5)。

build() {
  Column() {
    this.TopBar()
    if (this.phase === WerewolfPhase.ROLE_ASSIGN) {
      this.RoleAssignUI()
    } else if (this.phase === WerewolfPhase.WOLF_TURN) {
      this.WolfTurnUI()
    } else if (this.phase === WerewolfPhase.SEER_TURN) {
      this.SeerTurnUI()
    } // ... 其他阶段
    GameChatPanel({ canSpeak: this.phase === WerewolfPhase.DAY_DISCUSS && this.currentSpeaker === this.myId })
  }
  .backgroundColor(
    this.phase === WerewolfPhase.NIGHT_START || ... || this.phase === WerewolfPhase.GUARD_TURN
      ? '#1a1a2e' : '#F5F5F5'
  )
}

4.2 各角色回合 UI 差异对比

角色 交互组件 选中色 过滤条件 特殊元素
狼人 Grid(4列) #FF4444(红) 非狼人存活玩家 狼人同伴 Row
预言家 Grid(4列) #2196F3(蓝) 非自身存活玩家 查验结果 Text
女巫 Row(按钮) #4CAF50/#9C27B0 无(按钮式) 被杀提示 + 药水按钮
守卫 Grid(4列) #FF9800(橙) 全部存活玩家

狼人回合(WolfTurnUI)

狼人回合是信息最丰富的夜晚 UI。除了目标选择 Grid 外,还包含狼人同伴信息展示区域。同伴信息使用 Row 布局水平排列,每个狼人显示为"头像+昵称"组合。目标 Grid 过滤掉所有狼人角色,只显示可击杀的好人玩家。Grid 使用四列等宽模板(1fr 1fr 1fr 1fr),高度 200vp,宽度占屏幕 90%。

预言家回合(SeerTurnUI)

预言家回合的 Grid 过滤条件为 p.id !== this.myId,排除了自身。选中目标后,通过条件渲染(if (this.seerCheckResult !== ''))在 Grid 下方动态显示查验结果文本。查验结果使用绿色粗体字(#4CAF50FontWeight.Bold),确保信息醒目。Grid 高度为 160vp,比狼人回合的 200vp 略矮,因为需要为查验结果预留空间。

女巫回合(WitchTurnUI)

女巫回合是唯一不使用 Grid 选择目标的夜晚 UI。操作界面完全基于按钮:绿色解药按钮、紫色毒药按钮、灰色跳过按钮。按钮使用 ButtonType.Capsule 类型,高度 36vp,水平排列在 Row 中。每个按钮的显示由药水状态字段控制,已使用的药水按钮不渲染。如果当夜有人被狼人击杀,则在按钮上方显示红色被杀提示。

守卫回合(GuardTurnUI)

守卫回合的 Grid 展示全部存活玩家(包括守卫自身),这是唯一允许选择自身的夜晚角色 UI。选中色为橙色(#FF9800),高度 160vp。守卫回合是夜晚的最后一个角色回合,之后即进入天亮结算。

4.3 非当前角色的等待 UI

当夜晚阶段不属于当前玩家的角色回合时,UI 仅显示通用的等待提示。每个回合 Builder 中通过 isMyRole() 判断后,非当前角色玩家看到的界面结构如下:

Column {
  Text(phaseTitle)        // "狼人回合" / "预言家回合" / ...
  Text(phaseDesc)         // "XX正在行动...请等待"
}

phaseDesc 的内容在 enterWolfPhase()enterSeerPhase()enterWitchPhase()enterGuardPhase() 中根据角色判断动态设置:

const me = this.players.find((p: WerewolfPlayer) => p.id === this.myId)
if (me !== undefined && me.role === WerewolfRole.WEREWOLF && me.isAlive) {
  this.phaseDesc = '选择你要击杀的目标'
} else {
  this.phaseDesc = '狼人正在行动...请等待'
}

等待 UI 使用灰色字体(#CCCCCC),字号 16fp,位于白色标题文字下方 8vp 处。整个 Column 居中对齐,使用 FlexAlign.CenterHorizontalAlign.Center。背景色与夜晚氛围一致(#1a1a2e),通过 build() 方法根 Column 的 backgroundColor 条件表达式控制。

4.4 角色回合切换时序

夜晚各角色回合通过 setTimeout 链式调度,每个回合持续 5 秒:

NIGHT_START (2s) → WOLF_TURN (5s) → SEER_TURN (5s) → WITCH_TURN (5s) → GUARD_TURN (5s) → NIGHT_RESULT

每次切换时,selectedTarget 被重置为空字符串,确保上一个回合的选择不会影响下一个回合。切换逻辑在 enterXxxPhase() 方法中实现,每个方法先设置 phasephaseTitle,再根据角色判断设置 phaseDesc,最后启动 setTimeout 进入下一阶段。


5. 投票系统

5.1 投票流程概述

投票界面

投票是白天阶段的核心环节,发生在讨论结束后。所有存活玩家需要投票选择一名玩家放逐,得票最多的玩家被放逐出局。投票阶段由 WerewolfPhase.DAY_VOTE 表示,持续 10 秒(voteTimer 初始值为 10,每秒递减)。

5.2 选择目标 UI

投票界面(VoteUI() Builder)使用 List 组件展示所有存活的其他玩家(过滤条件 p.id !== this.myId,排除自身)。每个 ListItem 包含一个 Row,左侧显示玩家头像(fontSize 32)和昵称(fontSize 16),右侧显示选中标记。

@Builder
VoteUI() {
  Column() {
    Row() {
      Text('投票放逐').fontSize(18).fontWeight(FontWeight.Bold)
      Blank()
      Text(`${this.voteTimer}s`)
        .fontSize(16)
        .fontColor(this.voteTimer < 10 ? '#F44336' : '#999999')
    }
    .width('100%').padding({ left: 16, right: 16 })

    List({ space: 8 }) {
      ForEach(this.getAlivePlayers().filter(
        (p: WerewolfPlayer) => p.id !== this.myId),
        (player: WerewolfPlayer) => {
          ListItem() {
            Row() {
              Text(player.avatar).fontSize(32)
              Text(player.nickname).fontSize(16).margin({ left: 12 }).layoutWeight(1)
              if (this.selectedTarget === player.id) {
                Text('✓').fontSize(20).fontColor('#FF6B35')
              }
            }
            .padding(12)
            .backgroundColor(this.selectedTarget === player.id ? '#FFF3E0' : Color.White)
            .borderRadius(8)
            .onClick(() => { this.selectTarget(player.id) })
          }
        }, (player: WerewolfPlayer) => player.id)
    }
    .width('100%').layoutWeight(1).padding({ left: 16, right: 16 })

    Button('确认投票')
      .fontSize(16).height(44).type(ButtonType.Capsule)
      .backgroundColor('#FF6B35').fontColor(Color.White).width('90%')
      .margin({ bottom: 16 })
      .enabled(this.selectedTarget !== '')
      .onClick(() => { this.showVoteResult() })
  }
  .width('100%').layoutWeight(1)
}

5.3 selectedTarget 高亮机制

投票界面的高亮机制通过 selectedTarget 状态变量实现。当玩家点击某个玩家 ListItem 时,selectTarget(player.id) 更新 selectedTarget,触发 UI 重新渲染。选中的 ListItem 具有两处视觉变化:

  1. 背景色变化:从白色(Color.White)变为浅橙色(#FFF3E0),形成柔和的选中反馈
  2. 选中标记:在 Row 右侧出现橙色对勾"✓"(#FF6B35,fontSize 20),通过条件渲染 if (this.selectedTarget === player.id) 控制

这种双重高亮设计确保了投票选择的清晰可见性。由于 selectedTarget@State 变量,ArkUI 框架会自动追踪依赖关系,仅重新渲染受影响的 ListItem,而非整个 List。

5.4 确认投票

"确认投票"按钮位于界面底部,宽度占屏幕 90%,高度 44vp,使用橙色胶囊按钮样式。按钮的 enabled 属性绑定到 this.selectedTarget !== '',即只有在选中了投票目标后才可点击。未选中目标时按钮呈灰色不可交互状态,防止误操作。

点击确认后调用 showVoteResult() 方法,该方法执行以下逻辑:

  1. phase 切换到 VOTE_RESULT
  2. 如果 selectedTarget 不为空,查找目标玩家并标记为死亡(target.isAlive = false),显示"XXX 被投票放逐"
  3. 如果 selectedTarget 为空(玩家未投票),显示"平票,无人被放逐"
  4. 检查游戏结束条件:狼人全灭则好人胜,狼人数量大于等于好人数则狼人胜
  5. 如果游戏未结束,3 秒后进入下一个夜晚

5.5 投票计时器

投票阶段有 10 秒时间限制,由 voteTimer 状态变量和 setInterval 定时器控制:

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

计时器显示在界面右上角,当剩余时间少于 10 秒时变为红色(#F44336),提示玩家尽快做出选择。计时器归零时自动调用 showVoteResult(),未投票的玩家按弃权处理。

5.6 平票处理

当前实现中,平票处理逻辑较为简化。showVoteResult() 中,如果 selectedTarget 为空字符串,则显示"平票,无人被放逐"。但在多人在线场景中,平票判定需要统计所有玩家的投票结果,通过 WerewolfVoteResult 模型的 isTie 字段标识:

export class WerewolfVoteResult {
  targetId: string = ''
  targetName: string = ''
  voteCounts: Record<string, number> = {}
  isTie: boolean = false
}

voteCounts 使用 Record<string, number> 类型存储每个被投票玩家的得票数。isTie 标识是否存在平票情况。在完整的多人实现中,平票处理可能有多种策略:直接平票无人出局(当前策略)、平票加时投票、平票时随机放逐等。当前版本采用"平票无人出局"策略,更偏向好人阵营利益。


6. VoiceInput 语音发言

6.1 语音发言架构

狼人杀游戏支持语音发言功能,使用 HarmonyOS 的语音识别能力。语音输入的架构分为三层:

  1. UI 层:🎤 按钮组件(DiscussUI 中的 Button)
  2. 辅助层VoiceInputHelper 类(非 VoiceInput @Component)
  3. 处理层handleVoiceResult() 回调方法

6.2 VoiceInputHelper 类

VoiceInputHelper 是一个非 UI 的辅助类,封装了语音识别的启动和销毁逻辑。在 WerewolfGame 中以私有属性方式实例化:

private voiceHelper: VoiceInputHelper = new VoiceInputHelper()

注意这里使用的是 VoiceInputHelper 而非 VoiceInput @Component。VoiceInput 组件在 DiscussUI 中也有一处引用(VoiceInput({ canSpeak: ... })),但主要的语音处理流程通过 voiceHelper 完成。

6.3 语音按钮交互

语音按钮位于聊天输入区域,是一个圆形按钮(ButtonType.Circle),直径 36vp:

Button('🎤')
  .fontSize(16)
  .width(36).height(36)
  .type(ButtonType.Circle)
  .backgroundColor(this.currentSpeaker === this.myId ? '#FF6B35' : '#BDBDBD')
  .fontColor(Color.White)
  .margin({ left: 4 })
  .enabled(this.currentSpeaker === this.myId)
  .onClick(() => {
    this.voiceHelper.startListening((text: string) => {
      this.handleVoiceResult(text)
    })
  })

按钮设计要点:

  • 颜色区分:当前发言人为自己时橙色(#FF6B35),否则灰色(#BDBDBD
  • 启用控制:仅当 currentSpeaker === this.myId 时可点击
  • 点击行为:调用 voiceHelper.startListening() 启动语音识别,传入回调函数

6.4 handleVoiceResult 回调

语音识别结果通过 handleVoiceResult() 方法处理:

handleVoiceResult(text: string): void {
  if (text.trim() !== '') {
    const msg = ChatMessage.of(this.currentSpeakerName, text.trim())
    this.chatMessages = [...this.chatMessages, msg]
  }
}

处理逻辑:

  1. 检查语音识别文本是否为非空(text.trim() !== ''
  2. 使用 ChatMessage.of() 工厂方法创建消息对象,发送者为 currentSpeakerName
  3. 将新消息追加到 chatMessages 数组(使用展开运算符创建新数组引用,触发 UI 更新)

6.5 ChatMessage 模型

ChatMessage 是讨论阶段的聊天消息模型,定义在 WerewolfGame.ets 底部:

class ChatMessage {
  sender: string = ''
  content: string = ''

  static of(sender: string, content: string): ChatMessage {
    const m = new ChatMessage()
    m.sender = sender; m.content = content
    return m
  }
}

使用静态工厂方法 of() 创建实例,符合 ArkTS 的类实例化规范。sender 存储发言者昵称,content 存储消息文本内容。

6.6 语音发言流程图

[🎤 按钮] ──点击──> [voiceHelper.startListening()]
                           │
                           ▼
                    [HarmonyOS 语音识别引擎]
                           │
                           ▼
                    [识别结果回调 text: string]
                           │
                           ▼
                    [handleVoiceResult(text)]
                           │
                     text.trim() !== '' ?
                    /                    \
                  是                      否
                   │                      │
                   ▼                     忽略
          [ChatMessage.of(sender, text)]
                   │
                   ▼
          [chatMessages = [...chatMessages, msg]]
                   │
                   ▼
          [UI 自动刷新 List 显示新消息]

6.7 语音与文字双通道

讨论 UI 同时支持语音和文字两种发言方式。文字输入通过 TextInput 组件实现,当输入框有内容时动态显示"发送"按钮:

TextInput({ placeholder: this.currentSpeaker === this.myId ? '发言...' : '未轮到你', text: this.chatInput })
  .layoutWeight(1)
  .enabled(this.currentSpeaker === this.myId)
  .onChange((value: string) => { this.chatInput = value })

两种通道共享同一个 chatMessages 数组,消息展示方式完全一致。文字消息通过 sendChat() 方法发送:

sendChat(): void {
  if (this.chatInput.trim() !== '') {
    const me = this.players.find((p: WerewolfPlayer) => p.id === this.myId)
    const msg = ChatMessage.of(me?.nickname ?? '我', this.chatInput.trim())
    this.chatMessages = [...this.chatMessages, msg]
    this.chatInput = ''
  }
}

6.8 VoiceInputHelper 生命周期

VoiceInputHelperdestroy() 方法在组件销毁时调用:

aboutToDisappear(): void {
  if (this.speakerTimerId !== -1) {
    clearInterval(this.speakerTimerId)
  }
  this.voiceHelper.destroy()
}

这确保了语音识别资源在组件生命周期结束时被正确释放,避免内存泄漏和后台持续录音等问题。


7. 发言顺序 UI

7.1 发言顺序机制

白天讨论阶段(WerewolfPhase.DAY_DISCUSS)采用顺序发言制:所有存活玩家按固定顺序依次发言,每人有 30 秒发言时间。发言顺序由 speakerOrderIndex 状态变量追踪,从 0 开始递增。

7.2 玩家横排 ForEach 实现

发言顺序指示器使用 Row + ForEach 组件实现,将所有存活玩家横向排列:

Row() {
  ForEach(this.getAlivePlayers(), (player: WerewolfPlayer, idx: number) => {
    Column() {
      Text(player.avatar)
        .fontSize(20)
      Text(player.nickname)
        .fontSize(9)
        .maxLines(1)
    }
    .padding(4)
    .borderRadius(8)
    .backgroundColor(
      idx < this.speakerOrderIndex ? '#E8F5E9' :   // 已发言:绿色
      (idx === this.speakerOrderIndex ? '#FFF3E0' :  // 当前发言:橙色背景
       Color.White)                                    // 待发言:白色
    )
    .border({
      width: idx === this.speakerOrderIndex ? 2 : 0,
      color: '#FF6B35'
    })
  }, (player: WerewolfPlayer) => player.id)
}
.width('100%')
.padding({ left: 8, right: 8 })
.backgroundColor(Color.White)

7.3 三态视觉区分

每个玩家卡片根据发言状态呈现三种不同的视觉效果:

已发言(idx < speakerOrderIndex)

  • 背景色:浅绿色(#E8F5E9
  • 无边框
  • 含义:该玩家已完成发言,其他玩家可以参考其发言内容

当前发言(idx === speakerOrderIndex)

  • 背景色:浅橙色(#FFF3E0
  • 边框:2px 橙色边框(#FF6B35
  • 含义:该玩家正在发言,界面下方显示其发言输入区域

待发言(idx > speakerOrderIndex)

  • 背景色:白色(Color.White
  • 无边框
  • 含义:该玩家尚未发言,需要等待轮次

7.4 发言计时器

当前发言人的剩余时间显示在讨论界面顶部的信息栏中:

Row() {
  Text(`${this.currentSpeakerName} 发言`)
    .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF6B35')
  Blank()
  Text(`${this.speakerTimer}s`)
    .fontSize(20).fontWeight(FontWeight.Bold)
    .fontColor(this.speakerTimer < 10 ? '#F44336' : '#333333')
}
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor('#FFF3E0')

计时器使用 20fp 粗体字显示,当剩余时间少于 10 秒时变为红色(#F44336),形成紧迫感提示。整个信息栏使用浅橙色背景(#FFF3E0),与当前发言人的边框颜色呼应。

7.5 发言轮转逻辑

发言轮转由 startPlayerSpeech() 方法控制:

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

轮转逻辑要点:

  1. 清理旧定时器:每次启动新发言前先清理上一个定时器(speakerTimerId !== -1
  2. 终止条件:当 speakerOrderIndex >= alive.length 时,所有玩家已发言完毕,进入投票阶段
  3. 递归调用:定时器归零时,speakerOrderIndex++ 后递归调用 startPlayerSpeech() 启动下一位发言
  4. 状态更新:设置 currentSpeaker(ID)、currentSpeakerName(昵称)、speakerTimer(30 秒)

7.6 发言输入区域的条件渲染

发言输入区域仅对当前发言人可见:

if (this.currentSpeaker === this.myId) {
  VoiceInput({ canSpeak: this.phase === WerewolfPhase.DAY_DISCUSS && this.currentSpeaker === this.myId })
}

非当前发言人的输入框显示"未轮到你"占位文字,且 .enabled(false) 禁止输入。


8. GameChatPanel canSpeak 逻辑

8.1 canSpeak 参数定义

GameChatPanel 组件接收 canSpeak 布尔参数,控制聊天面板的发言权限。在 WerewolfGamebuild() 方法中,canSpeak 的绑定表达式为:

GameChatPanel({
  canSpeak: this.phase === WerewolfPhase.DAY_DISCUSS && this.currentSpeaker === this.myId
})

8.2 双重条件解析

canSpeak 的值由两个条件的逻辑与(&&)决定:

条件一:this.phase === WerewolfPhase.DAY_DISCUSS

确保只有在白天讨论阶段才允许发言。夜晚阶段(WOLF_TURN、SEER_TURN 等)、投票阶段(DAY_VOTE)、结果阶段(VOTE_RESULT、GAME_OVER)均不允许通过聊天面板发言。这一限制防止了在不当阶段泄露信息或干扰游戏流程。

条件二:this.currentSpeaker === this.myId

确保只有当前轮到发言的玩家才能发言。顺序发言制要求每个玩家在自己轮次内发言,其他玩家只能观看。这一限制保证了发言的有序性,避免了多人同时发言造成的混乱。

8.3 canSpeak 的传播链

canSpeak 参数的影响传播路径如下:

WerewolfGame.build()
  └─> GameChatPanel({ canSpeak: DAY_DISCUSS && currentSpeaker === myId })
        └─> TextInput.enabled(canSpeak)
        └─> Button('🎤').enabled(canSpeak)
        └─> Button('发送').visibility(canSpeak && chatInput !== '')

canSpeakfalse 时:

  • TextInput 禁用,显示灰色占位文字"未轮到你"
  • 🎤 语音按钮灰色不可点击(#BDBDBD
  • 发送按钮不显示

canSpeaktrue 时:

  • TextInput 启用,占位文字为"发言…"
  • 🎤 语音按钮橙色可点击(#FF6B35
  • 输入内容后显示发送按钮

8.4 与讨论 UI 内发言区域的区别

注意 GameChatPanelcanSpeakDiscussUI 内的发言区域是两个独立的控制点。DiscussUI 内直接嵌入了语音按钮和文本输入,通过 this.currentSpeaker === this.myId 条件控制启用状态。GameChatPanel 则是通用聊天面板组件,其 canSpeak 参数同时考虑了阶段和发言人两个条件。两者在功能上存在部分重叠,但在代码组织上分别处理:DiscussUI 负责发言交互区域,GameChatPanel 负责消息列表展示和辅助输入。


9. 游戏结束展示

9.1 结束条件判定

游戏结束条件在 showVoteResult() 方法中判定:

const wolves = this.players.filter(
  (p: WerewolfPlayer) => p.role === WerewolfRole.WEREWOLF && p.isAlive
)
const alive = this.players.filter((p: WerewolfPlayer) => p.isAlive)
if (wolves.length === 0) {
  // 好人阵营获胜
} else if (wolves.length >= alive.length - wolves.length) {
  // 狼人阵营获胜
} else {
  // 游戏继续,进入下一个夜晚
}

两种胜利条件:

  • 好人阵营:所有狼人被消灭(wolves.length === 0
  • 狼人阵营:存活狼人数量大于等于好人数量(wolves.length >= alive.length - wolves.length

9.2 身份揭晓 Grid

游戏结束后,GameOverUI() Builder 展示所有玩家的真实身份。使用 Grid 组件以四列布局排列所有玩家(包括已死亡玩家),每个 GridItem 显示三行信息:

Grid() {
  ForEach(this.players, (player: WerewolfPlayer) => {
    GridItem() {
      Column() {
        Text(player.avatar)
          .fontSize(32)
        Text(player.nickname)
          .fontSize(12)
          .margin({ top: 4 })
        Text(`${getRoleIcon(player.role)} ${getRoleName(player.role)}`)
          .fontSize(11)
          .fontColor(player.role === WerewolfRole.WEREWOLF ? '#F44336' : '#4CAF50')
          .margin({ top: 2 })
      }
      .padding(8)
    }
  }, (player: WerewolfPlayer) => player.id)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.width('90%')
.height(200)
.margin({ top: 8 })

身份揭晓的视觉设计要点:

  1. 角色图标+名称:使用 getRoleIcon()getRoleName() 映射函数,与角色分配阶段保持一致
  2. 颜色区分阵营:狼人角色显示红色(#F44336),好人角色显示绿色(#4CAF50
  3. 包含所有玩家:不区分存活与死亡,展示完整的身份信息

9.3 胜负展示

游戏结束界面顶部显示胜负结果:

  • 好人阵营获胜:显示"🎉"图标和"好人阵营获胜!所有狼人已被消灭"
  • 狼人阵营获胜:显示"🐺"图标和"狼人阵营获胜!狼人已占领村庄"

胜负结果由 winner 状态变量存储,在界面中通过条件表达式 this.winner === '好人阵营' ? '🎉' : '🐺' 选择顶部图标。

9.4 再来一局 / 返回大厅

游戏结束界面底部提供两个操作按钮,使用 Row 布局水平排列:

Row() {
  Button('再来一局')
    .fontSize(14)
    .layoutWeight(1)
    .height(44)
    .type(ButtonType.Capsule)
    .backgroundColor('#FF6B35')
    .fontColor(Color.White)
  Button('返回大厅')
    .fontSize(14)
    .layoutWeight(1)
    .height(44)
    .type(ButtonType.Capsule)
    .backgroundColor('#EEEEEE')
    .fontColor('#333333')
    .margin({ left: 12 })
    .onClick(() => { this.getUIContext().getRouter().back() })
}
.width('90%')
.margin({ top: 24 })

"再来一局"按钮使用橙色主色调,layoutWeight(1) 使两个按钮等宽。"返回大厅"按钮使用灰色,点击后调用 router.back() 返回上一页。当前版本中"再来一局"按钮尚未绑定 onClick 事件,需要在后续版本中实现重置游戏状态的逻辑(重新调用 startRoleReveal() 并重置所有状态变量)。


10. 边界情况与未来扩展

10.1 猎人开枪

当前状态:猎人角色枚举已定义(HUNTER = 4),但开枪技能尚未实现。

实现方案

需要在 WerewolfPhase 枚举中新增 HUNTER_SHOOT = 11 阶段。触发点在两个位置:

  1. 夜晚死亡showNightResult() 中检测死亡玩家是否为猎人
  2. 投票放逐showVoteResult() 中检测被放逐玩家是否为猎人

触发后的流程:

[玩家死亡] → 检测是否为猎人 → 是 → 进入 HUNTER_SHOOT 阶段
                                    → 否 → 正常流程继续

需要修改的代码位置:

  • WerewolfModel.ets:新增 HUNTER_SHOOT 枚举值
  • showNightResult():猎人死亡检测与阶段跳转
  • showVoteResult():猎人死亡检测与阶段跳转
  • build():新增 HUNTER_SHOOT 阶段的 UI 分支
  • 新增 HunterShootUI() Builder

需要注意的边界情况:

  • 猎人被女巫毒杀时是否可以开枪(不同规则版本有差异)
  • 猎人开枪击杀的玩家如果是猎人,是否触发连锁开枪(通常不允许)
  • 猎人开枪后需要重新检查游戏结束条件

10.2 白痴翻牌

当前状态:白痴角色尚未在 WerewolfRole 枚举中定义,当前以 VILLAGER 充当。

实现方案

  1. WerewolfRole 枚举中新增 IDIOT = 6
  2. getRoleNamegetRoleIcon 映射中新增条目(图标建议使用 🃏)
  3. WerewolfPlayer 中新增 canVote: boolean = true 字段
  4. WerewolfPhase 枚举中新增 IDIOT_REVEAL = 12 阶段
  5. showVoteResult() 中检测被放逐者是否为白痴

翻牌流程:

[投票放逐目标] → 检测是否为白痴
                    → 是 → 弹出翻牌确认 → 确认翻牌
                         → canVote = false
                         → isAlive 保持 true
                         → 显示"XX 是白痴,翻牌免死!"
                    → 否 → 正常放逐

白痴翻牌后的特殊规则:

  • 翻牌后保留存活状态,但失去投票权
  • 翻牌后仍然可以参与白天讨论发言
  • 翻牌后的白痴不能被再次投票放逐(已公开身份,投票无意义)
  • 需要在 VoteUI() 中过滤掉已翻牌的白痴

10.3 守卫连续守护限制

当前状态lastGuardTarget 字段已在 WerewolfNightAction 中定义,但 UI 层未实现过滤。

实现方案

GuardTurnUI() 的 Grid 中,对 lastGuardTarget 对应的玩家卡片进行视觉标记和交互限制:

ForEach(this.getAlivePlayers(), (player: WerewolfPlayer) => {
  GridItem() {
    Column() {
      Text(player.avatar).fontSize(32)
      Text(player.nickname).fontSize(12).fontColor(Color.White).margin({ top: 4 })
      if (player.id === this.nightAction.lastGuardTarget) {
        Text('昨晚已守').fontSize(10).fontColor('#FF4444').margin({ top: 2 })
      }
    }
    .padding(8).borderRadius(8)
    .backgroundColor(
      player.id === this.nightAction.lastGuardTarget ? '#222222' :
      (this.selectedTarget === player.id ? '#FF9800' : '#333355')
    )
    .opacity(player.id === this.nightAction.lastGuardTarget ? 0.5 : 1)
    .onClick(() => {
      if (player.id !== this.nightAction.lastGuardTarget) {
        this.selectTarget(player.id)
      }
    })
  }
}, (player: WerewolfPlayer) => player.id)

关键改动:

  1. lastGuardTarget 玩家添加"昨晚已守"标签
  2. 背景色使用更深的灰色(#222222)标记不可选
  3. 透明度降低至 0.5,视觉上区分
  4. onClick 中增加条件判断,禁止选择不可选目标

10.4 "同守同救必死"规则

当前状态:未实现。

当守卫守护的玩家同时被女巫使用解药时,按照标准规则该玩家应当死亡。实现此规则需要修改夜晚结算算法,在判定逻辑中增加双重保护的反效果判断:

if guardTarget === wolfTarget && witchSaveTarget === wolfTarget then
  // 同守同救,该玩家死亡
  死亡名单.append(wolfTarget)
end if

10.5 其他待实现的边界情况

女巫自救限制:部分规则中,女巫第一晚可以使用解药自救,但后续夜晚不能自救。需要在 WitchTurnUI() 中根据夜晚序号(dayNumber)控制自救按钮的显示。

女巫同晚双药限制:标准规则中女巫同一晚不能同时使用解药和毒药。当前实现中两个按钮同时显示,需要添加互斥逻辑:使用解药后隐藏毒药按钮,反之亦然。

空场判定:当所有好人均死亡但狼人仍有存活时,游戏应立即结束。当前仅在投票结束后检查,需要在夜晚结算后也增加检查。

断线重连:玩家网络中断后重新连接,需要恢复当前游戏状态。这需要将所有 @State 变量持久化到 AppStorage 或远程服务,并在 aboutToAppear() 中恢复。

角色死亡后的界面处理:当前实现中,死亡玩家的夜晚回合仍然显示"XX正在行动…请等待"。理想情况下,死亡玩家应看到独立的观战界面,只能观看不能操作。需要在 isMyRole() 之前增加 isAlive 检查。

多人在线同步:当前实现为单机模拟模式。多人在线需要通过 WebSocket 实现状态同步,服务端负责权威状态管理,客户端仅负责 UI 渲染和用户输入收集。所有 setTimeout 驱动的阶段切换需要改为服务端推送模式。

Logo

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

更多推荐