真心话大冒险:众包出题与回合制

一、游戏概述与社交心理学

真心话大冒险(TruthOrDare)是NearPlay中最具社交深度的游戏。与狼人杀的信息博弈、你画我猜的创意互动不同,真心话大冒险直接触及个人隐私和勇气边界——选择真心话要坦诚回答隐私问题,选择大冒险要执行挑战任务。这种"逼迫式"的社交互动在陌生人中尤其有效,能快速拉近人际距离。

1.1 社交心理学基础

真心话大冒险之所以在派对游戏中经久不衰,根植于三个核心的社交心理学机制。

第一,隐私披露的互惠效应。 社会心理学家阿特曼(Altman)的隐私调节理论指出,人际亲密度与自我披露深度呈正相关。当一个人在游戏中被迫披露隐私信息时,这种披露会触发观察者的"互惠披露"心理——看到他人坦诚,自己也会倾向于敞开心扉。真心话大冒险正是利用了这种心理机制:每一轮的隐私披露都在为下一轮的开放氛围铺路,形成正向循环。游戏进行到第三轮、第四轮时,玩家之间的心理防线已经大幅降低,话题的深度和真实性也会自然升级。在NearPlay的线上场景中,这种效应被进一步放大——屏幕的"半匿名性"让玩家在披露隐私时心理负担更低,而语音回答又保留了情感的即时性和真实感,两者的结合创造出一种介于完全匿名和面对面之间的独特社交温度。

第二,选择悖论与自主感。 尽管真心话大冒险看似是"被迫"披露,但被问者始终拥有选择权——选真心话还是大冒险。这种有限自主性(bounded autonomy)比完全强迫更容易让人接受:心理学实验证明,当人们在两个不理想选项中选择一个时,对结果的满意度显著高于被动接受相同结果。在游戏中,即使真心话很难回答、大冒险很丢脸,但因为"是我自己选的",玩家的抗拒心理会大幅降低。更微妙的是,选择本身也是社交信号——选真心话暗示"我愿意坦诚",选大冒险暗示"我不怕挑战",这两种信号都会影响其他玩家对你的印象构建,为后续社交互动提供线索。

第三,压力下的真实性。 当人在时间压力下做出回应时,理性审查能力下降,更容易暴露真实想法。真心话大冒险的60秒回答计时和8秒众包出题都在制造这种时间压力。认知心理学的双系统理论(System 1 vs System 2)可以解释这一现象:快系统(直觉、情感)在压力下占主导,慢系统(理性、自我保护)被抑制,因此回答更加真实和未经修饰。这也是为什么语音回答比文字回答更有社交价值——语音的语调、停顿、犹豫都是快系统的直接表达,很难被慢系统刻意伪装。

1.2 线上真心话的特殊挑战

将线下经典派对游戏搬到线上,真心话大冒险面临独特挑战。线下版本依赖物理在场——大冒险可以要求"拥抱左边的人"、“在阳台上大喊”,这些动作在物理空间中自然执行。线上版本必须重新定义大冒险的边界:什么样的挑战在视频/语音环境中依然有意义?答案是:涉及数字身份的挑战(发消息、发朋友圈)、表演性挑战(唱歌、模仿、做表情)、以及自我揭露式挑战(展示手机屏幕、念出聊天记录)。

NearPlay选择众包出题而非预设题库,正是为了解决这一挑战。预设题库中的题目是线下设计的,搬到线上后很多大冒险变得无法执行;而众包出题让同一场游戏的玩家根据当前环境(线上还是线下、彼此熟悉程度、设备能力)即时创作题目,天然适配当前场景。这种"环境自适应"是预设题库无法实现的。

1.3 游戏特色

NearPlay的真心话大冒险有独特的众包出题机制:不是预设题库随机抽题,而是让其他玩家现场出题。这确保了题目的针对性——了解你的人可以出更具挑战性的问题,陌生人可以出更天马行空的大冒险。众包出题8秒倒计时,时间紧迫增加了刺激感。

语音回答:回答真心话或描述大冒险执行情况时,可以使用语音输入。语音模式比打字更自然,也更真实——说话时的犹豫、停顿本身就是社交信息。

回合轮转:每个玩家轮流成为"被问者",选择真心话或大冒险,其他人出题,被问者回答,然后轮转下一位。

1.4 核心数据流

┌────────────┐    ┌──────────────┐    ┌──────────────┐    ┌──────────────┐    ┌────────┐
│ TURN_START │───▶│ CHOOSE_TYPE  │───▶│ CROWD_SOURCE │───▶│ SHOW_QUESTION│───▶│ RESPOND│
│ 1.5s提示   │    │ 选择真心/冒险│    │ 8s众包出题   │    │ 60s回答计时  │    │ 2s过渡 │
└────────────┘    └──────────────┘    └──────────────┘    └──────────────┘    └───┬────┘
                                                                            │
                                                                      ┌─────▼──────┐
                                                                      │ 下一位TURN  │
                                                                      │ _START     │
                                                                      └────────────┘

二、TruthOrDareModel完整解读

2.1 TruthOrDarePlayer数据模型

class TruthOrDarePlayer {
  id: string = ''
  nickname: string = ''
  avatar: string = ''
  isCurrentTurn: boolean = false
  choiceType: ChoiceType = ChoiceType.NONE
  question: string = ''
  hasAnswered: boolean = false

  static of(id: string, nick: string, avatar: string): TruthOrDarePlayer {
    const p = new TruthOrDarePlayer()
    p.id = id; p.nickname = nick; p.avatar = avatar
    return p
  }
}

TruthOrDarePlayer是一个8字段的数据类,比WerewolfPlayer简单得多——没有role(角色)和isAlive(存活状态)字段,因为真心话大冒险没有淘汰机制,所有玩家从始至终参与每一轮。但模型中预留了isCurrentTurn、choiceType、question和hasAnswered四个运行时字段,用于追踪每个玩家的回合状态。

id:玩家唯一标识,用于匹配当前回合(current.id === this.myId判断isMyTurn)和题目标注(QuestionSubmission.fromUserId)。

nickname:显示用昵称,出现在回合提示(${current.nickname} 的回合)、题目提交(sub.fromNickname)、以及头像栏中。

avatar:emoji头像字符,用于头像栏的Text渲染(Text(player.avatar).fontSize(28))。NearPlay统一使用emoji而非图片URL作为头像,简化了资源加载和显示逻辑。

isCurrentTurn:标记该玩家是否是当前被问者。这个字段在Model中定义但在当前Game页面的实际逻辑中并未使用——Game页面通过currentTurnIndex % players.length计算当前玩家,而非遍历players检查isCurrentTurn。这个字段是为未来WebSocket同步预留的:当游戏状态需要广播给所有客户端时,每个客户端可以直接从Player对象读取isCurrentTurn,而不需要重复计算。

choiceType:记录该玩家在当前回合选择了真心话还是大冒险。与isCurrentTurn类似,这个字段在当前MVP中未直接使用(Game页面用selectedType追踪),但为未来多客户端同步和回合历史记录提供数据基础。

question:该玩家被问到的题目。同样为未来扩展预留——当前MVP中题目通过currentQuestion字段追踪,但多客户端场景下每个玩家需要知道自己被问的题目。

hasAnswered:该玩家是否已回答。用于未来统计和UI显示——例如在头像栏中给已回答的玩家加✅标记。

static of()工厂方法:TruthOrDarePlayer使用与NearPlay其他Model一致的工厂模式创建实例。of()方法接收三个必填参数(id/nick/avatar),返回一个初始化好的Player对象。isCurrentTurn、choiceType、question、hasAnswered保持默认值(false/NONE/‘’/false),在游戏运行中逐步更新。

2.2 ChoiceType枚举

enum ChoiceType {
  NONE = 0,    // 未选择
  TRUTH = 1,   // 真心话
  DARE = 2     // 大冒险
}

三个枚举值对应三种状态。NONE作为初始状态,表示被问者尚未做出选择。这个设计看似简单,实际上解决了两个关键问题:

第一,类型安全。 如果不使用枚举而使用boolean(如isTruth: boolean),无法表达"尚未选择"这一状态——boolean只有true/false,没有"未决定"。NONE枚举值精确表达了"选择尚未发生"的语义,避免了使用undefined或null带来的空安全问题。

第二,类型一致性。 ChoiceType同时用于selectedType(Game页面的当前选择类型)和QuestionSubmission.type(题目提交的类型),确保两者的类型系统统一。collectQuestions()中的filter操作直接比较q.type === this.selectedType,枚举值的严格相等比较比字符串比较更安全、更高效。

枚举值的数字赋值(0/1/2)保证了序列化兼容性。当游戏状态通过WebSocket传输时,枚举值会被序列化为数字,接收方可以正确反序列化。如果使用字符串枚举,序列化体积更大,且容易因大小写或空格差异导致匹配失败。

2.3 TruthOrDarePhase五阶段枚举

enum TruthOrDarePhase {
  TURN_START = 0,
  CHOOSE_TYPE = 1,
  CROWD_SOURCE = 2,
  SHOW_QUESTION = 3,
  RESPOND = 4
}

五个阶段严格定义了游戏的状态机。每个阶段有明确的持续时间、UI展示、可执行操作和转换条件。与狼人杀的10+阶段相比,真心话大冒险只有5个阶段,状态机更简洁。这是因为真心话大冒险的每轮流程是线性的——从提示→选择→出题→回答→过渡,没有分支和并行状态。

值得注意的是,TruthOrDarePhase没有GAME_OVER阶段。游戏设计为无限轮转——只要玩家愿意,可以一直玩下去。这符合真心话大冒险的派对性质:没有"胜利"或"失败",只有"继续"或"离开"。

2.4 QuestionSubmission四字段模型

class QuestionSubmission {
  fromUserId: string = ''
  fromNickname: string = ''
  type: ChoiceType = ChoiceType.TRUTH
  content: string = ''

  static of(uid: string, nick: string, type: ChoiceType, content: string): QuestionSubmission {
    const q = new QuestionSubmission()
    q.fromUserId = uid; q.fromNickname = nick; q.type = type; q.content = content
    return q
  }
}

四字段模型是众包出题的数据基础。每条提交记录同时包含"谁出的题"(fromUserId/fromNickname)、“什么类型的题”(type)和"题目内容"(content)。

fromUserId:提交者的玩家ID,用于去重和未来功能(如"同一提交者只能出一道题"限制)。当前MVP允许同一玩家多次提交,fromUserId配合idx作为ForEach的key:${sub.fromUserId}_${idx}

fromNickname:提交者昵称,纯显示用途。存储昵称而非仅存储ID是为了避免每次渲染时遍历players数组查找昵称,这是一种空间换时间的优化。

type:题目类型,默认值为TRUTH(非NONE)。这个默认值选择有深意——QuestionSubmission只在CROWD_SOURCE阶段创建,此时selectedType已经被chooseType()设置为TRUTH或DARE,submitQuestion()传入的type参数就是当前selectedType,所以QuestionSubmission的type永远不会是NONE。默认值设为TRUTH是防御性的——即使type字段未被正确初始化,至少不会是NONE导致filter时被遗漏。

content:题目正文,submitQuestion()中trim()后传入,确保无前后空格。

static of()工厂方法:四个参数全部必填,没有可选字段,创建时必须提供完整信息。这保证了QuestionSubmission实例的完整性——不存在"匿名"或"无内容"的提交。

2.5 MockTruthOrDareData

class MockTruthOrDareData {
  static getTruthQuestions(): string[] {
    return [
      '你最近一次说谎是什么时候?',
      '你最害怕的事情是什么?',
      '你手机里最不想被人看到的APP是什么?',
      '你做过最后悔的一件事是什么?',
      '你觉得在座谁最好看?',
      '你的择偶标准是什么?',
    ]
  }

  static getDareQuestions(): string[] {
    return [
      '给你最近联系的人发一条"我想你了"',
      '模仿一种动物的叫声持续10秒',
      '用屁股写自己的名字',
      '做10个俯卧撑',
      '让你左边的人在你脸上画画',
      '大声唱一首歌的副歌部分',
    ]
  }

  static getPlayers(): TruthOrDarePlayer[] {
    return [
      TruthOrDarePlayer.of('u1', '小明', '👦'),
      TruthOrDarePlayer.of('u2', '阿花', '👧'),
      TruthOrDarePlayer.of('u3', '大壮', '🧑'),
      TruthOrDarePlayer.of('u4', '小美', '👩'),
    ]
  }
}

MockTruthOrDareData提供三类静态数据:真心话题库(6道)、大冒险题库(6道)和玩家列表(4人)。三个方法都是static,无需实例化即可调用,符合工具类的设计规范。

getTruthQuestions()和getDareQuestions()每次调用都返回新数组(字面量数组每次求值都创建新实例),这意味着不存在数组被外部修改导致数据污染的风险。如果未来题库变大,可以改为返回冻结数组的引用以提高性能。

getPlayers()返回4个预设玩家,ID为u1-u4。u1作为myId的默认值,意味着当前用户总是"小明",可以体验isMyTurn为true的流程。

三、6阶段详解

3.1 TURN_START(0)——回合启动

持续时间:1.5秒
UI展示:居中显示当前回合玩家昵称

startTurn(): void {
  this.phase = TruthOrDarePhase.TURN_START
  this.selectedType = ChoiceType.NONE
  this.currentQuestion = ''
  this.questionSubmissions = []
  this.mySubmission = ''
  this.answerText = ''
  const current = this.players[this.currentTurnIndex % this.players.length]
  if (current !== undefined) {
    this.phaseDesc = `${current.nickname} 的回合`
    this.isMyTurn = current.id === this.myId
  }
  setTimeout(() => { this.phase = TruthOrDarePhase.CHOOSE_TYPE }, 1500)
}

回合初始化的6个重置操作:每次startTurn()被调用时,重置6个状态字段。这确保了回合之间不会残留上一轮的数据。selectedType重置为NONE,确保被问者必须重新选择;currentQuestion清空,避免显示上一轮的题目;questionSubmissions清空,清除上一轮的出题记录;mySubmission清空,重置出题输入框;answerText清空,重置回答输入框。这种"全量重置"策略虽然简单粗暴,但彻底避免了状态泄漏——每个回合从干净的初始状态开始。

currentTurnIndex取模this.currentTurnIndex % this.players.length确保轮转不会越界。即使游戏进行多轮(currentTurnIndex超过玩家数),取模后仍然正确循环。例如4人游戏中,currentTurnIndex为5时,5%4=1,轮到第二位玩家。这种取模设计让currentTurnIndex可以永远递增,不需要在每轮结束时重置为0。

isMyTurn判断current.id === this.myId是整个游戏"角色区分"的核心——它决定了UI展示哪些交互元素。isMyTurn为true时,CHOOSE_TYPE阶段显示选择卡片;SHOW_QUESTION阶段显示回答输入框、语音按钮和完成按钮;GameChatPanel的canSpeak为true。isMyTurn为false时,这些交互元素全部隐藏,只显示旁观信息。

1.5秒的节奏设计:1.5秒是一个精心选择的时间窗口。太短(如0.5秒)会让玩家来不及注意到回合切换;太长(如3秒)会让等待变得无聊。1.5秒刚好够所有人看到"谁的回合",然后自然过渡到选择阶段。这个时间也给了所有客户端一个同步窗口——在WebSocket场景中,1.5秒的缓冲可以覆盖网络延迟差异。

TurnStartUI渲染

@Builder
TurnStartUI() {
  Column() {
    Text(this.phaseDesc)
      .fontSize(24)
      .fontWeight(FontWeight.Bold)
  }
  .width('100%')
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

UI极其简洁——一个居中的文字提示。layoutWeight(1)占据剩余空间,justifyContent(FlexAlign.Center)垂直居中。这种"留白"设计让回合提示成为唯一的视觉焦点,与后续阶段的复杂UI形成对比,创造了节奏变化。

3.2 CHOOSE_TYPE(1)——类型选择

持续时间:无限(等待被问者选择)
UI展示:两个大卡片——🤫真心话 vs 🔥大冒险

这是被问者的核心选择时刻。选择后立即进入众包出题阶段。UI设计强调二选一的仪式感——两个白色卡片左右排列,emoji图标醒目,点击任意一张进入下一阶段。

chooseType()逻辑

chooseType(type: ChoiceType): void {
  this.selectedType = type
  this.phase = TruthOrDarePhase.CROWD_SOURCE
  this.crowdSourceTimer = 8
  const timerId = setInterval(() => {
    this.crowdSourceTimer--
    if (this.crowdSourceTimer <= 0) {
      clearInterval(timerId)
      this.collectQuestions()
    }
  }, 1000)
}

选择类型后启动8秒众包出题倒计时。注意timerId是局部变量(不是类字段),因为它只在这个阶段使用,到时自动清除。这个设计与其他游戏中timerId作为类字段管理的方式不同——CROWD_SOURCE的timer只活跃8秒,不需要跨阶段管理,局部变量更简洁,也避免了忘记清除导致的泄漏。

ChooseTypeUI渲染

@Builder
ChooseTypeUI() {
  Column() {
    Text(`${this.players[this.currentTurnIndex % this.players.length]?.nickname ?? '玩家'},请选择:`)
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
    Row() {
      Column() {
        Text('🤫')
          .fontSize(48)
        Text('真心话')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .margin({ top: 8 })
      }
      .padding(24)
      .backgroundColor(Color.White)
      .borderRadius(16)
      .layoutWeight(1)
      .margin({ left: 16, right: 8 })
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => { this.chooseType(ChoiceType.TRUTH) })

      Column() {
        Text('🔥')
          .fontSize(48)
        Text('大冒险')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .margin({ top: 8 })
      }
      .padding(24)
      .backgroundColor(Color.White)
      .borderRadius(16)
      .layoutWeight(1)
      .margin({ left: 8, right: 16 })
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => { this.chooseType(ChoiceType.DARE) })
    }
    .margin({ top: 32 })
  }
  .width('100%')
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

两个选择卡片使用对称布局,各占layoutWeight(1)等宽,中间8px间距(left:8/right:8),左右16px外边距。padding 24px让卡片有足够的点击区域。白色背景+16px圆角,整洁大方。emoji图标使用48号字体,远大于文字的18号,创造强烈的视觉冲击力。这种"图大字小"的设计风格在移动端尤其有效——用户第一眼看到emoji图标就能理解选项含义,不需要阅读文字。

标题文字使用可选链操作符(?.nickname)和空值合并(?? '玩家'),这是防御性编程——虽然currentTurnIndex取模后应该总能找到玩家,但如果players数组为空(理论上的边界情况),不会崩溃而是显示"玩家,请选择:"。

无限等待的设计考量:CHOOSE_TYPE是唯一没有时间限制的阶段。为什么?因为这是被问者的核心决策时刻,施加时间压力会降低选择质量,也与游戏的"自主选择"理念冲突。如果被问者因为时间不够而随意选择,后续的出题和回答体验都会受损。无限等待尊重了被问者的决策时间,同时也暗示——你的选择很重要,值得认真想。

3.3 CROWD_SOURCE(2)——众包出题

持续时间:8秒
UI展示:出题阶段标题 + 已提交题目列表 + 出题输入框

这是众包出题的核心阶段。所有非被问者都可以提交题目,8秒倒计时结束后系统从提交的题目中随机选择一个。

众包出题的意义

  1. 个性化:其他玩家了解被问者,可以出针对性问题
  2. 参与感:所有人都在出题,不是被动等待
  3. 不可预测:被问者不知道谁出了什么题,增加紧张感
  4. 社交破冰:出题过程本身就是互动

8秒时间压力:8秒是一个经过考量的时间窗口。在4人游戏中,3个出题者每人平均只有不到3秒思考+输入时间。这种紧迫感迫使出题者凭直觉出题,而非精心设计——直觉出题往往更真实、更犀利、更有趣。8秒也足够输入一道简短题目(平均中文输入速度3-5字/秒,8秒可输入24-40字),但不够写长篇大论,这保证了题目的简洁性。

CrowdSourceUI渲染

@Builder
CrowdSourceUI() {
  Column() {
    Text(this.selectedType === ChoiceType.TRUTH ? '🤫 真心话 - 出题阶段' : '🔥 大冒险 - 出题阶段')
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
    Text(`其他人请提交题目 (${this.crowdSourceTimer}s)`)
      .fontSize(14)
      .fontColor('#666666')
      .margin({ top: 8 })

    List({ space: 8 }) {
      ForEach(this.questionSubmissions, (sub: QuestionSubmission) => {
        ListItem() {
          Row() {
            Text(`${sub.fromNickname}: `)
              .fontSize(14)
              .fontWeight(FontWeight.Medium)
            Text(sub.content)
              .fontSize(14)
              .fontColor('#666666')
          }
          .width('100%')
          .padding(8)
          .backgroundColor(Color.White)
          .borderRadius(8)
        }
      }, (sub: QuestionSubmission, idx: number) => `${sub.fromUserId}_${idx}`)
    }
    .width('100%')
    .layoutWeight(1)
    .padding({ left: 16, right: 16 })
    .margin({ top: 16 })

    Row() {
      TextInput({ placeholder: '输入你的题目', text: this.mySubmission })
        .layoutWeight(1)
        .onChange((value: string) => { this.mySubmission = value })
      Button('提交')
        .fontSize(14)
        .height(36)
        .type(ButtonType.Capsule)
        .backgroundColor('#FF6B35')
        .fontColor(Color.White)
        .margin({ left: 8 })
        .onClick(() => { this.submitQuestion() })
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 8 })
  }
  .width('100%')
  .layoutWeight(1)
}

UI分为三个区域:标题区(类型+倒计时)、题目列表区(layoutWeight(1)占剩余空间)、输入区(固定在底部)。这种"上中下"三段布局是移动端聊天类界面的经典模式——内容区域可滚动,输入框始终可见。

标题区根据selectedType动态切换emoji和文字——🤫配"真心话"、🔥配"大冒险",与选择卡片保持视觉一致性。倒计时数字((${this.crowdSourceTimer}s))用灰色小字显示,不抢视觉焦点但始终可见,给出时间紧迫感。

题目列表使用ForEach实时渲染,每次submitQuestion()追加新提交后,@State questionSubmissions的变化触发List重新渲染,新题目立即出现。白色卡片+8px圆角的每条题目,与页面整体的#F5F5F5灰色背景形成对比,保证可读性。

输入区的TextInput使用text参数绑定mySubmission,onChange同步更新。这是ArkUI受控组件的标准写法——text参数设置初始值/绑定值,onChange回调更新@State。提交按钮使用#FF6B35橙色,与Game的其他按钮保持品牌一致性。

3.4 SHOW_QUESTION(3)——题目展示与回答

题目展示

持续时间:最多60秒(被问者回答计时)
UI展示:题目卡片 + 回答输入框 + 🎤语音按钮 + ⏱计时器

题目展示后,被问者开始回答。isMyTurn为true时显示回答UI(输入框+语音+完成按钮),否则只显示题目等待。

ShowQuestionUI渲染详解

UI顶部是类型标签(真心话蓝色/大冒险红色),中间是题目卡片(白色背景、22号粗体、居中对齐),下方根据isMyTurn条件显示回答交互元素。

题目卡片占据85%宽度(width('85%')),两侧留7.5%的留白,视觉上不会顶到屏幕边缘。padding 24px让文字与卡片边界保持舒适距离。fontSize(22)比标题的fontSize(20)更大,确保题目是页面的视觉焦点。lineHeight(30)比默认行高更大,保证多行题目的可读性。

计时器行使用Row布局,左对齐计时器(⏱ ${this.respondTimer}s),右对齐语音按钮。计时器最后10秒变红(fontColor(this.respondTimer < 10 ? '#F44336' : '#666666')),与NearPlay统一的倒计时警告风格一致。

回答输入区使用Column布局,TextInput在下,完成按钮更下。完成按钮使用80%宽度和44px高度,创造足够大的点击区域。44px是iOS人机界面指南推荐的最小可点击区域尺寸,NearPlay沿用了这个标准。

非被问者的视角:当isMyTurn为false时,计时器行和回答输入区都不渲染,只显示题目卡片。这意味着其他玩家看到的是一道"静默"的题目——他们知道被问者正在回答,但看不到回答过程。这种信息不对称是故意的——真心话大冒险的回答应该在被问者主动分享时才可见,而非实时公开。

3.5 RESPOND(4)——过渡阶段

持续时间:2秒
UI展示:“✅ 已完成!” + “下一位玩家即将开始…”

respondComplete(): void {
  this.phase = TruthOrDarePhase.RESPOND
  setTimeout(() => {
    this.currentTurnIndex++
    this.startTurn()
  }, 2000)
}

respondComplete()是回答阶段的统一出口,无论是计时结束还是手动完成都走这个函数。设置phase为RESPOND,显示过渡UI,2秒后轮转。

RespondUI渲染

@Builder
RespondUI() {
  Column() {
    Text('✅ 已完成!')
      .fontSize(24)
      .fontWeight(FontWeight.Bold)
      .fontColor('#4CAF50')
    Text('下一位玩家即将开始...')
      .fontSize(14)
      .fontColor('#666666')
      .margin({ top: 8 })
  }
  .width('100%')
  .layoutWeight(1)
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

绿色"已完成"+ 灰色"下一位"提示。简洁明了,不占用过多屏幕时间。#4CAF50是Material Design的绿色500,NearPlay统一用于"成功/完成"状态。这个2秒过渡起到了"呼吸"作用——让所有人消化刚才的回答,心理上做好迎接下一位的准备。

currentTurnIndex++:在respondComplete的setTimeout回调中递增,而非在设置RESPOND阶段时递增。这确保了2秒过渡期间,头像栏仍然显示当前被问者(而非下一位),避免视觉跳变。

3.6 无GAME_OVER阶段

真心话大冒险没有固定的GAME_OVER阶段——游戏可以无限进行下去,直到所有玩家同意结束。当前MVP版本中,游戏通过"返回大厅"按钮退出,没有自动结束条件。这种设计符合真心话大冒险的派对性质——没有"赢"或"输",只有"玩"和"不玩了"。

四、众包出题机制深度分析

4.1 为什么众包比预设题库好

传统真心话大冒险APP几乎全部使用预设题库——数百道预写好的真心话和大冒险,游戏时随机抽取。这种方案实现简单、无需网络,但有三个根本缺陷:

缺陷一:题目与语境脱节。 预设题库是通用的,不针对特定玩家群体。"你最害怕的事情是什么?"这道题对陌生人很有效,但对已经互知底细的好友就很无聊。众包出题让出题者根据被问者的身份、性格、当前游戏氛围调整题目深度和方向——对内向的人可以出温和的真心话,对爱玩的人可以出更刺激的大冒险。

缺陷二:重复体验。 即使题库有200道题,玩3-4轮后就会出现重复。一旦玩家发现"这些题我都见过",游戏的新鲜感骤降。众包出题的题目库是无限的——每一场游戏、每一轮、每一个出题者组合都会产生不同的题目,理论上永远不会重复。

缺陷三:缺乏参与感。 预设题库模式下,非被问者完全是旁观者——看别人选、看别人答,自己的参与仅限于"围观"。众包出题让每个人都是出题者,8秒内绞尽脑汁想一道好题目本身就是游戏乐趣。出题的竞争性(谁出的题被选中)和协作性(大家合力为难被问者)创造了预设题库无法提供的参与感。

缺陷四:无法适应动态社交。 游戏进行中,玩家之间的关系在不断变化——第一轮还陌生,第三轮已经很熟。预设题库无法感知这种变化,第一轮和第十轮的题目深度相同。众包出题则自然跟随社交温度——随着玩家越来越熟,题目会自动变得更尖锐、更私人、更有趣。

4.2 8秒时间压力的心理效应

8秒倒计时是众包出题的关键设计决策。这个时间不是随意选择的——它基于对"创造性思维"与"时间压力"关系的心理学研究。

耶克斯-多德森定律(Yerkes-Dodson Law)指出,中等程度的压力最有利于表现。8秒恰好处于"足够紧迫迫使直觉出题"与"不至于太短导致大脑空白"的甜区。3秒太短——大多数人连组织一句话都来不及;15秒太长——出题者开始过度思考,题目反而变得拘谨和无聊。

8秒还有一个社交效应:它创造了"同时出题"的同步体验。所有出题者都在同一个8秒窗口内思考并提交,这种时间同步性强化了"我们一起在为难他"的群体归属感。如果改为"轮流出题",每个出题者有独立的时间窗口,这种同步感就消失了。

4.3 submitQuestion多次提交设计

玩家可以在8秒内提交多道题目。每次提交后mySubmission清空,可以立即输入下一题。所有提交都会显示在题目列表中。

允许多次提交的原因有三:

第一,提高题目池大小。 4人游戏中只有3个出题者,如果每人只能提交1道题,候选池只有3道题,随机性很弱(1/3概率)。允许多次提交后,候选池可能达到6-10道题,随机选择更公平,被问者也更难猜到"谁出了哪道题"。

第二,出题者的策略空间。 有些出题者想同时提交一道"保守题"和一道"激进题"——如果激进题被选中就更有趣,如果保守题被选中也不尴尬。多次提交允许这种"对冲策略",让出题者愿意参与(因为不担心自己的唯一一道题太出格)。

第三,快速出题者的奖励。 有些人口才好、反应快,8秒内能想出2-3道好题目。限制只能提交1道是对这些玩家的"惩罚"——他们的剩余时间被浪费了。允许多次提交让快速出题者有更多表达机会,也丰富了候选池。

4.4 众包出题与社交安全

众包出题的一个风险是:出题者可能提交不适当或冒犯性的题目。当前MVP没有审核机制——任何提交都进入候选池。这是一个已知的设计权衡:审核机制虽然能过滤不当题目,但会增加延迟(8秒内审核来不及)和降低参与感(出题者担心题目被拒)。

当前的"安全网"是随机选择机制——即使有人提交了不当题目,被选中的概率被其他正常题目稀释。未来可以添加"被问者可以拒答并重新抽题"的机制,作为事后安全网。

五、collectQuestions三分支算法

5.1 collectQuestions()完整逻辑

collectQuestions(): void {
  if (this.questionSubmissions.length === 0) {
    const truths = MockTruthOrDareData.getTruthQuestions()
    const dares = MockTruthOrDareData.getDareQuestions()
    if (this.selectedType === ChoiceType.TRUTH) {
      this.currentQuestion = truths[Math.floor(Math.random() * truths.length)]
    } else {
      this.currentQuestion = dares[Math.floor(Math.random() * dares.length)]
    }
  } else {
    const filtered = this.questionSubmissions.filter((q: QuestionSubmission) => q.type === this.selectedType)
    if (filtered.length > 0) {
      this.currentQuestion = filtered[Math.floor(Math.random() * filtered.length)].content
    } else {
      this.currentQuestion = this.questionSubmissions[Math.floor(Math.random() * this.questionSubmissions.length)].content
    }
  }
  this.phase = TruthOrDarePhase.SHOW_QUESTION
  if (this.isMyTurn) {
    this.startRespondTimer()
  }
}

5.2 分支一:无提交(questionSubmissions.length === 0)

当8秒内没有任何人提交题目时,从Mock题库随机抽取。这是兜底机制,确保游戏不会因为"没人出题"而卡住。

Mock题库的选取逻辑根据selectedType分流:TRUTH→getTruthQuestions(),DARE→getDareQuestions()。Math.floor(Math.random() * array.length)是最常见的均匀随机选取算法——生成[0, length)的随机索引,等概率抽取任一元素。

何时触发此分支? 主要有三种场景:一是玩家不熟悉众包出题机制,不知道要在8秒内出题;二是游戏刚开始时社交氛围尚未建立,出题者犹豫不敢出题;三是非被问者全部AFK(暂离),无人响应。无论哪种场景,Mock题库都能保证游戏继续进行,不会因为"冷场"而中断。

Mock题库的退化体验:虽然Mock题库保证了可用性,但游戏体验会显著退化——众包出题的核心价值(针对性、参与感、不可预测)全部丧失。因此这个分支应该被视为"最后的兜底",而非正常流程。未来可以通过UI引导(“轮到你了!给XX出一道题”)和社交激励(出题者积分、被选中的题目标注出处)来减少此分支的触发频率。

5.3 分支二:有提交,类型匹配(filtered.length > 0)

从提交中筛选与selectedType匹配的题目,随机抽取一道。这确保了题目类型与被问者选择一致——选了真心话不会抽到大冒险题目。

filter操作的性能考量questionSubmissions.filter(q => q.type === selectedType)创建一个新数组,遍历所有提交。在8秒窗口内,questionSubmissions的长度通常不超过10(4人游戏、每人最多3题),O(n)的filter操作完全可忽略。但如果未来扩展到8-10人游戏,每人可提交5题以上,候选池可能达到40-50道,filter仍然没有性能问题。

类型匹配的必要性:为什么需要filter?因为出题者在submitQuestion()中传入的type是this.selectedType——即当前被问者选择的类型。理论上所有提交的type都应该与selectedType匹配。但有一种边界情况:如果WebSocket同步有延迟,出题者在被问者选择类型之前就提交了题目(此时selectedType可能还是上一轮的类型或NONE),就会出现类型不匹配。filter操作作为类型安全网,确保即使有同步延迟,也能正确筛选。

随机选取的公平性filtered[Math.floor(Math.random() * filtered.length)]对每道匹配题目赋予相等的被选概率。这是一种"无偏见"的随机策略——没有偏向第一道提交的题目,也没有偏向最后一道。未来可以引入加权随机——根据出题者与被问者的亲密度、题目的历史选中率等维度调整权重,让"更好的题目"有更高的被选概率。

5.4 分支三:有提交,类型不匹配(filtered.length === 0)

所有人都提交了,但没有一道类型匹配。例如被问者选了真心话,但所有人都出了大冒险题目。此时从所有提交中随机抽取一道——虽然类型不匹配,但比Mock题库更有互动性。

为什么选择类型不匹配的玩家题目,而非退回Mock题库? 这是核心设计决策。考虑场景:被问者选了真心话,但3个出题者都出了大冒险题目(可能觉得大冒险更有趣)。如果此时退回Mock题库,出题者的所有努力都白费了——他们花了8秒出的题目被完全忽略。这会严重打击出题积极性,下一轮可能就不愿意出题了。

相反,使用不匹配类型的玩家题目,虽然违反了"真心话选真心话题"的规则,但保留了出题者的参与成果。被问者看到"你选了真心话,但大家只想给你出大冒险——这道大冒险是:做10个俯卧撑",这种"群体压力"反而增加了游戏的趣味性和社交张力。

设计哲学:优先使用玩家提交的题目,即使类型不匹配。玩家出的题永远比预设题库更有趣味性和针对性。这个哲学贯穿整个collectQuestions算法——三个分支的优先级是:类型匹配的玩家题目 > 类型不匹配的玩家题目 > Mock题库。

5.5 算法执行后的状态转换

collectQuestions()的最后两行完成状态转换:

this.phase = TruthOrDarePhase.SHOW_QUESTION
if (this.isMyTurn) {
  this.startRespondTimer()
}

无条件进入SHOW_QUESTION阶段,但只有isMyTurn为true时才启动60秒回答计时器。这个条件判断很重要——非被问者不需要计时器(他们不回答),避免创建无用的interval。

六、语音回答设计

6.1 VoiceInputHelper集成

Button('\u{1F3A4} 语音回答')
  .onClick(() => {
    this.voiceHelper.startListening((text: string) => {
      this.answerText = text
    })
  })

语音识别回调将文本填入answerText,但不自动提交(与你画我猜的语音自动提交不同)。因为真心话大冒险的回答可能很长,语音输入只是辅助,用户可以修改后手动点击完成。

VoiceInputHelper是NearPlay的语音输入工具类,封装了系统的语音识别API。startListening()方法启动麦克风,识别结果通过回调函数返回。回调中的text参数是识别出的文字,直接赋值给answerText触发@State刷新,TextInput自动显示识别结果。

6.2 与你画我猜语音的差异

你画我猜中的语音用于"猜词",识别结果直接与答案比较——语音输入即提交,不需要确认。这是因为猜词是一个"判断对错"的操作,结果只有两个(猜对/猜错),不需要人工审核。

真心话大冒险的回答则完全不同——回答没有"对错",只有"完成/未完成"。语音识别可能不准确(尤其涉及隐私话题时措辞微妙),用户需要审核和修改识别结果后才能提交。不自动提交给了用户这个修改空间。

此外,你画我猜的猜词通常很短(1-5个字),语音识别准确率较高;真心话大冒险的回答可能是一段叙述(“我最近一次说谎是昨天,跟妈妈说我在学习其实我在打游戏…”),长文本的语音识别更容易出错,需要修改。

6.3 语音回答的社交心理学

真心话的语音价值:语音回答更真实——说话时的语气、停顿、犹豫都是社交信息。文字回答可以精心措辞、反复修改,但语音回答是"一次性"的,更难掩饰真实情感。当你听到某人回答"你暗恋过谁"时声音突然变小、语速变慢,这些非语言信息比文字回答更有社交价值。

大冒险的语音实用性:执行大冒险时可能双手不方便打字(如"做10个俯卧撑"、“模仿动物叫声”),语音记录执行过程更实用。语音能捕捉到喘息声、笑声、环境声,这些都是大冒险执行的"证据",比文字"我做了10个俯卧撑"更有说服力。

6.4 answerText双向绑定

TextInput({ placeholder: '输入你的回答/记录执行情况', text: this.answerText })
  .onChange((value: string) => { this.answerText = value })

TextInput绑定answerText,语音输入和手动输入共享同一个字段。用户可以先用语音输入一段话,然后手动修改,最后点击完成提交。这种"语音+手动"的混合输入模式兼顾了便捷性和准确性——语音快速生成初稿,手动修改确保准确。

placeholder文字"输入你的回答/记录执行情况"覆盖了两种场景:真心话的回答(回答问题)和大冒险的记录(描述执行过程)。这种通用文案避免了根据selectedType动态切换placeholder的复杂性。

6.5 voiceHelper的生命周期管理

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

aboutToDisappear()中调用voiceHelper.destroy(),释放语音识别资源。这是必需的——语音识别使用麦克风,如果不释放,其他应用可能无法使用麦克风,且持续监听会消耗电量。destroy()与respondTimerId的清除一起在aboutToDisappear()中执行,确保页面销毁时所有资源都被正确释放。

七、GameChatPanel集成

7.1 canSpeak条件

GameChatPanel({ canSpeak: this.isMyTurn })

canSpeak = isMyTurn

这意味着:

  • 当前被问者:canSpeak = true(可以在聊天中补充回答)
  • 其他玩家:canSpeak = false(出题阶段通过submitQuestion提交,不通过聊天)

7.2 聊天与出题的通道分离

出题使用专用的submitQuestion()和QuestionSubmission模型,不通过GameChatPanel聊天。这保证了:

  1. 题目有结构化数据(fromUserId/type/content),便于后续筛选
  2. 聊天不干扰出题,两个功能互不干扰
  3. 题目列表和聊天列表独立显示

7.3 canSpeak的语义设计

为什么canSpeak只在isMyTurn时为true?因为在真心话大冒险中,“说话权"属于被问者——他/她是当前场景的焦点,其他人应该"听"而非"说”。这与狼人杀的"发言权"概念类似——当前发言人canSpeak=true,其他人canSpeak=false。

但真心话大冒险的canSpeak更宽松——被问者在整个SHOW_QUESTION阶段都可以通过聊天补充回答,不限于"正式发言"。聊天面板成为了回答的"旁通道"——正式回答通过answerText提交,补充说明或额外分享通过聊天发送。

非被问者的canSpeak=false不是绝对的"禁言"——它只是禁用了GameChatPanel的发送按钮,玩家仍然可以看到聊天内容。未来可以考虑在CROWD_SOURCE阶段也允许canSpeak=true,让出题者在出题之余可以闲聊,增加社交氛围。

7.4 GameChatPanel在页面布局中的位置

Column() {
  // 顶部导航栏
  // 玩家头像栏
  // 阶段UI(layoutWeight(1)占据剩余空间)
  GameChatPanel({ canSpeak: this.isMyTurn })
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')

GameChatPanel放在Column的最底部,位于阶段UI之下。它不会被layoutWeight(1)的阶段UI挤压,因为GameChatPanel自身有固定的内在高度(由内部布局决定)。这种"上推下留"的布局策略确保了聊天面板始终可见,不会因为阶段内容过多而滚动出屏幕。

八、玩家头像栏与currentTurnIndex

8.1 ForEach渲染

Row() {
  ForEach(this.players, (player: TruthOrDarePlayer, idx: number) => {
    Column() {
      Text(player.avatar)
        .fontSize(28)
      Text(player.nickname)
        .fontSize(10)
      if (idx === this.currentTurnIndex % this.players.length) {
        Text('👈')
          .fontSize(12)
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
  }, (player: TruthOrDarePlayer) => player.id)
}
.width('100%')
.padding(8)
.backgroundColor(Color.White)
.margin({ top: 4 })

玩家头像栏在页面顶部固定显示(位于导航栏下方),当前回合玩家有👈指示器。这与其他游戏(狼人杀的发言顺序条、你画我猜的画者标记)功能类似——让所有人知道当前轮到谁。

8.2 currentTurnIndex的双重角色

currentTurnIndex在TruthOrDareGame中扮演两个角色:一是轮转计数器(每次respondComplete()后递增),二是当前玩家索引(与players.length取模后索引数组)。这种"计数器+索引"的双重角色简化了状态管理——不需要分别维护"总轮数"和"当前玩家位置"两个字段。

取模运算currentTurnIndex % players.length在每个需要"当前玩家"的地方重复出现——startTurn()、ChooseTypeUI()、头像栏的if条件。这种重复虽然不够DRY(Don’t Repeat Yourself),但避免了引入额外的@State computed字段。在ArkUI中,@State的每次变更都触发UI重新渲染,如果引入computed字段需要额外的@Watch或@Computed装饰器,当前MVP选择简单的内联取模。

8.3 头像栏的视觉层级

头像栏使用白色背景,与页面整体的#F5F5F5灰色背景形成轻微对比。这种"白底浮出"效果暗示头像栏是"固定信息区"而非"可交互内容"。padding(8)和margin({ top: 4 })让头像栏与导航栏和内容区保持间距,视觉上不粘连。

每个玩家Column使用layoutWeight(1)等分水平空间,4人游戏中每人占25%宽度。alignItems(HorizontalAlign.Center)让头像、昵称、指示器都水平居中。emoji头像使用28号字体(比选择卡片的48号小),与昵称的10号字体形成层级——头像大、名字小,扫描时先看到头像再看到名字。

8.4 👈指示器的动态性

👈指示器只在idx === currentTurnIndex % players.length时渲染,其他位置不渲染任何占位元素。这意味着当currentTurnIndex递增时,👈从上一个位置"消失"并在新位置"出现"——这种离散跳变在视觉上非常醒目,有效地引导注意力。

未来可以添加动画效果——👈出现时使用opacity从0到1的渐显动画,消失时渐隐,让注意力转移更自然。但当前MVP省略了这种细节优化,优先保证功能完整性。

九、respondTimer 60秒计时器

9.1 计时器字段

@State respondTimer: number = 60
private respondTimerId: number = -1

被问者有60秒回答时间。这个时间比其他游戏长(狼人杀讨论30s、描述10s),因为真心话大冒险的回答可能涉及深思或执行动作。

9.2 startRespondTimer()

startRespondTimer(): void {
  this.respondTimer = 60
  if (this.respondTimerId !== -1) {
    clearInterval(this.respondTimerId)
  }
  this.respondTimerId = setInterval(() => {
    this.respondTimer--
    if (this.respondTimer <= 0) {
      clearInterval(this.respondTimerId)
      this.respondTimerId = -1
      this.respondComplete()
    }
  }, 1000)
}

安全清除:启动前先检查respondTimerId !== -1,清除可能残留的旧timer。这是防御性编程——虽然理论上每次complete后timer已清除,但边界情况(如快速切换回合)可能导致timer泄漏。

respondTimerId vs crowdSourceTimer的timerId:真心话大冒险使用respondTimerId(类字段,-1表示未激活),而CROWD_SOURCE阶段的timer使用局部变量timerId。这种差异反映了两者的生命周期不同:respondTimer可能跨阶段存在(SHOW_QUESTION阶段持续60秒),需要在aboutToDisappear()中清除;crowdSourceTimer只活跃8秒,局部变量到时自动清除,不需要跨阶段管理。

9.3 计时器颜色警告

Text(`${this.respondTimer}s`)
  .fontColor(this.respondTimer < 10 ? '#F44336' : '#666666')

最后10秒变红色警告,与NearPlay6种游戏统一的倒计时UI风格一致。10秒是"紧迫但不绝望"的阈值——足够让被问者加速回答,但不至于引发恐慌。红色#F44336与真心话大冒险的"大冒险"主题色一致,视觉上强化了紧迫感。

9.4 手动完成

Button('完成')
  .onClick(() => {
    if (this.respondTimerId !== -1) {
      clearInterval(this.respondTimerId)
      this.respondTimerId = -1
    }
    this.respondComplete()
  })

手动完成时清除timer并触发respondComplete()。这是最常见的退出路径——大多数回答不需要60秒。用户回答完毕后点击"完成",2秒过渡后进入下一位玩家的回合。

9.5 60秒的设计考量

为什么是60秒而非30秒或90秒?60秒是一个"够用但不浪费"的平衡点。真心话的回答平均需要10-20秒(组织语言+说出来),大冒险的执行可能需要20-40秒(如做俯卧撑、唱歌)。60秒覆盖了绝大多数场景,只有极少数"复杂大冒险"(如"给你通讯录第3个人打电话")可能需要更长时间。

如果时间太短(30秒),部分大冒险来不及执行就被强制结束,游戏体验变差;如果太长(90秒),大部分时间都在等待被问者点击"完成",效率低下。60秒是一个经验性的甜区。

十、Mock题库展示与设计哲学

10.1 MockTruthOrDareData

class MockTruthOrDareData {
  static getTruthQuestions(): string[] { ... }
  static getDareQuestions(): string[] { ... }
  static getPlayers(): TruthOrDarePlayer[] { ... }
}

三个静态方法提供Mock数据。getTruthQuestions()和getDareQuestions()返回题库数组,getPlayers()返回预设玩家列表。

10.2 真心话题库完整展示

1. 你最近一次说谎是什么时候?
2. 你最害怕的事情是什么?
3. 你手机里最不想被人看到的APP是什么?
4. 你做过最后悔的一件事是什么?
5. 你觉得在座谁最好看?
6. 你的择偶标准是什么?

10.3 大冒险题库完整展示

1. 给你最近联系的人发一条"我想你了"
2. 模仿一种动物的叫声持续10秒
3. 用屁股写自己的名字
4. 做10个俯卧撑
5. 让你左边的人在你脸上画画
6. 大声唱一首歌的副歌部分

10.4 题库设计哲学

Mock题库的6道真心话和6道大冒险并非随意选择,它们遵循三级递进结构:

第一级:温和破冰题。 "你最近一次说谎是什么时候?"和"给你最近联系的人发一条’我想你了’"属于这一级——任何人都能回答/执行,风险很低,适合游戏初期。

第二级:中度挑战题。 "你手机里最不想被人看到的APP是什么?"和"模仿一种动物的叫声持续10秒"属于这一级——涉及一定隐私或社交尴尬,但仍在大多数人的舒适区内。

第三级:深度刺激题。 "你觉得在座谁最好看?"和"用屁股写自己的名字"属于这一级——直接涉及对在场人的评价或高度尴尬的动作,可能引发强烈反应。

三级递进确保了从Mock题库随机抽取时,题目难度分布均匀——既有温和的也有刺激的,不会全是"安全题"或全是"高压题"。

大冒险的线上适配:6道大冒险中,“发消息”(1)和"唱歌"(6)天然适配线上场景;“模仿动物”(2)和"做俯卧撑"(4)需要视频才有效果,纯语音不够;“用屁股写名字”(3)和"脸上画画"(5)则需要线下物理在场。这种混合确保了线上和线下场景都有可用的大冒险题目,但也暴露了线上大冒险的局限性——未来需要专门为线上场景设计更多数字身份类大冒险。

10.5 Mock玩家设计

TruthOrDarePlayer.of('u1', '小明', '👦'),
TruthOrDarePlayer.of('u2', '阿花', '👧'),
TruthOrDarePlayer.of('u3', '大壮', '🧑'),
TruthOrDarePlayer.of('u4', '小美', '👩'),

4个预设玩家覆盖了性别和年龄的多样性:男孩(👦)、女孩(👧)、中性青年(🧑)、女性(👩)。ID从u1到u4,与myId默认值’u1’对应——默认用户是"小明",在第一轮isMyTurn为true。

4人规模是真心话大冒险的最低有效人数——3人出题(众包出题需要至少2-3个出题者才能形成竞争),1人被问。低于4人时众包出题的题目池太小,游戏体验不佳。4-8人是最佳人数范围,8人以上出题阶段可能过于混乱。

10.6 题库作为"种子"的定位

Mock题库在NearPlay中的定位是"种子"而非"主力"。游戏的核心体验来自众包出题,Mock题库只在"无人出题"时作为兜底使用。因此题库不需要太大——6道真心话+6道大冒险足够覆盖不同难度梯度,太多反而增加了维护成本且几乎不会被用到。

未来当题库上云后,Mock题库可以扩展为"推荐题库"——当出题者不知道出什么题时,可以从推荐题库中快速选择一道,而不是从零创作。这比完全众包更高效,又比完全预设更个性化。

十一、边界情况与未来扩展

11.1 无人出题

collectQuestions()已经处理了questionSubmissions.length === 0的情况,自动从Mock题库抽取。但这意味着游戏体验退化——众包出题的核心价值丧失。未来可以:

延长出题时间(8s→15s):8秒对于不熟悉游戏的玩家来说太短。可以根据游戏轮数动态调整——第一轮15秒,第二轮12秒,第三轮起8秒。随着玩家熟悉节奏,时间压力自然增加。

出题阶段强制每人必须提交:当所有非被问者都提交了至少一道题后,倒计时提前结束(不等待8秒),直接进入collectQuestions()。这奖励了积极出题的玩家,也避免了"等人"的无聊。

提供题库快速选择:在出题输入框下方展示3-5道推荐题目(从扩展题库中随机选取),出题者可以点击快速选择,也可以自行输入。这降低了出题门槛——“不想动脑子?选一道推荐题就行”。

11.2 题目审核

当前没有题目审核机制——任何提交都会进入候选池。未来可添加以下审核方案:

被问者拒答权:被问者看到题目后可以点击"拒答"(每轮最多1次),拒答后重新从候选池抽取下一道题。这给了被问者一个安全出口——“这道题太过了,换一道”。拒答次数限制(每轮1次)防止滥用——不能无限拒答直到抽到"好回答"的题。

房主审核模式:房主可以看到所有提交的题目,标记不适当的题目为"已过滤",被过滤的题目不参与collectQuestions()的随机选取。这适合"房主是信任锚"的熟人局。

举报与投票:回答完成后,其他玩家可以举报不当题目。累计被举报N次的题目进入黑名单,不再出现在推荐题库中。这是一种事后审核机制,不影响实时游戏节奏。

11.3 游戏结束条件

当前游戏无自动结束。未来可添加:

固定轮数结束:每人2轮后结束,总共N×2个回合。结束后展示"最勇敢玩家"(选择大冒险次数最多)、“最坦诚玩家”(选择真心话次数最多)等趣味统计。

投票结束:任一玩家提议结束,过半同意则游戏结束。投票不需要专门界面——在GameChatPanel中发送"/end"触发投票,避免打断游戏节奏。

积分系统:为回答质量评分——其他玩家对回答点赞/踩,点赞最多的玩家获胜。积分增加了竞争性,但也可能改变游戏的"坦诚"本质——玩家可能为了点赞而表演式回答,而非真实回答。

11.4 回答展示与广播

当前回答只保存在本地answerText中,其他玩家看不到。未来可通过WebSocket广播回答内容,让所有人都能看到/听到被问者的回答。

广播回答的设计需要考虑隐私——真心话的回答可能非常私人,被问者可能不希望被记录。解决方案:回答广播后不保存在服务器,只在客户端实时显示,“阅后即焚”。这与Snapchat的故事模式类似——回答存在一段时间后自动消失,降低隐私顾虑。

回答的展示形式也可以丰富化——语音回答直接播放音频(而非转文字),其他玩家可以听到语气和停顿;大冒险的执行可以拍照或录像作为证据广播。

11.5 大冒险拍照/录像

大冒险的执行可以拍照或录像作为证据,增加趣味性和可信度。集成Camera API拍照,或使用MediaRecorder录制短视频。

技术实现路径:调用@ohos.multimedia.camera拍摄照片,照片以base64编码后通过WebSocket广播给其他玩家。短视频(5-10秒)使用@ohos.multimedia.mediaRecorder录制,以低分辨率编码后传输。

隐私考量:拍照/录像涉及更高的隐私风险——比文字和语音更难"后悔"。解决方案:拍照前给被问者3秒倒计时确认(“即将拍照,确认?”),录像同样需要确认。录制的内容只在本轮游戏期间可见,游戏结束后自动删除。

11.6 众包出题的语音模式

当前出题只能打字。未来可添加语音出题——VoiceInputHelper识别语音后自动填入mySubmission。这让出题更自然,尤其在大冒险执行中双手不方便时。

语音出题与你画我猜的语音猜词类似——识别结果填入输入框,但不自动提交。出题者可以修改识别结果后点击"提交"。这种"语音+手动修改"的混合模式在出题场景中尤其有用——语音适合快速表达想法,手动修改适合精炼措辞。

11.7 玩家掉线与重连

当前MVP假设所有玩家始终在线。但真实场景中,玩家可能因网络问题掉线。如果被问者掉线,游戏会卡在CHOOSE_TYPE或SHOW_QUESTION阶段无限等待。

解决方案:为每个阶段添加"超时跳过"机制——如果被问者在CHOOSE_TYPE阶段超过30秒未选择,系统自动随机选择一个类型;如果在SHOW_QUESTION阶段掉线(通过心跳检测),60秒计时结束后自动跳过该回合。非被问者掉线影响较小——只是少了一个出题者,collectQuestions()的兜底机制可以处理。

11.8 回合历史记录

当前游戏没有历史记录——每轮结束后回答内容清空(answerText在startTurn()中被重置为空字符串)。未来可以添加"回合回顾"功能——展示之前所有轮的题目和回答,作为社交回忆。

历史记录的数据结构可以复用TruthOrDarePlayer的字段——choiceType记录选择、question记录题目、hasAnswered记录是否已回答。只需新增answerContent字段记录回答内容,每轮结束时将当前状态快照到历史数组中。

11.9 自定义规则变体

真心话大冒险有许多民间变体规则:如"真心话大冒险两级制"(温和版和刺激版)、“传瓶模式”(随机选人而非轮流)、“惩罚累积”(拒答的惩罚递增)等。未来可以让房主在创建房间时选择规则变体,或自定义参数(如出题时间、回答时间、是否允许拒答等)。

规则变体的技术实现:将硬编码的常量(8秒出题时间、60秒回答时间、2秒过渡时间)提取为配置对象,房主在创建房间时设置,所有客户端同步使用相同配置。这种"参数化"设计让同一套游戏逻辑支持多种玩法,避免为每种变体写一套代码。

11.10 跨房间众包题库

当前众包出题仅限于同一场游戏的玩家。未来可以扩展为"跨房间题库"——其他正在进行的真心话大冒险房间的出题者也可以为本房间的被问者出题。这大幅扩展了题目池的规模和多样性。

跨房间出题的技术挑战:需要中心化的题目分发服务,接收所有房间的题目提交,根据类型和难度标签路由到合适的房间。隐私问题也需要考虑——跨房间出题者不了解被问者,题目可能不合适。解决方案:跨房间题目标记来源(“来自其他房间的题目”),被问者可以选择只接受本房间题目或接受跨房间题目。

Logo

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

更多推荐