从跟课到脱稿重写:一个文字格斗游戏里踩过的 9 个坑

15 阅读6分钟

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 记场次,各自只在一处更新。


三个可以带走的习惯

  1. 写完运算,代一个具体值手动走一遍。上面 9 个坑有 6 个都能靠"代入 [0,1,0,3,12]/50/100 这种具体值"当场发现。
  2. 一个计算只留一个权威出口。攻防计算、自扣血、敌人增强,凡是"两处都能改同一个量"的设计,最后都会算两次或抵消一次。
  3. 测试能过 ≠ 边界能过。补三类用例:空操作(没零的数组)、极端(全员满值)、单元素(长度为 1)。我自己的力扣题就有"样例过了、提交挂边界"的例子。

写在最后

跟课版写完后,我尝试不看原项目全量重写——结果是"半脱稿":类结构、封装、校验方法能自己写出来,战斗双层循环的控制流还得对照着看。更意外的是重写过程本身引入了 3 个新 bug(坑 7/8/9),全部集中在"跨类协作的边界"上。

所以重写的价值不在于"写出来",而在于它精确地标出了"以为会了"和"真会了"的差距。下一步:week06 集合、Stream、异常——继续。


如果这篇对你有帮助,欢迎点赞收藏;也欢迎在评论区聊聊你踩过的"能编译但结果是错的"的坑。