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

Играта, която ще направим

Накратко: „Хрониките на Арканум“ е цяла RPG игра в конзолата, която се прави урок по урок: карта с поле на видимост, бой по бързина, рани, предмети, куестове и запис, в един файл на всеки от четирите езика.

Проблем

Игра, която да играеш

Досега игрите бяха упражнения: раси, които наследяват Character, бой от десет хода, стаи с команди. Те учеха езика, а играта беше повод. Никой не би ги играл втори път.

Сега правим обратното: цяла игра, която се играе, и всяка техника идва, защото играта я иска. Карта, която се открива, докато вървиш. Бой, в който редът зависи от бързината, а раната кърви и следващите ходове. Врагове, които те гонят и бягат, когато са зле. Предмети, магазин, нива, диалози, куестове, запис. И отдолу стек от екрани, който държи всичко подредено.

Казва се „Хрониките на Арканум“. Миньорите от старата мина под Сивия брод изчезват, а по пътеката към нея има кръв. Слизаш три нива надолу. На дъното те чака Пазачът на дълбините.

Пробвай

Играй

Това е истинската игра: JavaScript версията, пусната в браузъра. Избери клас, говори със старейшината, купи си нещо и слез в мината. На картата си @, а буквите са врагове. Ако тръгнеш срещу враг, започва бой.

Хрониките на Арканум

Мината под Сивия брод мълчи от три дни. По пътеката има кръв.

Щракни върху екрана, за да играеш с клавишите (стрелки или WASD, I, M, цифри); Enter отива на реда за команди. Записите остават в този браузър.

Командите на картата са w a s d (или стрелките, като щракнеш върху екрана), g взима, i е инвентарът, c е героят, m е менюто със запис и зареждане. В боя: 1 удар, 2 умение, 3 предмет, 4 бягство, а целта се избира с цифра. ? показва помощ.

По-точно, за любопитните

Играта в браузъра е същият файл, който ще видиш в уроците и ще пуснеш на компютъра. Само блокът, който в Node чете аргументите и пише записите във файлове, е заменен: тук входът идва от полето под екрана, а записите са в паметта на браузъра (localStorage). Всяка нова игра е с ново семе, затова светът е различен.

Идеята

Какво има в нея

  • Три класа. Варварин (много живот, брадва, която кара раните да кървят, „Ярост“), амазонка (копие, повече критични удари, „Двоен изстрел“) и вещица (малко живот, мана, огън, отрова и лечение). Всеки има дърво от умения с два клона.
  • Карта с поле на видимост. Виждаш само това, до което има пряка линия, а видяното остава в паметта (мъгла). Три нива, стълби, вода, врати, предмети по земята.
  • Бой, в който редът е по бързина. Точност срещу избягване, щета според оръжието, силата и защитата, критични удари, видове щета: режеща, пробождаща, тъпа, огън, отрова.
  • Рани и ефекти. Режещото оръжие отваря рана, която кърви всеки ход, и раните се трупат. Кървящият враг оставя следа по картата, а по нея можеш да го проследиш до леговището му. Има и отрова, горене, зашеметяване, регенерация, ярост и щит.
  • Врагове, които мислят. На картата спят, бродят, гонят те по най-краткия път и бягат, когато са зле. В боя преценяват какво им носи най-много: удар, умение или лечение.
  • Предмети от таблици. Оръжия, брони, пръстени и отвари, плячка с тегла, магазин, инвентар с места.
  • Нива, диалози, три куеста, шина от събития, запис и зареждане (развален запис се отказва с обяснение), повторение на боя и симулация за баланса.

Цялото минаване, с което се проверява играта, е 230 команди и 4396 реда изход: от създаването на героя до победата над шефа.

Идеята

Картата на кода

Играта е един файл от около 5500 реда на всеки език. Един файл, за да се пуска с една команда. Вътре е разделена на части (региони), а всяка част е в един от шест слоя:

СлойЧастиРедове
Данни и текстовеtext items-data enemies-data skills-data dialogs-data maps-data data-load map-load data-load-all1549
Вход, изход, рисуванеrandom io-text buffer terminal hud308
Екраните и стекътscreen screen-stack title create-hero town shop map-screen battle-screen replay-screen target-screen overlays1259
Светът и правилатаevent-bus entity inventory equip loot xp-levelup quests dialog level fov game save load1389
Бой, ефекти, изкуствен интелектeffects bleeding combat-order attack actions replay ai-battle pathfind ai-map857
Пускане и балансsimulate main node176

Числата са от JavaScript версията и се смятат при строенето на сайта. Близо половината редове са данни и текстове: предметите, враговете, картите и диалозите са таблици, а всеки текст на играта е на четири езика. Логиката не знае името на нито един меч.

По-точно, за любопитните

В истински проект частите са в отделни файлове и пакети: screens/, world/, combat/, data/. Тук са в един файл заради уроците, но границите са същите: кодът на екрана не пипа правилата на боя, а боят не печата нищо сам. Ако искаш да го разделиш, разрязваш по регионите.

Идеята

Цикълът на играта

Сърцето на всяка игра е цикъл, който се върти, докато играта не свърши. Тук той е на две места: стекът от екрани казва кой екран е отгоре, а цикълът го рисува, чете команда и му я дава:

// Цикълът на играта: нарисувай → прочети команда → обработи → обнови света → приложи промените
// в стека. Повтаря, докато стекът не опустее.
// Входът и изходът: io с четири метода. Блокът boot дава конзолата, страницата дава свой io.
interface Io {
void write(String text);
String readLine();
void save(String slot, String text);
String load(String slot);
void close();
}
static void main(Io io, Options options) {
// Семето е 32-битово число без знак (в JavaScript Number(...) >>> 0); тук са същите 32 бита в int.
int seed = (int) (long) options.seed;
if (!options.lang.equals("bg") && !options.lang.equals("en") && !options.lang.equals("de") && !options.lang.equals("es")) {
throw new RuntimeException("unknown language: " + options.lang + " (use bg, en, de or es)");
}
LANG = options.lang;
if (options.memorySaves) io.save("3", tamperedSave());
loadData();
int simulateCount = (int) options.simulate;
if (simulateCount > 0) {
simulate(io, simulateCount, seed);
return;
}
Terminal terminal = options.plain ? new PlainTerminal(io) : new AnsiTerminal(io);
Game game = new Game(io, seed);
CellBuffer buffer = new CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT);
game.stack.push(new TitleScreen(game));
game.stack.apply();
while (!game.stack.isEmpty()) {
buffer.clear();
game.stack.draw(buffer);
terminal.present(buffer);
String line = io.readLine();
if (line == null) {
io.write(t("ui.endOfInput") + "\n");
return;
}
terminal.echo(line);
game.rawInput = trimSpaces(line);
Screen top = game.stack.top();
top.handle(game.rawInput.toLowerCase(Locale.ROOT));
top.update();
game.stack.apply();
}
io.write(t("ui.goodbye") + "\n");
}

Всяка команда е един ход. Цикълът не знае нищо за карти, бой и магазини: той вика draw, handle и update на екрана отгоре, а после прилага промените в стека. Екранът решава всичко останало: картата мести героя, боят пуска удар, менюто записва. Затова нов екран не пипа цикъла.

Ето един истински ход от минаването, проследен отвътре: командата „1“ в боя с гоблина слиза през шестте слоя от картата на кода и се връща нагоре като новия кадър.

// Цикълът на играта: нарисувай → прочети команда → обработи → обнови света → приложи промените
// в стека. Повтаря, докато стекът не опустее.
// Входът и изходът: io с четири метода. Блокът boot дава конзолата, страницата дава свой io.
interface Io {
void write(String text);
String readLine();
void save(String slot, String text);
String load(String slot);
void close();
}
static void main(Io io, Options options) {
// Семето е 32-битово число без знак (в JavaScript Number(...) >>> 0); тук са същите 32 бита в int.
int seed = (int) (long) options.seed;
if (!options.lang.equals("bg") && !options.lang.equals("en") && !options.lang.equals("de") && !options.lang.equals("es")) {
throw new RuntimeException("unknown language: " + options.lang + " (use bg, en, de or es)");
}
LANG = options.lang;
if (options.memorySaves) io.save("3", tamperedSave());
loadData();
int simulateCount = (int) options.simulate;
if (simulateCount > 0) {
simulate(io, simulateCount, seed);
return;
}
Terminal terminal = options.plain ? new PlainTerminal(io) : new AnsiTerminal(io);
Game game = new Game(io, seed);
CellBuffer buffer = new CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT);
game.stack.push(new TitleScreen(game));
game.stack.apply();
while (!game.stack.isEmpty()) {
buffer.clear();
game.stack.draw(buffer);
terminal.present(buffer);
String line = io.readLine();
if (line == null) {
io.write(t("ui.endOfInput") + "\n");
return;
}
terminal.echo(line);
game.rawInput = trimSpaces(line);
Screen top = game.stack.top();
top.handle(game.rawInput.toLowerCase(Locale.ROOT));
top.update();
game.stack.apply();
}
io.write(t("ui.goodbye") + "\n");
}
Стъпка по стъпка, с думи
  1. Команда 56 от минаването: Ива (30/48) е в бой с гоблин копиеносец (16/16). Играчът пише „1“: удар.
  2. Вход, изход, рисуване: io.readLine() връща „1“. За този слой това е само текст: той не знае за боя.
  3. Екраните и стекът: отгоре е боят, под него картата. Горният екран получава „1“ и при един враг вика battle.heroAct(new AttackAction(…)).
  4. Още в top.handle: правилата. Зарът със семе (ръчна брадва, 4–7 в таблицата) дава 6. 6 + сила 7 − защита 1 = 12: „Ива смазва гоблин копиеносец (12).“
  5. Брадвата реже и отваря рана (шанс 35%): „Гоблин копиеносец кърви.“ На хода на гоблина тя взима 1 (16 → 4 → 3): „Гоблин копиеносец: тежка рана!“ Той отвръща: „Гоблин копиеносец драска Ива (1).“
  6. update и apply: никой не е викал push или pop, боят остава отгоре. Боят не е печатал нищо: 5 нови реда чакат в дневника му.
  7. Нова обиколка: екранът рисува в буфера 60 × 18 (Ива 29/48, гоблинът 3/16, „кръв:2“ = още 2 хода, последните 7 реда от дневника), терминалът го печата.
  8. Една команда, шест слоя, всеки със своята работа. Боят не знае нищо за екрана, затова може да върви и без него: това е симулацията.

Цикълът чака реда, без да прави нищо друго (четенето блокира). Когато входът свърши (няма повече редове), играта приключва чисто.

Идеята

Един код, четири езика, един изход

Играта е написана четири пъти: на Java, C#, Python и JavaScript, част по част и със същите имена. Проверката на сайта я пуска на четирите езика със записаните 230 команди и изисква байт по байт един и същ изход. Ако една версия хвърли зара другояче, боят тръгва по друг път и проверката пада.

Как става това, когато всеки език има свой Random? Играта не ползва вградения генератор. Тя има свой, малък и еднакъв навсякъде, само с цели числа:

// mulberry32: малък генератор със семе. Само 32-битови цели числа (int и >>>), затова
// дава една и съща поредица на всеки език.
static class Random {
int state;
Random(int seed) {
this.state = seed;
}
int next32() {
this.state = this.state + 0x6d2b79f5;
int z = this.state;
z = (z ^ (z >>> 15)) * (z | 1);
z = z ^ (z + (z ^ (z >>> 7)) * (z | 61));
return z ^ (z >>> 14);
}
// От 0 до n-1, без дробни числа.
int nextInt(int n) {
return (int) (Integer.toUnsignedLong(this.next32()) % n);
}
// От min до max, двете включени.
int range(int min, int max) {
return min + this.nextInt(max - min + 1);
}
boolean chance(int percent) {
return this.nextInt(100) < percent;
}
}

Същото семе дава същите числа, а същите числа дават същия бой. Това е полезно не само за проверката. Грешка, която играчът съобщи („на ход 412 вълкът ме удари през стена“), се повтаря точно, ако имаш семето и командите. Така работи и повторението на боя в играта.

По-точно, за любопитните

Правилата, за да е изходът еднакъв: деленето е само цяло; колекциите пазят реда на добавяне; сортирането винаги има второ правило (два врага с еднаква бързина се подреждат по номер), защото не всяко сортиране е стабилно. Числата се подравняват ръчно, без форматиране според езика на системата. Изглежда дребно, но всяко от тези правила е хванало разлика между версиите.

Кадър от минаването, на второто ниво на мината:

#.# #####.##### Сребърните жили
. ~~~~~~.~~~~~~ Ниво 2 от 3
~~~~~~.~~~~~~
~~~~~~.~~~~~~ Наоколо:
#####.##### G голем от руда
###########.#############
..................#......#
..................#......#
.G....@...........'......#
..................#......#
..................#....!.#
..................#......#
################# #######
Ива, варварин ур.4 опит 148/170 злато 37
Ж [##########--] 74/85 М [########] 7/7
Взимаш: сребърна руда.
„Сребро за ковачката“: Занеси рудата на ковачката Вела.
Голем от руда се събужда!

И боят с шефа, след като е призовал помощниците си:

=== Бой ===
Ива, варварин ур.6
Ж [#######-----] 63/103 М [###-----] 4/9
1) Пазач на дълбините [###-----] 53/120 гняв
2) Каменен слуга [########] 14/14
3) Каменен слуга [########] 14/14
Пазач на дълбините драска Ива (7).
Ива удря Пазач на дълбините (17).
Пазач на дълбините пропуска Ива.
Ива удря Пазач на дълбините (14).
Пазач на дълбините се пропуква и реве. От пукнатините
излизат два каменни слуги!
Действието на „щит“ върху Ива отминава.
1 удар 2 умение 3 предмет 4 бягство

Идеята

Пусни я на компютъра

Целият файл е тук: [Arcanum.java](/games/arcanum/Arcanum.java), [Arcanum.cs](/games/arcanum/Arcanum.cs), [arcanum.py](/games/arcanum/arcanum.py), [arcanum.js](/games/arcanum/arcanum.js). Сложи го в празна папка и го пусни:

Терминал
java Arcanum.java

В терминала играта чисти екрана и оцветява: кръвта е червена, маната синя, отровата зелена. С --plain печата кадрите като обикновен текст. С --seed=42 светът е винаги един и същ, а с --lang=en е на английски (има и de и es). Записите са в папка saves до играта.

Java 21 пуска файл с много класове направо, без javac.

Идеята

Как ще я строим

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

  1. Цикълът на играта: вход, обновяване, рисуване, ход срещу кадър, семе.
  2. Стек от екрани: екранът като обект, push, pop и replace, прозрачни екрани.
  3. Екрани, които връщат отговор: избор на цел, „Сигурен ли си?“, отложените промени.
  4. Рисуване в конзолата: буфер от клетки, рамки, ленти, цветове.
  5. Картата и гледането: карта от текст, сблъсък, камера, поле на видимост.
  6. Същества и показатели: композиция, показатели на слоеве.
  7. Бойната система: ред по бързина, точност, щета, критичен удар.
  8. Кръв, рани и ефекти: кървене, отрова, горене, натрупване, следата.
  9. Умения и действия като команди: мана, презареждане, повторение на боя.
  10. Врагове, които мислят: краен автомат, търсене на път, полезност.
  11. Предмети, инвентар, плячка: данни от таблици, екипировка, магазин.
  12. Нива, диалози, куестове: опит, дърво от умения, шина от събития.
  13. Запис и зареждане: сериализация, версия, проверка.
  14. Цялата игра и балансът: симулацията, числата, идеи за продължение.

Тест

Провери се

1. Играчът отваря инвентара, докато е на картата. Какво става със стека от екрани?
2. Защо една и съща игра на Java, C#, Python и JavaScript дава байт по байт един и същ изход?
3. Кое е вярно за цикъла на играта тук?
4. Играта има симулация (--simulate): хиляди боеве без екран. Защо може да я има?

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