在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

随机事件、属性养成、回合对战——一个看似复杂的模拟游戏,如何用ArkUI在单文件中实现?本文从事件系统设计到属性数值平衡,完整记录开发全过程。


一、项目缘起:为什么做"舍友打架模拟器"

1.1 创意来源

宿舍生活是中国大学生最独特的集体记忆之一。在这个十几平米的空间里,每天都在上演着各种"戏剧"——空调温度之争、谁偷用了我的洗发水、深夜打呼噜、外卖被拿错……这些看似琐碎的小事,构成了大学生活最鲜活的底色。

“舍友打架模拟器"的创意正是源于这些真实的宿舍日常。它不是一个提倡暴力的游戏,而是一个用幽默和夸张的方式呈现宿舍生活的模拟器——玩家需要在各种突发事件中做出选择,这些选择影响着角色的6维属性,而属性又决定了在"打架”(矛盾爆发)时的胜负。

1.2 玩法设计

游戏的核心循环:

第1天 → 随机事件 → 选择 → 属性变化
第2天 → 随机事件 → 选择 → 属性变化
第3天 → 🔥 矛盾爆发 → 回合对战 → 胜负影响属性
第4天 → 循环...

三个核心系统:

系统 说明 体验目标
🏠 随机事件 7种剧情,每种3个选择 做选择时有代入感、纠结感
📊 属性养成 6维属性,选择影响成长方向 培养"自己的角色"的养成感
⚔️ 回合对战 4个技能,AI决策 检验养成成果的成就感

1.3 技术选型

维度 选择 理由
语言 ArkTS 类型安全,适合复杂数据模型
UI框架 ArkUI 声明式,组件化
持久化 无(单局游戏,不存档) 游戏设计为"随时开一把"
包体积 < 100KB 纯逻辑+UI,无外部依赖

二、数据模型设计

2.1 6维属性系统

角色属性是游戏的核心,决定了事件选择的影响范围和战斗中的表现:

属性 英文 范围 影响
❤️ 生命值 hp 1~100 战斗中归零即败
💪 力量 str 1~50 增加技能伤害
🛡️ 防御 def 1~50 减少受到的伤害
⚡ 速度 spd 1~50 影响恢复技能效果
🧹 卫生 hyg 0~100 纯数值,无战斗影响
😊 心情 mood 0~100 影响伤害+治疗效果

设计原则

  • 战斗相关属性(hp/str/def/spd):上限50,让玩家可以专注培养特定方向
  • 生活属性(hyg/mood):上限100,更细粒度地反映事件影响
  • 所有属性都有下限保护Math.max(1, ...) / Math.max(0, ...) 确保属性不会归零

2.2 角色接口

interface DormCharacter {
  name: string        // 角色名
  emoji: string       // 头像表情
  hp: number          // 当前生命值
  maxHp: number       // 最大生命值
  str: number         // 力量
  def: number         // 防御
  spd: number         // 速度
  hyg: number         // 卫生
  mood: number        // 心情
  skills: DormSkill[] // 技能列表(4个)
}

对手生成逻辑:每次打架时重新生成,属性随机波动,保证每局体验不同:

this.enemy = this.newChar('舍友', '🤯');
this.enemy.hp = 80 + Math.floor(Math.random() * 40);    // 80-120
this.enemy.str = 8 + Math.floor(Math.random() * 8);      // 8-15
this.enemy.def = 8 + Math.floor(Math.random() * 6);      // 8-13

2.3 4技能系统

每个角色固定拥有4个技能:

技能 图标 基础伤害 效果 定位
平A 👊 8 普通攻击 稳定输出
怒吼 😤 14 攻击 主力技能
枕头大战 🛏️ 20 攻击 大招
求和 🤝 0 回血25 恢复技能

伤害计算公式(玩家攻击):

基础伤害 = 技能伤害 + 力量 × 0.5 + 心情 × 0.05
减免后 = 基础伤害 - 敌方防御 × 0.3
实际伤害 = max(3, 减免后) × 随机波动(0.8~1.2)

设计要点

  • 力量主导str × 0.5 是伤害的主要加成来源,每点力量提升0.5伤害
  • 心情微调mood × 0.05 让心情好的时候打得更有力
  • 防御减伤def × 0.3 每点防御减少0.3伤害
  • 最小伤害保护max(3, ...) 避免因防御太高而打不出伤害
  • 随机波动0.8~1.2 让每次攻击结果不同

恢复技能的效果受速度影响:

恢复量 = 25(基础)+ 速度 × 0.2

2.4 事件系统

事件系统是游戏事件驱动的核心。每个事件包含标题、描述、表情和3个选项:

interface DormEvent {
  title: string        // 事件标题(如"深夜打呼噜")
  desc: string         // 事件描述
  emoji: string        // 事件表情
  choices: EventChoice[]  // 3个选项
}

interface EventChoice {
  text: string         // 选项文字(如"忍了,继续睡")
  effect: string       // 效果描述(如"mood-5")
  effAttr: string      // 影响的属性
  effVal: number       // 影响数值
}

7种预设事件

事件 选项1(佛系) 选项2(刚正面) 选项3(骚操作)
😴 深夜打呼噜 mood-5 mood-3,hyg-2 str+2,mood-8
🧴 洗发水被用 mood-3 hyg+2 mood+3,hyg-3
❄️ 空调之争 mood+3 def+2 spd+3,mood-5
🍱 外卖被拿 hp-10 str+3 mood+5,hp+20
🧹 值日冲突 hyg+5,mood-3 str+2 spd+4,mood-8,hyg-5
⏰ 闹钟之争 mood-3 str+3,hp-5 spd+3,mood+3
🍿 零食被偷 mood-3,hp+10 spd+2 str+3,mood+3

设计原则

  • 没有纯正面选项:每个选项都有代价,让选择有纠结感
  • 数值有梯度:-3到-10不等,选择的影响可感知
  • 复合效果:部分选项同时影响多个属性,增加决策复杂度
  • 路线倾向:佛系路线(减mood)/ 刚正面路线(加str)/ 骚操作路线(加spd)

2.5 效果解析算法

事件选项的效果通过字符串编码,在运行时解析:

const parts = choice.effect.split(',');   // 按逗号分割多个效果
for (const part of parts) {
  const splitIdx = part.search(/[+-]\d+$/);  // 找到数字开始位置
  const attr = part.substring(0, splitIdx);   // 属性名
  const valStr = part.substring(splitIdx);     // 数值(如"+3")
  const val = Number.parseInt(valStr);
  switch (attr) {
    case 'hp': p.hp = Math.max(1, Math.min(p.maxHp, p.hp + val)); break;
    case 'str': p.str = Math.max(1, Math.min(50, p.str + val)); break;
    // ...
  }
}

为什么用字符串编码而不是直接传数值?因为ArkTS对复杂数据结构的序列化支持有限,用字符串编码可以保持 DormEvent 的接口简洁,也便于阅读和修改。


三、事件驱动 vs 回合对战

3.1 游戏状态机

游戏在三种"阶段"间切换:

phase: 'event' ──(选择)──→ phase: 'event' (day%3!=0)
   │                            │
   │                       day%3==0
   │                            ↓
   │                     phase: 'fight'
   │                            │
   │                    (一方HP归零)
   │                            ↓
   │                     showResult=true
   │                            │
   │                     点击"继续"
   │                            ↓
   └────────────────→ phase: 'event' (day++)

状态变量

@State phase: string       // 'event' | 'fight' | (result通过showResult控制)
@State day: number         // 天数,控制事件→战斗的切换
@State showResult: boolean // 战斗结果展示
@State isAnim: boolean     // 动画锁,防止技能连点

3.2 事件循环

每次事件触发流程:

nextEvent(): void {
  if (this.player.hp <= 0) { this.player.hp = 30; }  // 保底
  this.curEvent = this.events[Math.floor(Math.random() * this.events.length)];
  this.phase = 'event';
}

chooseChoice(idx: number): void {
  this.applyEffect(choice);     // 应用属性变化
  this.day++;                   // 天数+1
  if (this.day % 3 === 0) {
    this.startFight();          // 每3天触发战斗
  } else {
    this.nextEvent();           // 继续事件
  }
}

3.3 战斗循环

对战阶段的完整流程:

用户选择技能
  → playerSkill(idx)
    → 动画锁 (isAnim=true)
    → 执行技能效果
    → refreshState() 刷新UI
    → 判定敌方HP是否为0
      → 是:endFight(true)
      → 否:setTimeout 400ms 后 enemyTurn()
        
敌方回合
  → enemyTurn()
    → AI决策选择技能
    → 执行技能效果
    → refreshState() 刷新UI
    → 判定玩家HP是否为0
      → 是:endFight(false)
      → 否:isAnim=false (解锁)

AI决策逻辑

// 低血量时有40%概率回血,否则随机攻击
const idx = e.hp < e.maxHp * 0.3 && Math.random() < 0.4 ? 3 : Math.floor(Math.random() * 3);

与"班长大战团支书"中的AI策略一致——简单但有效。


四、UI实现详解

4.1 双视图导航

APP只有两个底部标签页:

🏠 宿舍 (dorm)  |  📊 属性 (attr)

宿舍视图内又根据 phaseshowResult 条件渲染三种子界面:

  • phase === 'event' → 事件卡片 + 选项列表
  • phase === 'fight' && !showResult → 对战界面
  • showResult === true → 结果界面

属性视图展示角色的完整属性面板。

4.2 事件卡片

事件卡片展示当前随机事件:

Column
├── Text: 事件表情 (48fp)
├── Text: 事件标题 (20fp, 粗体)
└── Text: 事件描述 (14fp, 灰色, 居中)

下方是3个选项按钮,每个展示选项文字和效果预览。

4.3 对战界面

对战界面分为三个区域:

区域①:双方状态条(顶部)

Row
├── Column (玩家)
│   ├── Text: 玩家emoji 48fp
│   ├── Text: 玩家名
│   ├── hpBar: HP血条 (红色)
├── Text: "⚡" 分隔符
└── Column (对手)
    ├── Text: 对手emoji 48fp
    ├── Text: 对手名
    └── hpBar: HP血条 (蓝色)

区域②:战斗日志(中间,flex:1)

List 展示所有战斗记录,根据消息来源不同显示不同颜色:

  • 😎 玩家行动 → 红色
  • 🤯 敌方行动 → 蓝色
  • 💥 伤害 → 亮红
  • 💚 恢复 → 绿色
  • 回合分隔 → 灰色

区域③:技能栏(底部)

Row
├── Column: 👊 平A (8)
├── Column: 😤 怒吼 (14)
├── Column: 🛏️ 枕头大战 (20)
└── Column: 🤝 求和 (回复)

动画锁激活时技能栏半透明,禁止点击。

4.4 HP血条组件

hpBar 是一个可复用的 @Builder:

@Builder
hpBar(v: number, m: number) {
  Row() {
    Column() {
      Column() { }
      .width(`${v / m * 100}%`).height('100%')
      .backgroundColor(this.getBarColor(v, m)).borderRadius(6);
    }
    .width('100%').height(10).backgroundColor('#E0E0E0').borderRadius(6);
    Text(`${v}/${m}`).fontSize(10).fontColor('#888').width(44).textAlign(TextAlign.End);
  }
}

颜色随HP百分比变化

  • 60%:绿色 #4CAF50

  • 30%-60%:橙色 #FF9800
  • <30%:红色 #E53935

4.5 属性视图

属性视图展示完整的角色面板:

Column
├── Row: 角色头像 (64fp) + 名称 + 生存天数
│
├── Column: 属性列表
│   ├── attrRow: ❤️ 生命    ████████░  80/100
│   ├── attrRow: 💪 力量    ██████░░░  30/50
│   ├── attrRow: 🛡️ 防御    ████░░░░░  20/50
│   ├── attrRow: ⚡ 速度    █████░░░░  25/50
│   ├── attrRow: 🧹 卫生    ███████░░  70/100
│   └── attrRow: 😊 心情    ██████░░░  60/100
│
└── Column: 技能列表
    ├── 👊 平A · 普通攻击 · 💥8
    ├── 😤 怒吼 · 大声咆哮 · 💥14
    ├── 🛏️ 枕头大战 · 用枕头猛砸 · 💥20
    └── 🤝 求和 · 冷静下来恢复HP · 💚+25

每个属性通过百分比宽度展示进度条,让玩家一目了然自己的角色成长方向。


五、@Builder 方法的最佳实践总结

在本次开发中,我们可以总结出 ArkUI @Builder 方法的几个关键规则:

规则1:必须标注 @Builder 装饰器

// ✅ 正确
@Builder
buildFightView() { ... }

// ❌ 错误 —— 缺少 @Builder 会导致包含 UI 语法的代码被当作普通方法解析
buildFightView() { ... }

症状:缺失 @Builder 时,方法内的 ForEachColumn()if 条件渲染等UI语法全部报错,因为ArkTS编译器不知道这是UI代码。

规则2:不能有 return 提前退出

@Builder
buildEventView() {
  // ❌ 错误
  if (this.curEvent === null) return;
  Column() { ... }
  
  // ✅ 正确
  if (this.curEvent != null) {
    Column() { ... }
  }
}

规则3:不能声明局部变量

@Builder
buildEventView() {
  // ❌ 错误
  const e = this.curEvent;
  
  // ✅ 正确:直接使用成员变量
  Text(this.curEvent.title)
  
  // ✅ 正确:提取到普通方法中计算
  Text(this.getEventTitle())
}

规则4:调用 @Builder 方法需要 this 前缀

@Builder
buildDormView() {
  Column() {
    this.buildEventView();   // ✅ 正确
    this.hpBar(v, m);        // ✅ 正确
  }
}

六、踩坑合集

坑1:@Builder 装饰器缺失 —— 70行代码集体报错

症状:连续70行UI代码全部报错,每个组件都显示"未定义"或"语法错误"。

根因buildFightView() 方法前缺少 @Builder 装饰器,ArkTS把 ForEachColumn()if 等UI语法都当作普通JavaScript语法解析,自然全部不通过。

修复:在方法前加上 @Builder

教训:方法名以 build 开头不代表它就是Builder。必须显式标注 @Builder

坑2:字符串引号不匹配

症状.width('100%).margin(...) 报错。

根因'100%) 的结束引号不对——应该是 '100%',但写成了 '100%)。在 @Builder 的链式调用中,这种引号错误特别容易被忽略,因为连续链式调用太长,眼睛不易聚焦。

修复:改为 '100%'

教训:长链式调用时注意引号的成对匹配。

坑3:数组解构赋值不可用

症状const [attr, valStr] = part.split(...) 报错。

根因:ArkTS不支持数组解构赋值(Array Destructuring)。

修复

// ❌ 不可用
const [attr, valStr] = part.split(/([+-]\d+)$/);

// ✅ 替代方案
const splitIdx = part.search(/[+-]\d+$/);
const attr = part.substring(0, splitIdx);
const valStr = part.substring(splitIdx);

ArkTS中不可用的JS特性列表更新

特性 示例 ArkTS
展开运算符 [...arr] / {...obj}
解构赋值 const [a, b] = arr
索引访问类型 Obj['key'] Type
console.error() console.error(msg)
Array.from() Array.from(iter)
parseInt() parseInt(s) ❌ (用Number.parseInt)
return in @Builder if (x) return;
局部变量 in @Builder const x = ...

坑4:状态对象属性修改不触发渲染

症状:修改 this.player.hp 后UI没有更新。

根因@State 对象的属性修改不会触发重新渲染。

修复:每次修改属性后,创建新对象替换:

// 属性修改
this.player.hp -= dmg;

// 触发渲染 —— 创建新对象
const p = this.player;
this.player = {
  name: p.name, emoji: p.emoji, hp: p.hp, maxHp: p.maxHp,
  str: p.str, def: p.def, spd: p.spd, hyg: p.hyg, mood: p.mood,
  skills: p.skills
};

为了方便复用,将这段代码封装为 refreshState() 方法,在每次属性变化后调用。

坑5:@Builder 中的复杂三元表达式

症状Text(...).fontColor(... ? ... : ... ? ... : ...) 多层嵌套导致代码可读性差。

根因:在 @Builder 中不能声明局部变量,所以复杂的条件逻辑必须内联在组件属性中。

优化方案:将对多个条件的判断提取到普通方法中:

// 普通方法中实现复杂逻辑
getLogColor(log: string): string {
  if (log.startsWith('😎')) return '#E53935';
  if (log.startsWith('🤯')) return '#2196F3';
  // ...
  return '#666';
}

// @Builder 中只调用方法
Text(log).fontColor(this.getLogColor(log))

七、数值平衡设计

7.1 为什么需要数值平衡

一个模拟游戏的可玩性很大程度上取决于数值平衡:

  • 如果玩家太容易赢,游戏会很快变得无聊
  • 如果玩家总是输,游戏会让人沮丧
  • 如果事件选择对属性没有实质影响,玩家会觉得"选什么都一样"

7.2 平衡策略

策略1:对手动态难度

敌人的属性根据随机值生成,每次战斗都不同:

this.enemy.hp = 80 + Math.floor(Math.random() * 40);  // 80~120
this.enemy.str = 8 + Math.floor(Math.random() * 8);    // 8~15
this.enemy.def = 8 + Math.floor(Math.random() * 6);    // 8~13

相比玩家的初始属性(hp=100, str=10, def=10),敌人的属性范围在玩家初始值上下波动,保证了:

  • 运气好时遇到弱敌,轻松获胜
  • 运气差时遇到强敌,艰难作战
  • 经过多次养成后属性成长的玩家,即使遇到强敌也有胜算

策略2:属性上限控制

战斗属性上限50,生活属性上限100。玩家不可能"全属性满级",必须做出取舍:

  • 想主升力量?力量和防御只能二选一
  • 想当血牛?生命和恢复只能二选一
  • 心情影响伤害也影响恢复,不能完全放弃

策略3:随机波动

±20%的伤害波动让即使是劣势方也有翻盘的机会,体现了"打架的偶然性"。

7.3 玩家成长曲线预估

天数 事件次数 打架次数 预计力量 预计生命
3 2 1 12-16 80-100
6 4 2 14-20 80-100
9 6 3 16-24 80-100
15 10 5 20-30 80-100

注:生命值在战斗中会大量消耗,每次战斗结束后恢复至30(如果战败)或100左右(如果战胜),因此生命值的提升主要依靠事件中的hp+选项。


八、项目结构与代码统计

8.1 文件结构

Index.ets (525行)
├── 类型定义 (~30行)
│   ├── enum AttrType
│   ├── interface DormSkill / DormCharacter
│   └── interface DormEvent / EventChoice
│
├── 游戏数据 (~60行)
│   ├── 7个随机事件(含3×7=21个选项)
│   └── 4个默认技能
│
├── 游戏逻辑 (~80行)
│   ├── newChar / nextEvent / chooseChoice
│   ├── applyEffect / startFight
│   ├── playerSkill / enemyTurn
│   └── endFight / afterFight / refreshState
│
├── UI辅助方法 (~30行)
│   ├── getStatusEmoji / getBarColor
│   └── statChip / navItem
│
└── @Builder视图 (~320行)
    ├── build() 入口
    ├── buildBottomNav / buildDormView
    ├── buildEventView / buildFightView
    ├── buildAttrView / buildAttrRow
    └── hpBar / statChip

8.2 代码量分布

模块 行数 占比
类型定义 ~30 6%
游戏数据(事件+技能) ~60 11%
游戏逻辑 ~80 15%
UI辅助 ~30 6%
UI视图 ~320 62%

与之前几个APP一样,UI代码占总代码量的60%左右,这是ArkUI声明式开发的特征。


九、总结与展望

9.1 项目复盘

维度 数据
开发周期 约1天
代码量 525行,单文件
事件数 7种
可选分支 21个(7×3)
属性维度 6维
技能数 4个/角色
战斗公式 伤害 = (技能+力量×0.5+心情×0.05)×(1-防御×0.3)×随机波动

9.2 “舍友打架模拟器” vs “班长大战团支书”

两者同属回合制对战类型,但设计上有所不同:

维度 班长大战团支书 舍友打架模拟器
核心玩法 纯对战 事件养成+对战
角色成长 固定属性 6维属性可养成
游戏时长 单局3分钟 可持续多轮
剧情元素 7种随机事件
AI对手 固定属性 随机属性动态难度
技能系统 4技能固定 4技能(效果受属性影响)

9.3 可扩展方向

1. 更多事件

目前只有7种事件,可以扩展到20+种,包括正面事件(“舍友请客吃饭”“一起看电影”)和连锁事件(“上次的矛盾升级了”)。

2. 道具系统

引入道具系统,玩家可以通过事件获得道具(“耳塞”“眼罩”“零食储备”),在对战中可以使用道具获得优势。

3. 多舍友

不止一个舍友,每个舍友有不同的性格和属性倾向,玩家的选择会影响与不同舍友的关系值。

4. 结局系统

根据玩家的属性养成方向和打架胜负,触发不同的结局(“和平共处”“一方称霸”"被赶出宿舍"等)。

5. 成就收集

“佛系玩家(连续10次选减mood选项)”“暴脾气(累计怒吼使用10次)”"打不死的小强(残血翻盘)"等成就。


附录:完整API清单

ArkUI组件

组件 用途
Column / Row 布局容器
Text 文本显示
Button 按钮
List / ListItem 战斗日志
ForEach 循环渲染选项/技能/日志

ArkUI属性方法

方法 用途
.fontSize() .fontWeight() .fontColor() 字体样式
.backgroundColor() .borderRadius() 背景与圆角
.layoutWeight() 弹性布局
.alignItems() .justifyContent() 对齐方式
.width() .height() 尺寸
.padding() .margin() 边距
.alignSelf() 单独对齐
.onClick() 点击事件
.opacity() 透明度(动画锁用)
.lineHeight() 行高
.textAlign() 文本对齐
.maxLines() .textOverflow() 文本截断

全局API

API 用途 ArkTS
Math.random() 随机数
Math.floor() 向下取整
Math.max() / Math.min() 值范围限制
Number.parseInt() 字符串转整数
setTimeout(cb, ms) 延迟执行
String.startsWith() 前缀判断
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐