Към съдържанието
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/. Тук са в един файл заради уроците, но границите са същите: кодът на екрана не пипа правилата на боя, а боят не печата нищо сам. Ако искаш да го разделиш, разрязваш по регионите.

Идеята

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

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

// Цикълът на играта: нарисувай → прочети команда → обработи → обнови света → приложи промените
// в стека. Повтаря, докато стекът не опустее.
// Входът и изходът: Write печата, ReadLine връща реда или null в края, Save и Load пазят записите.
interface IGameIo
{
void Write(string text);
string? ReadLine();
void Save(string slot, string text);
string? Load(string slot);
}
class Options
{
public bool Plain;
public long Seed = 2026;
public string Lang = "bg";
public long Simulate;
public bool MemorySaves;
}
static partial class Arcanum
{
// Main(io, options) е играта; входната точка Main(args) е в блока boot.
#pragma warning disable CS0028
public static void Main(IGameIo io, Options options)
#pragma warning restore CS0028
{
uint seed = unchecked((uint)options.Seed);
if (options.Lang != "bg" && options.Lang != "en" && options.Lang != "de" && options.Lang != "es")
{
throw new Exception("unknown language: " + options.Lang + " (use bg, en, de or es)");
}
LANG = options.Lang;
if (options.MemorySaves) io.Save("3", TamperedSave());
LoadData();
long simulateCount = options.Simulate;
if (simulateCount > 0)
{
Simulate(io, (int)simulateCount, seed);
return;
}
ITerminal 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.ToLowerInvariant());
top.Update();
game.Stack.Apply();
}
io.Write(T("ui.goodbye") + "\n");
}
}

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

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

// Цикълът на играта: нарисувай → прочети команда → обработи → обнови света → приложи промените
// в стека. Повтаря, докато стекът не опустее.
// Входът и изходът: Write печата, ReadLine връща реда или null в края, Save и Load пазят записите.
interface IGameIo
{
void Write(string text);
string? ReadLine();
void Save(string slot, string text);
string? Load(string slot);
}
class Options
{
public bool Plain;
public long Seed = 2026;
public string Lang = "bg";
public long Simulate;
public bool MemorySaves;
}
static partial class Arcanum
{
// Main(io, options) е играта; входната точка Main(args) е в блока boot.
#pragma warning disable CS0028
public static void Main(IGameIo io, Options options)
#pragma warning restore CS0028
{
uint seed = unchecked((uint)options.Seed);
if (options.Lang != "bg" && options.Lang != "en" && options.Lang != "de" && options.Lang != "es")
{
throw new Exception("unknown language: " + options.Lang + " (use bg, en, de or es)");
}
LANG = options.Lang;
if (options.MemorySaves) io.Save("3", TamperedSave());
LoadData();
long simulateCount = options.Simulate;
if (simulateCount > 0)
{
Simulate(io, (int)simulateCount, seed);
return;
}
ITerminal 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.ToLowerInvariant());
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-битови цели числа (uint и unchecked), затова
// дава една и съща поредица на всеки език.
class Random
{
public uint State;
public Random(uint seed)
{
State = seed;
}
public uint Next32()
{
unchecked
{
State = State + 0x6d2b79f5;
uint z = State;
z = (z ^ (z >> 15)) * (z | 1);
z = z ^ (z + (z ^ (z >> 7)) * (z | 61));
return z ^ (z >> 14);
}
}
// От 0 до n-1, без дробни числа.
public int NextInt(int n)
{
return (int)(Next32() % (uint)n);
}
// От min до max, двете включени.
public int Range(int min, int max)
{
return min + NextInt(max - min + 1);
}
public bool Chance(int percent)
{
return 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). Сложи го в празна папка и го пусни:

Терминал
dotnet new console -n Arcanum
cd Arcanum
# смени Program.cs с Arcanum.cs
dotnet run

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

Програмата е един файл с няколко класа и Main в Arcanum.

Идеята

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

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

  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 за достъпно обучение по програмиране ·