🇺🇦Дизайн та реалізація механізму подій в покроковій грі "Слобода"

Search for a command to run...

No comments yet. Be the first to comment.
This is the series related to my experience building game with Flutter and Dart. It is basically a diary. At first I implement feature and then add a post about it. https://locadederta.com/sloboda
Instead of writing another state/todo/counter/animation I decided to create some real-world content related to Dart&Flutter: Event System in my turn-based city building game Sloboda. Sloboda translates from Ukrainian as a settlement of free people Du...
Build. Craft. Explore.

Заселяємо південь України!

Hey! We just published the second content update for our game Sloboda 2. This update is free for anyone who already purchased our game. If you want to purchase it, then pick your platforms: Apple App Store: https://apps.apple.com/us/app/loca-deserta-...

Привіт, компана! Ось і настав час Січневого оновлення для нашої гри Слобода 2! Це оновлення безкоштовне для всіх, хто придбав гру. Якщо ж ще не придбали, то можете це зробити по цих посиланнях: Apple App Store: https://apps.apple.com/ua/app/loca-dese...

Статистика запуску гри Трохи більше двох тижнів тому ми випустили гру на всіх магазинах. І одразу ж попали в Топ платних ігор 🇺🇦українського App Store! Купити можна на iOS по посиланню: https://apps.apple.com/ua/app/loca-deserta-sloboda-2/id164250...

Захотілося розбавити статті про Flutter&Dart чимось прикладним, а не черговим описом Todo/Counter/REST/CRUD фігнею.
Під час розробки покрокової гри в стилі "побудуй селище" у мене постала необхідність у впроваджені сутності подій. За задумом, в залежності від показників вашого селища та часового проміжку, в грі мали б з'являтися різні явища/подіЇ. Наприклад: напад сарани весною, приїзд купців, якщо у вас на складі достатньо накопичено товарів для них, військові дії, якщо у вас є достатньо військ, прохання допомоги від інших селищ, якщо ваше селище доволі відоме і має військову силу. Щоб цей розвиток подій не був лінійним, я також додав елемент випадковості до кожної події. Наприклад, атака сарани має 20% шанс, відвідання купця - 100%.
Додаткові складність вносить також інтерактивність в ці події: на деякі події я можу погодитися, а можу і ні. Наприклад: вислати допомогу сусідній слободі. Я можу як послати туди військовий загін, так і проігнорувати. Кожен вибір має вплив на подальший розвиток подій і впливає на показники слободи. Вдалий військовий похід приносить прибуток і славу. Невдалий - втрата військ і ресурсів.
Приклад умовної події:

Але є ще події, які відбуваються безумовно (прості події). Наприклад: ви не можете вибрати, чи нападе на вас сарана, чи ні. Або чи прийдуть до вас гості, чи ні.

І щоб додати ще більшої непередбачуванності, кожна з подій має два розвитки: успішний і провальний. Вдалий напад сарани на вас забере ресурси. В той же час вдалий військовий похід принесе вам їх. Успіх/провал я вирішив також зробити елементом випадковості.
Приклад успішного результату події з торгівлею:

Поки що, у нас існує два типи подій: RandomTurnEvent та ChoicableRandomTurnEvent. Перший відповідає за прості події, другий - дає змогу користувачеві можливість активізувати себе.
Спочатку імплементуємо абстрактний класс RandomTurnEvent, який би повністю покривав потреби простих події:
abstract class RandomTurnEvent {
// ключ для локазізованної назви події
String localizedKey;
// список функцій, які перевіряють можливість активації події.
List<Function> conditions;
// вплив на ресурси слободи у випадку успіху
Stock stockSuccess;
// вплив на ресурси слободи у випадку провалу
Stock stockFailure;
// картинка, якщо подія успішна
String successIconPath = "images/events/success_event.png";
// картинка, якщо подія провальна
String failureIconPath = "images/events/failed_event.png";
// вплив на показники селища
CityProps cityPropsSuccess;
CityProps cityPropsFailure;
// ключі до локалізованих повідомлень
String successMessageKey;
String failureMessageKey;
// вірогідність активації події. Від 0 до 100.
int probability = 0;
// вірогідність успіху події. Від 0 до 100.
int successRate = 0;
Тепер заімплементуємо декілька методів.
bool satisfiesConditions(Sloboda city) {
for (var func in conditions) {
if (!func(city)) {
return false;
}
}
return true;
}
bool canHappen(Sloboda city) {
bool canHappen =
Random().nextInt(100) < probability && satisfiesConditions(city);
debugPrint(
'Event: $localizedKey satisfies: ${satisfiesConditions(city)}, will happen: $canHappen');
return canHappen;
}
Function execute(Sloboda city) {
var r = Random().nextInt(100);
return () {
bool success = r <= successRate;
return EventMessage(
event: this,
stock: success ? stockSuccess : stockFailure,
cityProps: success ? cityPropsSuccess : cityPropsFailure,
imagePath: success ? successIconPath : failureIconPath,
messageKey:
success ? this.successMessageKey : this.failureMessageKey);
};
}
Функція execute повертає іншу функцію. Про це буде описано нижче.
За допомогою всього трьох методів і з десятка властивостей класу, ми можемо повністю описати випадкову просту подію.
Для прикладу візьмемо напад сарани:
class SaranaInvasion extends RandomTurnEvent {
String localizedKey = 'randomTurnEvent.saranaInvasion';
String successMessageKey = 'randomTurnEvent.successSaranaInvasion';
String failureMessageKey = 'randomTurnEvent.failureSaranaInvasion';
int probability = 50;
int successRate = 40;
Stock stockSuccess = Stock(
values: {
RESOURCE_TYPES.FOOD: -50,
RESOURCE_TYPES.HORSE: -10,
},
);
Stock stockFailure = Stock();
List<Function> conditions = [
(Sloboda city) {
return city.currentSeason is SpringSeason;
},
];
}
Із умов настання цієї події у нас лише: весна. І з вірогідністю 50% сарана нападе на селище, і з вірогідністю 40% вона все-таки насесе шкоду (буде успішна). Для створення нових подій треба лише описати основні параметри RandomTurnEvent класу і не треба навіть додавати якусь логіку, якщо влаштовує стандартна.
Більш складний приклад настання події для приходу до вас переселенців з інших селищ:
List<Function> conditions = [
(Sloboda city) {
return city.currentSeason is AutumnSeason;
},
(Sloboda city) {
return city.props.getByType(CITY_PROPERTIES.GLORY) > 20;
},
(Sloboda city) {
try {
city.cityBuildings.firstWhere((element) => element is Church);
return true;
} catch (e) {
return false;
}
}
];
Якщо осінь, популярність селища більша за 20, якщо побудована церква, то до вас прийдуть ( з якоюсь випадковою вірогідністю) нові люди.
Розібралися з просто випадковими подіями, тепер реалізуємо події, які вимагають вибору гравця і вступають в силу на наступний хід гри (сезон).
Використовуючи міць ООП, ми можемо перевикористати весь клас RandomTurnEvent, і додати лише методи і властивості, які стосуються вибору.
Нових властивостей лише три:
abstract class ChoicableRandomTurnEvent extends RandomTurnEvent {
String localizedQuestionKey;
String localizedKeyYes;
String localizedKeyNo;
Вони використовуються для відображення повідомлень гравцю на екрані: запитання, текст у разі відповіді Так або Ні.
Особливість покрокових ігор в тому, що дія гравця на поточному ході насправді виконується лише на наступному. Наприклад: ви надіслали військо в похід. Результат походу ви одразу не знаєте (поки дійдуть війська і тд), тому його виконання має передатися на наступний хід.
Для цього ми додамо новий метод postExecute. Він якраз і буде зв'язуючою ланкую між ходами:
Function postExecute(Sloboda city) {
var r = Random().nextInt(100);
return () {
bool success = r <= successRate;
return EventMessage(
event: this,
stock: success ? stockSuccess : stockFailure,
cityProps: success ? cityPropsSuccess : cityPropsFailure,
messageKey:
success ? this.successMessageKey : this.failureMessageKey);
};
}
Метод повертає ще одну функцію, яка буде виконана під час настання наступного ходу.
Гравець же, вірніше віджет подій, працює з наступним методом:
Function makeChoice(bool yes, Sloboda city) {
if (yes) {
return this.postExecute(city);
} else {
return () {};
}
}
Він напряму викликається з обробчика діалогу. Якщо гравець погодився взяти участь в події, то в чергу подій додається результат виконання this.postExecute(city). Цей результат являє собою посилання на іншу функцію, яка ще не виконана.
В класі Sloboda, що являє собою обробчик всіх подій і відповідає за управління ресурсами, людьми, подіями, є список зареєстрованих подій:
Обробчик вибору гравця
void runChoicableEventResult(ChoicableRandomTurnEvent event) {
Function f = event.makeChoice(true, this);
_nextRandomEvents.add(f);
}
Під час обробки наступного ходу, виконується метод, який ітерує по всіх зареєстрованих подіях і виконує їх:
void _runAttachedEvents() {
for (var _event in _nextRandomEvents) {
EventMessage event = _event(); // це уже безпосередньо результат виконання вкладеної в postExecute функції
this.stock + event.stock;
this.addProps(event.cityProps);
....
class BuyPrisoners extends ChoicableRandomTurnEvent {
String successMessageKey = 'randomTurnEvent.successBuyPrisoners';
String failureMessageKey = 'randomTurnEvent.failureBuyPrisoners';
String localizedKeyYes = 'randomTurnEvent.BuyPrisonersYes';
String localizedKeyNo = 'randomTurnEvent.BuyPrisonersNo';
int probability = 100;
Stock stockSuccess = Stock(values: {
RESOURCE_TYPES.MONEY: -40,
});
Stock stockFailure = Stock(values: {
RESOURCE_TYPES.MONEY: 20,
});
CityProps cityPropsFailure = CityProps(
values: {
CITY_PROPERTIES.GLORY: 10,
CITY_PROPERTIES.CITIZENS: 15,
CITY_PROPERTIES.FAITH: 10,
},
);
CityProps cityPropsSuccess = CityProps(
values: {
CITY_PROPERTIES.CITIZENS: 5,
},
);
int successRate = 30;
String localizedKey = 'randomTurnEvent.BuyPrisoners';
String localizedQuestionKey = 'randomTurnEvent.BuyPrisonersQuestion';
List<Function> conditions = [
(Sloboda city) {
return city.currentSeason is AutumnSeason;
},
(Sloboda city) {
return city.stock.getByType(RESOURCE_TYPES.MONEY) > 40;
},
(Sloboda city) {
return ChoicableRandomTurnEvent.onceInYears<BuyPrisoners>(city, 3);
}
];
}
Клас Sloboda, який відповідає за роботу всього селища, має чергу наступних подій:
final Queue<RandomTurnEvent> pendingNextEvents = Queue();
Під час кожного ходу, Sloboda опитує всі події із списку і бере перші 3 з них, які можуть бути активними на даному ходу:
_queueNextEvents() {
List<RandomTurnEvent> choicableEvents =
getChoicableRandomEvents().take(3).toList();
pendingNextEvents.addAll(choicableEvents);
}
Ця черга використовується у віджеті, який показує доступні події один за одним:
Widget build(BuildContext context) {
final city = InheritedCity.of(context).city;
final Queue<RandomTurnEvent> pendingEvents = city.pendingNextEvents;
return Column(
children: <Widget>[
if (pendingEvents.isNotEmpty)
Expanded(
flex: 3,
child: PendingEventsView(
events: pendingEvents,
),
),
Коли гравець робить свій вибір, то з черги видаляється екземпляр цієї події і додається в журнал селища запис з вибором гравця:
void addChoicableEventWithAnswer(bool yes, ChoicableRandomTurnEvent event) {
events.add(
CityEvent(
season: currentSeason.previous,
yearHappened:
currentSeason is WinterSeason ? currentYear - 1 : currentYear,
sourceEvent: EventMessage(
stock: null,
event: event,
messageKey: event.choiceToStringKey(yes),
),
),
);
if (yes) {
runChoicableEventResult(event);
}
pendingNextEvents.remove(event);
_innerChanges.add(this);
}
Нажаль, в Dart немає можливості отримати всі під класи якогось класу, як це зроблемно в Smalltalk. Тому кожен новий клас подій необхідно додати в статичний список:
static List<RandomTurnEvent> allEvents = [
KoshoviyPohid(),
TartarsRaid(),
SaranaInvasion(),
ChildrenPopulation(),
SteppeFire(),
RunnersFromSuppression(),
SettlersArrived(),
GuestsFromSich(),
ChambulCapture(),
MerchantVisit(),
UniteWithNeighbours(),
HelpNeighbours(),
BuyPrisoners(),
AttackChambul(),
TrapChambulOnWayBack(),
HelpDefendSich(),
SendMoneyToSchoolInKaniv(),
SendMerchantToKanev(),
AttackCatholicChurches(),
HelpDefendAgainstCatholicRaiders(),
];


Реалізований механізм подій виявився доволі гручким. Кожна з подій може задавати свої необхідні умови, кастомізувати їх під свої потреби. Наприклад, подія "Відвідування купця", має свою власну postExecute імплементацію, так як їй треба визначити наявність товару і провести калькуляцію винагороди.
Function postExecute(Sloboda city) {
var r = Random().nextInt(100);
return () {
bool success = r <= successRate;
var furTotal = city.stock.getByType(RESOURCE_TYPES.FUR);
var fishTotal = city.stock.getByType(RESOURCE_TYPES.FISH);
var stock = Stock(values: {
RESOURCE_TYPES.FUR: -furTotal,
RESOURCE_TYPES.FISH: -fishTotal,
});
if (success) {
stock.addToType(RESOURCE_TYPES.MONEY, (furTotal + fishTotal) * 2);
}
return EventMessage(
event: this,
stock: stock,
cityProps: null,
messageKey:
success ? this.successMessageKey : this.failureMessageKey);
};
}
І що саме головне, з точки зору Flutter Widget, немає ніякої різниці, який тип подій відобразити гравцю:
// тут десь Column вище
..._events[key].reversed.map((event) {
var textStyle;
if (city.currentSeason.isNextTo(event.season)) {
textStyle = Theme.of(context).textTheme.headline6;
}
return Padding(
padding: const EdgeInsets.all(8.0),
child: SoftContainer(
child: Column(
children: <Widget>[
if (event.sourceEvent.imagePath != null)
Image.asset(
event.sourceEvent.imagePath,
width: 64,
),
FullWidth(
child: Text(
SlobodaLocalizations.getForKey(
event.sourceEvent.messageKey),
textAlign: TextAlign.center,
style: textStyle,
),
),
if (event.sourceEvent.stock != null)
StockMiniView(
stock: event.sourceEvent.stock,
stockSimulation: null,
),
if (event.sourceEvent.cityProps != null)
CityPropsMiniView(
props: event.sourceEvent.cityProps,
),
],
),
));
}).toList()
Збірки під десктоп платформи та андроід: https://github.com/gladimdim/sloboda/releases