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

Екрани, които връщат отговор

Накратко: Екран, който връща отговор, се затваря с pop(result) и подава резултата на екрана отдолу (onResult), а промените по стека се записват по време на хода и се прилагат в края му с apply.

Проблем

Екранът пита. Кой чува отговора?

В миналия урок екраните само се отваряха и затваряха. Сега един екран трябва да попита нещо и да получи отговор. Боят е с два врага, играчът натиска „удар“, а боят трябва да знае кого. Менюто трябва да попита „Сигурен ли си?“, преди да излезе от играта.

Първият опит е обща променлива: екранът за цел пише избора в selectedTarget, а боят го чете. Играчът избира Гоблина, после отваря избора пак и се отказва (x):

static String[] enemies = { "Вълк", "Гоблин" };
static int selectedTarget = -1;
static void targetScreen(String command) {
if (!command.equals("x")) selectedTarget = Integer.parseInt(command) - 1;
}
static void fightTurn() {
if (selectedTarget >= 0) System.out.println("Удряш " + enemies[selectedTarget] + ".");
else System.out.println("Никой не е ударен.");
}
// Играчът избира 2, после се отказва (x).
targetScreen("2");
fightTurn();
targetScreen("x");
fightTurn();
Изход
Удряш Гоблин.
Удряш Гоблин.

Вторият удар е по Гоблина, а играчът се е отказал. Изборът от миналия ход е още в променливата. Кой трябва да я върне на -1? Боят, след като я прочете? А ако същата променлива ползва и инвентарът? Общата променлива няма собственик, затова в нея остават стари отговори.

Вторият опит е целта да вика боя сама:

class TargetScreen extends Screen {
FightScreen fight;
@Override
void handle(String command) {
// Целта знае боя и го вика сама.
fight.attack(Integer.parseInt(command) - 1);
stack.pop();
}
}

Това върви, но целта вече знае боя. В играта „кого?“ питат и ударът, и заклинанието, и отварата, която лекува съюзник. Всеки от тях иска своя екран за цел, или един екран с if за всеки, който го ползва. Трябва път, по който отговорът се връща към този, който е питал, без онзи, който отговаря, да знае кой е той.

Идеята

pop(result) и onResult

Екранът, който пита, отваря отговарящия с push. Отговарящият се маха с pop и подава отговора: pop(result). Стекът знае кой е отдолу: точно този, който е питал. Затова му подава отговора, като вика неговия onResult.

Екранът получава още един метод. По подразбиране не прави нищо: повечето екрани никога не питат.

class Screen {
String name;
Screen(String name) {
this.name = name;
}
void draw() {}
void handle(String command) {}
void update() {}
// Отговорът на екрана, който е бил над този.
void onResult(Object result) {}
}

А pop от миналия урок става pop(result):

void pop(Object result) {
screens.remove(screens.size() - 1);
Screen below = top();
if (below != null) below.onResult(result);
}

Така двете страни не се познават. Целта знае въпроса и формата на отговора: номер на враг или null за „отказ“. Боят знае какво да прави с номера. Прилича на извикване на метод, който връща стойност, само че между въпроса и отговора минават ходове: играчът мисли, натиска, може да се откаже.

Виж го

Изборът на цел

Целта рисува списък с живите врагове и чака цифра. x е „назад“ и връща null. Номерът се връща от 0, защото е индекс в списъка:

class TargetScreen extends Screen {
ScreenStack stack;
List<Enemy> enemies;
TargetScreen(ScreenStack stack, List<Enemy> enemies) {
super("Цел");
this.stack = stack;
this.enemies = enemies;
}
@Override
void draw() {
String line = "Кого удряш?";
for (int i = 0; i < enemies.size(); i++) {
line += " " + (i + 1) + ") " + enemies.get(i).name;
}
System.out.println(line + " x) назад");
}
@Override
void handle(String command) {
if (command.equals("x")) {
stack.pop(null);
return;
}
int choice = Integer.parseInt(command);
if (choice >= 1 && choice <= enemies.size()) stack.pop(choice - 1);
}
}

Боят отваря целта при 1, а отговора чете в onResult. Когато враговете свършат, боят също връща отговор на екрана под себе си, картата: „победа“.

class FightScreen extends Screen {
ScreenStack stack;
List<Enemy> enemies = new ArrayList<>();
FightScreen(ScreenStack stack) {
super("Бой");
this.stack = stack;
enemies.add(new Enemy("Вълк", 4));
enemies.add(new Enemy("Гоблин", 7));
}
@Override
void draw() {
String line = "Бой:";
for (Enemy enemy : enemies) line += " " + enemy.name + " " + enemy.hp;
System.out.println(line + " | 1) удар");
}
@Override
void handle(String command) {
if (command.equals("1")) stack.push(new TargetScreen(stack, enemies));
}
@Override
void onResult(Object result) {
if (result == null) {
System.out.println("Размисляш. Никой не е ударен.");
return;
}
Enemy enemy = enemies.get((int) result);
enemy.hp -= 4;
if (enemy.hp > 0) {
System.out.println("Удряш " + enemy.name + " за 4.");
return;
}
System.out.println(enemy.name + " пада.");
enemies.remove(enemy);
if (enemies.isEmpty()) stack.pop("победа");
}
}

Програмата чете командите от входа. Всеки ход рисува само екрана на върха, за да е кратък изходът:

ScreenStack stack = new ScreenStack();
stack.push(new MapScreen(stack));
Scanner in = new Scanner(System.in);
while (stack.top() != null) {
stack.top().draw();
String command = in.nextLine().trim();
System.out.println("> " + command);
stack.top().handle(command);
}
System.out.println("Довиждане.");
Изход
Карта: отвари 2 | f) бой d) хвърли отвара q) изход
> f
Бой: Вълк 4 Гоблин 7 | 1) удар
> 1
Кого удряш? 1) Вълк 2) Гоблин x) назад
> 2
Удряш Гоблин за 4.
Бой: Вълк 4 Гоблин 3 | 1) удар
> 1
Кого удряш? 1) Вълк 2) Гоблин x) назад
> x
Размисляш. Никой не е ударен.
Бой: Вълк 4 Гоблин 3 | 1) удар
> 1
Кого удряш? 1) Вълк 2) Гоблин x) назад
> 2
Гоблин пада.
Бой: Вълк 4 | 1) удар
> 1
Кого удряш? 1) Вълк x) назад
> 1
Вълк пада.
Победа! Обратно на картата.
Карта: отвари 2 | f) бой d) хвърли отвара q) изход
> d
Сигурен ли си, че хвърляш отвара? 1) да 2) не
> 2
Отварата остава.
Карта: отвари 2 | f) бой d) хвърли отвара q) изход
> q
Сигурен ли си, че излизаш? 1) да 2) не
> 1
Довиждане.

Входът е:

Вход
f
1
2
1
x
1
2
1
1
d
2
q
1

Отказът (x) не удря никого: отговорът е празен и боят го знае. Изборът от миналия ход не е останал никъде.

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

Цялата програма, с картата и въпроса „Сигурен ли си?“ от следващата карта:

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
class Screen {
String name;
Screen(String name) {
this.name = name;
}
void draw() {}
void handle(String command) {}
void update() {}
// Отговорът на екрана, който е бил над този.
void onResult(Object result) {}
}
class ScreenStack {
List<Screen> screens = new ArrayList<>();
void push(Screen screen) {
screens.add(screen);
}
void pop(Object result) {
screens.remove(screens.size() - 1);
Screen below = top();
if (below != null) below.onResult(result);
}
Screen top() {
return screens.isEmpty() ? null : screens.get(screens.size() - 1);
}
String names() {
List<String> names = new ArrayList<>();
for (Screen screen : screens) names.add(screen.name);
return "[" + String.join(", ", names) + "]";
}
}
class Enemy {
String name;
int hp;
Enemy(String name, int hp) {
this.name = name;
this.hp = hp;
}
}
class TargetScreen extends Screen {
ScreenStack stack;
List<Enemy> enemies;
TargetScreen(ScreenStack stack, List<Enemy> enemies) {
super("Цел");
this.stack = stack;
this.enemies = enemies;
}
@Override
void draw() {
String line = "Кого удряш?";
for (int i = 0; i < enemies.size(); i++) {
line += " " + (i + 1) + ") " + enemies.get(i).name;
}
System.out.println(line + " x) назад");
}
@Override
void handle(String command) {
if (command.equals("x")) {
stack.pop(null);
return;
}
int choice = Integer.parseInt(command);
if (choice >= 1 && choice <= enemies.size()) stack.pop(choice - 1);
}
}
class FightScreen extends Screen {
ScreenStack stack;
List<Enemy> enemies = new ArrayList<>();
FightScreen(ScreenStack stack) {
super("Бой");
this.stack = stack;
enemies.add(new Enemy("Вълк", 4));
enemies.add(new Enemy("Гоблин", 7));
}
@Override
void draw() {
String line = "Бой:";
for (Enemy enemy : enemies) line += " " + enemy.name + " " + enemy.hp;
System.out.println(line + " | 1) удар");
}
@Override
void handle(String command) {
if (command.equals("1")) stack.push(new TargetScreen(stack, enemies));
}
@Override
void onResult(Object result) {
if (result == null) {
System.out.println("Размисляш. Никой не е ударен.");
return;
}
Enemy enemy = enemies.get((int) result);
enemy.hp -= 4;
if (enemy.hp > 0) {
System.out.println("Удряш " + enemy.name + " за 4.");
return;
}
System.out.println(enemy.name + " пада.");
enemies.remove(enemy);
if (enemies.isEmpty()) stack.pop("победа");
}
}
class ConfirmScreen extends Screen {
ScreenStack stack;
String question;
ConfirmScreen(ScreenStack stack, String question) {
super(question);
this.stack = stack;
this.question = question;
}
@Override
void draw() {
System.out.println(question + " 1) да 2) не");
}
@Override
void handle(String command) {
if (command.equals("1")) stack.pop(true);
else if (command.equals("2")) stack.pop(false);
}
}
class MapScreen extends Screen {
ScreenStack stack;
int potions = 2;
String pending = "";
MapScreen(ScreenStack stack) {
super("Карта");
this.stack = stack;
}
@Override
void draw() {
System.out.println("Карта: отвари " + potions + " | f) бой d) хвърли отвара q) изход");
}
@Override
void handle(String command) {
if (command.equals("f")) {
stack.push(new FightScreen(stack));
} else if (command.equals("d")) {
pending = "drop";
stack.push(new ConfirmScreen(stack, "Сигурен ли си, че хвърляш отвара?"));
} else if (command.equals("q")) {
pending = "quit";
stack.push(new ConfirmScreen(stack, "Сигурен ли си, че излизаш?"));
}
}
@Override
void onResult(Object result) {
String asked = pending;
pending = "";
if (asked.equals("drop")) {
if (result.equals(true)) {
potions--;
System.out.println("Хвърляш една отвара.");
} else {
System.out.println("Отварата остава.");
}
} else if (asked.equals("quit")) {
if (result.equals(true)) stack.pop(null);
} else if (result.equals("победа")) {
System.out.println("Победа! Обратно на картата.");
}
}
}
public class Main {
public static void main(String[] args) {
ScreenStack stack = new ScreenStack();
stack.push(new MapScreen(stack));
Scanner in = new Scanner(System.in);
while (stack.top() != null) {
stack.top().draw();
String command = in.nextLine().trim();
System.out.println("> " + command);
stack.top().handle(command);
}
System.out.println("Довиждане.");
}
}

Идеята

Един въпрос за всичко: „Сигурен ли си?“

Екранът с въпрос не знае какво пита. Той показва текста, който е получил, и връща true или false:

class ConfirmScreen extends Screen {
ScreenStack stack;
String question;
ConfirmScreen(ScreenStack stack, String question) {
super(question);
this.stack = stack;
this.question = question;
}
@Override
void draw() {
System.out.println(question + " 1) да 2) не");
}
@Override
void handle(String command) {
if (command.equals("1")) stack.pop(true);
else if (command.equals("2")) stack.pop(false);
}
}

Картата го ползва за два различни въпроса: дали да хвърли отвара и дали да излезе. Но отговорът е само „да“ или „не“. Откъде картата знае на кой въпрос е? Помни какво е питала. Преди push записва pending, а когато дойде отговорът, го прочита и веднага го изчиства:

class MapScreen extends Screen {
ScreenStack stack;
int potions = 2;
String pending = "";
MapScreen(ScreenStack stack) {
super("Карта");
this.stack = stack;
}
@Override
void draw() {
System.out.println("Карта: отвари " + potions + " | f) бой d) хвърли отвара q) изход");
}
@Override
void handle(String command) {
if (command.equals("f")) {
stack.push(new FightScreen(stack));
} else if (command.equals("d")) {
pending = "drop";
stack.push(new ConfirmScreen(stack, "Сигурен ли си, че хвърляш отвара?"));
} else if (command.equals("q")) {
pending = "quit";
stack.push(new ConfirmScreen(stack, "Сигурен ли си, че излизаш?"));
}
}
@Override
void onResult(Object result) {
String asked = pending;
pending = "";
if (asked.equals("drop")) {
if (result.equals(true)) {
potions--;
System.out.println("Хвърляш една отвара.");
} else {
System.out.println("Отварата остава.");
}
} else if (asked.equals("quit")) {
if (result.equals(true)) stack.pop(null);
} else if (result.equals("победа")) {
System.out.println("Победа! Обратно на картата.");
}
}
}

Изчистването е важно. Ако pending остане „drop“, следващият отговор от боя („победа“) ще се чете като отговор на въпроса за отварата. Това е същата грешка като общата променлива от началото, но вече вътре в един екран, който сам е собственик на въпроса си. Истинската игра прави същото: боят помни дали е питал за удар, за умение или за отвара.

Проблем

Стекът се мени, докато ходът още тече

Досега цикълът викаше само handle на върха. Истинският ход има и update: екранът обновява състоянието си след командата. Ето същия бой, но всеки екран пише в дневник какво прави. Целта в update преброява враговете в списъка си:

class TargetScreen extends Screen {
ScreenStack stack;
List<String> enemies;
TargetScreen(ScreenStack stack, List<String> enemies) {
super("Цел");
this.stack = stack;
this.enemies = enemies;
}
@Override
void handle(String command) {
int index = Integer.parseInt(command) - 1;
System.out.println("Цел взима " + command + " и връща " + index);
stack.pop(index);
}
@Override
void update() {
System.out.println("Цел се обновява, врагове в списъка: " + enemies.size());
}
}

Цикълът е като в истинската игра: взима върха, дава му командата, обновява го.

Screen top = stack.top();
top.handle(command);
top.update();
import java.util.ArrayList;
import java.util.List;
class Screen {
String name;
Screen(String name) {
this.name = name;
}
void draw() {}
void handle(String command) {}
void update() {}
// Отговорът на екрана, който е бил над този.
void onResult(Object result) {}
}
class ScreenStack {
List<Screen> screens = new ArrayList<>();
void push(Screen screen) {
screens.add(screen);
}
void pop(Object result) {
screens.remove(screens.size() - 1);
Screen below = top();
if (below != null) below.onResult(result);
}
Screen top() {
return screens.isEmpty() ? null : screens.get(screens.size() - 1);
}
String names() {
List<String> names = new ArrayList<>();
for (Screen screen : screens) names.add(screen.name);
return "[" + String.join(", ", names) + "]";
}
}
class TargetScreen extends Screen {
ScreenStack stack;
List<String> enemies;
TargetScreen(ScreenStack stack, List<String> enemies) {
super("Цел");
this.stack = stack;
this.enemies = enemies;
}
@Override
void handle(String command) {
int index = Integer.parseInt(command) - 1;
System.out.println("Цел взима " + command + " и връща " + index);
stack.pop(index);
}
@Override
void update() {
System.out.println("Цел се обновява, врагове в списъка: " + enemies.size());
}
}
class FightScreen extends Screen {
ScreenStack stack;
List<String> enemies = new ArrayList<>(List.of("Вълк", "Гоблин"));
FightScreen(ScreenStack stack) {
super("Бой");
this.stack = stack;
}
@Override
void handle(String command) {
System.out.println("Бой взима " + command + " и отваря Цел");
stack.push(new TargetScreen(stack, enemies));
}
@Override
void onResult(Object result) {
String enemy = enemies.remove((int) result);
System.out.println("Бой получава " + result + ": " + enemy + " пада");
if (enemies.isEmpty()) {
System.out.println("Бой: няма врагове, връща победа");
stack.pop("победа");
}
}
}
class MapScreen extends Screen {
ScreenStack stack;
MapScreen(ScreenStack stack) {
super("Карта");
this.stack = stack;
}
@Override
void onResult(Object result) {
System.out.println("Карта получава " + result + ": отваря Ново ниво");
stack.push(new Screen("Ново ниво"));
}
}
public class Main {
public static void main(String[] args) {
ScreenStack stack = new ScreenStack();
stack.push(new MapScreen(stack));
stack.push(new FightScreen(stack));
for (String command : new String[] { "1", "2", "1", "1" }) {
System.out.println("> " + command);
Screen top = stack.top();
top.handle(command);
top.update();
}
System.out.println(stack.names());
}
}
Изход
> 1
Бой взима 1 и отваря Цел
> 2
Цел взима 2 и връща 1
Бой получава 1: Гоблин пада
Цел се обновява, врагове в списъка: 1
> 1
Бой взима 1 и отваря Цел
> 1
Цел взима 1 и връща 0
Бой получава 0: Вълк пада
Бой: няма врагове, връща победа
Карта получава победа: отваря Ново ниво
Цел се обновява, врагове в списъка: 0
[Карта, Ново ниво]

Погледни последния ход. Целта още не е свършила своя handle, а вътре в него pop вече я е махнал, боят е ударил Вълка, боят е върнал „победа“, а картата е отворила „Ново ниво“. Чак после цикълът вика update на целта: екран, който вече не е в стека, обновява списък с 0 врагове за бой, който е свършил. В истинската игра такъв update би прочел враг по номер от празен списък и играта щеше да спре с грешка.

Ако цикълът попита пак за върха преди update, щеше да обнови „Ново ниво“: грешния екран, който още не е нарисуван нито веднъж. Корените са два. Стекът се променя по средата на хода. А отговорите вървят вложено: onResult на боя работи вътре в handle на целта, а onResult на картата вътре в него.

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

Още по-лошо е, когато се обхождат всички екрани в стека (например всеки има update) и един от тях се маха по време на обхождането. Тогава езиците се държат различно:

List<String> screens = new ArrayList<>(List.of("Карта", "Съобщение", "Бой", "Цел"));
try {
for (String screen : screens) {
System.out.println("update " + screen);
if (screen.equals("Съобщение")) screens.remove(screen);
}
} catch (ConcurrentModificationException e) {
System.out.println("ConcurrentModificationException");
}
Изход
update Карта
update Съобщение
ConcurrentModificationException

Java забелязва промяната и при следващата стъпка хвърля ConcurrentModificationException. Внимавай: ако махнеш предпоследния елемент, грешка няма. hasNext() сравнява мястото с новата дължина, казва „няма повече“ и цикълът спира тихо, без последния:

List<String> screens = new ArrayList<>(List.of("Карта", "Съобщение", "Бой", "Цел"));
try {
for (String screen : screens) {
System.out.println("update " + screen);
if (screen.equals("Бой")) screens.remove(screen);
}
} catch (ConcurrentModificationException e) {
System.out.println("ConcurrentModificationException");
}
Изход
update Карта
update Съобщение
update Бой

Грешка, която спира програмата, поне се вижда. Тиха грешка, при която един екран не е обновен в един ход, се хваща много по-трудно.

Идеята

Отложени промени: записвай сега, прилагай после

Поправката е стекът да не се пипа по време на хода. push и pop вече не местят нищо. Те само записват промяната в списък changes. В края на хода apply минава по списъка и прилага промените една по една. Промяната е малък обект: вид, екран (за push) и отговор (за pop).

class Change {
String kind;
Screen screen;
Object result;
Change(String kind, Screen screen, Object result) {
this.kind = kind;
this.screen = screen;
this.result = result;
}
}
class ScreenStack {
List<Screen> screens = new ArrayList<>();
List<Change> changes = new ArrayList<>();
void push(Screen screen) {
changes.add(new Change("push", screen, null));
}
void pop(Object result) {
changes.add(new Change("pop", null, result));
}
Screen top() {
return screens.isEmpty() ? null : screens.get(screens.size() - 1);
}
void apply() {
int index = 0;
while (index < changes.size()) {
Change change = changes.get(index);
index++;
if (change.kind.equals("push")) {
screens.add(change.screen);
} else if (change.kind.equals("pop")) {
screens.remove(screens.size() - 1);
Screen below = top();
if (below != null) below.onResult(change.result);
}
}
changes.clear();
}
}

Цикълът вика apply последен:

Screen top = stack.top();
top.handle(command);
top.update();
stack.apply();

Същият бой със същите екрани, сменен е само стекът:

import java.util.ArrayList;
import java.util.List;
class Screen {
String name;
Screen(String name) {
this.name = name;
}
void draw() {}
void handle(String command) {}
void update() {}
// Отговорът на екрана, който е бил над този.
void onResult(Object result) {}
}
class Change {
String kind;
Screen screen;
Object result;
Change(String kind, Screen screen, Object result) {
this.kind = kind;
this.screen = screen;
this.result = result;
}
}
class ScreenStack {
List<Screen> screens = new ArrayList<>();
List<Change> changes = new ArrayList<>();
void push(Screen screen) {
changes.add(new Change("push", screen, null));
}
void pop(Object result) {
changes.add(new Change("pop", null, result));
}
Screen top() {
return screens.isEmpty() ? null : screens.get(screens.size() - 1);
}
void apply() {
int index = 0;
while (index < changes.size()) {
Change change = changes.get(index);
index++;
if (change.kind.equals("push")) {
screens.add(change.screen);
} else if (change.kind.equals("pop")) {
screens.remove(screens.size() - 1);
Screen below = top();
if (below != null) below.onResult(change.result);
}
}
changes.clear();
}
String names() {
List<String> names = new ArrayList<>();
for (Screen screen : screens) names.add(screen.name);
return "[" + String.join(", ", names) + "]";
}
}
class TargetScreen extends Screen {
ScreenStack stack;
List<String> enemies;
TargetScreen(ScreenStack stack, List<String> enemies) {
super("Цел");
this.stack = stack;
this.enemies = enemies;
}
@Override
void handle(String command) {
int index = Integer.parseInt(command) - 1;
System.out.println("Цел взима " + command + " и връща " + index);
stack.pop(index);
}
@Override
void update() {
System.out.println("Цел се обновява, врагове в списъка: " + enemies.size());
}
}
class FightScreen extends Screen {
ScreenStack stack;
List<String> enemies = new ArrayList<>(List.of("Вълк", "Гоблин"));
FightScreen(ScreenStack stack) {
super("Бой");
this.stack = stack;
}
@Override
void handle(String command) {
System.out.println("Бой взима " + command + " и отваря Цел");
stack.push(new TargetScreen(stack, enemies));
}
@Override
void onResult(Object result) {
String enemy = enemies.remove((int) result);
System.out.println("Бой получава " + result + ": " + enemy + " пада");
if (enemies.isEmpty()) {
System.out.println("Бой: няма врагове, връща победа");
stack.pop("победа");
}
}
}
class MapScreen extends Screen {
ScreenStack stack;
MapScreen(ScreenStack stack) {
super("Карта");
this.stack = stack;
}
@Override
void onResult(Object result) {
System.out.println("Карта получава " + result + ": отваря Ново ниво");
stack.push(new Screen("Ново ниво"));
}
}
public class Main {
public static void main(String[] args) {
ScreenStack stack = new ScreenStack();
stack.push(new MapScreen(stack));
stack.push(new FightScreen(stack));
stack.apply();
for (String command : new String[] { "1", "2", "1", "1" }) {
System.out.println("> " + command);
Screen top = stack.top();
top.handle(command);
top.update();
stack.apply();
}
System.out.println(stack.names());
}
}
Изход
> 1
Бой взима 1 и отваря Цел
> 2
Цел взима 2 и връща 1
Цел се обновява, врагове в списъка: 2
Бой получава 1: Гоблин пада
> 1
Бой взима 1 и отваря Цел
> 1
Цел взима 1 и връща 0
Цел се обновява, врагове в списъка: 1
Бой получава 0: Вълк пада
Бой: няма врагове, връща победа
Карта получава победа: отваря Ново ниво
[Карта, Ново ниво]

Сега целта завършва handle и update, докато още е на върха и всичко около нея е както преди хода. Чак после apply я маха, а боят получава отговора си. Всеки екран работи в стек, който не се мени под краката му.

Три подробности в apply са важни:

  • while с индекс, а не foreach. Докато apply прилага pop, боят получава отговора и може да запише нова промяна: pop(победа). Тя отива в края на същия списък, а while чете дължината му наново и стига и до нея. foreach би хвърлил грешката от предишната карта.
  • Списъкът се изчиства накрая. Иначе в следващия ход същите промени ще се приложат пак.
  • Редът е редът на записване. Ако екран запише replace и после push в един ход, стекът ги прави точно така.

Виж го

Един ход, промяна по промяна

class ScreenStack {
List<Screen> screens = new ArrayList<>();
List<Change> changes = new ArrayList<>();
void push(Screen screen) {
changes.add(new Change("push", screen, null));
}
void pop(Object result) {
changes.add(new Change("pop", null, result));
}
Screen top() {
return screens.isEmpty() ? null : screens.get(screens.size() - 1);
}
void apply() {
int index = 0;
while (index < changes.size()) {
Change change = changes.get(index);
index++;
if (change.kind.equals("push")) {
screens.add(change.screen);
} else if (change.kind.equals("pop")) {
screens.remove(screens.size() - 1);
Screen below = top();
if (below != null) below.onResult(change.result);
}
}
changes.clear();
}
}
// Един ход.
Screen top = stack.top();
top.handle(command);
top.update();
stack.apply();
Стъпка по стъпка, с думи
  1. Последният ход на боя. В стека са Карта, Бой и Цел отгоре. Остана само Вълкът. Списъкът с промени е празен.
  2. Командата 1 отива към върха, Цел. Тя вика pop(0): 0 е номерът на избрания враг. Стекът не мърда. Промяната само се записва в changes.
  3. update минава на Цел, която още е в стека. Всичко, което вижда, е както преди хода: боят още не е свършил.
  4. Краят на хода: apply взима промяна 0. Цел излиза от стека, а 0 слиза до Бой през onResult. Вълкът пада, враговете свършиха и Бой вика pop(победа): нова промяна в края на списъка.
  5. while чете дължината на changes наново, затова стига и до новата промяна. Бой излиза, а Карта получава „победа“. Тя записва push(Ново ниво).
  6. Третата промяна е push: Ново ниво влиза в стека, над Карта.
  7. Списъкът свърши. changes се изчиства, за да не се приложат същите промени и в следващия ход. Стекът е Карта, Ново ниво. Всеки екран получи отговора си след хода, а не по средата му.

Пробвай

Попълни

Това е цикълът на apply. Какво трябва да е условието, за да стигне и до промените, които се записват, докато той работи?

void apply() {
    int index = 0;
    while (index < ) {
        Change change = changes.get(index);
        index++;
        if (change.kind.equals("push")) {
            screens.add(change.screen);
        } else if (change.kind.equals("pop")) {
            screens.remove(screens.size() - 1);
            Screen below = top();
            if (below != null) below.onResult(change.result);
        }
    }
    changes.clear();
}

Пробвай

Предвиди изхода

NamedScreen е екран, който само казва какво е получил. Стекът е този с отложените промени. Какво печата програмата?

ScreenStack stack = new ScreenStack();
stack.push(new NamedScreen("Карта"));
stack.push(new NamedScreen("Бой"));
stack.apply();
stack.pop("победа");
stack.push(new NamedScreen("Ново ниво"));
System.out.println("Отгоре: " + stack.top().name);
stack.apply();
System.out.println("Отгоре: " + stack.top().name);
Отговор

Идеята

Преходи: replace и reset

Не всяка смяна на екран е въпрос и отговор. Някои са преходи, след които няма връщане. За тях стекът има още две промени:

void replace(Screen screen) {
changes.add(new Change("replace", screen, null));
}
void reset(Screen screen) {
changes.add(new Change("reset", screen, null));
}

В apply има по един клон за тях:

} else if (change.kind.equals("replace")) {
screens.remove(screens.size() - 1);
screens.add(change.screen);
} else if (change.kind.equals("reset")) {
screens.clear();
if (change.screen != null) screens.add(change.screen);
}
  • replace маха горния екран и слага друг на неговото място. Началният екран отваря създаването на герой, а то отваря селото. Със push началният екран щеше да остане отдолу и при „назад“ играчът щеше да се върне в него посред играта.
  • reset чисти целия стек и слага един екран (или нито един: тогава играта свършва). За него има две нужди: героят е паднал (екранът за край) и зареден е запис (селото или картата).

Защо за края на играта да не се ползва pop? Стекът е Село, Карта, Бой. Ето двата начина:

ScreenStack stack = new ScreenStack();
stack.push(new NamedScreen("Заглавие"));
stack.apply();
System.out.println(stack.names());
stack.replace(new NamedScreen("Нов герой"));
stack.apply();
System.out.println(stack.names());
stack.replace(new NamedScreen("Село"));
stack.apply();
System.out.println(stack.names());
System.out.println("Героят пада. С pop:");
ScreenStack popped = inFight();
popped.pop("загуба");
popped.pop("загуба");
popped.push(new NamedScreen("Край"));
popped.apply();
System.out.println(popped.names());
System.out.println("Героят пада. С reset:");
ScreenStack clean = inFight();
clean.reset(new NamedScreen("Край"));
clean.apply();
System.out.println(clean.names());
Изход
[Заглавие]
[Нов герой]
[Село]
Героят пада. С pop:
Карта получава загуба
Село получава загуба
[Село, Край]
Героят пада. С reset:
[Край]

С pop картата и селото получават „загуба“ и реагират, сякаш боят е свършил по нормалния начин. Трябва и да знаеш колко екрана има отдолу. А селото остава под екрана за край. reset не вика onResult на никого: това не е отговор, а нов старт.

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

replace и reset също са отложени: записват се и се прилагат в apply, в реда, в който са записани. Създаването на героя в играта вика replace(селото) и веднага push(съобщението с началото на историята). След apply стекът е село и съобщение отгоре, а не обратното.

Цялата програма:

import java.util.ArrayList;
import java.util.List;
class Screen {
String name;
Screen(String name) {
this.name = name;
}
void draw() {}
void handle(String command) {}
void update() {}
// Отговорът на екрана, който е бил над този.
void onResult(Object result) {}
}
class Change {
String kind;
Screen screen;
Object result;
Change(String kind, Screen screen, Object result) {
this.kind = kind;
this.screen = screen;
this.result = result;
}
}
class ScreenStack {
List<Screen> screens = new ArrayList<>();
List<Change> changes = new ArrayList<>();
void push(Screen screen) {
changes.add(new Change("push", screen, null));
}
void pop(Object result) {
changes.add(new Change("pop", null, result));
}
void replace(Screen screen) {
changes.add(new Change("replace", screen, null));
}
void reset(Screen screen) {
changes.add(new Change("reset", screen, null));
}
Screen top() {
return screens.isEmpty() ? null : screens.get(screens.size() - 1);
}
void apply() {
int index = 0;
while (index < changes.size()) {
Change change = changes.get(index);
index++;
if (change.kind.equals("push")) {
screens.add(change.screen);
} else if (change.kind.equals("pop")) {
screens.remove(screens.size() - 1);
Screen below = top();
if (below != null) below.onResult(change.result);
} else if (change.kind.equals("replace")) {
screens.remove(screens.size() - 1);
screens.add(change.screen);
} else if (change.kind.equals("reset")) {
screens.clear();
if (change.screen != null) screens.add(change.screen);
}
}
changes.clear();
}
String names() {
List<String> names = new ArrayList<>();
for (Screen screen : screens) names.add(screen.name);
return "[" + String.join(", ", names) + "]";
}
}
class NamedScreen extends Screen {
NamedScreen(String name) {
super(name);
}
@Override
void onResult(Object result) {
System.out.println(name + " получава " + result);
}
}
public class Main {
static ScreenStack inFight() {
ScreenStack stack = new ScreenStack();
stack.push(new NamedScreen("Село"));
stack.push(new NamedScreen("Карта"));
stack.push(new NamedScreen("Бой"));
stack.apply();
return stack;
}
public static void main(String[] args) {
ScreenStack stack = new ScreenStack();
stack.push(new NamedScreen("Заглавие"));
stack.apply();
System.out.println(stack.names());
stack.replace(new NamedScreen("Нов герой"));
stack.apply();
System.out.println(stack.names());
stack.replace(new NamedScreen("Село"));
stack.apply();
System.out.println(stack.names());
System.out.println("Героят пада. С pop:");
ScreenStack popped = inFight();
popped.pop("загуба");
popped.pop("загуба");
popped.push(new NamedScreen("Край"));
popped.apply();
System.out.println(popped.names());
System.out.println("Героят пада. С reset:");
ScreenStack clean = inFight();
clean.reset(new NamedScreen("Край"));
clean.apply();
System.out.println(clean.names());
}
}

Идеята

В истинската игра

Стекът на „Хрониките на Арканум“ е същият, с още три неща:

// Стекът не се променя по време на обработката: push, pop и replace само записват промяната, а
// apply() ги прилага след хода. Иначе екран, който се маха от стека, би продължил да работи.
static class StackChange {
String kind;
Screen screen;
Object result;
StackChange(String kind, Screen screen, Object result) {
this.kind = kind;
this.screen = screen;
this.result = result;
}
}
static class ScreenStack {
List<Screen> screens = new ArrayList<>();
List<StackChange> changes = new ArrayList<>();
void push(Screen screen) {
this.changes.add(new StackChange("push", screen, null));
}
void pop(Object result) {
this.changes.add(new StackChange("pop", null, result));
}
void replace(Screen screen) {
this.changes.add(new StackChange("replace", screen, null));
}
// Изчиства целия стек (зареждане, край на играта); с null стекът остава празен.
void reset(Screen screen) {
this.changes.add(new StackChange("reset", screen, null));
}
Screen top() {
return this.screens.size() == 0 ? null : this.screens.get(this.screens.size() - 1);
}
boolean isEmpty() {
return this.screens.size() == 0 && this.changes.size() == 0;
}
boolean contains(String kindName) {
for (Screen screen : this.screens) {
if (screen.kindName.equals(kindName)) return true;
}
return false;
}
// pop на празен стек в JavaScript не прави нищо; в Java remove хвърля, затова има проверка.
void removeTop() {
if (this.screens.size() > 0) this.screens.remove(this.screens.size() - 1);
}
void apply() {
int index = 0;
while (index < this.changes.size()) {
StackChange change = this.changes.get(index);
index++;
if (change.kind.equals("push")) {
this.screens.add(change.screen);
} else if (change.kind.equals("pop")) {
this.removeTop();
Screen below = this.top();
if (below != null) below.onResult(change.result);
} else if (change.kind.equals("replace")) {
this.removeTop();
this.screens.add(change.screen);
} else {
this.screens = new ArrayList<>();
if (change.screen != null) this.screens.add(change.screen);
}
}
this.changes = new ArrayList<>();
}
// Рисуването започва от най-горния непрозрачен екран и върви нагоре.
void draw(CellBuffer buffer) {
int first = this.screens.size() - 1;
while (first > 0 && this.screens.get(first).isTransparent()) first--;
for (int i = first; i < this.screens.size(); i++) {
if (i >= 0) this.screens.get(i).draw(buffer);
}
}
}
  • isEmpty гледа и промените. Играта започва с push на началния екран, а излизането е reset без екран. Цикълът спира, когато няма нито екрани, нито промени, които чакат.
  • contains пита дали в стека вече има екран от даден вид. Така екранът за ново ниво не влиза два пъти, ако в един ход се вземат две нива.
  • draw рисува отдолу нагоре от най-горния непрозрачен екран, като в миналия урок.

Изборът на цел в играта е прозрачен екран над боя. Той не удря сам: връща номера с pop, а боят решава какво да прави. До него са общият избор от списък (умение, отвара) и изборът на място за запис, който връща името на мястото, а не номер:

// Изборът на цел е прозрачен екран над боя. Не удря сам: връща номера на целта с pop(result),
// а боят решава какво да прави с нея.
static class TargetScreen extends Screen {
Battle battle;
String note;
TargetScreen(Game game, Battle battle) {
super(game);
this.battle = battle;
this.note = "";
}
@Override
boolean isTransparent() {
return true;
}
@Override
void draw(CellBuffer buffer) {
List<Enemy> enemies = this.battle.aliveEnemies();
buffer.box(24, 9, 36, enemies.size() + 4, t("target.title"));
for (int i = 0; i < enemies.size(); i++) {
buffer.text(26, 10 + i, (i + 1) + ") " + pad(enemies.get(i).name, 22) + " " + enemies.get(i).hp + "/" + enemies.get(i).maxHp(), enemies.get(i).kind.color);
}
buffer.text(26, 11 + enemies.size(), this.note.length() > 0 ? this.note : t("target.help"), "gray");
}
@Override
void handle(String command) {
if (command.equals("x")) {
this.game.stack.pop(null);
return;
}
Integer choice = parseIntStrict(command);
if (choice == null || choice < 1 || choice > this.battle.aliveEnemies().size()) {
this.note = t("ui.unknown", "command", command);
return;
}
this.game.stack.pop(choice - 1);
}
}
// Общ избор от списък (умение, отвара, слот): връща номера от 0 или null.
static class ChoiceScreen extends Screen {
String title;
List<String> labels;
String note;
ChoiceScreen(Game game, String title, List<String> labels) {
super(game);
this.title = title;
this.labels = labels;
this.note = "";
}
@Override
boolean isTransparent() {
return true;
}
@Override
void draw(CellBuffer buffer) {
buffer.box(4, 3, 52, this.labels.size() + 4, this.title);
for (int i = 0; i < this.labels.size(); i++) buffer.text(6, 4 + i, (i + 1) + ") " + this.labels.get(i), "default");
buffer.text(6, 5 + this.labels.size(), this.note.length() > 0 ? this.note : t("choice.help"), "gray");
}
@Override
void handle(String command) {
if (command.equals("x")) {
this.game.stack.pop(null);
return;
}
Integer choice = parseIntStrict(command);
if (choice == null || choice < 1 || choice > this.labels.size()) {
this.note = t("ui.unknown", "command", command);
return;
}
this.game.stack.pop(choice - 1);
}
}
static class SlotScreen extends ChoiceScreen {
SlotScreen(Game game, String mode) {
super(game, t(mode.equals("save") ? "slot.saveTitle" : "slot.loadTitle"), slotLabels(game));
}
// В Java конструкторът първо вика super, затова етикетите се правят в отделна функция.
static List<String> slotLabels(Game game) {
List<String> labels = new ArrayList<>();
for (String slot : SAVE_SLOTS) labels.add(t("slot.label", "slot", slot, "summary", slotSummary(game.io, slot)));
return labels;
}
@Override
void handle(String command) {
Integer choice = parseIntStrict(command);
if (choice != null && choice >= 1 && choice <= SAVE_SLOTS.size()) {
this.game.stack.pop(SAVE_SLOTS.get(choice - 1));
return;
}
super.handle(command);
}
}

Боят помни какво е питал (pending) и чете отговора в onResult:

void onResult(Object result) {
Battle battle = this.battle;
Hero hero = battle.hero;
Pending pending = this.pending;
this.pending = null;
if (result == null || pending == null) return;
int index = (Integer) result;
if (pending.kind.equals("attack")) {
battle.heroAct(new AttackAction(hero, battle.aliveEnemies().get(index)));
} else if (pending.kind.equals("skill")) {
Skill skill = SKILLS.get(hero.skills.get(index));
if (hero.mana < skill.mana) {
this.note = t("battle.noMana", "skill", nm(skill));
} else if (hero.cooldownOf(skill.id) > 0) {
this.note = t("battle.cooldown", "skill", nm(skill), "turns", hero.cooldownOf(skill.id));
} else if (skill.target.equals("one") && battle.aliveEnemies().size() > 1) {
this.pending = new Pending("skillTarget", skill);
this.game.stack.push(new TargetScreen(this.game, battle));
} else {
battle.heroAct(new SkillAction(hero, skill, battle.aliveEnemies().get(0)));
}
} else if (pending.kind.equals("skillTarget")) {
battle.heroAct(new SkillAction(hero, pending.skill, battle.aliveEnemies().get(index)));
} else {
battle.heroAct(new ItemAction(hero, this.potionIds().get(index)));
}
}

Виж клона за умение. Играчът е избрал умение, което иска цел, а враговете са повече от един. Тогава боят, вътре в onResult, записва нов въпрос: push на избора на цел. Това става по време на apply, а while го стига и го прилага в същия ход. Играчът вижда списъка с врагове веднага след списъка с умения.

Менюто е другият, който пита. „Сигурен ли си?“ е същият общ екран, а отговорът „да“ на въпроса за изход е reset без екран: стекът опустява и играта свършва.

static class ConfirmScreen extends Screen {
String question;
ConfirmScreen(Game game, String question) {
super(game);
this.question = question;
}
@Override
boolean isTransparent() {
return true;
}
@Override
void draw(CellBuffer buffer) {
buffer.box(16, 6, 28, 5, "");
buffer.text(18, 7, this.question, "white");
buffer.text(18, 8, "1) " + t("ui.yes") + " 2) " + t("ui.no"), "default");
}
@Override
void handle(String command) {
if (command.equals("1")) this.game.stack.pop(true);
else if (command.equals("2") || command.equals("x")) this.game.stack.pop(false);
}
}
void onResult(Object result) {
Game game = this.game;
String pending = this.pending;
this.pending = "";
if (result == null) return;
if (pending.equals("save")) {
game.io.save((String) result, saveGame(game));
this.note = t("menu.saved", "slot", result);
} else if (pending.equals("load")) {
String error = loadGame(game, game.io.load((String) result));
if (error != null) this.note = error;
else enterWorld(game, t("load.done", "slot", result));
} else if (pending.equals("quit") && Boolean.TRUE.equals(result)) {
game.stack.reset(null);
}
}

Пробвай

Намери грешката

Колега пренареди два реда в apply. Програмата с боя от „Отложени промени“ вече печата:

class ScreenStack {
List<Screen> screens = new ArrayList<>();
List<Change> changes = new ArrayList<>();
void push(Screen screen) {
changes.add(new Change("push", screen, null));
}
void pop(Object result) {
changes.add(new Change("pop", null, result));
}
Screen top() {
return screens.isEmpty() ? null : screens.get(screens.size() - 1);
}
void apply() {
int index = 0;
while (index < changes.size()) {
Change change = changes.get(index);
index++;
if (change.kind.equals("push")) {
screens.add(change.screen);
} else if (change.kind.equals("pop")) {
Screen below = top();
screens.remove(screens.size() - 1);
if (below != null) below.onResult(change.result);
}
}
changes.clear();
}
}
Изход
> 1
Бой взима 1 и отваря Цел
> 2
Цел взима 2 и връща 1
Цел се обновява, врагове в списъка: 2
> 1
Бой взима 1 и отваря Цел
> 1
Цел взима 1 и връща 0
Цел се обновява, врагове в списъка: 2
[Карта, Бой]

Целта връща номер, но боят никога не научава: и двата врага са живи, а стекът накрая е картата и боят. Кой ред се изпълнява твърде рано?

Избери реда с грешката.

По-точно

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

Защо не обратно извикване (callback)? Боят може да даде на целта функция: „когато играчът избере, извикай това“.

// Боят казва на целта какво да прави с избора.
stack.push(new TargetScreen(enemies, index -> attack(index)));

Това работи и е често срещано. Цената е в подробностите. Функцията се вика от целта, вътре в нейния handle: пак сме по средата на хода, освен ако и тя не се отложи. Функцията държи боя, а играта трябва да може да се запише: стек от екрани с данни се записва, а функция не можеш да запишеш във файл. И редът става невидим: кой ще бъде извикан, се разбира чак когато видиш кой какво е подал. onResult казва същото с правило, което се вижда в стека: отговорът винаги отива към екрана отдолу.

Същото, другаде. В Android една активност отваря друга и чака резултат (startActivityForResult, днес Activity Result API). Модалните диалози в десктоп приложенията връщат стойност, когато се затворят („OK“ или „Отказ“). Навигацията в мобилните приложения също е стек: отваряш екран отгоре, а с „назад“ се връщаш, понякога с резултат.

С await. На JavaScript, C# и Python може да се напише const target = await targetScreen.open(): боят „чака“ отговора, като че ли е обикновено извикване. Изглежда по-естествено, но в походова игра с един цикъл е по-трудно. Боят вече не е обект, който получава команди, а функция, спряна по средата. Нейното състояние е скрито в това „къде е спряла“, което не се записва във файл. И пак трябва да е ясно в кой момент от хода продължава.

Типът на отговора. Тук отговорът е Object, и боят знае, че е номер. Може и по-строго: целта връща свой тип (например запис с номера) или екранът е шаблонен по типа на отговора. В играта отговорите са малко и прости (номер, „да“ или „не“, име на място, резултат от боя), затова стига проверката в pending.

Упражнение

Магазин с два въпроса

Реши задачата в редактора си. Започни от стека с отложени промени от този урок и от ConfirmScreen.

Магазин. ShopScreen има 20 злато и продава отвара за 5 и меч за 12. Изборът на стока отваря QuantityScreen, който връща броя (от 1 до 9) или нищо, ако играчът натисне x. После магазинът отваря ConfirmScreen с въпроса „Купуваш ли 2 x Отвара за 10 злато?“. Един екран събира два отговора един след друг: onResult разбира кой отговор е дошъл по pending, който минава от "count" към "confirm". Ако златото не стига, покупката не става. x в магазина го затваря.

Обърни внимание: ConfirmScreen се отваря от onResult, тоест докато apply още работи. Затова цикълът в apply чете дължината на списъка наново.

Вход
1
2
1
2
1
2
2
x
2
1
1
1
1
1
x
Изход
Магазин: злато 20 | 1) Отвара 5 2) Меч 12 x) изход
> 1
Колко бройки? 1-9 | x) назад
> 2
Купуваш ли 2 x Отвара за 10 злато? 1) да 2) не
> 1
Купуваш 2 x Отвара.
Магазин: злато 10 | 1) Отвара 5 2) Меч 12 x) изход
> 2
Колко бройки? 1-9 | x) назад
> 1
Купуваш ли 1 x Меч за 12 злато? 1) да 2) не
> 2
Не купуваш.
Магазин: злато 10 | 1) Отвара 5 2) Меч 12 x) изход
> 2
Колко бройки? 1-9 | x) назад
> x
Размисляш.
Магазин: злато 10 | 1) Отвара 5 2) Меч 12 x) изход
> 2
Колко бройки? 1-9 | x) назад
> 1
Купуваш ли 1 x Меч за 12 злато? 1) да 2) не
> 1
Нямаш толкова злато.
Магазин: злато 10 | 1) Отвара 5 2) Меч 12 x) изход
> 1
Колко бройки? 1-9 | x) назад
> 1
Купуваш ли 1 x Отвара за 5 злато? 1) да 2) не
> 1
Купуваш 1 x Отвара.
Магазин: злато 5 | 1) Отвара 5 2) Меч 12 x) изход
> x
Довиждане.

Още задачи

  • Продажба. Добави s) продай: магазинът пита коя стока (още един екран с отговор) и колко бройки, и връща половината цена, закръглена надолу. Колко стойности ще има pending сега?
  • Торба. Пази купените неща в списък и го печатай при изход. Отвара, купена два пъти, е един ред с 3 бройки, а не два реда.
  • Без pending. Направи QuantityScreen и ConfirmScreen да връщат запис с име на въпроса и стойност. Може ли тогава магазинът да мине без pending? Какво печелиш и какво губиш?

Тест

Провери се

1. Защо екранът за избор на цел не вика боя сам, а връща номер с pop?
2. Картата отвори „Сигурен ли си?“ и получи true. Откъде знае дали да излезе от играта, или да хвърли отвара?
3. Защо apply обхожда промените с while (index < дължината), а не с foreach?
4. Колега изтри последния ред на apply, който изчиства changes. Какво става?
5. Героят пада в боя. Стекът е Село, Карта, Бой. Защо играта вика reset(Край), а не два pop и един push?

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