Играта, която ще направим
Накратко: „Хрониките на Арканум“ е цяла 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-all | 1549 |
| Вход, изход, рисуване | random io-text buffer terminal hud | 308 |
| Екраните и стекът | screen screen-stack title create-hero town shop map-screen battle-screen replay-screen target-screen overlays | 1259 |
| Светът и правилата | event-bus entity inventory equip loot xp-levelup quests dialog level fov game save load | 1389 |
| Бой, ефекти, изкуствен интелект | effects bleeding combat-order attack actions replay ai-battle pathfind ai-map | 857 |
| Пускане и баланс | simulate main node | 176 |
Числата са от 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");}Стъпка по стъпка, с думи
- Команда 56 от минаването: Ива (30/48) е в бой с гоблин копиеносец (16/16). Играчът пише „1“: удар.
- Вход, изход, рисуване: io.readLine() връща „1“. За този слой това е само текст: той не знае за боя.
- Екраните и стекът: отгоре е боят, под него картата. Горният екран получава „1“ и при един враг вика battle.heroAct(new AttackAction(…)).
- Още в top.handle: правилата. Зарът със семе (ръчна брадва, 4–7 в таблицата) дава 6. 6 + сила 7 − защита 1 = 12: „Ива смазва гоблин копиеносец (12).“
- Брадвата реже и отваря рана (шанс 35%): „Гоблин копиеносец кърви.“ На хода на гоблина тя взима 1 (16 → 4 → 3): „Гоблин копиеносец: тежка рана!“ Той отвръща: „Гоблин копиеносец драска Ива (1).“
- update и apply: никой не е викал push или pop, боят остава отгоре. Боят не е печатал нищо: 5 нови реда чакат в дневника му.
- Нова обиколка: екранът рисува в буфера 60 × 18 (Ива 29/48, гоблинът 3/16, „кръв:2“ = още 2 хода, последните 7 реда от дневника), терминалът го печата.
- Една команда, шест слоя, всеки със своята работа. Боят не знае нищо за екрана, затова може да върви и без него: това е симулацията.
Цикълът чака реда, без да прави нищо друго (четенето блокира). Когато входът свърши (няма повече редове), играта приключва чисто.
Идеята
Един код, четири езика, един изход
Играта е написана четири пъти: на 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/91) Пазач на дълбините [###-----] 53/120 гняв2) Каменен слуга [########] 14/143) Каменен слуга [########] 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 до играта.
javac.Идеята
Как ще я строим
Всеки следващ урок взима една част от играта, обяснява защо е такава и я строи с по-малки програми, които можеш да пуснеш. После показва същата част в истинската игра.
- Цикълът на играта: вход, обновяване, рисуване, ход срещу кадър, семе.
- Стек от екрани: екранът като обект, push, pop и replace, прозрачни екрани.
- Екрани, които връщат отговор: избор на цел, „Сигурен ли си?“, отложените промени.
- Рисуване в конзолата: буфер от клетки, рамки, ленти, цветове.
- Картата и гледането: карта от текст, сблъсък, камера, поле на видимост.
- Същества и показатели: композиция, показатели на слоеве.
- Бойната система: ред по бързина, точност, щета, критичен удар.
- Кръв, рани и ефекти: кървене, отрова, горене, натрупване, следата.
- Умения и действия като команди: мана, презареждане, повторение на боя.
- Врагове, които мислят: краен автомат, търсене на път, полезност.
- Предмети, инвентар, плячка: данни от таблици, екипировка, магазин.
- Нива, диалози, куестове: опит, дърво от умения, шина от събития.
- Запис и зареждане: сериализация, версия, проверка.
- Цялата игра и балансът: симулацията, числата, идеи за продължение.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Списъци · Стек, опашка и свързан списък · HashMap и HashSet