第 4 课 · 血量 → 胜负 → 经验 → 三选一成长

生存三分钟

怪物贴身时,血条告诉你还剩多少机会;击败怪物后,选择一次成长。每次只改一小处,先预测,再运行,最后解释为什么。

未连接

13项通过数:0 / 13

整帧回归:未连接

在课程主页输入GitHub用户名后追踪本次提交。

本节学习目标

  • 读 hp/maxHp,把血量换算成会缩短的血条(01)。
  • 用秒数倒计时做受伤保护与闪烁反馈,一帧两只怪也只扣一次血(02)。
  • 只在战斗模式累计时间,撑满三分钟结算这一局的胜负(03)。
  • 击败怪物才给经验,攒够经验升一级(04)。
  • 升级时暂停模拟、三选一改一项数值;旧子弹按发射时记下的攻击结算(05)。
  • 只改一个数,先预测再实测,解释夹值与参数的区别(06)。

开始前:只读眼前需要的文件

六章,13个TODO分别评分。每个定位标记都有自己的自动结果,06也会真实运行数值实验。起点保留待实现部分,能编译运行不代表已经完成;画面、声音、自然三分钟体验和解释质量仍要现场检查。

从公开库的lesson-4 分支取得课包。已有自己的复刻和upstream时,先保存当前课的改动、用 git status 确认状态,再按 Git手册的取课流程 新建自己的练习分支:

git fetch upstream
git switch -c my-lesson-4 --no-track upstream/lesson-4

已建过my-lesson-4就不再用 -c,先查看自己的分支再继续。origin是你的复刻,upstream是课程上游库;只在自己的练习分支修改,不覆盖上一课。只想先离线读课包,可在lesson-4分支页面点Code → Download ZIP并解压,自动提交评测仍按Git手册走复刻流程;不要用main分支的ZIP代替本课起点。

取课后先启动一次。在 MSYS2 UCRT64 的课包目录执行:

make run

这一课代码不再都挤在一个 main.cpp 里,而是拆开了:main.cpp 只做调度(开窗口、InitGame、每帧 UpdateGame 与绘制),game.cpp 管数据与规则,drawing.cpp 只读数据画画。

能打开窗口只是能启动的未完成起点,不代表六项任务已完成。关闭窗口后,直接到 01定位血条函数,先画固定100像素的绿色条;现在不用执行其他命令或通读字段。

阶段一 · 血量与反馈

01 · 画会缩短的血条

src/drawing.cpp → DrawHealthBar → TODO(L4-01) ? 未连接

在指定文件 Ctrl+F 搜上面的标记,不靠固定行号。需要列出13个位置时才运行 make todo。先读helper旁边几行,找「输入什么、修改什么、返回什么」;留白里的 (void)dt 或 (void)index 只是避免未使用参数警告,写逻辑时可移除。

本项只用 player.hp/maxHp 两个int字段:当前血量与血量上限。drawing.cpp 只读数据画画,别在绘制里改HP。

定位时再看:短main与文件职责

29行的 src/main.cpp 负责开窗口、加载资源、InitGame();循环读按键和一份 GetFrameTime() → UpdateGame(dt) → 镜头、音乐、绘制;退出释放资源。今天不改这条主线。

src/game.h 给当前任务的数据和函数声明;声明末尾的分号告诉你怎样调用,带花括号的定义才执行工作。extern Player player; 是同一份数据的名字,不新建玩家;src/game.cpp 唯一定义数据并处理规则,不要在头文件再定义一份player,也不用新建类。src/drawing.cpp 只读这些数据画画、不改数据,本课第一处改动 DrawHealthBar 就在这里。地图、镜头、对象池、assets.h 与 audio.h 由课包提供,不必通读。

先读 DrawHealthBar 中已有的一行:DrawRectangle(16, 66, 200, 16, GRAY)。五个参数依次是左上角x、y、宽、高、颜色,单位是屏幕像素。它先画灰背景;在紧接的 TODO(L4-01) 后新增绿色前景,不删灰底,后画的绿色才盖住背景。沿 DrawGame → DrawHUD → DrawHealthBar 这条短路线理解:main每帧调用DrawGame,所以HUD每帧重新读取血量,不需要自己加绘制循环或先跳遍文件。

  1. 先只写同位置、同高度、宽100的 DrawRectangle(x, y, width, height, GREEN) 调用,make run 看是否覆盖半条灰底。
  2. 再在前景调用前保存局部float比例。先判断maxHp是否大于0:否则比例取0;只有分母有效才用 static_cast<float>(player.hp) / player.maxHp,避免整数 50/100 得0或先做除零。
  3. 把比例夹在0..1,先用纸上25/100、负血、超上限推值;再把200乘比例转换成int宽度,最后用这个宽度替换固定100。每次只接一步,后面的半血与边界实验看绿色条是否符合预测;绘制不修改HP。
血量换算成血条宽度 当前血量除以血量上限得到 0 到 1 的比例,比例再乘以 200 像素得到绿色条宽度。血量 50、上限 100 时比例为 0.5,宽度 100 像素,正好是满条 200 像素的一半。 hp / maxHp 50 / 100 ÷ 比例 0.5 × 200 宽度 100 px 血条 比例 0.5 × 200px = 绿色 100px(灰底满条 200px)
卡住时,只做下一小步

先纸上写「血量 → 比例 → 像素」三列,再在函数里保存局部比例。把小于零、大于一的情况分别处理,最后才转换像素。比如25/100应是0.25、50像素;1/3乘200约66.67,转成整数是66,不四舍五入。若颜色盖住文字,先核对调用位置;这个函数已在HUD背景之后调用,不要搬到世界绘制里面。

第一次受控实验:先读公共初态与撤销方法。

先备份;只在main开头那次 InitGame(); 之后、while 之前插入以下临时块,不放进循环。每次从这份完整初态重开,再在注释处接一个任务的片段;不按移动/射击/Enter/TAB,除非实验要求。

{
    practiceMode = true;
    debugMode = false;
    InitGame();
    // 这里只接一个任务的实验片段。
}

InitGame 保留模式,并给合法出生位置、Playing、HP/maxHP=100/100、attack=1、shotInterval=0.3秒、level=1、xp=0、保护/冷却/生存计时/kills/score=0、空弹怪。战斗实验须在片段里明确设 practiceMode=false。做完删除整个临时块,重新 make run,Enter核对恢复;实验调用不是函数答案。

先算再看:公共初态后只设 player.hp=50;,应半条100像素;再分别试100、0、负数与超过maxHp,应该满/空/空/满,maxHp=0也应安全空条。每次只改这个实验值。走远后HUD仍固定;若半血空了,先查除法,别改镜头。看L4-01结果,再撤销临时设置。

第一次跑01时读:评分命令与红色结果

运行 make test 后找 L4-01;13个标记共用此命令,没有另一个分组目标。报告以 TODO_PROTOCOL L4-v2 开头,TODO_STATUS id passed total 的数字依次对应01、02-A/B/C、03-A/B、04-A/B、05-A/B/C/D、06。起点尚未实现应红;需逐条定位再运行 make test VERBOSE=1,读取PASS/FAIL、定位、预期与实际。终端保持ASCII英文,避免乱码。

TODO_BLOCKED 表示必要前置未完成,检查尚未执行,不是通过,也不是本项已经被证实写错。05-A需要04-B的升级成功路径,06需要05-B的升级效果;其他局部检查尽量直接设置初态。INTEGRATION_STATUS 单独检查整帧联动,不重复计入13项;它仍红时先看涉及的后续任务,不推倒已通过的局部函数。全部13项及整帧都通过才算自动检查完成。

课包应有 tests/test_todos.cpp、tests/test_support.h、tests/fake.cpp;缺文件先确认完整起点,不删目标假通过。

后续任务按需查:字段类型、状态与单位

Player.rect 是Rectangle身体,speed 是float每帧移动量;hp/maxHp/attack/xp/level 是int,invulnerability/shotInterval 是float秒数。Bullet 的 rect/vx/vy/active/damage 分别为Rectangle、float、float、bool、int;Enemy 的 rect/kind/hp/active 分别为Rectangle、EnemyKind、int、bool。survivalTime/shotCooldown 是float秒数,kills 是int,upgradeChoices 是三项UpgradeKind数组。弹怪仍按帧移动,只有计时用秒,别给旧速度再乘dt。

GameState 有 Playing/ChoosingUpgrade/Won/Lost 四值;用 GameState::Playing 写类型所属的值。

02 · 保护、闪烁与受伤声

src/game.cpp → UpdateProtection(float dt) → TODO(L4-02-A) ? 未连接

src/game.cpp → TakeContactDamage() → TODO(L4-02-B) ? 未连接

src/drawing.cpp → PlayerVisible() → TODO(L4-02-C) ? 未连接

本项 player.invulnerability 和参数dt都是float秒数;只减计时,不给仍按帧的移动速度乘dt。

先带着问题读三处已有语句:main中 float dt = GetFrameTime(); UpdateGame(dt); 取得这一帧秒数并传下去;game.cpp的UpdateGame中 UpdateProtection(dt) 每正常帧只调用一次,随后才 UpdateEnemies(dt);UpdateEnemies只看 if (CheckCollisionRecs(enemies[i].rect, player.rect)) 内的 TakeContactDamage();,即每只活动怪确认碰撞后才调用。传dt、循环怪物和碰撞调用都已给,不在TODO里再写一遍。

先手推0.8减0.3得0.5,再减0.6得负数,所以需要夹到0。把「剩余值减本帧秒数,再写回」落成一条局部赋值 player.invulnerability = player.invulnerability - dt;,随后自己补小于0的处理;不要只计算临时结果却忘记写回。接触函数先确认 gameState==GameState::Playing,其他状态提前return;再读保护,受保护也提前return;通过后再组织扣血夹零→立即写保护/播放受伤声→判断致死状态。下面两怪实验中,第一只把共享保护从0写成0.8,第二次调用读的就是这个新值,因此在你写的提前退出处停止,不再扣血;不是每怪各有一份玩家保护。

  1. 先做倒计时:player.invulnerability 减dt一次,最低0;公共初态后先一次设 player.invulnerability=0.8f;,再依次调用 UpdateProtection(0.3f)、UpdateProtection(0.6f),预期约0.5→0。
  2. 再做有效接触:Playing且未保护才扣10HP,最低0,立即设置 INVUL_SECONDS=0.8f 并调用现成 PlayHurtSound()。血量归零立刻设Lost;保护中不扣、不响。接触怪物仍活着。
  3. 最后只控制图像:无保护始终可见;保护时用 static_cast<int>(player.invulnerability * 10) % 2,结果为0显示、1隐藏。PlayerVisible 返回bool;G身体框仍显示,碰撞身体不消失。

只往公共初态接这两只合法普通怪,先预测首帧90还是80,再观察HP和闪烁:

enemies[0] = {player.rect, EnemyKind::Grunt, 1, true};
enemies[1] = {player.rect, EnemyKind::Grunt, 1, true};

首次接触应90,两怪都还在;约0.8秒后仍接触才再伤。02-B的局部检查与独立整帧回归核对次数,现场试听受伤声一次;子弹命中仍是 PlayHitSound,别混用。短调用实验里 TakeContactDamage() 表示一次已经确认的接触事件,它本身不搜索怪物。计时helper可直接单测;整帧暂停由UpdateGame管理,不给所有计时helper重复加门;但TakeContactDamage自身只接受Playing中的接触事件,这个状态前提仍须检查。

再读返回值怎样接到画面:PlayerVisible起点只有固定 return true;;DrawGame已有 if (PlayerVisible()) DrawSprite(SpriteId::Player, player.rect);。true才画角色,false只跳过这一行,身体数据和碰撞不动。可以在这个TODO内暂把固定返回改成 return false;,运行看图像消失,再恢复;这不是最终规则。最终用「无保护返回true;有保护返回余数是否等于0的比较结果」两条路径替换固定返回,保留前面的秒数乘10取整数再模2公式:余数0→比较为true→绘制,余数1→比较为false→跳过绘制。不要直接返回 % 2 的整数结果,因为0转false、非0转true,会把显示规则反过来。不要在判断前留一个永远先执行的return,否则后面的逻辑走不到。

闪烁可先不用怪物:公共初态设保护0.35秒,第一次可见判断的整数段是3、余数1,返回false,图像隐藏;换成0.25秒,整数段2、余数0,返回true,图像显示。自然帧会继续减保护,所以观察的是一段交替过程,不是要求某个小数永久停住。两怪实验先查HP,再查图像,最后试听,一次只排一个问题。

为什么一帧两怪也只伤一次?

第一只有效接触立即写入保护,第二只读到新值便跳过。若等循环结束才保护,两只会各扣10。计时只在整帧入口减一次,不能每遇到一怪减一次。

阶段二 · 撑过三分钟

03 · 撑满三分钟,结算这一局

src/game.cpp → UpdateSurvival(float dt) → TODO(L4-03-A) ? 未连接

src/game.cpp → CheckWin() → TODO(L4-03-B) ? 未连接

survivalTime 是float秒数。战斗模式才累计 survivalTime 秒,封顶 SURVIVAL_SECONDS=180;仍Playing且战斗到时才Won,不能覆盖Lost。练习不生存计时、不自然刷怪,但人工怪物、接触和XP测试有效。死亡在02完成,结算画面由 DrawResult 提供,显示时间、kills、level和score。

只读 game.cpp 末尾 UpdateGame(float dt):它是真暂停/终局的整帧入口。Lost/Won整帧返回;正常帧计时→运动/射击→接触,死亡立即停止;之后命中→胜利→升级。负dt先夹0。测试整帧必须调用它,不能直接调低层函数后声称「暂停失效」。

读末尾门的先后:UpdateEnemies(dt) 可能写Lost,紧接的 if (gameState == GameState::Lost) return; 已拦住致死帧;之后是 HandleHits(); CheckWin();,最后只有Playing才 BeginUpgrade()。这些调用与门由课包提供,不重复添加。由此推导本项职责:UpdateSurvival判断战斗并更新秒数;CheckWin核状态、模式与时间,只负责成立时写Won;不能把任何到时状态都改成Won。先圈条件再写更新,把死亡优先留在现有顺序中。

不用先等三分钟:公共初态接 practiceMode=false; survivalTime=179.99f; UpdateGame(0.02f);,无怪应Won且时间180。另一次加 player.hp=10; 和02的第一只重叠怪,再调这一帧,应Lost而非Won。然后用练习初态只调 UpdateGame(181.0f),时间仍0。自然帧恢复后再演示三分钟。

GameEnded() 由课包提供,仅Won或Lost返回true;ChoosingUpgrade不是结束。因此main传给音乐的是这个bool,选择时音乐继续,终局才暂停。不要把「不是Playing」直接当结束,否则选成长时就误停音乐。相机与绘制仍被main调用,这是继续展示冻结的一局,不是继续推进模拟。

终局等待数据不变。Enter全重置但保留当前模式/调试;TAB切模式并全重置;G仅视觉。检查HP100、经验0、等级1、攻击1、间隔0.3、保护/冷却/计时/kills/score=0、旧弹怪清空、候选重置、镜头回出生点;音乐随重开恢复。查看03-A/B与独立整帧结果。

阶段三 · 经验与成长

04 · 击败怪物才获得经验

src/game.cpp → RewardKill(EnemyKind kind) → TODO(L4-04-A) ? 未连接

src/game.cpp → BeginUpgrade() → TODO(L4-04-B) ? 未连接

kills/player.xp/player.level/player.attack 都是int。先只读 HandleHits 的死亡小分支:实际击败才调用 RewardKill。每只 kills+1、player.xp+10;kind不改变经验,原score仍按配置算。三血重甲前两次命中没有击败,第三次才给经验,接触也不给。

ExperienceNeeded() 课包已提供,固定返回100。你先在 BeginUpgrade 检查Playing、level<6、xp≥100,成立才扣100并升一级,保留余量;这里只做04那一处,抽取和暂停留给05。等级初始1,最多6,满级继续攒XP但不弹。

在HandleHits只圈 if (enemies[j].hp <= 0) 及里面的 RewardKill(enemies[j].kind):死亡条件成立才进入。里面已有退出活动和 score += ConfigOf(enemies[j].kind).score;,RewardKill只补击败数与XP,不重复score、不再扣怪物血。

BeginUpgrade中04-B和05-A是同一条成功路径的前后两步。先把「非Playing、等级已满、经验不足」各自转为失败便退出整个函数的条件;不能仅跳过04的扣经验,却让后面05无条件执行。通过后才扣100、升一级;05时在这条成功路径后接抽候选→设ChoosingUpgrade。04先完成条件与扣升,暂不实现05,不挪走两处TODO。

这里先分清三种数字:命中是子弹确实扣到怪物HP;击败是怪物HP降到零后退出活动;score是按种类给的分数。只有后两者中的击败数量决定固定经验。读死亡分支时圈出调用,不把奖励挪到循环外;循环外既不知道这次是否死亡,也可能一次处理多只。奖励函数不用再扣怪物HP。

预测表自己填:xp=99/100/235、level=1,调用一次 BeginUpgrade() 后应分别为99/1、0/2、135/2(XP/等级)。公共初态改一个xp值再调;04尚无暂停时不放任多帧运行,用04-B结果看单次扣经验/升级。满级6、xp100应不扣不升。实际击败再用战斗验证;别为了变绿给「每次命中」加奖励。

05 · 三选一:分三次停下来

src/game.cpp → BeginUpgrade() → TODO(L4-05-A) ? 未连接

src/game.cpp → ApplyUpgrade(int index) → TODO(L4-05-B) ? 未连接

第一小段 · 先出现选择,再恢复

UpgradeKind 四值是 Attack/FireRate/MaxHp/Heal。课包的 FillUpgradeChoices(UpgradeKind out[3]) 已从四种打乱取三个不同值;04升级成立之后,你调用它填写 upgradeChoices,进入ChoosingUpgrade。面板和1/2/3按键课包已接好,传入 ApplyUpgrade 的index是0/1/2。

先就近读UpdateGame的ChoosingUpgrade分支:已有1/2/3调用ApplyUpgrade后无条件return,整帧不会走到计时、移动或末尾升级;末尾又只有Playing才BeginUpgrade。暂停门和选择当帧返回已提供,不在helper里复制。回到BeginUpgrade确认04任何条件失败都会退出整个函数,05抽候选与改状态只能接在成功扣XP/升等级之后。

这一小段先只做合法选择与恢复,不急着填四种效果:只接受ChoosingUpgrade中的0..2,完成一次便回Playing,非法索引或其他状态不改数据。游戏规则是选择期间暂停所有模拟,UI与音乐继续;现场只观察下面初态能看见的项目。

先按 公共合法初态 重开,再在触发升级之前接下面整个片段:保护0.5秒、冷却0.2秒、一颗待飞行旧弹和一只远处普通怪。练习模式不自然刷怪,避免自然怪干扰;这两个人工对象仍有效。默认镜头下y=560的两个对象在面板底边540与底HUD顶边646之间,且不碰现有墙,便于看见位置。invulnerability/shotCooldown 都是float秒数,旧弹的vx=9是每帧位移,damage=1是int快照;位置和活动标记按Bullet/Enemy现有字段顺序填写,不改刷怪逻辑。

player.invulnerability = 0.5f;
shotCooldown = 0.2f;
bullets[0] = {{700, 560, 10, 10}, 9, 0, true, 1};
enemies[0] = {{900, 560, 40, 40}, EnemyKind::Grunt, 1, true};
player.xp = 100;
BeginUpgrade();

面板出现、等级2、xp0。先等,再按W和箭头,肉眼只看旧弹与人工怪的位置停住;按一个数字的选择帧仍不运动,下一帧旧弹飞行、怪重新追来。角色与保护闪烁被面板遮住,不作为现场观察项;练习时间原本为0、不自然刷怪,也不能据此证明暂停。角色、保护、生存计时、冷却及自然刷怪的暂停交 独立整帧检查;保留非零保护与冷却作为整帧检查条件,自动验证不等于肉眼现场验证。先只做恢复时四效果还没写,数值不增长是允许的暂态,05的四个标记全通过要等三段完成。停下来演示这两个可见对象的暂停和恢复,再往下写。

第二小段 · 一次只写一种效果

upgradeChoices 是三项UpgradeKind数组。先验证状态是ChoosingUpgrade且index在0..2,不成立就退出;验证通过后才读取 upgradeChoices[index],否则可能越界。给每种效果自行列「读哪些字段、写哪些字段、哪里夹上限」,再用已学的switch/分支组织,效果应用完才回Playing。不要把索引误当选项类型,随机后「1」不总是攻击。

  • Attack:player.attack 加1。
  • FireRate:player.shotInterval 乘 GetUpgradeParameters().fireRateMultiplier(默认0.8),最低 MIN_SHOT_INTERVAL=0.12 秒。
  • MaxHp:maxHp加20,hp也加20并夹上限,保留缺血量;70/100变90/120。
  • Heal:hp加 GetUpgradeParameters().healAmount(默认30)夹到maxHp;90/100变100/100,不加上限。

课包提供的 GetUpgradeParameters() 返回本次使用的参数,只读其两个字段即可;主线不要硬编码30与0.8,否则06的实验值无法进入真实效果。其余两个效果及上下限规则不变。

四效果逐个从 公共合法初态 重开验证。例如接 player.hp=70; gameState=GameState::ChoosingUpgrade; upgradeChoices[0]=UpgradeKind::MaxHp; ApplyUpgrade(0);,应90/120与Playing;在同一场景再调一次 ApplyUpgrade(0) 不应再加。替换选项/血量前重新建立初态,再预测对应效果。

XP余量另开一次实验,不沿用刚才回血后的状态:从公共合法初态重开,设 player.xp=235;,再调用一次 BeginUpgrade(); 触发第一面板,应level2、xp135、ChoosingUpgrade。只按一次数字:选择帧应用一次并回Playing,XP仍135、等级仍2,这一帧始终返回;下一帧由 UpdateGame(dt) 才再次检查条件、扣100、升至3级并弹第二面板,余35。不要在选择帧直接调 BeginUpgrade() 代替下一帧入口;第二次选择须再新按一次数字,不能一键连选。满级用另一份公共初态,先设level6、xp100,再触发,应不扣不升、不弹面板。停下来解释两种回血的区别与余量。

第三小段 · 射速要能体验,旧弹不追溯

src/game.cpp → FireBullets() → TODO(L4-05-C) ? 未连接

src/game.cpp → FireBullets() → TODO(L4-05-D) ? 未连接

player.shotInterval/shotCooldown 都是float秒数,bullet.damage/player.attack 是int。只看该函数两处:冷却>0时不能新发射;成功找到空槽才把 player.attack 记到 bullet.damage,把 shotCooldown 设为当前间隔。池满不设冷却、不响。课包已在UpdateGame每帧减冷却夹0;按住箭头发射,方向优先Right>Left>Up>Down,每次一颗、不斜射。

把规则接到已有找槽代码:先看函数前面的TODO05-C,再看循环里的 if (bullets[i].active) continue; 和成功创建后的 return;。continue跳过活动槽;空槽才执行创建区,return保证本次只发一颗。冷却未归零的提前退出应写在05-C处、进入找槽之前。05-D在空槽创建区,把现有 bullets[i].damage = INITIAL_ATTACK; 的右侧替换成当前玩家攻击,不另造bullet变量;在同一成功区设置shotCooldown,再沿已有声音与return退出。若所有槽都活动,循环只continue并走到函数末尾,不能设置冷却。方向、找槽、声音调用都已给,不移动或重写它们。

快照的理由先用纸上时间线解释:发出旧弹时攻击1,选择界面暂停,攻击升到2,恢复后旧弹命中;它携带的damage仍是1。若命中时再读取player.attack,就会把旧弹也变强。间隔表示「两次成功发射之间等多久」,数值越小才越快;射速提升不改变子弹飞行速度。

子弹伤害在发射时记下,不随后续升级改变 子弹发射时把当时的攻击力记进 damage。升级把攻击从 1 提到 2 后,已经飞出的旧子弹命中仍只扣 1,只有升级后新发射的子弹才带 2。 升级前 attack=1 升级后 attack=2 发射旧弹 · damage 记下 1 旧弹命中 · 仍扣 1 升级后新弹 · damage 2

先按住右键比较0.3与一次FireRate后的0.24秒;再试多次降到0.12不再降低。攻击实验:让一颗damage=1的旧弹存在,再升级attack到2,新弹damage=2,旧弹仍1。用05-C/D局部检查验证满池与快照,不必手打128颗。第三次停下来讲「出生时记下」和「随时读取」的区别,再跑01–05的12个标记及独立整帧回归。

阶段四 · 自己做实验

06 · 只改一个数,预测再实测

src/my_experiment.h → myExperiment → TODO(L4-06) ? 未连接

先完成05-B并保留主线。第06项不直接改 ApplyUpgrade 的常量:只在 myExperiment 提交一个实验参数。评分器从同一初态分别调用真实升级函数,输出基线与实验;普通游戏仍用回血30、射速倍率0.8,测试后自动恢复。

  1. 选变量、定初态。variable 选 ExperimentVariable::HealAmount 或 ExperimentVariable::FireRateMultiplier,设 submitted=true。只改选中的 healAmount(int,1..100,与30不同)或 fireRateMultiplier(float,大于0且小于1,与0.8不同);另一个保持默认。normal 和 boundary 是两个 ExperimentScenario,各填合法 hp/maxHp/shotInterval:1≤HP≤maxHp≤10000,间隔为有限的0.12..10秒。normal须让基线和实验都不夹值且结果不同;boundary须从未满血或大于0.12秒的间隔出发,让两次都触到回血上限或射速下限。只在这里换初态,不同时改成长规则。浮点绝对差小于0.0001按相等处理;倍率变化和normal结果差异应明显大于此容差,记录保留输出的小数。
  2. 先写预测,再测量。每个场景的 predictedBaseline/predictedExperiment 填一次升级后的预测:回血看HP,射速看间隔秒数。回血先相加再夹到maxHp;射速先乘倍率再取0.12下限。保留这次预测,即使它错了也不倒填。暂留 recorded=false,运行 make test,找 [EXPERIMENT] normal 与 [EXPERIMENT] boundary 的baseline/experiment实测;此时06因记录未完成仍红是正常的。若显示BLOCKED先补05-B;若初态/参数非法先改输入,不能把未运行当测量。
  3. 记录、分析,再核对。将实际数值填入 observedBaseline/observedExperiment,再设每个场景 recorded=true。predictionMatched 是bool,只有两次预测都符合实测才true;actualChange 填实验实测−基线实测;baselineError/experimentError 分别填实测−对应预测,保留正负号。explanation 自己解释规则或修正原因,不靠文字非空给分。再运行 make test:观察和分析必须吻合真实运行,预测错但正确修正仍可通过。
填字段时再看:不用猜长花括号顺序

ExperimentSpec 保存变量和两个场景;ExperimentScenario 保存初态、预测、实测与分析。可以在原有初始化处用下面这个写法逐项赋值,仍只保留一份myExperiment,不改tests。示例只展示写法,不给完成实验答案:

inline ExperimentSpec myExperiment = [] {
    ExperimentSpec e;
    // 在这里逐项填写,例如 e.normal.hp = 你选择的合法血量;
    // e.variable、实验参数、normal/boundary 的预测与记录由你填写。
    return e;
}();

普通场景先算离上限/下限有多远,再选参数;边界场景只改初态去触边界。某个结果不同就对照预测误差,区分参数带来的变化与原来的夹值规则。测试会恢复参数,不需要把实验数值硬写回主线。清空myExperiment会回到未提交,不是撤销主线功能。

课后:补主线,再二选一

A · 成长数值实验:接06,在两个场景通过后另选初态探索:预测夹值何时开始遮住参数变化,带基线/实验实测与解释,不追加新系统。

B · 操作序列:设计两条序列,其中一条含「选择 → Enter → TAB → G」。逐步预测模式、状态、HP、XP、等级与计时,再运行核对。未完成主线先补;不用修改固定tests换绿。

保留完整课包与主线版本,按Git手册在自己的练习分支提交,记下未解问题与下次函数入口。自动检查不能代替现场画面和声音验收。