JavaSE 第 5 周,跟着黑马课程写完了「文字格斗游戏」,又用一晚上半脱稿全量重写了一遍。这篇记录踩过的 9 个坑——它们分成两类:编译能过、测试能过,但运行结果是错的,以及重写时新引入的协作边界问题。
项目长什么样
需求文档加上课程,最终形态是一个纯控制台游戏:
- 三层结构:
domain(实体类)/ui(界面交互)/app(入口) - 登录注册:用户名格式+唯一性校验、密码二次确认、5 位验证码(4 字母 1 数字,随机位置)、连续 3 次密码错误锁号
- 角色体系:
Character父类 +HeroCharacter/EnemyCharacter子类,防御姿态用重写takeDamage实现 - 战斗循环:玩家三技能(普攻 / 1.8 倍强力一击 / 生命汲取)、敌人 50% 概率放技能、每 3 胜属性提升、战后回血、随机敌人逐场变强
九个坑
坑 1:判重只比较了第一个字符
public boolean contains(ArrayList<User> list, String username) {
for (int i = 0; i < list.size(); i++) {
char c = username.charAt(i);
if (c == list.get(i).getUsername().charAt(i)) return true;
}
return false;
}
两个错误叠加:i 是"第几个用户"却被拿去戳用户名里的字符(用户数和用户名长度毫无关系,短用户名直接越界);而且只比同下标一个字符就返回 true——注册过 abc123 后,abc999 首字母相同就被判"已存在"。
修法:
public boolean contains(ArrayList<User> list, String username) {
for (int i = 0; i < list.size(); i++) {
if (list.get(i).getUsername().equals(username)) { // 整串比较
return true;
}
}
return false;
}
教训:单字符相等只是必要条件,不是充分条件。
坑 2:三元表达式把"伤害减半"写成了"伤害归 2"
damage /= 2>1 ? damage/2 : 1; // 实际是 damage / (damage/2) = 2
条件 2>1 恒真,三重运算嵌套后每次挨打只掉 2 点血。正确写法:
damage = damage / 2 > 1 ? damage / 2 : 1; // 减半,最低 1 点
教训:a / b > c ? x : y 的优先级是 / → > → ?:。读三元必须按运算顺序拆,不能按空格目测分组。
坑 3:血条掉一滴血就全空——整数除法
int field = (int)((HP / maxHP) * 20); // 50/100 = 0,不是 0.5
int 除 int 结果就是 int,小数在除的那一刻就丢了。修法:
int field = (int)((HP * 1.0 / maxHP) * 20); // 50/100 → 0.5 → 10 格,半血显示半条
先让分子变成 double,后面全按小数算。
坑 4:敌人增强被自己抹掉
c.HP += 10;
c.HP = c.maxHP; // 加完立刻重置,增强白干
正确写法是上限和当前值一起涨:c.maxHP += 10; c.HP = c.maxHP;。
教训:改一行之前先问"下一行会不会把它覆盖掉"。
坑 5:属性点没分完就能建号(视频里没讲)
课程代码只校验了"单项超过剩余点数",没管"总量没分完":
// ❌ 原版:只保证单项不超,不管最后剩多少
for (int i = 0; i < attributes.length; i++) {
int input = sc.nextInt();
if (input > points) input = points;
points -= input;
values[i] = input;
}
// 直接建号——用户哪怕只分配了 5 点,也能开始游戏
HeroCharacter player = new HeroCharacter(username, 100 + values[0] * 10, ...);
修复后(外层包一个重分配循环,分完才放行):
while (true) {
points = 20;
values = new int[3]; // 每轮重置,防残留
for (...) { ... }
if (points == 0) break; // 分完才放行
System.out.println("还有 " + points + " 点未分配,请重新分配");
}
HeroCharacter player = new HeroCharacter(username, ...); // 循环外建号
这算"需求漏洞"而不是语法错误:需求文档写了"共 20 点",但没人保证用户会花完。防御性编程要把这种"合法但不完整"的输入也拦下来。
坑 6:技能写了"消耗 10HP",但没扣
System.out.println("💥消耗10HP,你使用了强力一击...");
enemy.takeDamage(damage2);
// 忘了 player.takeDamage(10);
修法(把扣血补在攻击之后):
if (player.getHP() > 10) {
int damage2 = calculateDamage((int)(player.getAttack() * 1.8), enemy.getDefense());
enemy.takeDamage(damage2);
player.takeDamage(10); // 真实扣掉技能消耗的 10HP
}
教训:文案和代码是两回事,消息里写的副作用必须在代码里真实存在。
坑 7(重写引入):防御被算了两次,高防敌人越打越肥
calculateDamage 已经 attack - defense,takeDamage 里又减了一次防御:
// FightingGame
int damage = calculateDamage(attack, defense); // 14 - 20 → 保底 1
enemy.takeDamage(damage);
// Character.takeDamage
damage -= defense; // 1 - 20 = -19
HP -= damage; // HP 反而 +19!
结果:玩家打重装坦克时,坦克每挨一刀回血 19 点,永远打不死。生命汲取的"自扣 10HP"也会因为防御高变成自奶。
修法:同一个计算只留一个权威出口——防御只在 calculateDamage 减,takeDamage 只负责扣血和归零:
// Character.takeDamage
public void takeDamage(int damage) {
HP -= damage; // 防御不在这里再减
if (HP < 0) {
HP = 0;
}
}
(另一种改法是删掉 calculateDamage 里的减法、只留 takeDamage 减——但那样"保底 1 点伤害"的兜底会失效,所以选择删这里。)
坑 8(重写引入):两个校验被错误嵌套
if (!checkLen(3, 16, username)) {
if (contains(list, username)) { // 长度不合法关"是否已存在"什么事?
System.out.println("用户名必须在3-16之间");
continue;
}
}
长度不合法但用户名不重复时,既不提示也不 continue——2 位用户名直接注册成功。
修法:校验拆平、每条独立提示 + continue;顺序上先本地格式(便宜),后唯一性(真实项目要查库,昂贵):
if (!checkLen(3, 16, username)) {
System.out.println("用户名必须在3-16之间,请重新输入");
continue;
}
if (!checknumber(username)) {
System.out.println("用户名只能由字母、数字组成,不能是纯数字");
continue;
}
if (contains(list, username)) {
System.out.println("用户名已经存在,请重新输入");
continue;
}
u.setUsername(username);
break;
坑 9(重写引入):计数加了两处,场次跳号
胜利分支 count++,继续分支又 count++,第 1 场打完显示"第 3 场":
// ❌ 两处都加
if (!enemy.isAlive()) {
wins++;
count++; // 加一次
break;
}
// ……战后询问
if ("y".equalsIgnoreCase(choice)) {
count++; // 又加一次 → 跳号
continue;
}
修法:胜利只记胜场,场次只在"确定继续下一场"时加一次:
if (!enemy.isAlive()) {
wins++; // 只记胜场
break;
}
if ("y".equalsIgnoreCase(choice)) {
count++;
continue;
}
教训:一个递增只对应一个职责——wins 记胜场、count 记场次,各自只在一处更新。
三个可以带走的习惯
- 写完运算,代一个具体值手动走一遍。上面 9 个坑有 6 个都能靠"代入
[0,1,0,3,12]/50/100这种具体值"当场发现。 - 一个计算只留一个权威出口。攻防计算、自扣血、敌人增强,凡是"两处都能改同一个量"的设计,最后都会算两次或抵消一次。
- 测试能过 ≠ 边界能过。补三类用例:空操作(没零的数组)、极端(全员满值)、单元素(长度为 1)。我自己的力扣题就有"样例过了、提交挂边界"的例子。
写在最后
跟课版写完后,我尝试不看原项目全量重写——结果是"半脱稿":类结构、封装、校验方法能自己写出来,战斗双层循环的控制流还得对照着看。更意外的是重写过程本身引入了 3 个新 bug(坑 7/8/9),全部集中在"跨类协作的边界"上。
所以重写的价值不在于"写出来",而在于它精确地标出了"以为会了"和"真会了"的差距。下一步:week06 集合、Stream、异常——继续。
如果这篇对你有帮助,欢迎点赞收藏;也欢迎在评论区聊聊你踩过的"能编译但结果是错的"的坑。