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

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

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

Проблем

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

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

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

enemies = ["Вълк", "Гоблин"]
selected_target = -1
def target_screen(command):
global selected_target
if command != "x":
selected_target = int(command) - 1
def fight_turn():
if selected_target >= 0:
print("Удряш " + enemies[selected_target] + ".")
else:
print("Никой не е ударен.")
# Играчът избира 2, после се отказва (x).
target_screen("2")
fight_turn()
target_screen("x")
fight_turn()
Изход
Удряш Гоблин.
Удряш Гоблин.

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

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

class TargetScreen(Screen):
def handle(self, command):
# Целта знае боя и го вика сама.
self.fight.attack(int(command) - 1)
self.stack.pop()

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

Идеята

pop(result) и onResult

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

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

class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# Отговорът на екрана, който е бил над този.
def on_result(self, result):
pass

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

def pop(self, result):
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(result)

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

Виж го

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

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

class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Цел")
self.stack = stack
self.enemies = enemies
def draw(self):
line = "Кого удряш?"
for i in range(len(self.enemies)):
line += f" {i + 1}) {self.enemies[i].name}"
print(line + " x) назад")
def handle(self, command):
if command == "x":
self.stack.pop(None)
return
choice = int(command)
if 1 <= choice <= len(self.enemies):
self.stack.pop(choice - 1)

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

class FightScreen(Screen):
def __init__(self, stack):
super().__init__("Бой")
self.stack = stack
self.enemies = [Enemy("Вълк", 4), Enemy("Гоблин", 7)]
def draw(self):
line = "Бой:"
for enemy in self.enemies:
line += f" {enemy.name} {enemy.hp}"
print(line + " | 1) удар")
def handle(self, command):
if command == "1":
self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result):
if result is None:
print("Размисляш. Никой не е ударен.")
return
enemy = self.enemies[result]
enemy.hp -= 4
if enemy.hp > 0:
print("Удряш " + enemy.name + " за 4.")
return
print(enemy.name + " пада.")
self.enemies.remove(enemy)
if not self.enemies:
self.stack.pop("победа")

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

stack = ScreenStack()
stack.push(MapScreen(stack))
while stack.top() is not None:
stack.top().draw()
command = input().strip()
print("> " + command)
stack.top().handle(command)
print("Довиждане.")
Изход
Карта: отвари 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) не удря никого: отговорът е празен и боят го знае. Изборът от миналия ход не е останал никъде.

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

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

class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# Отговорът на екрана, който е бил над този.
def on_result(self, result):
pass
class ScreenStack:
def __init__(self):
self.screens = []
def push(self, screen):
self.screens.append(screen)
def pop(self, result):
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(result)
def top(self):
return self.screens[-1] if self.screens else None
def names(self):
return "[" + ", ".join(s.name for s in self.screens) + "]"
class Enemy:
def __init__(self, name, hp):
self.name = name
self.hp = hp
class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Цел")
self.stack = stack
self.enemies = enemies
def draw(self):
line = "Кого удряш?"
for i in range(len(self.enemies)):
line += f" {i + 1}) {self.enemies[i].name}"
print(line + " x) назад")
def handle(self, command):
if command == "x":
self.stack.pop(None)
return
choice = int(command)
if 1 <= choice <= len(self.enemies):
self.stack.pop(choice - 1)
class FightScreen(Screen):
def __init__(self, stack):
super().__init__("Бой")
self.stack = stack
self.enemies = [Enemy("Вълк", 4), Enemy("Гоблин", 7)]
def draw(self):
line = "Бой:"
for enemy in self.enemies:
line += f" {enemy.name} {enemy.hp}"
print(line + " | 1) удар")
def handle(self, command):
if command == "1":
self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result):
if result is None:
print("Размисляш. Никой не е ударен.")
return
enemy = self.enemies[result]
enemy.hp -= 4
if enemy.hp > 0:
print("Удряш " + enemy.name + " за 4.")
return
print(enemy.name + " пада.")
self.enemies.remove(enemy)
if not self.enemies:
self.stack.pop("победа")
class ConfirmScreen(Screen):
def __init__(self, stack, question):
super().__init__(question)
self.stack = stack
self.question = question
def draw(self):
print(self.question + " 1) да 2) не")
def handle(self, command):
if command == "1":
self.stack.pop(True)
elif command == "2":
self.stack.pop(False)
class MapScreen(Screen):
def __init__(self, stack):
super().__init__("Карта")
self.stack = stack
self.potions = 2
self.pending = ""
def draw(self):
print("Карта: отвари " + str(self.potions) + " | f) бой d) хвърли отвара q) изход")
def handle(self, command):
if command == "f":
self.stack.push(FightScreen(self.stack))
elif command == "d":
self.pending = "drop"
self.stack.push(ConfirmScreen(self.stack, "Сигурен ли си, че хвърляш отвара?"))
elif command == "q":
self.pending = "quit"
self.stack.push(ConfirmScreen(self.stack, "Сигурен ли си, че излизаш?"))
def on_result(self, result):
asked = self.pending
self.pending = ""
if asked == "drop":
if result is True:
self.potions -= 1
print("Хвърляш една отвара.")
else:
print("Отварата остава.")
elif asked == "quit":
if result is True:
self.stack.pop(None)
elif result == "победа":
print("Победа! Обратно на картата.")
stack = ScreenStack()
stack.push(MapScreen(stack))
while stack.top() is not None:
stack.top().draw()
command = input().strip()
print("> " + command)
stack.top().handle(command)
print("Довиждане.")

Идеята

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

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

class ConfirmScreen(Screen):
def __init__(self, stack, question):
super().__init__(question)
self.stack = stack
self.question = question
def draw(self):
print(self.question + " 1) да 2) не")
def handle(self, command):
if command == "1":
self.stack.pop(True)
elif command == "2":
self.stack.pop(False)

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

class MapScreen(Screen):
def __init__(self, stack):
super().__init__("Карта")
self.stack = stack
self.potions = 2
self.pending = ""
def draw(self):
print("Карта: отвари " + str(self.potions) + " | f) бой d) хвърли отвара q) изход")
def handle(self, command):
if command == "f":
self.stack.push(FightScreen(self.stack))
elif command == "d":
self.pending = "drop"
self.stack.push(ConfirmScreen(self.stack, "Сигурен ли си, че хвърляш отвара?"))
elif command == "q":
self.pending = "quit"
self.stack.push(ConfirmScreen(self.stack, "Сигурен ли си, че излизаш?"))
def on_result(self, result):
asked = self.pending
self.pending = ""
if asked == "drop":
if result is True:
self.potions -= 1
print("Хвърляш една отвара.")
else:
print("Отварата остава.")
elif asked == "quit":
if result is True:
self.stack.pop(None)
elif result == "победа":
print("Победа! Обратно на картата.")

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

Проблем

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

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

class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Цел")
self.stack = stack
self.enemies = enemies
def handle(self, command):
index = int(command) - 1
print("Цел взима " + command + " и връща " + str(index))
self.stack.pop(index)
def update(self):
print("Цел се обновява, врагове в списъка: " + str(len(self.enemies)))

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

top = stack.top()
top.handle(command)
top.update()
class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# Отговорът на екрана, който е бил над този.
def on_result(self, result):
pass
class ScreenStack:
def __init__(self):
self.screens = []
def push(self, screen):
self.screens.append(screen)
def pop(self, result):
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(result)
def top(self):
return self.screens[-1] if self.screens else None
def names(self):
return "[" + ", ".join(s.name for s in self.screens) + "]"
class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Цел")
self.stack = stack
self.enemies = enemies
def handle(self, command):
index = int(command) - 1
print("Цел взима " + command + " и връща " + str(index))
self.stack.pop(index)
def update(self):
print("Цел се обновява, врагове в списъка: " + str(len(self.enemies)))
class FightScreen(Screen):
def __init__(self, stack):
super().__init__("Бой")
self.stack = stack
self.enemies = ["Вълк", "Гоблин"]
def handle(self, command):
print("Бой взима " + command + " и отваря Цел")
self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result):
enemy = self.enemies.pop(result)
print("Бой получава " + str(result) + ": " + enemy + " пада")
if not self.enemies:
print("Бой: няма врагове, връща победа")
self.stack.pop("победа")
class MapScreen(Screen):
def __init__(self, stack):
super().__init__("Карта")
self.stack = stack
def on_result(self, result):
print("Карта получава " + str(result) + ": отваря Ново ниво")
self.stack.push(Screen("Ново ниво"))
stack = ScreenStack()
stack.push(MapScreen(stack))
stack.push(FightScreen(stack))
for command in ["1", "2", "1", "1"]:
print("> " + command)
top = stack.top()
top.handle(command)
top.update()
print(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) и един от тях се маха по време на обхождането. Тогава езиците се държат различно:

screens = ["Карта", "Съобщение", "Бой", "Цел"]
for screen in screens:
print("update " + screen)
if screen == "Съобщение":
screens.remove(screen)
Изход
update Карта
update Съобщение
update Цел
Python не казва нищо. for върви по индекс: Съобщение е махнато, всичко се измества с едно наляво и Бой, който беше следващ, се прескача. Програмата върви нататък с грешен резултат.

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

Идеята

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

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

class Change:
def __init__(self, kind, screen, result):
self.kind = kind
self.screen = screen
self.result = result
class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
self.changes.clear()

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

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

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

class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# Отговорът на екрана, който е бил над този.
def on_result(self, result):
pass
class Change:
def __init__(self, kind, screen, result):
self.kind = kind
self.screen = screen
self.result = result
class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
self.changes.clear()
def names(self):
return "[" + ", ".join(s.name for s in self.screens) + "]"
class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Цел")
self.stack = stack
self.enemies = enemies
def handle(self, command):
index = int(command) - 1
print("Цел взима " + command + " и връща " + str(index))
self.stack.pop(index)
def update(self):
print("Цел се обновява, врагове в списъка: " + str(len(self.enemies)))
class FightScreen(Screen):
def __init__(self, stack):
super().__init__("Бой")
self.stack = stack
self.enemies = ["Вълк", "Гоблин"]
def handle(self, command):
print("Бой взима " + command + " и отваря Цел")
self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result):
enemy = self.enemies.pop(result)
print("Бой получава " + str(result) + ": " + enemy + " пада")
if not self.enemies:
print("Бой: няма врагове, връща победа")
self.stack.pop("победа")
class MapScreen(Screen):
def __init__(self, stack):
super().__init__("Карта")
self.stack = stack
def on_result(self, result):
print("Карта получава " + str(result) + ": отваря Ново ниво")
self.stack.push(Screen("Ново ниво"))
stack = ScreenStack()
stack.push(MapScreen(stack))
stack.push(FightScreen(stack))
stack.apply()
for command in ["1", "2", "1", "1"]:
print("> " + command)
top = stack.top()
top.handle(command)
top.update()
stack.apply()
print(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 тук случайно също би минал, но while с индекс казва изрично „чети дължината наново“ и не зависи от това как върви итераторът.
  • Списъкът се изчиства накрая. Иначе в следващия ход същите промени ще се приложат пак.
  • Редът е редът на записване. Ако екран запише replace и после push в един ход, стекът ги прави точно така.

Виж го

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

class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
self.changes.clear()
# Един ход.
top = stack.top()
top.handle(command)
top.update()
stack.apply()
Стъпка по стъпка, с думи
  1. Последният ход на боя. В стека са Карта, Бой и Цел отгоре. Остана само Вълкът. Списъкът с промени е празен.
  2. Командата 1 отива към върха, Цел. Тя вика pop(0): 0 е номерът на избрания враг. Стекът не мърда. Промяната само се записва в changes.
  3. update минава на Цел, която още е в стека. Всичко, което вижда, е както преди хода: боят още не е свършил.
  4. Краят на хода: apply взима промяна 0. Цел излиза от стека, а 0 слиза до Бой през on_result. Вълкът пада, враговете свършиха и Бой вика pop(победа): нова промяна в края на списъка.
  5. while чете дължината на changes наново, затова стига и до новата промяна. Бой излиза, а Карта получава „победа“. Тя записва push(Ново ниво).
  6. Третата промяна е push: Ново ниво влиза в стека, над Карта.
  7. Списъкът свърши. changes се изчиства, за да не се приложат същите промени и в следващия ход. Стекът е Карта, Ново ниво. Всеки екран получи отговора си след хода, а не по средата му.

Пробвай

Попълни

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

def apply(self):
    index = 0
    while index < :
        change = self.changes[index]
        index += 1
        if change.kind == "push":
            self.screens.append(change.screen)
        elif change.kind == "pop":
            self.screens.pop()
            below = self.top()
            if below is not None:
                below.on_result(change.result)
    self.changes.clear()

Пробвай

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

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

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

Идеята

Преходи: replace и reset

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

def replace(self, screen):
self.changes.append(Change("replace", screen, None))
def reset(self, screen):
self.changes.append(Change("reset", screen, None))

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

elif change.kind == "replace":
self.screens.pop()
self.screens.append(change.screen)
elif change.kind == "reset":
self.screens.clear()
if change.screen is not None:
self.screens.append(change.screen)
  • replace маха горния екран и слага друг на неговото място. Началният екран отваря създаването на герой, а то отваря селото. Със push началният екран щеше да остане отдолу и при „назад“ играчът щеше да се върне в него посред играта.
  • reset чисти целия стек и слага един екран (или нито един: тогава играта свършва). За него има две нужди: героят е паднал (екранът за край) и зареден е запис (селото или картата).

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

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

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

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

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

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

class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# Отговорът на екрана, който е бил над този.
def on_result(self, result):
pass
class Change:
def __init__(self, kind, screen, result):
self.kind = kind
self.screen = screen
self.result = result
class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def replace(self, screen):
self.changes.append(Change("replace", screen, None))
def reset(self, screen):
self.changes.append(Change("reset", screen, None))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
elif change.kind == "replace":
self.screens.pop()
self.screens.append(change.screen)
elif change.kind == "reset":
self.screens.clear()
if change.screen is not None:
self.screens.append(change.screen)
self.changes.clear()
def names(self):
return "[" + ", ".join(s.name for s in self.screens) + "]"
class NamedScreen(Screen):
def on_result(self, result):
print(self.name + " получава " + str(result))
def in_fight():
stack = ScreenStack()
stack.push(NamedScreen("Село"))
stack.push(NamedScreen("Карта"))
stack.push(NamedScreen("Бой"))
stack.apply()
return stack
stack = ScreenStack()
stack.push(NamedScreen("Заглавие"))
stack.apply()
print(stack.names())
stack.replace(NamedScreen("Нов герой"))
stack.apply()
print(stack.names())
stack.replace(NamedScreen("Село"))
stack.apply()
print(stack.names())
print("Героят пада. С pop:")
popped = in_fight()
popped.pop("загуба")
popped.pop("загуба")
popped.push(NamedScreen("Край"))
popped.apply()
print(popped.names())
print("Героят пада. С reset:")
clean = in_fight()
clean.reset(NamedScreen("Край"))
clean.apply()
print(clean.names())

Идеята

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

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

# Стекът не се променя по време на обработката: push, pop и replace само записват промяната, а
# apply() ги прилага след хода. Иначе екран, който се маха от стека, би продължил да работи.
class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(SimpleNamespace(kind='push', screen=screen, result=None))
def pop(self, result):
self.changes.append(SimpleNamespace(kind='pop', screen=None, result=result))
def replace(self, screen):
self.changes.append(SimpleNamespace(kind='replace', screen=screen, result=None))
# Изчиства целия стек (зареждане, край на играта); с None стекът остава празен.
def reset(self, screen):
self.changes.append(SimpleNamespace(kind='reset', screen=screen, result=None))
def top(self):
return None if len(self.screens) == 0 else self.screens[-1]
def is_empty(self):
return len(self.screens) == 0 and len(self.changes) == 0
def contains(self, kind_name):
for screen in self.screens:
if getattr(screen, 'kind_name', None) == kind_name:
return True
return False
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == 'push':
self.screens.append(change.screen)
elif change.kind == 'pop':
# pop() на празен списък в Python е грешка; в JavaScript връща undefined.
if len(self.screens) > 0:
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
elif change.kind == 'replace':
if len(self.screens) > 0:
self.screens.pop()
self.screens.append(change.screen)
else:
self.screens = []
if change.screen is not None:
self.screens.append(change.screen)
self.changes = []
# Рисуването започва от най-горния непрозрачен екран и върви нагоре.
def draw(self, buffer):
first = len(self.screens) - 1
while first > 0 and self.screens[first].is_transparent():
first -= 1
for i in range(first, len(self.screens)):
if i >= 0:
self.screens[i].draw(buffer)
  • isEmpty гледа и промените. Играта започва с push на началния екран, а излизането е reset без екран. Цикълът спира, когато няма нито екрани, нито промени, които чакат.
  • contains пита дали в стека вече има екран от даден вид. Така екранът за ново ниво не влиза два пъти, ако в един ход се вземат две нива.
  • draw рисува отдолу нагоре от най-горния непрозрачен екран, като в миналия урок.

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

# Изборът на цел е прозрачен екран над боя. Не удря сам: връща номера на целта с pop(result),
# а боят решава какво да прави с нея.
class TargetScreen(Screen):
def __init__(self, game, battle):
super().__init__(game)
self.battle = battle
self.note = ''
def is_transparent(self):
return True
def draw(self, buffer):
enemies = self.battle.alive_enemies()
buffer.box(24, 9, 36, len(enemies) + 4, t('target.title'))
for i in range(len(enemies)):
buffer.text(26, 10 + i, str(i + 1) + ') ' + pad(enemies[i].name, 22) + ' ' + str(enemies[i].hp) + '/' + str(enemies[i].max_hp()), enemies[i].kind.color)
buffer.text(26, 11 + len(enemies), self.note if len(self.note) > 0 else t('target.help'), 'gray')
def handle(self, command):
if command == 'x':
self.game.stack.pop(None)
return
choice = parse_int_strict(command)
if choice is None or choice < 1 or choice > len(self.battle.alive_enemies()):
self.note = t('ui.unknown', {'command': command})
return
self.game.stack.pop(choice - 1)
# Общ избор от списък (умение, отвара, слот): връща номера от 0 или None.
class ChoiceScreen(Screen):
def __init__(self, game, title, labels):
super().__init__(game)
self.title = title
self.labels = labels
self.note = ''
def is_transparent(self):
return True
def draw(self, buffer):
buffer.box(4, 3, 52, len(self.labels) + 4, self.title)
for i in range(len(self.labels)):
buffer.text(6, 4 + i, str(i + 1) + ') ' + self.labels[i], 'default')
buffer.text(6, 5 + len(self.labels), self.note if len(self.note) > 0 else t('choice.help'), 'gray')
def handle(self, command):
if command == 'x':
self.game.stack.pop(None)
return
choice = parse_int_strict(command)
if choice is None or choice < 1 or choice > len(self.labels):
self.note = t('ui.unknown', {'command': command})
return
self.game.stack.pop(choice - 1)
class SlotScreen(ChoiceScreen):
def __init__(self, game, mode):
labels = []
for slot in SAVE_SLOTS:
labels.append(t('slot.label', {'slot': slot, 'summary': slot_summary(game.io, slot)}))
super().__init__(game, t('slot.saveTitle' if mode == 'save' else 'slot.loadTitle'), labels)
def handle(self, command):
choice = parse_int_strict(command)
if choice is not None and choice >= 1 and choice <= len(SAVE_SLOTS):
self.game.stack.pop(SAVE_SLOTS[choice - 1])
return
super().handle(command)

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

def on_result(self, result):
battle = self.battle
hero = battle.hero
pending = self.pending
self.pending = None
if result is None or pending is None:
return
if pending.kind == 'attack':
battle.hero_act(AttackAction(hero, battle.alive_enemies()[result]))
elif pending.kind == 'skill':
skill = SKILLS[hero.skills[result]]
if hero.mana < skill.mana:
self.note = t('battle.noMana', {'skill': nm(skill)})
elif hero.cooldown_of(skill.id) > 0:
self.note = t('battle.cooldown', {'skill': nm(skill), 'turns': hero.cooldown_of(skill.id)})
elif skill.target == 'one' and len(battle.alive_enemies()) > 1:
self.pending = SimpleNamespace(kind='skillTarget', skill=skill)
self.game.stack.push(TargetScreen(self.game, battle))
else:
battle.hero_act(SkillAction(hero, skill, battle.alive_enemies()[0]))
elif pending.kind == 'skillTarget':
battle.hero_act(SkillAction(hero, pending.skill, battle.alive_enemies()[result]))
else:
battle.hero_act(ItemAction(hero, self.potion_ids()[result]))

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

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

class ConfirmScreen(Screen):
def __init__(self, game, question):
super().__init__(game)
self.question = question
def is_transparent(self):
return True
def draw(self, buffer):
buffer.box(16, 6, 28, 5, '')
buffer.text(18, 7, self.question, 'white')
buffer.text(18, 8, '1) ' + t('ui.yes') + ' 2) ' + t('ui.no'), 'default')
def handle(self, command):
if command == '1':
self.game.stack.pop(True)
elif command == '2' or command == 'x':
self.game.stack.pop(False)
def on_result(self, result):
game = self.game
pending = self.pending
self.pending = ''
if result is None:
return
if pending == 'save':
game.io.save(result, save_game(game))
self.note = t('menu.saved', {'slot': result})
elif pending == 'load':
error = load_game(game, game.io.load(result))
if error is not None:
self.note = error
else:
enter_world(game, t('load.done', {'slot': result}))
elif pending == 'quit' and result is True:
game.stack.reset(None)

Пробвай

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

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

class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
below = self.top()
self.screens.pop()
if below is not None:
below.on_result(change.result)
self.changes.clear()
Изход
> 1
Бой взима 1 и отваря Цел
> 2
Цел взима 2 и връща 1
Цел се обновява, врагове в списъка: 2
> 1
Бой взима 1 и отваря Цел
> 1
Цел взима 1 и връща 0
Цел се обновява, врагове в списъка: 2
[Карта, Бой]

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

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

По-точно

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

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

# Боят казва на целта какво да прави с избора.
self.stack.push(TargetScreen(self.enemies, lambda index: self.attack(index)))

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

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

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

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

Упражнение

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

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

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

Обърни внимание: ConfirmScreen се отваря от on_result, тоест докато 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 за достъпно обучение по програмиране ·