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

Идеята

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

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

# Цикълът на играта: нарисувай → прочети команда → обработи → обнови света → приложи промените
# в стека. Повтаря, докато стекът не опустее.
def main(io, options):
global LANG
seed = 2026 if options.seed is None else to_uint32(options.seed)
if options.lang != 'bg' and options.lang != 'en' and options.lang != 'de' and options.lang != 'es':
raise RuntimeError('unknown language: ' + str(options.lang) + ' (use bg, en, de or es)')
LANG = options.lang
if options.memory_saves:
io.save('3', tampered_save())
load_data()
simulate_count = 0 if options.simulate is None else options.simulate
if simulate_count > 0:
simulate(io, simulate_count, seed)
return
terminal = PlainTerminal(io) if options.plain is True else AnsiTerminal(io)
game = Game(io, seed)
buffer = CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT)
game.stack.push(TitleScreen(game))
game.stack.apply()
while not game.stack.is_empty():
buffer.clear()
game.stack.draw(buffer)
terminal.present(buffer)
line = io.read_line()
if line is None:
io.write(t('ui.endOfInput') + '\n')
return
terminal.echo(line)
game.raw_input = trim_spaces(line)
top = game.stack.top()
top.handle(game.raw_input.lower())
top.update()
game.stack.apply()
io.write(t('ui.goodbye') + '\n')

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

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

# Цикълът на играта: нарисувай → прочети команда → обработи → обнови света → приложи промените
# в стека. Повтаря, докато стекът не опустее.
def main(io, options):
global LANG
seed = 2026 if options.seed is None else to_uint32(options.seed)
if options.lang != 'bg' and options.lang != 'en' and options.lang != 'de' and options.lang != 'es':
raise RuntimeError('unknown language: ' + str(options.lang) + ' (use bg, en, de or es)')
LANG = options.lang
if options.memory_saves:
io.save('3', tampered_save())
load_data()
simulate_count = 0 if options.simulate is None else options.simulate
if simulate_count > 0:
simulate(io, simulate_count, seed)
return
terminal = PlainTerminal(io) if options.plain is True else AnsiTerminal(io)
game = Game(io, seed)
buffer = CellBuffer(SCREEN_WIDTH, SCREEN_HEIGHT)
game.stack.push(TitleScreen(game))
game.stack.apply()
while not game.stack.is_empty():
buffer.clear()
game.stack.draw(buffer)
terminal.present(buffer)
line = io.read_line()
if line is None:
io.write(t('ui.endOfInput') + '\n')
return
terminal.echo(line)
game.raw_input = trim_spaces(line)
top = game.stack.top()
top.handle(game.raw_input.lower())
top.update()
game.stack.apply()
io.write(t('ui.goodbye') + '\n')
Стъпка по стъпка, с думи
  1. Команда 56 от минаването: Ива (30/48) е в бой с гоблин копиеносец (16/16). Играчът пише „1“: удар.
  2. Вход, изход, рисуване: io.read_line() връща „1“. За този слой това е само текст: той не знае за боя.
  3. Екраните и стекът: отгоре е боят, под него картата. Горният екран получава „1“ и при един враг вика battle.hero_act(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-битови цели числа (маска & 0xFFFFFFFF и ръчно
# imul), затова дава една и съща поредица на всеки език.
MASK32 = 0xFFFFFFFF
# Math.imul от JavaScript: произведението, от което остават само долните 32 бита.
def imul(a, b):
return (a * b) & MASK32
# „x >>> 0“ от JavaScript: число към 32 бита без знак (нечисло и безкрайност стават 0).
def to_uint32(value):
if value != value or value == float('inf') or value == float('-inf'):
return 0
return int(value) & MASK32
class Random:
def __init__(self, seed):
self.state = seed & MASK32
def next32(self):
self.state = (self.state + 0x6D2B79F5) & MASK32
z = self.state
z = imul(z ^ (z >> 15), z | 1)
z = (z ^ (z + imul(z ^ (z >> 7), z | 61))) & MASK32
return (z ^ (z >> 14)) & MASK32
# От 0 до n-1, без дробни числа.
def next_int(self, n):
return self.next32() % n
# От min до max, двете включени.
def range(self, low, high):
return low + self.next_int(high - low + 1)
def chance(self, percent):
return self.next_int(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). Сложи го в празна папка и го пусни:

Терминал
python arcanum.py

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

Трябва Python 3.12 или по-нов.

Идеята

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

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

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