随机事件、属性养成、回合对战——一个看似复杂的模拟游戏如何用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维属性系统角色属性是游戏的核心决定了事件选择的影响范围和战斗中的表现属性英文范围影响❤️ 生命值hp1~100战斗中归零即败 力量str1~50增加技能伤害️ 防御def1~50减少受到的伤害⚡ 速度spd1~50影响恢复技能效果 卫生hyg0~100纯数值无战斗影响 心情mood0~100影响伤害治疗效果设计原则战斗相关属性hp/str/def/spd上限50让玩家可以专注培养特定方向生活属性hyg/mood上限100更细粒度地反映事件影响所有属性都有下限保护Math.max(1, ...)/Math.max(0, ...)确保属性不会归零2.2 角色接口interfaceDormCharacter{name:string// 角色名emoji:string// 头像表情hp:number// 当前生命值maxHp:number// 最大生命值str:number// 力量def:number// 防御spd:number// 速度hyg:number// 卫生mood:number// 心情skills:DormSkill[]// 技能列表4个}对手生成逻辑每次打架时重新生成属性随机波动保证每局体验不同this.enemythis.newChar(舍友,);this.enemy.hp80Math.floor(Math.random()*40);// 80-120this.enemy.str8Math.floor(Math.random()*8);// 8-15this.enemy.def8Math.floor(Math.random()*6);// 8-132.3 4技能系统每个角色固定拥有4个技能技能图标基础伤害效果定位平A8普通攻击稳定输出怒吼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.22.4 事件系统事件系统是游戏事件驱动的核心。每个事件包含标题、描述、表情和3个选项interfaceDormEvent{title:string// 事件标题如深夜打呼噜desc:string// 事件描述emoji:string// 事件表情choices:EventChoice[]// 3个选项}interfaceEventChoice{text:string// 选项文字如忍了继续睡effect:string// 效果描述如mood-5effAttr:string// 影响的属性effVal:number// 影响数值}7种预设事件事件选项1佛系选项2刚正面选项3骚操作 深夜打呼噜mood-5mood-3,hyg-2str2,mood-8 洗发水被用mood-3hyg2mood3,hyg-3❄️ 空调之争mood3def2spd3,mood-5 外卖被拿hp-10str3mood5,hp20 值日冲突hyg5,mood-3str2spd4,mood-8,hyg-5⏰ 闹钟之争mood-3str3,hp-5spd3,mood3 零食被偷mood-3,hp10spd2str3,mood3设计原则没有纯正面选项每个选项都有代价让选择有纠结感数值有梯度-3到-10不等选择的影响可感知复合效果部分选项同时影响多个属性增加决策复杂度路线倾向佛系路线减mood/ 刚正面路线加str/ 骚操作路线加spd2.5 效果解析算法事件选项的效果通过字符串编码在运行时解析constpartschoice.effect.split(,);// 按逗号分割多个效果for(constpartofparts){constsplitIdxpart.search(/[-]\d$/);// 找到数字开始位置constattrpart.substring(0,splitIdx);// 属性名constvalStrpart.substring(splitIdx);// 数值如3constvalNumber.parseInt(valStr);switch(attr){casehp:p.hpMath.max(1,Math.min(p.maxHp,p.hpval));break;casestr:p.strMath.max(1,Math.min(50,p.strval));break;// ...}}为什么用字符串编码而不是直接传数值因为ArkTS对复杂数据结构的序列化支持有限用字符串编码可以保持DormEvent的接口简洁也便于阅读和修改。三、事件驱动 vs 回合对战3.1 游戏状态机游戏在三种阶段间切换phase: event ──(选择)──→ phase: event (day%3!0) │ │ │ day%30 │ ↓ │ phase: fight │ │ │ (一方HP归零) │ ↓ │ showResulttrue │ │ │ 点击继续 │ ↓ └────────────────→ phase: event (day)状态变量Statephase:string// event | fight | (result通过showResult控制)Stateday:number// 天数控制事件→战斗的切换StateshowResult:boolean// 战斗结果展示StateisAnim:boolean// 动画锁防止技能连点3.2 事件循环每次事件触发流程nextEvent():void{if(this.player.hp0){this.player.hp30;}// 保底this.curEventthis.events[Math.floor(Math.random()*this.events.length)];this.phaseevent;}chooseChoice(idx:number):void{this.applyEffect(choice);// 应用属性变化this.day;// 天数1if(this.day%30){this.startFight();// 每3天触发战斗}else{this.nextEvent();// 继续事件}}3.3 战斗循环对战阶段的完整流程用户选择技能 → playerSkill(idx) → 动画锁 (isAnimtrue) → 执行技能效果 → refreshState() 刷新UI → 判定敌方HP是否为0 → 是endFight(true) → 否setTimeout 400ms 后 enemyTurn() 敌方回合 → enemyTurn() → AI决策选择技能 → 执行技能效果 → refreshState() 刷新UI → 判定玩家HP是否为0 → 是endFight(false) → 否isAnimfalse (解锁)AI决策逻辑// 低血量时有40%概率回血否则随机攻击constidxe.hpe.maxHp*0.3Math.random()0.4?3:Math.floor(Math.random()*3);与班长大战团支书中的AI策略一致——简单但有效。四、UI实现详解4.1 双视图导航APP只有两个底部标签页 宿舍 (dorm) | 属性 (attr)宿舍视图内又根据phase和showResult条件渲染三种子界面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是一个可复用的 BuilderBuilderhpBar(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%绿色#4CAF5030%-60%橙色#FF980030%红色#E539354.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 方法的最佳实践总结在本次开发中我们可以总结出 ArkUIBuilder方法的几个关键规则规则1必须标注 Builder 装饰器// ✅ 正确BuilderbuildFightView(){...}// ❌ 错误 —— 缺少 Builder 会导致包含 UI 语法的代码被当作普通方法解析buildFightView(){...}症状缺失Builder时方法内的ForEach、Column()、if条件渲染等UI语法全部报错因为ArkTS编译器不知道这是UI代码。规则2不能有 return 提前退出BuilderbuildEventView(){// ❌ 错误if(this.curEventnull)return;Column(){...}// ✅ 正确if(this.curEvent!null){Column(){...}}}规则3不能声明局部变量BuilderbuildEventView(){// ❌ 错误constethis.curEvent;// ✅ 正确直接使用成员变量Text(this.curEvent.title)// ✅ 正确提取到普通方法中计算Text(this.getEventTitle())}规则4调用 Builder 方法需要 this 前缀BuilderbuildDormView(){Column(){this.buildEventView();// ✅ 正确this.hpBar(v,m);// ✅ 正确}}六、踩坑合集坑1Builder 装饰器缺失 —— 70行代码集体报错症状连续70行UI代码全部报错每个组件都显示未定义或语法错误。根因buildFightView()方法前缺少Builder装饰器ArkTS把ForEach、Column()、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)$/);// ✅ 替代方案constsplitIdxpart.search(/[-]\d$/);constattrpart.substring(0,splitIdx);constvalStrpart.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)returnin Builderif (x) return;❌局部变量 in Builderconst x ...❌坑4状态对象属性修改不触发渲染症状修改this.player.hp后UI没有更新。根因State对象的属性修改不会触发重新渲染。修复每次修改属性后创建新对象替换// 属性修改this.player.hp-dmg;// 触发渲染 —— 创建新对象constpthis.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()方法在每次属性变化后调用。坑5Builder 中的复杂三元表达式症状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.hp80Math.floor(Math.random()*40);// 80~120this.enemy.str8Math.floor(Math.random()*8);// 8~15this.enemy.def8Math.floor(Math.random()*6);// 8~13相比玩家的初始属性hp100, str10, def10敌人的属性范围在玩家初始值上下波动保证了运气好时遇到弱敌轻松获胜运气差时遇到强敌艰难作战经过多次养成后属性成长的玩家即使遇到强敌也有胜算策略2属性上限控制战斗属性上限50生活属性上限100。玩家不可能全属性满级必须做出取舍想主升力量力量和防御只能二选一想当血牛生命和恢复只能二选一心情影响伤害也影响恢复不能完全放弃策略3随机波动±20%的伤害波动让即使是劣势方也有翻盘的机会体现了打架的偶然性。7.3 玩家成长曲线预估天数事件次数打架次数预计力量预计生命32112-1680-10064214-2080-10096316-2480-1001510520-3080-100注生命值在战斗中会大量消耗每次战斗结束后恢复至30如果战败或100左右如果战胜因此生命值的提升主要依靠事件中的hp选项。八、项目结构与代码统计8.1 文件结构Index.ets (525行) ├── 类型定义 (~30行) │ ├── enum AttrType │ ├── interface DormSkill / DormCharacter │ └── interface DormEvent / EventChoice │ ├── 游戏数据 (~60行) │ ├── 7个随机事件含3×721个选项 │ └── 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 / statChip8.2 代码量分布模块行数占比类型定义~306%游戏数据事件技能~6011%游戏逻辑~8015%UI辅助~306%UI视图~32062%与之前几个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()文本截断全局APIAPI用途ArkTSMath.random()随机数✅Math.floor()向下取整✅Math.max()/Math.min()值范围限制✅Number.parseInt()字符串转整数✅setTimeout(cb, ms)延迟执行✅String.startsWith()前缀判断✅