面向对象设计 - 法术

7
我正在开发我的第一个Java项目,这是一个基本的角色扮演游戏。现在我正在开发法术部分,需要一些面向对象设计的指导。
我有一个抽象类Character,它有一些子类(如mage,fighter,rogue,cleric)。
Mage和cleric(目前而言,cleric没有mana,但可能会改变)都是施法者。
我还有一个Spell类,其中包含一些信息(如spell name,mana cost等)。MageSpellsList和ClericSpellsList是另外两个类,它们都有Spell类的列表。我还有一个Effects类(施法时应该使用它)。
如何进行良好的面向对象设计以处理法术(解决方案不应包括Effects类,我可以稍后处理)?
也许使用“SpellCaster”接口,其中包括一些方法,如castSpell和showSpellbook,这样Mage和Cleric将实现该接口?
也许MageSpellsList和ClericSpellsList应该是Spell的子类?我的目标是使用castSpell(“spell name here”)并让castSpell完成工作,通过良好的面向对象设计,而不是为每个法术编写特定的方法(并且在mage和Cleric之间没有重复代码)
Mage.java:
public class Mage extends Character {

    private List<Spell> spellBook;
    private int mana;
    private int CurrentMana;

    public Mage(String name) {

        super(name);

        setName(name);
        setCharacterClass("Mage");
        setLevel(1);
        setHitDice(4);

        setStrength(10);
        setConstitution(10);
        setDexterity(14);
        setIntelligence(16);
        setWisdom(14);
        setCharisma(10);

        setHp((int) (4 + getModifier(getConstitution())));
        setCurrentHp(getHp());
        setArmorClass(10 + getModifier(getDexterity()));
        setBaseAttackBonus(0);

        setMana(20 + 2 * getModifier(getIntelligence()));
        setCurrentMana(getMana());
        spellBook = new ArrayList<Spell>();

    }

    public int getMana() {
        return mana;
    }

    public int getCurrentMana() {
        return CurrentMana;
    }

    protected void setMana(int mna) {
        mana = mna;
    }

    protected void setCurrentMana(int CurrMana) {
        CurrentMana = CurrMana;
    }

    public void showSpellBook() {

        for (Iterator<Spell> iter = spellBook.iterator(); iter.hasNext(); ) {
            Spell spell = iter.next();
            System.out.println("Spell name: " + spell.getSpellName());
            System.out.println("Spell effect: " + spell.getEffect());
        }
    }

    public void addToSpellBook(String spellName) {

        Spell newSpell;
        newSpell = MageSpellsList.getSpell(spellName);
        spellBook.add(newSpell);
        System.out.println(newSpell.getSpellName() + " has been added to the spellbook");

    }


    public void chooseSpells() {
        System.out.println();
    }

    void castSpell(String spellName, Character hero, Character target) {
        try {
            Spell spell = MageSpellsList.getSpell(spellName);
            System.out.println("You casted: " + spellName);
            System.out.println("Spell effect: " + spell.getEffect());
        } catch (Exception e) {
            System.out.println("No such spell");
        }
    }
}

Spell.java:

public class Spell {
    private String name;
    private int spellLevel;
    private String effect;
    private int manaCost;
    private int duration;

    Spell(String name, int spellLevel, String effect, int manaCost, int duration) {
        this.name = name;
        this.spellLevel = spellLevel;
        this.effect = effect;
        this.manaCost = manaCost;
        this.duration= duration;
    }

    String getSpellName() { return name; }

    int getSpellLevel() { return spellLevel; }

    String getEffect() { return effect; }

    int getManaCost() {
        return manaCost;
    }

    int getDuration() { return  duration; }
}

MageSpellsList.java:

public class MageSpellsList {
    static List<Spell> MageSpellsList = new ArrayList<Spell>();

    static {
        MageSpellsList.add(new Spell("Magic Missiles", 1, "damage", 2, 0));
        MageSpellsList.add(new Spell("Magic Armor", 1, "changeStat", 2, 0));
        MageSpellsList.add(new Spell("Scorching Ray ", 2, "damage", 4, 0));
        MageSpellsList.add(new Spell("Fireball", 3, "damage", 5,0 ));
        MageSpellsList.add(new Spell("Ice Storm", 4, "damage", 8, 0));
    }

    static void  showSpellsOfLevel(int spellLevel) {
        try {
            for (Iterator<Spell> iter = MageSpellsList.iterator(); iter.hasNext(); ) {
                Spell spell = iter.next();
                if (spellLevel == spell.getSpellLevel()) {
                    System.out.println("Spell name: " + spell.getSpellName());
                    System.out.println("Spell effect: " + spell.getEffect());
                }
            }
        } catch (Exception e){
            System.out.println("Epells of level " + spellLevel + " haven't been found in spells-list");
        }
    }

    static Spell getSpell(String spellName) {
        try {
            for (Iterator<Spell> iter = MageSpellsList.iterator(); iter.hasNext(); ) {
                Spell spell = iter.next();
                if (spellName.equals(spell.getSpellName())) {
                    return spell;
                }
            }
        } catch (Exception e){
            System.out.println(spellName + " haven't been found in spells-list");
            return null;
        }
        return null;
    }
}

Effects.java:

public class Effects {

    public  void  damage(int dice, Character attacker, Character target){

        int damage = DiceRoller.roll(dice);
        System.out.println(attacker.getName() + " dealt " + damage + " damage to " + target.getName());
        target.setCurrentHp(target.getCurrentHp() - damage);
    }

    public static void damage(int n, int dice, int bonus, Character target) {

        int damage = DiceRoller.roll(n,dice,bonus);
        System.out.println("You dealt" + damage + "damage to " + target.getName());
        target.setCurrentHp(target.getCurrentHp() - damage);
    }

    public static void heal(int n, int dice, int bonus, Character target) {

        int heal = DiceRoller.roll(n,dice,bonus);
        if (heal + target.getCurrentHp() >= target.getHp()) {
            target.setCurrentHp(target.getHp());
        } else {
            target.setCurrentHp(target.getCurrentHp() + heal);
        }

        System.out.println("You healed" + heal + " hit points!");
    }

    public static void changeStat(String stat, int mod, Character target){

        System.out.println(stat + " + " + mod);

        switch (stat) {
            case "strength":
                target.setStrength(target.getStrength() + mod);
                break;
            case "constitution":
                target.setConstitution(target.getConstitution() + mod);
                break;
            case "dexterity":
                target.setDexterity(target.getDexterity() + mod);
                break;
            case "intelligence":
                target.setIntelligence(target.getIntelligence() + mod);
                break;
            case "wisdom":
                target.setWisdom(target.getWisdom() + mod);
                break;
            case "charisma":
                target.setCharisma(target.getCharisma() + mod);
                break;
            case "armorClass":
                target.setArmorClass(target.getArmorClass() + mod);
                break;
        }
    }
}

1
两个建议:
  1. 不要在这里使用 List 来表示咒语,而应该使用 SetList 可以有重复元素,而 Set 不行。这也是为什么类名 MageSpellsList 可能不太好:它暴露了实现细节(你恰好使用了 List)。也许只暴露 Collection
  2. 定义一个包含你拥有的统计数据的 enum,并在例如 changeStat 中使用它。
- Tomas
顺便提一句,你可能想把超类Character改个名字,因为这个名字和java.lang.Character冲突了 :) - Tomas
如果name作为参数传递给构造函数,那么您可能不需要调用setName(name)了吧? - Tomas
是的,我知道setName是多余的。一旦我确定有更好的设计,这里的事情就会改变。所以我现在不担心这个。 - Niminim
枚举类型本质上是被赞美的(字符串)常量。与仅有字符串常量相比,主要优势在于它们是完整的类并且可以包含自己的代码,因此通常会比字符串常量更加灵活。我会尝试为您准备一个例子! - Tomas
显示剩余2条评论
5个回答

6

前言

我尽可能地将类通用化,以避免出现许多只代表不同数据而不是不同结构的特定类。此外,我试图将数据结构与游戏机制分开。特别是,我尝试将战斗机制集中在一个地方,而不是将它们分散在不同的类中,并且我尽量不硬编码任何数据。在本回答中,我们将涵盖角色、他们的能力/法术,能力的效果以及战斗机制

角色

考虑一个代表你的角色的PlayableCharacter,这是一个标准的数据类。它提供了增加或减少生命值和法力值的方法,以及可用能力的集合。

class PlayableCharacter {
    private final int maxHealth;
    private int health;
    private final int maxResource;    // mana, energy and so on
    private int resource;
    private final Collection<Ability> abilities;

    // getters and setters
}

能力

能力同样是数据类。它们代表法力值成本、触发效果等等。通常我会将其表示为普通类,然后从外部数据文件中读取各个能力。但这里我们可以跳过这一步,使用枚举来声明它们。

enum Ability {
    FIREBALL("Fireball", 3, 5, new Effect[] {
        new Effect(Mechanic.DAMAGE, 10, 0),
        new Effect(Mechanic.BURN, 2, 3)
    });

    private final String name;
    private final int level;
    private final int cost;
    private final List<Effect> effects;
}

效果

最后,效果告诉我们一个技能的作用。它造成多少伤害,持续多久,如何影响角色。再次强调,这些都是数据,没有游戏逻辑。

class Effect {
    private final Mechanic effect;
    private final int value;
    private final int duration;
}

机制只是一个枚举。
enum Mechanic {
    DAMAGE, BURN;
}

机制

现在是让事情正常工作的时候了。这个类将与您的游戏循环进行交互,您必须提供游戏状态(例如哪些角色正在战斗)。

class BattleEngine {
    void useAbility(PlayableCharacter source, PlayableCharacter target, Ability ability) {
        // ...
    }
}

你如何实现每个机制是由你决定的。它可以从一个恶魔开关或为每个Mechanic编写if/else语句开始,也可以将代码移到Mechanic枚举中,或者将其移到私有嵌套类中,并使用EnumMap来检索每个处理程序。

示例机制

interface MechanicHandler {
    void apply(PlayableCharacter source, PlayableCharacter target, Effect effect);
}

class BattleEngine {
    private final Map<Mechanic, MechanicHandler> mechanics;

    void useAbility(PlayableCharacter source, PlayableCharacter target, Ability ability) {
        source.decreaseResource(ability.getCost());
        for (Effect effect: ability.getEffects()) {
            MechanicHandler mh = mechanics.get(e.getMechanic());
            mh.apply(source, target, effect);
        }
    }

    private static final class DicePerLevel implements MechanicHandler {
        @Override
        public void apply(PlayableCharacter source, PlayableCharacter target, Effect effect) {
            int levels = Math.min(effect.getValue(), source.getLevel());
            int damage = 0;
            for (int i = 0; i < levels; ++i) {
                int roll; // roll a d6 die
                damage += roll;
            }
            target.decreaseHealth(damage);
        }
    }
}

看起来这是一个不错的方法。如果我需要某些能力更灵活,你会推荐我做什么?以伤害法术为例,它可以远远超出常数。对于“灼热光线”的伤害,可以为一个灼热光线增加3d6点伤害 + 每升3级增加另一个灼热光线,最多总共3个光线。也可以只是施法者等级* d6 ,最多10d6。你会推荐我做什么? - Niminim
@Niminim 如果我要使用不同于固定伤害的机制,例如 DAMAGE_DICE_PER_LEVEL,那么这种机制将获取 PlayableCharacter source 的等级,掷骰子并相应地乘以结果。请注意,这只是基本框架。如果您将这些机制实现为独立的方法(或嵌套类),并且向它们提供游戏状态(攻击角色、目标等),它们可以一直延伸到您需要的程度。 - afsantos
你能否编辑代码,为每个施法者等级(最高10d6)造成1d6伤害的拼写逻辑? - Niminim
@asfsantos - 你能添加一些逻辑,以便我可以看到实现的示例吗? - Niminim
2
@Niminim 我添加了一个示例,使用了映射和嵌套类。在这个示例中,机械处理程序计算并处理等于每个角色级别的1d6伤害(最大10级,使用Effect中的'value'字段注册10级)。 - afsantos
感谢您添加示例 :) - Niminim

3
SpellCaster咒语书应该是一个 Map<String, Spell>,这样在施法时可以根据名称查找。 Spell类应该定义一个抽象方法来应用对Character的效果。我认为“SpellCaster”接口没有意义,因为castSpell()方法的实现总是相同的(行为被委托给Spell本身)。
以下是一个示例场景:
Mage fireMage = new Mage("Red Niminim");
fireMage.addSpell(new Fireball());
fireMage.addAttribute(Attribute.RESIST_FIRE);
fireMage.addAttribute(Attribute.WEAK_TO_COLD);

Mage iceMage = new Mage("Blue Niminim");
fireMage.addSpell(new Icestorm());
fireMage.addAttribute(Attribute.RESIST_COLD);
fireMage.addAttribute(Attribute.WEAK_TO_FIRE);

Cleric cleric = new Cleric("Friar Joe");
cleric.addSpell(new Heal());

// battle!

fireMage.castSpell("Fireball", cleric);     // 15 damage
fireMage.castSpell("Fireball", iceMage);    // 30 damage
fireMage.castSpell("Fireball", fireMage);   // 0 damage

iceMage.castSpell("Icestorm", cleric);      // 15 damage
iceMage.castSpell("Icestorm", fireMage);    // 30 damage
iceMage.castSpell("Icestorm", iceMage);     // 0 damage

cleric.castSpell("Heal", cleric);           // 15 healed

Attribute.java

public enum Attribute {
    RESIST_FIRE, WEAK_TO_FIRE, RESIST_COLD, WEAK_TO_COLD;
}

Spell.java

public abstract class Spell {

    private String name;
    private int manaCost;

    public Spell(String name, int manaCost) {
        this.name = name;
        this.manaCost = manaCost;
    }

    public abstract void apply(Character character);

    public String getName() {
        return name;
    }

    public int getManaCost() {
        return manaCost;
    }
}

SpellCaster.java(片段)

public void castSpell(String name, Character character) {
    getSpellBook().get(name).apply(character);
}

public void addSpell(Spell spell) {
    getSpellBook().put(spell.getName(), spell);
}

Fireball.java

public class Fireball extends Spell {

    private static final String NAME = "Fireball";
    private static final int MANA_COST = 8;
    private static final int DAMAGE_AMOUNT = 15;

    public Fireball() {
        super(NAME, MANA_COST);
    }

    @Override
    public void apply(Character character) {
        int damage = DAMAGE_AMOUNT;
        if (character.getAttributes().contains(Attribute.RESIST_FIRE)) {
            damage = 0;
        }
        else if (character.getAttributes().contains(Attribute.WEAK_TO_FIRE)) {
            damage = damage * 2;
        }
        character.setCurrentHp(character.getCurrentHp() - damage);
    }
}

Icestorm.java

public class Icestorm extends Spell {

    private static final String NAME = "Icestorm";
    private static final int MANA_COST = 8;
    private static final int DAMAGE_AMOUNT = 15;

    public Icestorm() {
        super(NAME, MANA_COST);
    }

    @Override
    public void apply(Character character) {
        int damage = DAMAGE_AMOUNT;
        if (character.getAttributes().contains(Attribute.RESIST_COLD)) {
            damage = 0;
        }
        else if (character.getAttributes().contains(Attribute.WEAK_TO_COLD)) {
            damage = damage * 2;
        }
        character.setCurrentHp(character.getCurrentHp() - damage);
    }
}

Heal.java

public class Heal extends Spell {

    private static final String NAME = "Heal";
    private static final int MANA_COST = 10;
    private static final int HEAL_AMOUNT = 15; 

    public Heal() {
        super(NAME, MANA_COST);
    }

    @Override
    public void apply(Character character) {
        character.setCurrentHp(character.getCurrentHp() + HEAL_AMOUNT);
    }
}

1
是的,那肯定可以实现,但这样我就会有数十个只用于咒语的类,而我尽量避免这种情况。 - Niminim
1
@Niminim - 这基本上是策略模式。当你这样做时,类的数量会迅速增加,但你必须以某种方式存储那些信息,而这是在我的看法中最干净的方法,而不是将那些信息放在另一个类中。你可以进一步发展这个想法,制作一个单独的 Spellbook 项目,将其导出为 JAR,加载到你的游戏中,然后你就有了 Spell DLC,你的游戏可以通过这个 Map 方法很好地处理它。 - OneCricketeer
3
你可以改变咒语类的细节程度,但关键点是apply()方法,在Spell超类中是抽象的。 - Joe Coder
1
@Niminim 我同意你的观点。这种方法会导致大量的类。那么游戏开发者如何避免这种情况呢?他们使用脚本。至于我,我肯定会避免为新法术创建一个新类。那太疯狂了。想象一下调整一个法术,你必须重新编译整个游戏。明天我在处理完工作后可能会给你我的解决方案。 - user3437460
1
@Niminim 我已经更新了我的示例,使其更加复杂(添加了字符属性),以更好地说明使用策略模式的好处。 - Joe Coder
显示剩余7条评论

3
这里有一个例子,展示了如何在Effects类中使用enum代替字符串。为了避免与java.lang.Character的冲突,我将您的Character类重命名为PlayerCharacter

Effects.java:

public class Effects {
...
    public static void changeStat(Stat status, int mod, PlayerCharacter target) {
        System.out.println(status + " + " + mod);

        status.effect(mod).accept(target);
    }
}

有点更清晰了,是吗?它是如何工作的?魔法都在enum中:

Stat.java:

import java.util.function.Consumer;
import java.util.function.IntUnaryOperator;
import java.util.function.ObjIntConsumer;
import java.util.function.ToIntFunction;

public enum Stat {
    STRENGTH(PlayerCharacter::getStrength, PlayerCharacter::setStrength),
    CONSTITUTION(PlayerCharacter::getConstitution, PlayerCharacter::setStrength),
    DEXTERITY(PlayerCharacter::getDexterity, PlayerCharacter::setDexterity),
    INTELLIGENCE(PlayerCharacter::getIntelligence, PlayerCharacter::setIntelligence),
    WISDOM(PlayerCharacter::getWisdom, PlayerCharacter::setWisdom),
    CHARISMA(PlayerCharacter::getCharisma, PlayerCharacter::setCharisma),
    ARMORCLASS(PlayerCharacter::getArmorClass, PlayerCharacter::setArmorClass);

    Stat(ToIntFunction<PlayerCharacter> findcurrentvalue, ObjIntConsumer<PlayerCharacter> setnewvalue) {
        this.findcurrentvalue = findcurrentvalue;
        this.setnewvalue = setnewvalue;
    }

    private ToIntFunction<PlayerCharacter> findcurrentvalue;
    private ObjIntConsumer<PlayerCharacter> setnewvalue;

    Consumer<PlayerCharacter> effect(int mod) {
        return target -> {
            setnewvalue.accept(target, findcurrentvalue.applyAsInt(target) + mod);
        };
    }
}

这两种神秘的类型ToIntFunctionObjIntConsumer函数接口

  • ToIntFunction将某种对象(这里是PlayerCharacter)作为输入并返回一个int
  • ObjIntConsumer将某种对象(这里是PlayerCharacter)和一个int作为输入,并返回无。

如果您愿意,还可以创建自己的函数接口,如下所示:

Effect.java:

@FunctionalInterface
public interface Effect<T extends PlayerCharacter> {
    void affect(T t);
}

Stat.java:

    ...
    Effect<PlayerCharacter> effect(IntUnaryOperator calculator) {
        return target -> {
            setnewvalue.accept(target, calculator.applyAsInt(findcurrentvalue.applyAsInt(target)));
        };
    }
    ...

那么您可以在"changeStat"函数中这样做:
public class Effects {
...
    public static void changeStat(Stat status, int mod, PlayerCharacter target) {
        System.out.println(status + " + " + mod);

        status.effect(x -> x + mod).affect(target);
    }
}

这样你可以在Effects类中决定会发生什么。嗯,我不认为角色的stats会因为法术而改变太多,但是类似的机制可以用于HP等方面 :)。 x -> x + mod这一部分也可以来自法术本身。它是一个以int为参数并返回int的函数,在Java中被称为IntUnaryOperator
Effects.java:
...
    public static void boost(int dice, PlayerCharacter target) {
        int value = DiceRoller.roll(dice);
        changeStat(Stat.STRENGTH, x -> x + value, target);
    }

    public static void changeStat(Stat status, IntUnaryOperator change, PlayerCharacter target) {
        status.effect(change).affect(target);
    }
...

在这里,咒语(在这种情况下是增强,我刚刚发明的!)将通过调用带有三个参数的changeStat来使玩家的力量(STRENGTH常量)增加骰子点数。它通过以下方式实现:

  1. STRENGTH → 告诉该方法要更改哪个状态。
  2. 一个“公式”用于更改值(请注意,您不需要知道值,只需知道公式!)。
  3. 要影响的目标。

正如您所看到的,这里不需要知道如何找到力量值或将其设置为其他值。所有这些都由enum处理,因此您可以保持您的咒语代码干净整洁。

您甚至可以直接内联changeStat方法到咒语方法中,因为它实际上没有任何“真正”的代码 - 该逻辑隐藏在enum中。

整洁而美观 :)


谢谢你的示例,Thomas!这个实现有一些我还不熟悉的方法和接口。对于一个Java初学者来说,它看起来比我想要的解决方案要复杂一些。 - Niminim
2
我认为有趣的是Java 8导致Java开发人员使用设计模式,这些模式自C函数指针以来我就没有见过了。 - Joe Coder
@JoeCoder 作为一个老的 C 程序员,我完全明白你的意思 :) - Tomas

1
我认为你的想法是个好主意,创建一个包含castSpell()方法的SpellCaster接口可以定义角色的行为或能力。
在Mage或Cleric类中,我会将可用法术列表作为实例字段。也许创建一个继承自Character的抽象类叫做SpellCaster会是个好主意。SpellCaster类可以声明法术列表,而子类(如Mage和Cleric)则可以添加特定的法术。
暂时不需要Effects类。每个法术可以处理自己的行为。例如,调用castSpell("spellName", hero, target)时,可以将所需参数传递给法术对象,它可以负责造成伤害或更改状态。
此外,可能会有多个Spell子类。例如,DamageSpellBuffDebuff。超类Spell有一个apply()方法,每个子类都可以使用自己的行为实现它。调用castSpell()时,您将控制委派给Spell的特定子类,该子类封装了行为,并确切知道它是否应该造成伤害或更改统计数据。这本质上是策略模式

是的,那么Spell应该是一个抽象类。 - Indrek Ots
1
只是一个想法,如果你有其他不是咒语施法者但可以使用一些咒语的角色,那么使用“SpellCaster”接口可能是个好主意。这样就定义了角色可能执行的行为。 - Indrek Ots
好的。那么我将创建一个抽象类Spell和几个子类。还有一个名为SpellCaster的接口,用于施法者和具有类似法术能力的角色。 - Niminim
1
很抱歉要投反对票,但这里有很多错误的建议。首先,您不需要SpellCaster接口;所有施法者在执行castSpell()时具有完全相同的行为,详见下面的答案。此外,建议为非“SpellCasters”使用名为“SpellCasters”的接口是令人困惑且领域模型糟糕的建议。 - Joe Coder
@JoeCoder 很好的观点,在这种情况下,castSpell() 的行为是相同的,我没有完全考虑清楚。 - Indrek Ots
显示剩余4条评论

1
为什么要将法术与技能区别对待?战士职业可能没有魔法法术,但应该能够执行类特定动作,例如旋风斩。
可玩角色类(PlayableCharacter):抽象类,定义了处理资源(再生速率、最大值、对角色的影响)、技能和装备的抽象方法。并实现了所有基础功能。
法力角色类(ManaCharacter):扩展自可玩角色类,将其资源处理为法力。
法师类(Mage):扩展自法力角色类,只需实现定义其可以使用何种装备、执行何种特殊技能等方法。

为什么要将法术与技能区分开来?战士职业可能没有魔法法术,但它应该能够执行类特定的动作,比如旋风斩。我一直有这个想法,但我还没有开始考虑技能和能力。你的方法听起来对长期来说很不错。 - Niminim
你会如何处理法术(包括法术数据和施法)? - Niminim
能力只是角色类中的方法,与SpellCaster.castSpell()没有区别。例如,Fighter.whirlwind()也可以是一种能力。 - Joe Coder

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接