Цикълът на играта
Накратко: Цикълът на играта повтаря едно и също, докато играта не свърши: рисува света, чете команда, обработва я и обновява състоянието, а семето прави всяко минаване повторимо.
Проблем
Програма, която свършва
Обикновената програма тече отгоре надолу и свършва. Прочита числата, смята, печата, край. Играта не може така. Тя трябва да чака играча, а между две команди има свят: къде е героят, къде е гоблинът, колко хода са минали. Този свят трябва да оцелее до следващата команда.
Първото, което идва наум, е while (true), който чете ред и прави всичко вътре. Коридор от 12 клетки: ти си @, гоблинът g те гони, изходът е >:
Scanner in = new Scanner(System.in);int hero = 4;int goblin = 0;while (true) { String command = in.nextLine(); if (command.equals("q")) break; if (command.equals("a")) hero--; if (command.equals("d")) hero++; String row = ""; for (int x = 0; x <= 11; x++) { row += x == hero ? "@" : x == goblin ? "g" : x == 11 ? ">" : "."; } System.out.println(row); if (hero == 11) { System.out.println("Изход!"); break; } goblin += goblin < hero ? 1 : -1; if (Math.abs(hero - goblin) <= 1) { System.out.println("Гоблинът те хвана."); break; }}Играчът пише x (сбъркал е клавиша) и после a:
g...@......>.g.@.......>Гоблинът те хвана.Три неща не са наред:
- Преди първата команда екранът е празен: програмата рисува чак след като прочете ред.
- Печатната грешка
xе ход: гоблинът е стъпил. Програмата не различава „играчът направи нещо“ от „играчът сбърка клавиша“. - Последният кадър е
.g.@: между вас има празна клетка, а гоблинът те хваща. Кадърът е отпечатан след твоята стъпка и преди тази на гоблина. Положението, в което играта свърши, никой не го е видял.
И две неща не стават изобщо. Не можеш да нарисуваш екрана наново без ход: след помощта с ? или след инвентара рисуването трябва да се копира. Не можеш да провериш правилото „гоблинът стъпва към героя“, без да пишеш команди и да четеш текст. Причината е една: светът, правилата и рисуването са смесени на едно място.
Идеята
Четири стъпки в кръг
Разделяме ги:
- Състоянието е всичко, което светът е, в един обект: героят, гоблинът, изходът, броячът на ходовете, съобщението, краят. Екранът не е част от него. Екранът се смята от него.
drawсамо чете състоянието и печата кадър. Може да се вика колкото пъти искаш.handleпревежда командата в намерение:moveе -1, 1 или 0. Никого не мести.updateсменя света: ходът, героят, гоблинът, краят. Не печата нищо.
Цикълът ги вика в кръг: нарисувай, прочети, обработи, обнови. Това е цикълът на играта (game loop). Има го във всяка игра, от този коридор до Арканум.
class State { int hero = 4; int goblin = 0; int exit = 11; int turn = 0; int move = 0; String message = ""; boolean over = false;}
Scanner in = new Scanner(System.in);State state = new State();while (!state.over) { draw(state); String command = read(in); handle(state, command); update(state);}draw(state);over спира цикъла, а последното draw след него показва края: изхода или гоблина, със съобщението.
Четенето е отделна функция, защото входът може да свърши:
static String read(Scanner in) { if (!in.hasNextLine()) return null; String line = in.nextLine(); System.out.println("> " + line); return line;}hasNextLine() казва дали има още ред. Без тази проверка nextLine() хвърля NoSuchElementException в края на входа. Ехото > a е, за да се вижда в изхода коя команда е дошла. Конзолата на играча я показва сама.
По-точно, за любопитните
Без проверката за края на входа всеки език се чупи различно. Java хвърля изключение, Python също. C# е най-коварен: Console.ReadLine() връща null, сравнението command == "q" е невярно, и първият опит не разбира, че входът е свършил: печата кадри с празни ходове, докато гоблинът не стигне героя сам. JavaScript получава undefined и прави същото. Входът свършва по-често, отколкото мислиш: файл с команди, затворен терминал, проверката на сайта.
Виж го
Един ход отблизо
Ето трите функции. В тях е цялата игра:
static void draw(State s) { String row = ""; for (int x = 0; x <= s.exit; x++) { if (x == s.hero) row += "@"; else if (x == s.goblin) row += "g"; else if (x == s.exit) row += ">"; else row += "."; } System.out.println("Ход " + s.turn + " " + row); if (!s.message.isEmpty()) System.out.println(s.message);}
static void handle(State s, String command) { s.move = 0; s.message = ""; if (command == null) { s.message = "Входът свърши."; s.over = true; } else if (command.equals("a")) { s.move = -1; } else if (command.equals("d")) { s.move = 1; } else if (command.equals("q")) { s.message = "Тръгна си."; s.over = true; } else { s.message = "Не разбирам „" + command + "“: ходът не мина."; }}
static void update(State s) { if (s.move == 0) return; s.turn++; s.hero = Math.max(0, s.hero + s.move); if (s.hero == s.exit) { s.message = "Изход! Избяга за " + s.turn + " хода."; s.over = true; return; } if (Math.abs(s.hero - s.goblin) > 1) s.goblin += s.goblin < s.hero ? 1 : -1; if (Math.abs(s.hero - s.goblin) <= 1) { s.message = "Гоблинът те хвана."; s.over = true; }}Scanner in = new Scanner(System.in);State state = new State();while (!state.over) { draw(state); String command = read(in); handle(state, command); update(state);}draw(state);
static void draw(State s) { String row = ""; for (int x = 0; x <= s.exit; x++) { if (x == s.hero) row += "@"; else if (x == s.goblin) row += "g"; else if (x == s.exit) row += ">"; else row += "."; } System.out.println("Ход " + s.turn + " " + row); if (!s.message.isEmpty()) System.out.println(s.message);}
static void handle(State s, String command) { s.move = 0; s.message = ""; if (command == null) { s.message = "Входът свърши."; s.over = true; } else if (command.equals("a")) { s.move = -1; } else if (command.equals("d")) { s.move = 1; } else if (command.equals("q")) { s.message = "Тръгна си."; s.over = true; } else { s.message = "Не разбирам „" + command + "“: ходът не мина."; }}
static void update(State s) { if (s.move == 0) return; s.turn++; s.hero = Math.max(0, s.hero + s.move); if (s.hero == s.exit) { s.message = "Изход! Избяга за " + s.turn + " хода."; s.over = true; return; } if (Math.abs(s.hero - s.goblin) > 1) s.goblin += s.goblin < s.hero ? 1 : -1; if (Math.abs(s.hero - s.goblin) <= 1) { s.message = "Гоблинът те хвана."; s.over = true; }}Стъпка по стъпка, с думи
- Ход 1 е минал: героят е на клетка 5, гоблинът на клетка 1. Целият свят е обектът със състоянието, а цикълът е пак горе.
- draw минава по 12-те клетки и печата кадъра .g...@.....>. Само чете състоянието: в панела нищо не се сменя.
- read чака. Светът стои, докато играчът не натисне Enter. Пристига редът „a“.
- handle само превежда командата в намерение: move = -1. Още никой не е мръднал.
- update сменя света. Броячът на ходовете става 2, а героят стъпва от 5 на 4. Не е изходът, затова ходът продължава.
- Гоблинът е на 3 клетки, затова стъпва към героя: от 1 на 2. Вече са 2: не е хванат, играта продължава.
- Отново горе draw печата ..g.@......>: кадърът идва след целия ход, затова играчът вижда къде е гоблинът наистина.
С командите d, a, x и седем пъти d програмата печата:
Ход 0 g...@......>> dХод 1 .g...@.....>> aХод 2 ..g.@......>> xХод 2 ..g.@......>Не разбирам „x“: ходът не мина.> dХод 3 ...g.@.....>> dХод 4 ....g.@....>> dХод 5 .....g.@...>> dХод 6 ......g.@..>> dХод 7 .......g.@.>> dХод 8 ........g.@>> dХод 9 ........g..@Изход! Избяга за 9 хода.На x кадърът се рисува наново, но ходът остава 2 и гоблинът стои: update вижда move = 0 и не прави нищо. Това става, защото draw не пипа света. Гоблинът стъпва след героя, а кадърът идва след целия ход, затова виждаш точно къде е. На изхода гоблинът не стъпва: играта свършва преди неговата стъпка.
Правилата вече могат да се проверят без екран и без писане на команди. Сглобяваш състояние на ръка, викаш update и гледаш числата:
State near = new State();near.hero = 5;near.goblin = 3;near.move = -1;update(near);System.out.println(near.hero + " " + near.goblin + " " + near.message);
State far = new State();far.move = 1;update(far);System.out.println(far.hero + " " + far.goblin + " " + far.turn);4 3 Гоблинът те хвана.5 1 1Това е тест на правилото „гоблинът хваща, ако е до теб“. В истинска игра такива проверки са стотици. С първия опит не можеше да се напише нито една.
По-точно, за любопитните
Защо handle не мести героя направо? В коридора би могъл. Но в Арканум едно и също a значи „наляво“ на картата, „назад“ в менюто и „нищо“ в диалога. Командата се разбира от екрана, а светът се сменя от правилата. И второ: команда, която не е ход (печатна грешка, отваряне на инвентара, помощ), не трябва да движи света. Затова намерението се записва отделно, а update решава дали изобщо има ход.
Пробвай
Подреди цикъла
Подреди редовете на цикъла:
handle(state, command);while (!state.over) {}update(state);draw(state);String command = read(in);
Пробвай
Предвиди изхода
Същата програма получава командите a, x, a. Правилата са тези:
static void handle(State s, String command) { s.move = 0; s.message = ""; if (command == null) { s.message = "Входът свърши."; s.over = true; } else if (command.equals("a")) { s.move = -1; } else if (command.equals("d")) { s.move = 1; } else if (command.equals("q")) { s.message = "Тръгна си."; s.over = true; } else { s.message = "Не разбирам „" + command + "“: ходът не мина."; }}
static void update(State s) { if (s.move == 0) return; s.turn++; s.hero = Math.max(0, s.hero + s.move); if (s.hero == s.exit) { s.message = "Изход! Избяга за " + s.turn + " хода."; s.over = true; return; } if (Math.abs(s.hero - s.goblin) > 1) s.goblin += s.goblin < s.hero ? 1 : -1; if (Math.abs(s.hero - s.goblin) <= 1) { s.message = "Гоблинът те хвана."; s.over = true; }}Пробвай
Намери грешката
Колега реши, че гоблинът трябва да се движи „на всеки кадър“, и премести стъпката му от update в draw. С командите d, x, x, d програмата печата:
Ход 0 .g..@......>> dХод 1 ..g..@.....>> xХод 1 ...g.@.....>Не разбирам „x“: ходът не мина.> xХод 1 ....g@.....>Не разбирам „x“: ходът не мина.> dХод 2 .....g@....>Ход 2 .....g@....>Входът свърши.Още на кадър 0 гоблинът е на клетка 1. На двете печатни грешки ходът стои 1, а гоблинът тича. Накрая е до героя и нищо не става. На кой ред е грешката?
Избери реда с грешката.
Стъпката на гоблина е промяна на света, а draw се вика на всеки кадър, не на всеки ход: и при печатна грешка, и в края. Затова гоблинът се движи, когато ход няма. А проверката за хващане е в update, преди стъпката, и не вижда гоблина до теб. Редът трябва да е в update, между хода на героя и проверката.
Идеята
Ход и кадър
Коридорът е походов: светът стои, докато не дойде команда. Един ход, един кадър. Конзолата дава цял ред чак след Enter и четенето чака, затова играта в конзолата е походова по природа. Така са шахът, roguelike игрите (Rogue, NetHack) и боевете в много стратегии.
Игра в реално време не чака. Светът се движи по часовник: ако не правиш нищо, гоблинът пак идва. Цикълът се върти десетки пъти в секунда: поглежда дали има натиснат клавиш (без да чака), обновява света за изминалото време и рисува. Едно рисуване е кадър (frame), а една стъпка на света е отделно нещо.
Четирите части са същите. Сменя се кой върти цикъла: редът на играча или часовникът. И там разделянето на draw и update е още по-важно: бърз монитор рисува 144 кадъра в секунда, но светът трябва да стъпва с еднакво темпо на всеки компютър.
По-точно, за любопитните
Фиксиран такт. Ако светът стъпва „колкото време е минало от миналия кадър“, физиката зависи от кадрите: при бавен кадър стрелата прескача стената, а два компютъра никога не дават същото. Затова светът стъпва на равни парчета, например 50 ms. Минатото време се натрупва (accumulator), и цикълът прави толкова стъпки, колкото парчета са се събрали. Остатъкът чака следващия кадър.
Тук часовникът е измислен (списък с моментите на кадрите), за да е изходът един и същ. Бързият компютър рисува на неравни интервали, бавният рядко:
static void run(String name, int[] times) { int step = 50; int acc = 0; int x = 0; int last = times[0]; for (int now : times) { acc += now - last; last = now; int steps = 0; while (acc >= step) { x += 3; steps++; acc -= step; } System.out.println(name + " " + now + " ms: стъпки " + steps + ", x = " + x + ", остатък " + acc + " ms"); }}
run("бърз", new int[] {0, 16, 40, 45, 110, 126, 150});run("бавен", new int[] {0, 90, 150});бърз 0 ms: стъпки 0, x = 0, остатък 0 msбърз 16 ms: стъпки 0, x = 0, остатък 16 msбърз 40 ms: стъпки 0, x = 0, остатък 40 msбърз 45 ms: стъпки 0, x = 0, остатък 45 msбърз 110 ms: стъпки 2, x = 6, остатък 10 msбърз 126 ms: стъпки 0, x = 6, остатък 26 msбърз 150 ms: стъпки 1, x = 9, остатък 0 msбавен 0 ms: стъпки 0, x = 0, остатък 0 msбавен 90 ms: стъпки 1, x = 3, остатък 40 msбавен 150 ms: стъпки 2, x = 9, остатък 0 msНа 150 ms и двата са направили по 3 стъпки и стрелата е на 9. Кадрите са различни, светът е същият. Остатъкът се ползва и за рисуването: при 10 ms от 50 стрелата се рисува 20% по пътя към следващата си позиция (интерполация), за да не трепти.
Четене без чакане и сурови клавиши. Терминалът сам държи реда, докато не натиснеш Enter. Програмата получава нищо, докато не дойде целият ред. За реално време терминалът трябва да мине в „суров“ режим (raw mode), в който всеки клавиш идва веднага, а програмата трябва да пита „има ли клавиш?“, без да чака.
Java няма вграден суров режим. В Linux и macOS терминалът се превключва отвън (stty raw -echo), а System.in.available() казва колко байта чакат, без да блокира. На практика се ползва библиотека като JLine или Lanterna.Арканум остава походова. Същият код трябва да върви в браузъра, в проверката (командите идват от файл) и в терминала на всеки. Редът текст работи навсякъде. Суровият режим не съществува за файл.
Идеята
Семе и повторяемост
Играта има нужда от случайност: удар или пропуск, каква плячка пада, накъде тръгва вълкът. Но грешка, която зависи от случайността, не може да се повтори. Затова генераторът има семе (seed): от едно и също семе излизат едни и същи числа.
Това е генераторът на Арканум:
// 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; }}Генераторът помни едно-единствено число, state. Всяко next32() го мести с една голяма константа и разбърква битовете му. nextInt(n) взима остатъка при деление на n, тоест число от 0 до n − 1. Зар d20 е range(1, 20). Хвърляме го пет пъти със семе 2026, после пак със 2026, после със 7:
static void roll(int seed) { Random dice = new Random(seed); String line = "семе " + seed + ":"; for (int i = 0; i < 5; i++) line += " " + dice.range(1, 20); System.out.println(line);}
roll(2026);roll(2026);roll(7);семе 2026: 16 19 3 17 18семе 2026: 16 19 3 17 18семе 7: 13 11 15 11 16Същото семе, същите числа. На четирите езика.
int range(int min, int max) {
return min + this.nextInt();
}От min до max включително има max - min + 1 числа. Остатъкът дава от 0 до броя минус едно, а отместването с min го слага на място. Без + 1 двайсетицата никога не пада.
По-точно, за любопитните
Защо не вграденият Random? Всеки език има свой алгоритъм. Java ползва линеен конгруентен генератор с 48 бита, .NET друг, Python Mersenne Twister, а Math.random() в JavaScript изобщо не приема семе. Едно и също семе дава различни числа на различните езици, а в JavaScript не можеш да го зададеш. mulberry32 е десетина реда, затова е написан четири пъти по един и същ начин.
Числата трябва да са 32-битови без знак навсякъде. Java няма int без знак: >>> мести битовете, без да пази знака, а Integer.toUnsignedLong гледа същите 32 бита като число без знак, преди остатъка.
Идеята
Само цели числа
Семето не стига. Вземи едно и също деление на четирите езика:
System.out.println(-7 / 2);System.out.println(10.0 / 2);-35.0На четирите езика изходът е такъв:
| Език | цяло деление на −7 на 2 | 10 / 2 с дроб |
|---|---|---|
| Java | -3 | 5.0 |
| C# | -3 | 5 |
| Python | -4 | 5.0 |
| JavaScript | -3 | 5 |
Python закръгля цялото деление надолу, а другите към нулата. Дробното число се печата с .0 или без. Един различен знак в изхода, и боят на Python вече не е същият като на Java. Затова каноните смятат само с цели числа, делят изрично и не делят отрицателни числа. Сортирането винаги има второ правило, а колекциите пазят реда на добавяне.
Наградата е голяма. Със семето, командите и версията на играта едно минаване е функция: същият вход дава същия изход. Играч пише: „На ход 412 вълкът ме удари през стената.“ Ако играта пази семето и командите му, пускаш ги и стигаш точно до ход 412, колкото пъти трябва, под дебъгер. Проверката на сайта прави същото: записаните команди и семе 2026 дават на четирите езика байт по байт един и същ изход.
Идеята
Истинският цикъл на Арканум
Ето цикъла на играта, без съкращения:
// Цикълът на играта: нарисувай → прочети команда → обработи → обнови света → приложи промените// в стека. Повтаря, докато стекът не опустее.
// Входът и изходът: 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");}Ред по ред:
game.stack.push(new TitleScreen(game))слага първия екран. Целият свят е вgame, както в коридора е вstate.- Цикълът върви, докато в стека от екрани има нещо. Изходът от менюто маха всички екрани и играта свършва. Това е темата на следващия урок.
- Нарисувай. Екраните рисуват в буфер от клетки (знак и цвят), а терминалът решава как да го покаже: като обикновен текст или с цветове. Това е в урока за рисуването в конзолата.
- Прочети. Ако входът е свършил, играта казва „край на входа“ и спира. Същото като нашето
null. Ехото на реда е като нашето> a. - Обработи и обнови. Само екранът най-отгоре получава командата: картата, боят или менюто.
- Приложи. Петата стъпка, която коридорът няма. Екраните искат промени в стека, докато обработват (започни бой, затвори менюто), а те се прилагат чак в края на хода. Защо не веднага, е в урока за екраните, които връщат отговор.
Къде е ходът на света? В коридора е в update. В Арканум картата вика worldTurn() от своя handle, само когато героят наистина е направил нещо: стъпил, отворил врата, изчакал. На печатна грешка или на i за инвентара светът не мърда. Това е нашето „move е 0, няма ход“:
// Ходът на света след хода на героя: ефектите, бавното възстановяване, враговете, засадата.void worldTurn() { Level level = this.level(); Hero hero = this.hero; this.turn += 1; this.stats.turns += 1; computeFov(level, hero.x, hero.y); tickEffects(hero, this); if (!hero.isAlive()) { this.heroDied(); return; } if (this.turn % 3 == 0 && !hero.hasEffect("bleed") && !hero.hasEffect("poison")) { hero.heal(1); if (hero.mana < hero.maxMana()) hero.mana += 1; } for (Enemy enemy : new ArrayList<>(level.enemies)) { if (!enemy.isAlive()) continue; enemyTurnOnMap(this, level, enemy); boolean visible = level.visible[enemy.y][enemy.x]; tickEffects(enemy, visible ? this : this.quiet); if (!enemy.isAlive()) { enemy.dead = true; if (visible) this.say(t("map.bledOut", "name", enemy.name)); // Изкървелият враг извън боя не хвърля плячка: нищо не го е обрало. this.enemyKilled(enemy, new Spoils()); } } this.pruneDead(); computeFov(level, hero.x, hero.y); for (Enemy enemy : level.enemies) { if (enemy.state.equals("chase") && distance(enemy.x, enemy.y, hero.x, hero.y) <= 1) { this.startBattle(enemy); return; } }}Броячът на ходовете, ефектите (кървенето тече всеки ход), бавното възстановяване на всеки трети ход, ходът на всеки враг, враговете, които са изкървели. И краят, точно като гоблина: враг, който гони и е до героя, започва бой. В боя ходовете са по друго правило, по бързина, затова всеки екран сам решава кога е ход, а update на картата само преизчислява какво се вижда.
Упражнение
Гоблинът спи
Реши задачата в редактора си.
Спящият гоблин. Гоблинът в началото спи и се рисува като z. Събужда се, когато героят е на 3 клетки или по-близо. В хода, в който се събуди, не мърда, а после гони както досега и се рисува като g. Цикълът не се променя: трябват ти едно поле в състоянието, един знак в draw и няколко реда в update.
Входът:
ddaaaaХод 0 z...@......>> dХод 1 z....@.....>> dХод 2 z.....@....>> aХод 3 z....@.....>> aХод 4 z...@......>> aХод 5 g..@.......>> aХод 6 .g@........>Гоблинът те хвана.Още задачи
- Изчакай. Добави команда
s: героят стои, но ходът минава и гоблинът стъпва. Как ще различиш „стой“ от „печатна грешка“ вhandle? - Запис на командите. Пази всяка прочетена команда в списък и в края го запиши във файл, по една команда на ред. Пусни програмата пак с този файл като вход: изходът трябва да е същият. Така работи повторението на боя в Арканум.
- Два гоблина. Втори гоблин тръгва от клетка 11 и пази изхода. Кой стъпва пръв, когато и двамата са до героя?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Методи на обекта · Интерфейси · Изключения · Стек, опашка и свързан списък
- Задачи по темата: Хрониките на Арканум