Към съдържанието
wannacode

RPG 3: цялата игра

Проблем

От един бой към игра

Героите и чудовищата вече се бият. Играта обаче е повече от един бой: създаваш герой, нареждаш чудовищата, с които ще се бие, и той минава през тях едно по едно. Между боевете почива или се лекува с магия.

Ако всичко това е в main, там ще има героя, масив с чудовищата, номера на следващото, броячи, проверки. А и кой ще пази играча да не започне бой без герой или да поиска магия от варварин?

Състоянието на играта и правилата за него трябва да са на едно място: в клас Game.

Идеята

Класът Game

Героят и чудовищата са състоянието на играта. Методите на Game са правилата: какво може да направи играчът и какво става след това. Първото е създаването на герой:

private Character hero;
private Monster[] monsters = new Monster[5];
private int count;
private int next;
private Random random;
public Game(Random random) {
this.random = random;
}
public void createHero(String name, String race) {
if (race.equals("паладин")) {
hero = new Paladin(name);
} else if (race.equals("варварин")) {
hero = new Barbarian(name);
} else if (race.equals("вещица")) {
hero = new Witch(name);
} else {
throw new GameException("Няма такава раса: " + race);
}
}
Генераторът на случайни числа идва отвън, през конструктора. В истинската игра ще е new Random(), а в примерите е със семе, за да е изходът винаги един и същ.

Непознатата раса не е повод да се създаде герой наслуки. Методът хвърля GameException: свое изключение за всеки ход, който правилата не позволяват. За него има отделна карта по-нататък.

Идеята

Редица от чудовища

Чудовищата чакат на опашка: кое е добавено първо, с него е първият бой. Тук опашката е масив и два индекса:

public void addMonster(Monster monster) {
if (count == monsters.length) {
throw new GameException("Редицата е пълна: най-много " + monsters.length + " чудовища");
}
monsters[count] = monster;
count++;
}
public Monster createRandomMonster() {
int kind = random.nextInt(3);
if (kind == 0) {
return new FallenShaman();
}
if (kind == 1) {
return new SkeletonKing();
}
return new Butcher();
}
public boolean hasMonsters() {
return next < count;
}
  • count е колко чудовища са добавени. Новото отива на monsters[count].
  • next е кое е следващото за бой. След всеки бой расте с 1, а когато стигне count, редицата е свършила.
Масивът е с 5 места. Шестото чудовище не влиза: addMonster хвърля GameException, преди да пише извън масива.

createRandomMonster е бонусът от старото упражнение: чудовище по жребий.

По-точно, за любопитните
  • Старото упражнение пазеше чудовищата в истинска опашка, Queue<Monster>. Опашката сама помни кое е първо и не се пълни. Тя идва в модул „Колекции“ (ArrayDeque): там count и next изчезват, а редицата расте колкото трябва.
  • Чудовищата преди next остават в масива. Не пречат: никой не ги чете вече.

Идеята

Нива и опит

След всяка победа героят получава опит, колкото е бил животът на чудовището. На ниво 1 трябват 100 опит за ниво 2, на ниво 2 трябват 200 за ниво 3 и т.н. Новото ниво дава 10 живот повече, 3 сила повече и пълен живот:

public boolean gainExperience(int exp) {
experience += exp;
boolean up = false;
while (experience >= level * 100) {
experience -= level * 100;
levelUp();
up = true;
}
return up;
}
public void levelUp() {
level++;
maxHealth += 10;
power += 3;
health = maxHealth;
}

Методите са в Character. Опитът над прага остава за следващото ниво. Цикълът while е за случая, в който една победа стига за две нива.

Полетата maxHealth, level и experience са нови в Character. С maxHealth лечението вече не вдига живота над най-големия.

Пробвай

На кое ниво е варваринът?

Грок започва на ниво 1 със сила 20. Печели срещу шамана (60 опит) и срещу краля (90 опит).

Какво ще отпечата програмата? Ниво, опит, сила.

public boolean gainExperience(int exp) {
experience += exp;
boolean up = false;
while (experience >= level * 100) {
experience -= level * 100;
levelUp();
up = true;
}
return up;
}
public void levelUp() {
level++;
maxHealth += 10;
power += 3;
health = maxHealth;
}
Character grok = new Barbarian("Грок");
grok.gainExperience(60);
grok.gainExperience(90);
System.out.println(grok.getLevel() + " " + grok.getExperience() + " " + grok.getPower());
Отговор

Виж го

Битката и почивката

startBattle първо проверява дали боят е възможен. После взима следващото чудовище и пуска боя до смърт от втората част. Връща дали героят е оцелял:

public boolean startBattle() {
if (hero == null) {
throw new GameException("Първо създай герой");
}
if (!hasMonsters()) {
throw new GameException("Няма чудовища в редицата");
}
Monster monster = monsters[next];
next++;
int rounds = 0;
while (hero.isAlive() && monster.isAlive()) {
rounds++;
hero.attack(monster);
if (monster.isAlive()) {
monster.attack(hero);
}
}
if (!hero.isAlive()) {
System.out.println(hero.getName() + " пада в рунд " + rounds + ". Побеждава " + monster.getName());
return false;
}
System.out.println(monster.getName() + " е победен за " + rounds + " рунда. " + hero.getName() + ": живот " + hero.getHealth() + "/" + hero.getMaxHealth());
if (hero.gainExperience(monster.getReward())) {
System.out.println(hero.getName() + " е на ниво " + hero.getLevel() + ": живот " + hero.getMaxHealth() + ", сила " + hero.getPower());
}
return true;
}

recover е изборът след боя. Магия може само герой, който реализира IHealable. Почивката е 5 хода по 10 живот:

public void recover(String way) {
if (way.equals("почивка")) {
for (int turn = 0; turn < 5; turn++) {
hero.restore(10);
}
System.out.println(hero.getName() + " почива 5 хода: живот " + hero.getHealth() + "/" + hero.getMaxHealth());
} else if (way.equals("магия")) {
if (!(hero instanceof IHealable)) {
throw new GameException(hero.getName() + " не владее лечебна магия");
}
System.out.println(hero.useAbility());
} else {
throw new GameException("Не разбирам „" + way + "“: почивка или магия?");
}
}

В старото упражнение героят чакаше 5 секунди. Тук почивката е в ходове, защото в ходова игра времето се мери в ходове, а и проверката на играта не бива да чака.

Виж го

Цялата игра

Варваринът Грок срещу шамана и две случайни чудовища. След всеки бой почива. Ако падне, играта свършва:

Game game = new Game(new Random(12));
game.createHero("Грок", "варварин");
game.addMonster(new FallenShaman());
game.addMonster(game.createRandomMonster());
game.addMonster(game.createRandomMonster());
while (game.hasMonsters()) {
if (!game.startBattle()) {
System.out.println("Край на играта");
break;
}
game.recover("почивка");
}
Изход
Падналият шаман е победен за 3 рунда. Грок: живот 90/110
Грок почива 5 хода: живот 110/110
Падналият шаман е победен за 3 рунда. Грок: живот 90/110
Грок е на ниво 2: живот 120, сила 23
Грок почива 5 хода: живот 120/120
Касапинът е победен за 6 рунда. Грок: живот 70/120
Грок почива 5 хода: живот 120/120
Сравни изхода в двата таба. Семето е 12 и в двата езика, но генераторите са различни и случайните чудовища са други. В Java Грок среща втори шаман и Касапина, а в C# Касапина и Скелетния крал.

Game game = new Game(new Random(12));
game.createHero("Грок", "варварин");
game.addMonster(new FallenShaman());
game.addMonster(game.createRandomMonster());
game.addMonster(game.createRandomMonster());
while (game.hasMonsters()) {
if (!game.startBattle()) {
System.out.println("Край на играта");
break;
}
game.recover("почивка");
}
Стъпка по стъпка, с думи
  1. Играта получава генератор със семе 12. Героят е Грок, а масивът има 5 места, още празни.
  2. Всяко addMonster() пише на monsters[count] и вдига count. Второто и третото са по жребий: Падналият шаман и Касапинът.
  3. next е 0: излиза Падналият шаман. Победен е за 3 рунда и на Грок остават 90/110.
  4. next става 1. Почивката е 5 хода по 10 живот: 110/110.
  5. next е 1: излиза Падналият шаман. Победен е за 3 рунда и на Грок остават 90/110. Опитът стига за ниво 2: живот 120, сила 23.
  6. next става 2. Почивката е 5 хода по 10 живот: 120/120.
  7. next е 2: излиза Касапинът. Победен е за 6 рунда и на Грок остават 70/120.
  8. next става 3. Почивката е 5 хода по 10 живот: 120/120.
  9. next е 3, колкото е count: hasMonsters() връща false и цикълът свършва.

Идеята

Неправилен ход: GameException

Играчът може да поиска бой без герой, раса, която я няма, или магия от варварин. Това не е бъг в програмата. Това е ход, който правилата не позволяват. Методите на Game хвърлят за него свое изключение:

class GameException extends RuntimeException {
public GameException(String message) {
super(message);
}
}

Играта хваща само него, казва на играча защо не става и продължава със следващия ход:

Game game = new Game(new Random(12));
String[] commands = { "бой", "герой Грок дракон", "герой Грок варварин", "магия", "сън" };
for (String command : commands) {
String[] words = command.split(" ");
try {
if (words[0].equals("бой")) {
game.startBattle();
} else if (words[0].equals("герой")) {
game.createHero(words[1], words[2]);
System.out.println("Героят е " + words[1]);
} else {
game.recover(words[0]);
}
} catch (GameException e) {
System.out.println("Не става: " + e.getMessage());
}
}
Изход
Не става: Първо създай герой
Не става: Няма такава раса: дракон
Героят е Грок
Не става: Грок не владее лечебна магия
Не става: Не разбирам „сън“: почивка или магия?

Защо свое изключение, а не готовото IllegalArgumentException? Защото catch (GameException e) хваща само отказите по правилата. Ако в играта има бъг и тя хвърли NullPointerException, той няма да се скрие като „Не става“, а ще спре програмата и ще го видиш.

Пробвай

Намери грешката

Шестото чудовище трябва да получи „Редицата е пълна“. Вместо това програмата спира с ArrayIndexOutOfBoundsException. На кой ред е грешката?

Избери реда с грешката.

Пробвай

Кое липсва?

От кой клас наследява GameException?

class GameException extends  {
    public GameException(String message) {
        super(message);
    }
}

Упражнение

Играй и разширявай

Реши задачите в редактора си. Започни от класовете в урока.

1. Меню. Командите идват от входа, по една на ред, до „край“:

  • „герой Име раса“ създава героя;
  • „чудовище шаман“ (или „крал“, „касапин“) добавя готово чудовище, а „чудовище Име живот сила“ добавя свое: клас Beast от расата „звяр“;
  • „случайно“ добавя случайно чудовище;
  • „бой“ пуска следващия бой, а „почивка“ и „магия“ са изборът след него.

Всеки отказ е GameException, която менюто хваща и казва „Не става: …“. Семето е 12.

Вход
герой Морена елф
герой Морена вещица
чудовище Вълк 50 9
чудовище дракон
чудовище шаман
случайно
бой
магия
бой
почивка
бой
край
Изход
Не става: Няма такава раса: елф
Героят е Морена
В редицата: Вълк
Не става: Няма такова чудовище: дракон
В редицата: Падналият шаман
В редицата: Падналият шаман
Вълк е победен за 4 рунда. Морена: живот 68/95
Морена призовава стихиите: живот 83/95
Падналият шаман е победен за 4 рунда. Морена: живот 47/95
Морена е на ниво 2: живот 105, сила 19
Морена почива 5 хода: живот 105/105
Падналият шаман е победен за 4 рунда. Морена: живот 69/105

2. Напредък. Добави в Game метод showProgress(), който показва нивото и колко опит има героят от колко нужни за следващото ниво. Паладинът Ардан минава през два шамана и Скелетния крал. След всеки бой показва напредъка и се лекува с магия.

Изход
Падналият шаман е победен за 5 рунда. Ардан: живот 72/120
Ниво 1, опит 60/100
Ардан се лекува: живот 92/120
Падналият шаман е победен за 5 рунда. Ардан: живот 44/120
Ардан е на ниво 2: живот 130, сила 17
Ниво 2, опит 20/200
Ардан се лекува: живот 130/130
Скелетният крал е победен за 8 рунда. Ардан: живот 18/130
Ниво 2, опит 110/200
Ардан се лекува: живот 38/130

3. Бос. Клас Diablo, наследник на Monster: 160 живот, 14 сила. Всеки трети негов удар е двоен. Метод canChallenge(Character hero) казва дали героят е поне на ниво 2. Ако не е, startBattle хвърля GameException и Диабло остава в редицата. Пусни го веднъж срещу Грок на ниво 1, после в нова игра след шамана и краля.

Изход
Не става: Диабло се бие само с герои от ниво 2 нагоре
Падналият шаман е победен за 3 рунда. Грок: живот 90/110
Грок почива 5 хода: живот 110/110
Скелетният крал е победен за 6 рунда. Грок: живот 45/110
Грок е на ниво 2: живот 120, сила 23
Грок почива 5 хода: живот 120/120
Диабло е победен за 7 рунда. Грок: живот 24/120
Грок е на ниво 3: живот 130, сила 26
Грок почива 5 хода: живот 130/130

Продължи сам

Старото упражнение предлагаше още идеи. Някои искат инструмент, който идва по-нататък:

  • Умения с ниво. Клас Skill с име и ниво, useSkill(Character target) и upgradeSkill(). Става и сега, с масив от умения в героя.
  • Инвентар. Клас Item с име и ефект, applyEffect(Character target), и Inventory, в който предметите се добавят и махат. С масив е тромаво. Списъкът ArrayList идва в модул „Колекции“.
  • Босове с изисквания. Бос, който иска определени предмети и умения, преди да приеме боя, и пуска плячка след победа. Иска инвентара отгоре.
  • Екрани. Меню, карта, инвентар: всеки екран е обект с show(), hide() и update(), а отворените екрани са в стек, за да може „назад“. Стекът идва в модул „Колекции“.
  • Запис и зареждане. Героят, нивото и най-добрият резултат се пишат във файл и се четат при следващото пускане. Файловете идват в модул „Текст и файлове“.

Тест

Провери се

1. Редицата е масив от 5 места, count е 3, next е 1. Какво значи това?
2. Героят е на ниво 1 с 80 опит и печели 130 опит. Какво става?
3. Защо играта хваща само GameException, а не всяко изключение?
4. Защо почивката е 5 хода, а не 5 секунди чакане?
5. GameException наследява Exception вместо RuntimeException. Какво става?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·