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

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

Проблем

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

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

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

Състоянието на играта и правилата за него трябва да са на едно място: в клас 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);
}
}
Генераторът на случайни числа идва отвън, през конструктора. В истинската игра ще е Random() без семе, а в примерите е Random(12), за да е изходът винаги един и същ.Полетата са с _ отпред: те са на играта и отвън не се пипат. [None] * 5 е списък с 5 места, на всяко от които още няма нищо (None).

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

Идеята

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

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

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 е колко чудовища са добавени. Новото отива на self._monsters[self._count].
  • _next е кое е следващото за бой. След всеки бой расте с 1, а когато стигне _count, редицата е свършила.
Списъкът е с 5 места. Шестото чудовище не влиза: add_monster хвърля GameError, преди да пише извън списъка. Списъкът в Python може и да расте с append, но тук не расте нарочно: правилото на играта е „най-много 5“.

create_random_monster е бонусът от старото упражнение: чудовище по жребий. randrange(3) дава 0, 1 или 2.

По-точно, за любопитните
  • Старото упражнение пазеше чудовищата в истинска опашка. В Python тя е deque: сама помни кое е първо и не се пълни. Идва в модул „Колекции“: там _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 е за случая, в който една победа стига за две нива.

Полетата _max_health, _level и _experience са нови в Character. С _max_health лечението вече не вдига живота над най-големия. Отвън се четат през свойствата max_health, level и experience, както полетата от първата част.

Пробвай

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

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

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

def gain_experience(self, exp):
self._experience += exp
up = False
while self._experience >= self._level * 100:
self._experience -= self._level * 100
self.level_up()
up = True
return up
def level_up(self):
self._level += 1
self._max_health += 10
self._power += 3
self._health = self._max_health
grok = Barbarian("Грок")
grok.gain_experience(60)
grok.gain_experience(90)
print(grok.level, grok.experience, grok.power)
Отговор

Виж го

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

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

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 е изборът след боя. Магия може само герой, който наследява Healable (isinstance го проверява). Почивката е 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# Касапина и Скелетния крал, а в Python и JavaScript два пъти Скелетния крал.

game = Game(Random(12))
game.create_hero("Грок", "варварин")
game.add_monster(FallenShaman())
game.add_monster(game.create_random_monster())
game.add_monster(game.create_random_monster())
while game.has_monsters():
if not game.start_battle():
print("Край на играта")
break
game.recover("почивка")
Стъпка по стъпка, с думи
  1. Играта получава генератор със семе 12. Героят е Грок, а списъкът има 5 места, още празни.
  2. Всяко add_monster() пише на self._monsters[self._count] и вдига self._count. Второто и третото са по жребий: Скелетният крал и Скелетният крал.
  3. self._next е 0: излиза Падналият шаман. Победен е за 3 рунда и на Грок остават 90/110.
  4. self._next става 1. Почивката е 5 хода по 10 живот: 110/110.
  5. self._next е 1: излиза Скелетният крал. Победен е за 6 рунда и на Грок остават 45/110. Опитът стига за ниво 2: живот 120, сила 23.
  6. self._next става 2. Почивката е 5 хода по 10 живот: 120/120.
  7. self._next е 2: излиза Скелетният крал. Победен е за 5 рунда и на Грок остават 68/120.
  8. self._next става 3. Почивката е 5 хода по 10 живот: 118/120.
  9. self._next е 3, колкото е self._count: has_monsters() връща False и цикълът свършва.

Идеята

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

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

class GameException extends RuntimeException {
public GameException(String message) {
super(message);
}
}
В Python името на своето изключение завършва на Error, като повечето готови: ValueError, TypeError, IndexError.

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

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());
}
}
Изход
Не става: Първо създай герой
Не става: Няма такава раса: дракон
Героят е Грок
Не става: Грок не владее лечебна магия
Не става: Не разбирам „сън“: почивка или магия?

Защо свое изключение, а не готовото ValueError? Защото except GameError хваща само отказите по правилата. Ако в играта има бъг и тя хвърли AttributeError, той няма да се скрие като „Не става“, а ще спре програмата и ще го видиш. Например recover преди да има герой: self._hero е None и hero.restore(10) дава AttributeError: 'NoneType' object has no attribute 'restore'.

Пробвай

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

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

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

Пробвай

Кое липсва?

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

class GameError():
    pass

Упражнение

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

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

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

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

Всеки отказ е GameError, която менюто хваща и казва „Не става: …“. Семето е 12. Редовете се четат с input().

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

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

Изход
Падналият шаман е победен за 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 сила. Всеки трети негов удар е двоен. Метод can_challenge(hero) казва дали героят е поне на ниво 2. Ако не е, start_battle хвърля GameError и Диабло остава в редицата. Пусни го веднъж срещу Грок на ниво 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 с име и ниво, use_skill(target) и upgrade_skill(). Става и сега, с списък от умения в героя.
  • Инвентар. Клас Item с име и ефект, apply_effect(target), и Inventory, в който предметите се добавят и махат. Добавянето става и сега: списъкът расте с append. Махането на елемент от средата идва в модул „Колекции“.
  • Босове с изисквания. Бос, който иска определени предмети и умения, преди да приеме боя, и пуска плячка след победа. Иска инвентара отгоре.
  • Екрани. Меню, карта, инвентар: всеки екран е обект с show(), hide() и update(), а отворените екрани са в стек, за да може „назад“. Стекът идва в модул „Колекции“.
  • Запис и зареждане. Героят, нивото и най-добрият резултат се пишат във файл и се четат при следващото пускане. Файловете идват в модул „Текст и файлове“.

Тест

Провери се

1. Редицата е списък от 5 места, _count е 3, _next е 1. Какво значи това?
2. Героят е на ниво 1 с 80 опит и печели 130 опит. Какво става?
3. Защо играта хваща само GameError, а не всяко изключение?
4. Защо почивката е 5 хода, а не 5 секунди чакане?
5. Защо class GameError(Exception): pass няма __init__?

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