Екрани, които връщат отговор
Накратко: Екран, който връща отговор, се затваря с pop(result) и подава резултата на екрана отдолу (onResult), а промените по стека се записват по време на хода и се прилагат в края му с apply.
Проблем
Екранът пита. Кой чува отговора?
В миналия урок екраните само се отваряха и затваряха. Сега един екран трябва да попита нещо и да получи отговор. Боят е с два врага, играчът натиска „удар“, а боят трябва да знае кого. Менюто трябва да попита „Сигурен ли си?“, преди да излезе от играта.
Първият опит е обща променлива: екранът за цел пише избора в selectedTarget, а боят го чете. Играчът избира Гоблина, после отваря избора пак и се отказва (x):
static string[] enemies = { "Вълк", "Гоблин" };static int selectedTarget = -1;
static void TargetScreen(string command){ if (command != "x") selectedTarget = int.Parse(command) - 1;}
static void FightTurn(){ if (selectedTarget >= 0) Console.WriteLine("Удряш " + enemies[selectedTarget] + "."); else Console.WriteLine("Никой не е ударен.");}
// Играчът избира 2, после се отказва (x).TargetScreen("2");FightTurn();TargetScreen("x");FightTurn();Удряш Гоблин.Удряш Гоблин.Вторият удар е по Гоблина, а играчът се е отказал. Изборът от миналия ход е още в променливата. Кой трябва да я върне на -1? Боят, след като я прочете? А ако същата променлива ползва и инвентарът? Общата променлива няма собственик, затова в нея остават стари отговори.
Вторият опит е целта да вика боя сама:
class TargetScreen : Screen{ FightScreen fight;
public override void Handle(string command) { // Целта знае боя и го вика сама. fight.Attack(int.Parse(command) - 1); stack.Pop(); }}Това върви, но целта вече знае боя. В играта „кого?“ питат и ударът, и заклинанието, и отварата, която лекува съюзник. Всеки от тях иска своя екран за цел, или един екран с if за всеки, който го ползва. Трябва път, по който отговорът се връща към този, който е питал, без онзи, който отговаря, да знае кой е той.
Идеята
pop(result) и onResult
Екранът, който пита, отваря отговарящия с push. Отговарящият се маха с pop и подава отговора: pop(result). Стекът знае кой е отдолу: точно този, който е питал. Затова му подава отговора, като вика неговия OnResult.
Екранът получава още един метод. По подразбиране не прави нищо: повечето екрани никога не питат.
class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Отговорът на екрана, който е бил над този. public virtual void OnResult(object? result) { }}А pop от миналия урок става pop(result):
public void Pop(object? result){ Screens.RemoveAt(Screens.Count - 1); Screen? below = Top(); if (below != null) below.OnResult(result);}Така двете страни не се познават. Целта знае въпроса и формата на отговора: номер на враг или null за „отказ“. Боят знае какво да прави с номера. Прилича на извикване на метод, който връща стойност, само че между въпроса и отговора минават ходове: играчът мисли, натиска, може да се откаже.
Виж го
Изборът на цел
Целта рисува списък с живите врагове и чака цифра. x е „назад“ и връща null. Номерът се връща от 0, защото е индекс в списъка:
class TargetScreen : Screen{ ScreenStack stack; List<Enemy> enemies;
public TargetScreen(ScreenStack stack, List<Enemy> enemies) : base("Цел") { this.stack = stack; this.enemies = enemies; }
public override void Draw() { string line = "Кого удряш?"; for (int i = 0; i < enemies.Count; i++) { line += " " + (i + 1) + ") " + enemies[i].Name; } Console.WriteLine(line + " x) назад"); }
public override void Handle(string command) { if (command == "x") { stack.Pop(null); return; } int choice = int.Parse(command); if (choice >= 1 && choice <= enemies.Count) stack.Pop(choice - 1); }}Боят отваря целта при 1, а отговора чете в OnResult. Когато враговете свършат, боят също връща отговор на екрана под себе си, картата: „победа“.
class FightScreen : Screen{ ScreenStack stack; List<Enemy> enemies = new List<Enemy>();
public FightScreen(ScreenStack stack) : base("Бой") { this.stack = stack; enemies.Add(new Enemy("Вълк", 4)); enemies.Add(new Enemy("Гоблин", 7)); }
public override void Draw() { string line = "Бой:"; foreach (Enemy enemy in enemies) line += " " + enemy.Name + " " + enemy.Hp; Console.WriteLine(line + " | 1) удар"); }
public override void Handle(string command) { if (command == "1") stack.Push(new TargetScreen(stack, enemies)); }
public override void OnResult(object? result) { if (result == null) { Console.WriteLine("Размисляш. Никой не е ударен."); return; } Enemy enemy = enemies[(int)result]; enemy.Hp -= 4; if (enemy.Hp > 0) { Console.WriteLine("Удряш " + enemy.Name + " за 4."); return; } Console.WriteLine(enemy.Name + " пада."); enemies.Remove(enemy); if (enemies.Count == 0) stack.Pop("победа"); }}Програмата чете командите от входа. Всеки ход рисува само екрана на върха, за да е кратък изходът:
ScreenStack stack = new ScreenStack();stack.Push(new MapScreen(stack));while (stack.Top() != null){ stack.Top()!.Draw(); string command = Console.ReadLine()!.Trim(); Console.WriteLine("> " + command); stack.Top()!.Handle(command);}Console.WriteLine("Довиждане.");Карта: отвари 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Довиждане.Входът е:
f121x1211d2q1Отказът (x) не удря никого: отговорът е празен и боят го знае. Изборът от миналия ход не е останал никъде.
По-точно, за любопитните
Цялата програма, с картата и въпроса „Сигурен ли си?“ от следващата карта:
class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Отговорът на екрана, който е бил над този. public virtual void OnResult(object? result) { }}
class ScreenStack{ public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen) { Screens.Add(screen); }
public void Pop(object? result) { Screens.RemoveAt(Screens.Count - 1); Screen? below = Top(); if (below != null) below.OnResult(result); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public string Names() { return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]"; }}
class Enemy{ public string Name; public int Hp;
public Enemy(string name, int hp) { Name = name; Hp = hp; }}
class TargetScreen : Screen{ ScreenStack stack; List<Enemy> enemies;
public TargetScreen(ScreenStack stack, List<Enemy> enemies) : base("Цел") { this.stack = stack; this.enemies = enemies; }
public override void Draw() { string line = "Кого удряш?"; for (int i = 0; i < enemies.Count; i++) { line += " " + (i + 1) + ") " + enemies[i].Name; } Console.WriteLine(line + " x) назад"); }
public override void Handle(string command) { if (command == "x") { stack.Pop(null); return; } int choice = int.Parse(command); if (choice >= 1 && choice <= enemies.Count) stack.Pop(choice - 1); }}
class FightScreen : Screen{ ScreenStack stack; List<Enemy> enemies = new List<Enemy>();
public FightScreen(ScreenStack stack) : base("Бой") { this.stack = stack; enemies.Add(new Enemy("Вълк", 4)); enemies.Add(new Enemy("Гоблин", 7)); }
public override void Draw() { string line = "Бой:"; foreach (Enemy enemy in enemies) line += " " + enemy.Name + " " + enemy.Hp; Console.WriteLine(line + " | 1) удар"); }
public override void Handle(string command) { if (command == "1") stack.Push(new TargetScreen(stack, enemies)); }
public override void OnResult(object? result) { if (result == null) { Console.WriteLine("Размисляш. Никой не е ударен."); return; } Enemy enemy = enemies[(int)result]; enemy.Hp -= 4; if (enemy.Hp > 0) { Console.WriteLine("Удряш " + enemy.Name + " за 4."); return; } Console.WriteLine(enemy.Name + " пада."); enemies.Remove(enemy); if (enemies.Count == 0) stack.Pop("победа"); }}
class ConfirmScreen : Screen{ ScreenStack stack; string question;
public ConfirmScreen(ScreenStack stack, string question) : base(question) { this.stack = stack; this.question = question; }
public override void Draw() { Console.WriteLine(question + " 1) да 2) не"); }
public override void Handle(string command) { if (command == "1") stack.Pop(true); else if (command == "2") stack.Pop(false); }}
class MapScreen : Screen{ ScreenStack stack; int potions = 2; string pending = "";
public MapScreen(ScreenStack stack) : base("Карта") { this.stack = stack; }
public override void Draw() { Console.WriteLine("Карта: отвари " + potions + " | f) бой d) хвърли отвара q) изход"); }
public override void Handle(string command) { if (command == "f") { stack.Push(new FightScreen(stack)); } else if (command == "d") { pending = "drop"; stack.Push(new ConfirmScreen(stack, "Сигурен ли си, че хвърляш отвара?")); } else if (command == "q") { pending = "quit"; stack.Push(new ConfirmScreen(stack, "Сигурен ли си, че излизаш?")); } }
public override void OnResult(object? result) { string asked = pending; pending = ""; if (asked == "drop") { if (result is true) { potions--; Console.WriteLine("Хвърляш една отвара."); } else { Console.WriteLine("Отварата остава."); } } else if (asked == "quit") { if (result is true) stack.Pop(null); } else if (result is "победа") { Console.WriteLine("Победа! Обратно на картата."); } }}
class Program{ static void Main() { ScreenStack stack = new ScreenStack(); stack.Push(new MapScreen(stack)); while (stack.Top() != null) { stack.Top()!.Draw(); string command = Console.ReadLine()!.Trim(); Console.WriteLine("> " + command); stack.Top()!.Handle(command); } Console.WriteLine("Довиждане."); }}Идеята
Един въпрос за всичко: „Сигурен ли си?“
Екранът с въпрос не знае какво пита. Той показва текста, който е получил, и връща true или false:
class ConfirmScreen : Screen{ ScreenStack stack; string question;
public ConfirmScreen(ScreenStack stack, string question) : base(question) { this.stack = stack; this.question = question; }
public override void Draw() { Console.WriteLine(question + " 1) да 2) не"); }
public override void Handle(string command) { if (command == "1") stack.Pop(true); else if (command == "2") stack.Pop(false); }}Картата го ползва за два различни въпроса: дали да хвърли отвара и дали да излезе. Но отговорът е само „да“ или „не“. Откъде картата знае на кой въпрос е? Помни какво е питала. Преди push записва pending, а когато дойде отговорът, го прочита и веднага го изчиства:
class MapScreen : Screen{ ScreenStack stack; int potions = 2; string pending = "";
public MapScreen(ScreenStack stack) : base("Карта") { this.stack = stack; }
public override void Draw() { Console.WriteLine("Карта: отвари " + potions + " | f) бой d) хвърли отвара q) изход"); }
public override void Handle(string command) { if (command == "f") { stack.Push(new FightScreen(stack)); } else if (command == "d") { pending = "drop"; stack.Push(new ConfirmScreen(stack, "Сигурен ли си, че хвърляш отвара?")); } else if (command == "q") { pending = "quit"; stack.Push(new ConfirmScreen(stack, "Сигурен ли си, че излизаш?")); } }
public override void OnResult(object? result) { string asked = pending; pending = ""; if (asked == "drop") { if (result is true) { potions--; Console.WriteLine("Хвърляш една отвара."); } else { Console.WriteLine("Отварата остава."); } } else if (asked == "quit") { if (result is true) stack.Pop(null); } else if (result is "победа") { Console.WriteLine("Победа! Обратно на картата."); } }}Изчистването е важно. Ако pending остане „drop“, следващият отговор от боя („победа“) ще се чете като отговор на въпроса за отварата. Това е същата грешка като общата променлива от началото, но вече вътре в един екран, който сам е собственик на въпроса си. Истинската игра прави същото: боят помни дали е питал за удар, за умение или за отвара.
Проблем
Стекът се мени, докато ходът още тече
Досега цикълът викаше само Handle на върха. Истинският ход има и Update: екранът обновява състоянието си след командата. Ето същия бой, но всеки екран пише в дневник какво прави. Целта в update преброява враговете в списъка си:
class TargetScreen : Screen{ ScreenStack stack; List<string> enemies;
public TargetScreen(ScreenStack stack, List<string> enemies) : base("Цел") { this.stack = stack; this.enemies = enemies; }
public override void Handle(string command) { int index = int.Parse(command) - 1; Console.WriteLine("Цел взима " + command + " и връща " + index); stack.Pop(index); }
public override void Update() { Console.WriteLine("Цел се обновява, врагове в списъка: " + enemies.Count); }}Цикълът е като в истинската игра: взима върха, дава му командата, обновява го.
Screen top = stack.Top()!;top.Handle(command);top.Update();class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Отговорът на екрана, който е бил над този. public virtual void OnResult(object? result) { }}
class ScreenStack{ public List<Screen> Screens = new List<Screen>();
public void Push(Screen screen) { Screens.Add(screen); }
public void Pop(object? result) { Screens.RemoveAt(Screens.Count - 1); Screen? below = Top(); if (below != null) below.OnResult(result); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public string Names() { return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]"; }}
class TargetScreen : Screen{ ScreenStack stack; List<string> enemies;
public TargetScreen(ScreenStack stack, List<string> enemies) : base("Цел") { this.stack = stack; this.enemies = enemies; }
public override void Handle(string command) { int index = int.Parse(command) - 1; Console.WriteLine("Цел взима " + command + " и връща " + index); stack.Pop(index); }
public override void Update() { Console.WriteLine("Цел се обновява, врагове в списъка: " + enemies.Count); }}
class FightScreen : Screen{ ScreenStack stack; List<string> enemies = new List<string> { "Вълк", "Гоблин" };
public FightScreen(ScreenStack stack) : base("Бой") { this.stack = stack; }
public override void Handle(string command) { Console.WriteLine("Бой взима " + command + " и отваря Цел"); stack.Push(new TargetScreen(stack, enemies)); }
public override void OnResult(object? result) { string enemy = enemies[(int)result!]; enemies.RemoveAt((int)result!); Console.WriteLine("Бой получава " + result + ": " + enemy + " пада"); if (enemies.Count == 0) { Console.WriteLine("Бой: няма врагове, връща победа"); stack.Pop("победа"); } }}
class MapScreen : Screen{ ScreenStack stack;
public MapScreen(ScreenStack stack) : base("Карта") { this.stack = stack; }
public override void OnResult(object? result) { Console.WriteLine("Карта получава " + result + ": отваря Ново ниво"); stack.Push(new Screen("Ново ниво")); }}
class Program{ static void Main() { ScreenStack stack = new ScreenStack(); stack.Push(new MapScreen(stack)); stack.Push(new FightScreen(stack)); foreach (string command in new[] { "1", "2", "1", "1" }) { Console.WriteLine("> " + command); Screen top = stack.Top()!; top.Handle(command); top.Update(); } Console.WriteLine(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 List<string> { "Карта", "Съобщение", "Бой", "Цел" };try{ foreach (string screen in screens) { Console.WriteLine("update " + screen); if (screen == "Съобщение") screens.Remove(screen); }}catch (InvalidOperationException){ Console.WriteLine("InvalidOperationException");}update Картаupdate СъобщениеInvalidOperationExceptionforeach хвърля InvalidOperationException („Collection was modified“).Грешка, която спира програмата, поне се вижда. Тиха грешка, при която един екран не е обновен в един ход, се хваща много по-трудно.
Идеята
Отложени промени: записвай сега, прилагай после
Поправката е стекът да не се пипа по време на хода. push и pop вече не местят нищо. Те само записват промяната в списък changes. В края на хода apply минава по списъка и прилага промените една по една. Промяната е малък обект: вид, екран (за push) и отговор (за pop).
class Change{ public string Kind; public Screen? Screen; public object? Result;
public Change(string kind, Screen? screen, object? result) { Kind = kind; Screen = screen; Result = result; }}class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change change = Changes[index]; index++; if (change.Kind == "push") { Screens.Add(change.Screen!); } else if (change.Kind == "pop") { Screens.RemoveAt(Screens.Count - 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();Същият бой със същите екрани, сменен е само стекът:
class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Отговорът на екрана, който е бил над този. public virtual void OnResult(object? result) { }}
class Change{ public string Kind; public Screen? Screen; public object? Result;
public Change(string kind, Screen? screen, object? result) { Kind = kind; Screen = screen; Result = result; }}
class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change change = Changes[index]; index++; if (change.Kind == "push") { Screens.Add(change.Screen!); } else if (change.Kind == "pop") { Screens.RemoveAt(Screens.Count - 1); Screen? below = Top(); if (below != null) below.OnResult(change.Result); } } Changes.Clear(); }
public string Names() { return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]"; }}
class TargetScreen : Screen{ ScreenStack stack; List<string> enemies;
public TargetScreen(ScreenStack stack, List<string> enemies) : base("Цел") { this.stack = stack; this.enemies = enemies; }
public override void Handle(string command) { int index = int.Parse(command) - 1; Console.WriteLine("Цел взима " + command + " и връща " + index); stack.Pop(index); }
public override void Update() { Console.WriteLine("Цел се обновява, врагове в списъка: " + enemies.Count); }}
class FightScreen : Screen{ ScreenStack stack; List<string> enemies = new List<string> { "Вълк", "Гоблин" };
public FightScreen(ScreenStack stack) : base("Бой") { this.stack = stack; }
public override void Handle(string command) { Console.WriteLine("Бой взима " + command + " и отваря Цел"); stack.Push(new TargetScreen(stack, enemies)); }
public override void OnResult(object? result) { string enemy = enemies[(int)result!]; enemies.RemoveAt((int)result!); Console.WriteLine("Бой получава " + result + ": " + enemy + " пада"); if (enemies.Count == 0) { Console.WriteLine("Бой: няма врагове, връща победа"); stack.Pop("победа"); } }}
class MapScreen : Screen{ ScreenStack stack;
public MapScreen(ScreenStack stack) : base("Карта") { this.stack = stack; }
public override void OnResult(object? result) { Console.WriteLine("Карта получава " + result + ": отваря Ново ниво"); stack.Push(new Screen("Ново ниво")); }}
class Program{ static void Main() { ScreenStack stack = new ScreenStack(); stack.Push(new MapScreen(stack)); stack.Push(new FightScreen(stack)); stack.Apply(); foreach (string command in new[] { "1", "2", "1", "1" }) { Console.WriteLine("> " + command); Screen top = stack.Top()!; top.Handle(command); top.Update(); stack.Apply(); } Console.WriteLine(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{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change change = Changes[index]; index++; if (change.Kind == "push") { Screens.Add(change.Screen!); } else if (change.Kind == "pop") { Screens.RemoveAt(Screens.Count - 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 отива към върха, Цел. Тя вика Pop(0): 0 е номерът на избрания враг. Стекът не мърда. Промяната само се записва в Changes.
- Update минава на Цел, която още е в стека. Всичко, което вижда, е както преди хода: боят още не е свършил.
- Краят на хода: Apply взима промяна 0. Цел излиза от стека, а 0 слиза до Бой през OnResult. Вълкът пада, враговете свършиха и Бой вика Pop(победа): нова промяна в края на списъка.
- while чете дължината на Changes наново, затова стига и до новата промяна. Бой излиза, а Карта получава „победа“. Тя записва Push(Ново ниво).
- Третата промяна е Push: Ново ниво влиза в стека, над Карта.
- Списъкът свърши. Changes се изчиства, за да не се приложат същите промени и в следващия ход. Стекът е Карта, Ново ниво. Всеки екран получи отговора си след хода, а не по средата му.
Пробвай
Попълни
Това е цикълът на apply. Какво трябва да е условието, за да стигне и до промените, които се записват, докато той работи?
public void Apply()
{
int index = 0;
while (index < )
{
Change change = Changes[index];
index++;
if (change.Kind == "push")
{
Screens.Add(change.Screen!);
}
else if (change.Kind == "pop")
{
Screens.RemoveAt(Screens.Count - 1);
Screen? below = Top();
if (below != null) below.OnResult(change.Result);
}
}
Changes.Clear();
}Дължината се чете при всяко завъртане: Changes.Count. Ако я запомниш в променлива преди цикъла, промените, които onResult записва по време на apply, ще бъдат изтрити от изчистването в края и никога няма да се приложат.
Пробвай
Предвиди изхода
NamedScreen е екран, който само казва какво е получил. Стекът е този с отложените промени. Какво печата програмата?
ScreenStack stack = new ScreenStack();stack.Push(new NamedScreen("Карта"));stack.Push(new NamedScreen("Бой"));stack.Apply();stack.Pop("победа");stack.Push(new NamedScreen("Ново ниво"));Console.WriteLine("Отгоре: " + stack.Top()!.Name);stack.Apply();Console.WriteLine("Отгоре: " + stack.Top()!.Name);Идеята
Преходи: replace и reset
Не всяка смяна на екран е въпрос и отговор. Някои са преходи, след които няма връщане. За тях стекът има още две промени:
public void Replace(Screen screen){ Changes.Add(new Change("replace", screen, null));}
public void Reset(Screen? screen){ Changes.Add(new Change("reset", screen, null));}В apply има по един клон за тях:
else if (change.Kind == "replace"){ Screens.RemoveAt(Screens.Count - 1); Screens.Add(change.Screen!);}else if (change.Kind == "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();Console.WriteLine(stack.Names());stack.Replace(new NamedScreen("Нов герой"));stack.Apply();Console.WriteLine(stack.Names());stack.Replace(new NamedScreen("Село"));stack.Apply();Console.WriteLine(stack.Names());
Console.WriteLine("Героят пада. С pop:");ScreenStack popped = InFight();popped.Pop("загуба");popped.Pop("загуба");popped.Push(new NamedScreen("Край"));popped.Apply();Console.WriteLine(popped.Names());
Console.WriteLine("Героят пада. С reset:");ScreenStack clean = InFight();clean.Reset(new NamedScreen("Край"));clean.Apply();Console.WriteLine(clean.Names());[Заглавие][Нов герой][Село]Героят пада. С pop:Карта получава загубаСело получава загуба[Село, Край]Героят пада. С reset:[Край]С pop картата и селото получават „загуба“ и реагират, сякаш боят е свършил по нормалния начин. Трябва и да знаеш колко екрана има отдолу. А селото остава под екрана за край. reset не вика onResult на никого: това не е отговор, а нов старт.
По-точно, за любопитните
replace и reset също са отложени: записват се и се прилагат в apply, в реда, в който са записани. Създаването на героя в играта вика replace(селото) и веднага push(съобщението с началото на историята). След apply стекът е село и съобщение отгоре, а не обратното.
Цялата програма:
class Screen{ public string Name;
public Screen(string name) { Name = name; }
public virtual void Draw() { }
public virtual void Handle(string command) { }
public virtual void Update() { }
// Отговорът на екрана, който е бил над този. public virtual void OnResult(object? result) { }}
class Change{ public string Kind; public Screen? Screen; public object? Result;
public Change(string kind, Screen? screen, object? result) { Kind = kind; Screen = screen; Result = result; }}
class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public void Replace(Screen screen) { Changes.Add(new Change("replace", screen, null)); }
public void Reset(Screen? screen) { Changes.Add(new Change("reset", screen, null)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change change = Changes[index]; index++; if (change.Kind == "push") { Screens.Add(change.Screen!); } else if (change.Kind == "pop") { Screens.RemoveAt(Screens.Count - 1); Screen? below = Top(); if (below != null) below.OnResult(change.Result); } else if (change.Kind == "replace") { Screens.RemoveAt(Screens.Count - 1); Screens.Add(change.Screen!); } else if (change.Kind == "reset") { Screens.Clear(); if (change.Screen != null) Screens.Add(change.Screen); } } Changes.Clear(); }
public string Names() { return "[" + string.Join(", ", Screens.Select(s => s.Name)) + "]"; }}
class NamedScreen : Screen{ public NamedScreen(string name) : base(name) { }
public override void OnResult(object? result) { Console.WriteLine(Name + " получава " + result); }}
class Program{ static ScreenStack InFight() { ScreenStack stack = new ScreenStack(); stack.Push(new NamedScreen("Село")); stack.Push(new NamedScreen("Карта")); stack.Push(new NamedScreen("Бой")); stack.Apply(); return stack; }
static void Main() { ScreenStack stack = new ScreenStack(); stack.Push(new NamedScreen("Заглавие")); stack.Apply(); Console.WriteLine(stack.Names()); stack.Replace(new NamedScreen("Нов герой")); stack.Apply(); Console.WriteLine(stack.Names()); stack.Replace(new NamedScreen("Село")); stack.Apply(); Console.WriteLine(stack.Names());
Console.WriteLine("Героят пада. С pop:"); ScreenStack popped = InFight(); popped.Pop("загуба"); popped.Pop("загуба"); popped.Push(new NamedScreen("Край")); popped.Apply(); Console.WriteLine(popped.Names());
Console.WriteLine("Героят пада. С reset:"); ScreenStack clean = InFight(); clean.Reset(new NamedScreen("Край")); clean.Apply(); Console.WriteLine(clean.Names()); }}Идеята
В истинската игра
Стекът на „Хрониките на Арканум“ е същият, с още три неща:
// Стекът не се променя по време на обработката: Push, Pop и Replace само записват промяната, а// Apply() ги прилага след хода. Иначе екран, който се маха от стека, би продължил да работи.class ScreenChange{ public string Kind = ""; public Screen? Screen; public object? Result;}
class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<ScreenChange> Changes = new List<ScreenChange>();
public void Push(Screen screen) { Changes.Add(new ScreenChange { Kind = "push", Screen = screen, Result = null }); }
public void Pop(object? result) { Changes.Add(new ScreenChange { Kind = "pop", Screen = null, Result = result }); }
public void Replace(Screen screen) { Changes.Add(new ScreenChange { Kind = "replace", Screen = screen, Result = null }); }
// Изчиства целия стек (зареждане, край на играта); с null стекът остава празен. public void Reset(Screen? screen) { Changes.Add(new ScreenChange { Kind = "reset", Screen = screen, Result = null }); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public bool IsEmpty() { return Screens.Count == 0 && Changes.Count == 0; }
public bool Contains(string kindName) { foreach (Screen screen in Screens) { if (screen.KindName == kindName) return true; } return false; }
public void Apply() { int index = 0; while (index < Changes.Count) { ScreenChange change = Changes[index]; index++; if (change.Kind == "push") { Screens.Add(change.Screen!); } else if (change.Kind == "pop") { Screens.RemoveAt(Screens.Count - 1); Screen? below = Top(); if (below != null) below.OnResult(change.Result); } else if (change.Kind == "replace") { Screens.RemoveAt(Screens.Count - 1); Screens.Add(change.Screen!); } else { Screens = new List<Screen>(); if (change.Screen != null) Screens.Add(change.Screen); } } Changes = new List<ScreenChange>(); }
// Рисуването започва от най-горния непрозрачен екран и върви нагоре. public void Draw(CellBuffer buffer) { int first = Screens.Count - 1; while (first > 0 && Screens[first].IsTransparent()) first--; for (int i = first; i < Screens.Count; i++) { if (i >= 0) Screens[i].Draw(buffer); } }}isEmptyгледа и промените. Играта започва с push на началния екран, а излизането е reset без екран. Цикълът спира, когато няма нито екрани, нито промени, които чакат.containsпита дали в стека вече има екран от даден вид. Така екранът за ново ниво не влиза два пъти, ако в един ход се вземат две нива.drawрисува отдолу нагоре от най-горния непрозрачен екран, като в миналия урок.
Изборът на цел в играта е прозрачен екран над боя. Той не удря сам: връща номера с pop, а боят решава какво да прави. До него са общият избор от списък (умение, отвара) и изборът на място за запис, който връща името на мястото, а не номер:
// Изборът на цел е прозрачен екран над боя. Не удря сам: връща номера на целта с Pop(result),// а боят решава какво да прави с нея.class TargetScreen : Screen{ public Battle Battle; public string Note;
public TargetScreen(Game game, Battle battle) : base(game) { Battle = battle; Note = ""; }
public override bool IsTransparent() { return true; }
public override void Draw(CellBuffer buffer) { List<Enemy> enemies = Battle.AliveEnemies(); buffer.Box(24, 9, 36, enemies.Count + 4, T("target.title")); for (int i = 0; i < enemies.Count; i++) { buffer.Text(26, 10 + i, (i + 1) + ") " + Pad(enemies[i].Name, 22) + " " + enemies[i].Hp + "/" + enemies[i].MaxHp(), enemies[i].Kind.Color); } buffer.Text(26, 11 + enemies.Count, Note.Length > 0 ? Note : T("target.help"), "gray"); }
public override void Handle(string command) { if (command == "x") { Game.Stack.Pop(null); return; } long? choice = ParseIntStrict(command); if (choice == null || choice < 1 || choice > Battle.AliveEnemies().Count) { Note = T("ui.unknown", ("command", command)); return; } Game.Stack.Pop((int)choice.Value - 1); }}
// Общ избор от списък (умение, отвара, слот): връща номера от 0 или null.class ChoiceScreen : Screen{ public string Title; public List<string> Labels; public string Note;
public ChoiceScreen(Game game, string title, List<string> labels) : base(game) { Title = title; Labels = labels; Note = ""; }
public override bool IsTransparent() { return true; }
public override void Draw(CellBuffer buffer) { buffer.Box(4, 3, 52, Labels.Count + 4, Title); for (int i = 0; i < Labels.Count; i++) buffer.Text(6, 4 + i, (i + 1) + ") " + Labels[i], "default"); buffer.Text(6, 5 + Labels.Count, Note.Length > 0 ? Note : T("choice.help"), "gray"); }
public override void Handle(string command) { if (command == "x") { Game.Stack.Pop(null); return; } long? choice = ParseIntStrict(command); if (choice == null || choice < 1 || choice > Labels.Count) { Note = T("ui.unknown", ("command", command)); return; } Game.Stack.Pop((int)choice.Value - 1); }}
class SlotScreen : ChoiceScreen{ // C# вика конструктора на базовия клас преди тялото, затова надписите се смятат в отделен метод. public SlotScreen(Game game, string mode) : base(game, T(mode == "save" ? "slot.saveTitle" : "slot.loadTitle"), SlotLabels(game)) { }
static List<string> SlotLabels(Game game) { List<string> labels = new List<string>(); foreach (string slot in SAVE_SLOTS) labels.Add(T("slot.label", ("slot", slot), ("summary", SlotSummary(game.Io, slot)))); return labels; }
public override void Handle(string command) { long? choice = ParseIntStrict(command); if (choice != null && choice >= 1 && choice <= SAVE_SLOTS.Length) { Game.Stack.Pop(SAVE_SLOTS[(int)choice.Value - 1]); return; } base.Handle(command); }}Боят помни какво е питал (pending) и чете отговора в OnResult:
public override void OnResult(object? result){ Battle battle = Battle; Hero hero = battle.Hero; PendingChoice? pending = Pending; Pending = null; if (result == null || pending == null) return; int index = (int)result; if (pending.Kind == "attack") { battle.HeroAct(new AttackAction(hero, battle.AliveEnemies()[index])); } else if (pending.Kind == "skill") { Skill skill = SKILLS.Get(hero.Skills[index]); if (hero.Mana < skill.Mana) { Note = T("battle.noMana", ("skill", Nm(skill))); } else if (hero.CooldownOf(skill.Id) > 0) { Note = T("battle.cooldown", ("skill", Nm(skill)), ("turns", hero.CooldownOf(skill.Id))); } else if (skill.Target == "one" && battle.AliveEnemies().Count > 1) { Pending = new PendingChoice { Kind = "skillTarget", Skill = skill }; Game.Stack.Push(new TargetScreen(Game, battle)); } else { battle.HeroAct(new SkillAction(hero, skill, battle.AliveEnemies()[0])); } } else if (pending.Kind == "skillTarget") { battle.HeroAct(new SkillAction(hero, pending.Skill!, battle.AliveEnemies()[index])); } else { battle.HeroAct(new ItemAction(hero, PotionIds()[index])); }}Виж клона за умение. Играчът е избрал умение, което иска цел, а враговете са повече от един. Тогава боят, вътре в onResult, записва нов въпрос: push на избора на цел. Това става по време на apply, а while го стига и го прилага в същия ход. Играчът вижда списъка с врагове веднага след списъка с умения.
Менюто е другият, който пита. „Сигурен ли си?“ е същият общ екран, а отговорът „да“ на въпроса за изход е reset без екран: стекът опустява и играта свършва.
class ConfirmScreen : Screen{ public string Question;
public ConfirmScreen(Game game, string question) : base(game) { Question = question; }
public override bool IsTransparent() { return true; }
public override void Draw(CellBuffer buffer) { buffer.Box(16, 6, 28, 5, ""); buffer.Text(18, 7, Question, "white"); buffer.Text(18, 8, "1) " + T("ui.yes") + " 2) " + T("ui.no"), "default"); }
public override void Handle(string command) { if (command == "1") Game.Stack.Pop(true); else if (command == "2" || command == "x") Game.Stack.Pop(false); }}public override void OnResult(object? result){ Game game = Game; string pending = Pending; Pending = ""; if (result == null) return; if (pending == "save") { game.Io.Save((string)result, SaveGame(game)); Note = T("menu.saved", ("slot", result)); } else if (pending == "load") { string? error = LoadGame(game, game.Io.Load((string)result)); if (error != null) Note = error; else EnterWorld(game, T("load.done", ("slot", result))); } else if (pending == "quit" && result is bool yes && yes) { game.Stack.Reset(null); }}Пробвай
Намери грешката
Колега пренареди два реда в apply. Програмата с боя от „Отложени промени“ вече печата:
class ScreenStack{ public List<Screen> Screens = new List<Screen>(); public List<Change> Changes = new List<Change>();
public void Push(Screen screen) { Changes.Add(new Change("push", screen, null)); }
public void Pop(object? result) { Changes.Add(new Change("pop", null, result)); }
public Screen? Top() { return Screens.Count == 0 ? null : Screens[Screens.Count - 1]; }
public void Apply() { int index = 0; while (index < Changes.Count) { Change change = Changes[index]; index++; if (change.Kind == "push") { Screens.Add(change.Screen!); } else if (change.Kind == "pop") { Screen? below = Top(); Screens.RemoveAt(Screens.Count - 1); if (below != null) below.OnResult(change.Result); } } Changes.Clear(); }}> 1Бой взима 1 и отваря Цел> 2Цел взима 2 и връща 1Цел се обновява, врагове в списъка: 2> 1Бой взима 1 и отваря Цел> 1Цел взима 1 и връща 0Цел се обновява, врагове в списъка: 2[Карта, Бой]Целта връща номер, но боят никога не научава: и двата врага са живи, а стекът накрая е картата и боят. Кой ред се изпълнява твърде рано?
Избери реда с грешката.
Екранът отдолу се чете, преди горният да е махнат. Тогава „отдолу“ всъщност е самата цел и отговорът отива при нея, а нейният OnResult не прави нищо. Първо махни горния екран, после вземи върха: той е този, който е питал.
По-точно
По-точно, за любопитните
Защо не обратно извикване (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 чете дължината на списъка наново.
1212122x211111xМагазин: злато 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? Какво печелиш и какво губиш?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Списъци · Стек, опашка и свързан списък · Ламбди и делегати
- Задачи по темата: Хрониките на Арканум