UI dialogue
In this chapter we will show event-driven logic based on UI dialogue system in simple text-based game, where dialogue widget shows a message and awaits for user to decide on the option to choose from.
Pattern used in this example applies not only to text-based games, but also in any modern game that has dialogues for conversations - similar approach even applies cut scenes system. The point here is to showcase suspension and resumes with events.
UI dialogue system with events
Typical dialogue system uses asset-driven container type for storing conversation points that have message with available options, that each points to another conversation point when confirmed, effectively building a graph of conversation flow.
Asset-driven systems like that are rightfully state machines and it’s nothing wrong with that per se, but i want to make sure you, dear reader, understand that we are not talking about dialogue system specifically, rather we talk about event-driven logic in general, so please don’t dismiss this example, as it’s goal is to demonstrate events orchestrating suspension, not focusing on a game feature.
Let’s start with a widget object:
use crossterm::{
event::{Event, KeyCode},
style::Stylize,
terminal::size,
};
use moirai_book_samples::{
events::Events,
game::{Game, GameState, GameStateChange},
terminal::Terminal,
utils::{is_key_pressed, text_size, text_wrap},
};
use std::{collections::HashMap, sync::mpsc::Sender, time::Duration};
fn main() {
Game::new(Example::default()).run_blocking();
}
struct Example {
terminal: Terminal,
events: Events<DialogueEvent>,
dialogue: DialogueWidget,
conversation: Conversation,
}
impl Default for Example {
fn default() -> Self {
let conversation = Conversation::default()
.point(
"start",
ConversationPoint::new("Hello, Adventurer!\nWhere would you like to go?")
.option(ConversationOption::new("Tavern", "tavern"))
.option(ConversationOption::new("Forest", "forest"))
.option(ConversationOption::new("Bed", "bed")),
)
.point(
"tavern",
ConversationPoint::new("You entered the tavern and found a cozy spot to rest.")
.option(ConversationOption::new("Order a beer", "beer"))
.option(ConversationOption::new("Exit", "start")),
)
.point(
"beer",
ConversationPoint::new("You ordered a refreshing beer and enjoyed your time!")
.option(ConversationOption::new("Pay and leave", "pay"))
.option(ConversationOption::new("Don't pay and leave", "no-pay")),
)
.point(
"pay",
ConversationPoint::new("You paid the bartender and left the tavern.")
.option(ConversationOption::new("Exit", "start")),
)
.point(
"no-pay",
ConversationPoint::new(
"The bartender caught you! You had to run away to the forest!",
)
.option(ConversationOption::new("Run", "forest")),
)
.point(
"forest",
ConversationPoint::new("You ventured into the forest. Rogue wolf appeared!")
.option(ConversationOption::new("Fight wolf with sword", "fight"))
.option(ConversationOption::new("Run back to town", "start")),
)
.point(
"fight",
ConversationPoint::new("You bravely fought the wolf and won!")
.option(ConversationOption::new("Return to town", "start")),
)
.point(
"bed",
ConversationPoint::new("You went to sleep. Next day, you feel refreshed!")
.option(ConversationOption::new("Wake up", "start")),
);
let events = Events::default();
let on_confirm = events.sender();
Self {
terminal: Terminal::default(),
events,
dialogue: DialogueWidget::new("start", on_confirm),
conversation,
}
}
}
impl GameState for Example {
fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
let events = Terminal::events().collect::<Vec<_>>();
self.dialogue.handle_input(&events, &self.conversation);
// Receive all pending events sent from UI.
for event in self.events.receive() {
match event {
DialogueEvent::ShowDialogue { id } => {
// We got an event from dialogue widget, so we update said
// widget to show new conversation point.
self.dialogue = DialogueWidget::new(id, self.events.sender());
}
}
}
self.terminal.begin_draw(true);
self.dialogue.draw(&mut self.terminal, &self.conversation);
self.terminal.end_draw();
if is_key_pressed(&events, KeyCode::Esc) {
GameStateChange::Quit
} else {
GameStateChange::None
}
}
}
enum DialogueEvent {
ShowDialogue { id: String },
}
struct DialogueWidget {
// Current conversation point ID.
id: String,
// Currently selected option index.
index: usize,
// Sender to emit events when an option is confirmed.
on_confirm: Sender<DialogueEvent>,
}
impl DialogueWidget {
fn new(id: impl ToString, on_confirm: Sender<DialogueEvent>) -> Self {
Self {
id: id.to_string(),
index: 0,
on_confirm,
}
}
fn handle_input(&mut self, events: &[Event], conversation: &Conversation) {
let Some(conversation_point) = conversation.get(&self.id) else {
return;
};
// Move to previous option.
if is_key_pressed(events, KeyCode::Up) {
self.index = (self.index + conversation_point.options.len() - 1)
% conversation_point.options.len();
} else
// Move to next option.
if is_key_pressed(events, KeyCode::Down) {
self.index = (self.index + 1) % conversation_point.options.len();
} else
// Confirm selection.
if is_key_pressed(events, KeyCode::Enter) {
let id = conversation_point.options[self.index].jump_to.clone();
self.on_confirm
.send(DialogueEvent::ShowDialogue { id })
.unwrap();
self.index = 0;
}
}
fn draw(&self, terminal: &mut Terminal, conversation: &Conversation) {
let Some(conversation_point) = conversation.get(&self.id) else {
return;
};
let (w, _) = size().unwrap();
let message = text_wrap(&conversation_point.message, w as usize);
terminal.display([0, 0], &message);
let mut y = text_size(&message).y;
terminal.display([0, y as u16], "-".repeat(w as usize));
y += 1;
for (i, option) in conversation_point.options.iter().enumerate() {
let prefix = if i == self.index { "> " } else { " " };
let content = text_wrap(&format!("{}{}", prefix, option.text), w as usize - 1);
let height = text_size(&content).y;
let content = if i == self.index {
content.on_white().black().bold().italic()
} else {
content.on_black().white()
};
terminal.display([0, y as u16], content);
y += height;
}
}
}
#[derive(Clone)]
struct ConversationPoint {
message: String,
options: Vec<ConversationOption>,
}
impl ConversationPoint {
fn new(message: impl ToString) -> Self {
Self {
message: message.to_string(),
options: Default::default(),
}
}
fn option(mut self, option: ConversationOption) -> Self {
self.options.push(option);
self
}
}
#[derive(Clone)]
struct ConversationOption {
text: String,
jump_to: String,
}
impl ConversationOption {
fn new(text: impl ToString, jump_to: impl ToString) -> Self {
Self {
text: text.to_string(),
jump_to: jump_to.to_string(),
}
}
}
#[derive(Default)]
struct Conversation {
points: HashMap<String, ConversationPoint>,
}
impl Conversation {
fn point(mut self, id: impl ToString, point: ConversationPoint) -> Self {
self.points.insert(id.to_string(), point);
self
}
fn get(&self, id: &str) -> Option<&ConversationPoint> {
self.points.get(id)
}
}
Usually dialogue widget has some information about what conversation point we are showing with what options, as well as some means to notify game about user taking decision - here we use events to signal confirming user-selected option.
Within this widget we also react on user changing selected option, confirming which sends an event to game:
use crossterm::{
event::{Event, KeyCode},
style::Stylize,
terminal::size,
};
use moirai_book_samples::{
events::Events,
game::{Game, GameState, GameStateChange},
terminal::Terminal,
utils::{is_key_pressed, text_size, text_wrap},
};
use std::{collections::HashMap, sync::mpsc::Sender, time::Duration};
fn main() {
Game::new(Example::default()).run_blocking();
}
struct Example {
terminal: Terminal,
events: Events<DialogueEvent>,
dialogue: DialogueWidget,
conversation: Conversation,
}
impl Default for Example {
fn default() -> Self {
let conversation = Conversation::default()
.point(
"start",
ConversationPoint::new("Hello, Adventurer!\nWhere would you like to go?")
.option(ConversationOption::new("Tavern", "tavern"))
.option(ConversationOption::new("Forest", "forest"))
.option(ConversationOption::new("Bed", "bed")),
)
.point(
"tavern",
ConversationPoint::new("You entered the tavern and found a cozy spot to rest.")
.option(ConversationOption::new("Order a beer", "beer"))
.option(ConversationOption::new("Exit", "start")),
)
.point(
"beer",
ConversationPoint::new("You ordered a refreshing beer and enjoyed your time!")
.option(ConversationOption::new("Pay and leave", "pay"))
.option(ConversationOption::new("Don't pay and leave", "no-pay")),
)
.point(
"pay",
ConversationPoint::new("You paid the bartender and left the tavern.")
.option(ConversationOption::new("Exit", "start")),
)
.point(
"no-pay",
ConversationPoint::new(
"The bartender caught you! You had to run away to the forest!",
)
.option(ConversationOption::new("Run", "forest")),
)
.point(
"forest",
ConversationPoint::new("You ventured into the forest. Rogue wolf appeared!")
.option(ConversationOption::new("Fight wolf with sword", "fight"))
.option(ConversationOption::new("Run back to town", "start")),
)
.point(
"fight",
ConversationPoint::new("You bravely fought the wolf and won!")
.option(ConversationOption::new("Return to town", "start")),
)
.point(
"bed",
ConversationPoint::new("You went to sleep. Next day, you feel refreshed!")
.option(ConversationOption::new("Wake up", "start")),
);
let events = Events::default();
let on_confirm = events.sender();
Self {
terminal: Terminal::default(),
events,
dialogue: DialogueWidget::new("start", on_confirm),
conversation,
}
}
}
impl GameState for Example {
fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
let events = Terminal::events().collect::<Vec<_>>();
self.dialogue.handle_input(&events, &self.conversation);
// Receive all pending events sent from UI.
for event in self.events.receive() {
match event {
DialogueEvent::ShowDialogue { id } => {
// We got an event from dialogue widget, so we update said
// widget to show new conversation point.
self.dialogue = DialogueWidget::new(id, self.events.sender());
}
}
}
self.terminal.begin_draw(true);
self.dialogue.draw(&mut self.terminal, &self.conversation);
self.terminal.end_draw();
if is_key_pressed(&events, KeyCode::Esc) {
GameStateChange::Quit
} else {
GameStateChange::None
}
}
}
enum DialogueEvent {
ShowDialogue { id: String },
}
struct DialogueWidget {
// Current conversation point ID.
id: String,
// Currently selected option index.
index: usize,
// Sender to emit events when an option is confirmed.
on_confirm: Sender<DialogueEvent>,
}
impl DialogueWidget {
fn new(id: impl ToString, on_confirm: Sender<DialogueEvent>) -> Self {
Self {
id: id.to_string(),
index: 0,
on_confirm,
}
}
fn handle_input(&mut self, events: &[Event], conversation: &Conversation) {
let Some(conversation_point) = conversation.get(&self.id) else {
return;
};
// Move to previous option.
if is_key_pressed(events, KeyCode::Up) {
self.index = (self.index + conversation_point.options.len() - 1)
% conversation_point.options.len();
} else
// Move to next option.
if is_key_pressed(events, KeyCode::Down) {
self.index = (self.index + 1) % conversation_point.options.len();
} else
// Confirm selection.
if is_key_pressed(events, KeyCode::Enter) {
let id = conversation_point.options[self.index].jump_to.clone();
self.on_confirm
.send(DialogueEvent::ShowDialogue { id })
.unwrap();
self.index = 0;
}
}
fn draw(&self, terminal: &mut Terminal, conversation: &Conversation) {
let Some(conversation_point) = conversation.get(&self.id) else {
return;
};
let (w, _) = size().unwrap();
let message = text_wrap(&conversation_point.message, w as usize);
terminal.display([0, 0], &message);
let mut y = text_size(&message).y;
terminal.display([0, y as u16], "-".repeat(w as usize));
y += 1;
for (i, option) in conversation_point.options.iter().enumerate() {
let prefix = if i == self.index { "> " } else { " " };
let content = text_wrap(&format!("{}{}", prefix, option.text), w as usize - 1);
let height = text_size(&content).y;
let content = if i == self.index {
content.on_white().black().bold().italic()
} else {
content.on_black().white()
};
terminal.display([0, y as u16], content);
y += height;
}
}
}
#[derive(Clone)]
struct ConversationPoint {
message: String,
options: Vec<ConversationOption>,
}
impl ConversationPoint {
fn new(message: impl ToString) -> Self {
Self {
message: message.to_string(),
options: Default::default(),
}
}
fn option(mut self, option: ConversationOption) -> Self {
self.options.push(option);
self
}
}
#[derive(Clone)]
struct ConversationOption {
text: String,
jump_to: String,
}
impl ConversationOption {
fn new(text: impl ToString, jump_to: impl ToString) -> Self {
Self {
text: text.to_string(),
jump_to: jump_to.to_string(),
}
}
}
#[derive(Default)]
struct Conversation {
points: HashMap<String, ConversationPoint>,
}
impl Conversation {
fn point(mut self, id: impl ToString, point: ConversationPoint) -> Self {
self.points.insert(id.to_string(), point);
self
}
fn get(&self, id: &str) -> Option<&ConversationPoint> {
self.points.get(id)
}
}
Then somewhere there is a game system, that listens for such events and handle updating widget to point to new conversation point:
use crossterm::{
event::{Event, KeyCode},
style::Stylize,
terminal::size,
};
use moirai_book_samples::{
events::Events,
game::{Game, GameState, GameStateChange},
terminal::Terminal,
utils::{is_key_pressed, text_size, text_wrap},
};
use std::{collections::HashMap, sync::mpsc::Sender, time::Duration};
fn main() {
Game::new(Example::default()).run_blocking();
}
struct Example {
terminal: Terminal,
events: Events<DialogueEvent>,
dialogue: DialogueWidget,
conversation: Conversation,
}
impl Default for Example {
fn default() -> Self {
let conversation = Conversation::default()
.point(
"start",
ConversationPoint::new("Hello, Adventurer!\nWhere would you like to go?")
.option(ConversationOption::new("Tavern", "tavern"))
.option(ConversationOption::new("Forest", "forest"))
.option(ConversationOption::new("Bed", "bed")),
)
.point(
"tavern",
ConversationPoint::new("You entered the tavern and found a cozy spot to rest.")
.option(ConversationOption::new("Order a beer", "beer"))
.option(ConversationOption::new("Exit", "start")),
)
.point(
"beer",
ConversationPoint::new("You ordered a refreshing beer and enjoyed your time!")
.option(ConversationOption::new("Pay and leave", "pay"))
.option(ConversationOption::new("Don't pay and leave", "no-pay")),
)
.point(
"pay",
ConversationPoint::new("You paid the bartender and left the tavern.")
.option(ConversationOption::new("Exit", "start")),
)
.point(
"no-pay",
ConversationPoint::new(
"The bartender caught you! You had to run away to the forest!",
)
.option(ConversationOption::new("Run", "forest")),
)
.point(
"forest",
ConversationPoint::new("You ventured into the forest. Rogue wolf appeared!")
.option(ConversationOption::new("Fight wolf with sword", "fight"))
.option(ConversationOption::new("Run back to town", "start")),
)
.point(
"fight",
ConversationPoint::new("You bravely fought the wolf and won!")
.option(ConversationOption::new("Return to town", "start")),
)
.point(
"bed",
ConversationPoint::new("You went to sleep. Next day, you feel refreshed!")
.option(ConversationOption::new("Wake up", "start")),
);
let events = Events::default();
let on_confirm = events.sender();
Self {
terminal: Terminal::default(),
events,
dialogue: DialogueWidget::new("start", on_confirm),
conversation,
}
}
}
impl GameState for Example {
fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
let events = Terminal::events().collect::<Vec<_>>();
self.dialogue.handle_input(&events, &self.conversation);
// Receive all pending events sent from UI.
for event in self.events.receive() {
match event {
DialogueEvent::ShowDialogue { id } => {
// We got an event from dialogue widget, so we update said
// widget to show new conversation point.
self.dialogue = DialogueWidget::new(id, self.events.sender());
}
}
}
self.terminal.begin_draw(true);
self.dialogue.draw(&mut self.terminal, &self.conversation);
self.terminal.end_draw();
if is_key_pressed(&events, KeyCode::Esc) {
GameStateChange::Quit
} else {
GameStateChange::None
}
}
}
enum DialogueEvent {
ShowDialogue { id: String },
}
struct DialogueWidget {
// Current conversation point ID.
id: String,
// Currently selected option index.
index: usize,
// Sender to emit events when an option is confirmed.
on_confirm: Sender<DialogueEvent>,
}
impl DialogueWidget {
fn new(id: impl ToString, on_confirm: Sender<DialogueEvent>) -> Self {
Self {
id: id.to_string(),
index: 0,
on_confirm,
}
}
fn handle_input(&mut self, events: &[Event], conversation: &Conversation) {
let Some(conversation_point) = conversation.get(&self.id) else {
return;
};
// Move to previous option.
if is_key_pressed(events, KeyCode::Up) {
self.index = (self.index + conversation_point.options.len() - 1)
% conversation_point.options.len();
} else
// Move to next option.
if is_key_pressed(events, KeyCode::Down) {
self.index = (self.index + 1) % conversation_point.options.len();
} else
// Confirm selection.
if is_key_pressed(events, KeyCode::Enter) {
let id = conversation_point.options[self.index].jump_to.clone();
self.on_confirm
.send(DialogueEvent::ShowDialogue { id })
.unwrap();
self.index = 0;
}
}
fn draw(&self, terminal: &mut Terminal, conversation: &Conversation) {
let Some(conversation_point) = conversation.get(&self.id) else {
return;
};
let (w, _) = size().unwrap();
let message = text_wrap(&conversation_point.message, w as usize);
terminal.display([0, 0], &message);
let mut y = text_size(&message).y;
terminal.display([0, y as u16], "-".repeat(w as usize));
y += 1;
for (i, option) in conversation_point.options.iter().enumerate() {
let prefix = if i == self.index { "> " } else { " " };
let content = text_wrap(&format!("{}{}", prefix, option.text), w as usize - 1);
let height = text_size(&content).y;
let content = if i == self.index {
content.on_white().black().bold().italic()
} else {
content.on_black().white()
};
terminal.display([0, y as u16], content);
y += height;
}
}
}
#[derive(Clone)]
struct ConversationPoint {
message: String,
options: Vec<ConversationOption>,
}
impl ConversationPoint {
fn new(message: impl ToString) -> Self {
Self {
message: message.to_string(),
options: Default::default(),
}
}
fn option(mut self, option: ConversationOption) -> Self {
self.options.push(option);
self
}
}
#[derive(Clone)]
struct ConversationOption {
text: String,
jump_to: String,
}
impl ConversationOption {
fn new(text: impl ToString, jump_to: impl ToString) -> Self {
Self {
text: text.to_string(),
jump_to: jump_to.to_string(),
}
}
}
#[derive(Default)]
struct Conversation {
points: HashMap<String, ConversationPoint>,
}
impl Conversation {
fn point(mut self, id: impl ToString, point: ConversationPoint) -> Self {
self.points.insert(id.to_string(), point);
self
}
fn get(&self, id: &str) -> Option<&ConversationPoint> {
self.points.get(id)
}
}
Finally, let's show the conversation graph we've made, just to show you some scale:
use crossterm::{
event::{Event, KeyCode},
style::Stylize,
terminal::size,
};
use moirai_book_samples::{
events::Events,
game::{Game, GameState, GameStateChange},
terminal::Terminal,
utils::{is_key_pressed, text_size, text_wrap},
};
use std::{collections::HashMap, sync::mpsc::Sender, time::Duration};
fn main() {
Game::new(Example::default()).run_blocking();
}
struct Example {
terminal: Terminal,
events: Events<DialogueEvent>,
dialogue: DialogueWidget,
conversation: Conversation,
}
impl Default for Example {
fn default() -> Self {
let conversation = Conversation::default()
.point(
"start",
ConversationPoint::new("Hello, Adventurer!\nWhere would you like to go?")
.option(ConversationOption::new("Tavern", "tavern"))
.option(ConversationOption::new("Forest", "forest"))
.option(ConversationOption::new("Bed", "bed")),
)
.point(
"tavern",
ConversationPoint::new("You entered the tavern and found a cozy spot to rest.")
.option(ConversationOption::new("Order a beer", "beer"))
.option(ConversationOption::new("Exit", "start")),
)
.point(
"beer",
ConversationPoint::new("You ordered a refreshing beer and enjoyed your time!")
.option(ConversationOption::new("Pay and leave", "pay"))
.option(ConversationOption::new("Don't pay and leave", "no-pay")),
)
.point(
"pay",
ConversationPoint::new("You paid the bartender and left the tavern.")
.option(ConversationOption::new("Exit", "start")),
)
.point(
"no-pay",
ConversationPoint::new(
"The bartender caught you! You had to run away to the forest!",
)
.option(ConversationOption::new("Run", "forest")),
)
.point(
"forest",
ConversationPoint::new("You ventured into the forest. Rogue wolf appeared!")
.option(ConversationOption::new("Fight wolf with sword", "fight"))
.option(ConversationOption::new("Run back to town", "start")),
)
.point(
"fight",
ConversationPoint::new("You bravely fought the wolf and won!")
.option(ConversationOption::new("Return to town", "start")),
)
.point(
"bed",
ConversationPoint::new("You went to sleep. Next day, you feel refreshed!")
.option(ConversationOption::new("Wake up", "start")),
);
let events = Events::default();
let on_confirm = events.sender();
Self {
terminal: Terminal::default(),
events,
dialogue: DialogueWidget::new("start", on_confirm),
conversation,
}
}
}
impl GameState for Example {
fn frame(&mut self, _delta_time: Duration) -> GameStateChange {
let events = Terminal::events().collect::<Vec<_>>();
self.dialogue.handle_input(&events, &self.conversation);
// Receive all pending events sent from UI.
for event in self.events.receive() {
match event {
DialogueEvent::ShowDialogue { id } => {
// We got an event from dialogue widget, so we update said
// widget to show new conversation point.
self.dialogue = DialogueWidget::new(id, self.events.sender());
}
}
}
self.terminal.begin_draw(true);
self.dialogue.draw(&mut self.terminal, &self.conversation);
self.terminal.end_draw();
if is_key_pressed(&events, KeyCode::Esc) {
GameStateChange::Quit
} else {
GameStateChange::None
}
}
}
enum DialogueEvent {
ShowDialogue { id: String },
}
struct DialogueWidget {
// Current conversation point ID.
id: String,
// Currently selected option index.
index: usize,
// Sender to emit events when an option is confirmed.
on_confirm: Sender<DialogueEvent>,
}
impl DialogueWidget {
fn new(id: impl ToString, on_confirm: Sender<DialogueEvent>) -> Self {
Self {
id: id.to_string(),
index: 0,
on_confirm,
}
}
fn handle_input(&mut self, events: &[Event], conversation: &Conversation) {
let Some(conversation_point) = conversation.get(&self.id) else {
return;
};
// Move to previous option.
if is_key_pressed(events, KeyCode::Up) {
self.index = (self.index + conversation_point.options.len() - 1)
% conversation_point.options.len();
} else
// Move to next option.
if is_key_pressed(events, KeyCode::Down) {
self.index = (self.index + 1) % conversation_point.options.len();
} else
// Confirm selection.
if is_key_pressed(events, KeyCode::Enter) {
let id = conversation_point.options[self.index].jump_to.clone();
self.on_confirm
.send(DialogueEvent::ShowDialogue { id })
.unwrap();
self.index = 0;
}
}
fn draw(&self, terminal: &mut Terminal, conversation: &Conversation) {
let Some(conversation_point) = conversation.get(&self.id) else {
return;
};
let (w, _) = size().unwrap();
let message = text_wrap(&conversation_point.message, w as usize);
terminal.display([0, 0], &message);
let mut y = text_size(&message).y;
terminal.display([0, y as u16], "-".repeat(w as usize));
y += 1;
for (i, option) in conversation_point.options.iter().enumerate() {
let prefix = if i == self.index { "> " } else { " " };
let content = text_wrap(&format!("{}{}", prefix, option.text), w as usize - 1);
let height = text_size(&content).y;
let content = if i == self.index {
content.on_white().black().bold().italic()
} else {
content.on_black().white()
};
terminal.display([0, y as u16], content);
y += height;
}
}
}
#[derive(Clone)]
struct ConversationPoint {
message: String,
options: Vec<ConversationOption>,
}
impl ConversationPoint {
fn new(message: impl ToString) -> Self {
Self {
message: message.to_string(),
options: Default::default(),
}
}
fn option(mut self, option: ConversationOption) -> Self {
self.options.push(option);
self
}
}
#[derive(Clone)]
struct ConversationOption {
text: String,
jump_to: String,
}
impl ConversationOption {
fn new(text: impl ToString, jump_to: impl ToString) -> Self {
Self {
text: text.to_string(),
jump_to: jump_to.to_string(),
}
}
}
#[derive(Default)]
struct Conversation {
points: HashMap<String, ConversationPoint>,
}
impl Conversation {
fn point(mut self, id: impl ToString, point: ConversationPoint) -> Self {
self.points.insert(id.to_string(), point);
self
}
fn get(&self, id: &str) -> Option<&ConversationPoint> {
self.points.get(id)
}
}
Observations
This pattern of event-driven asynchronous logic flow is correct and widely used in games, because it allows to properly await for game state changes, and it’s easy to implement.
Clean declaration, messy execution
When we compare declararing conversation graph to how it’s executed, we can see the difference - IMHO the declarative part is the only part that keeps it sane to reason about, as it tells the user in single place, how high level flow in the graph looks like.
But now imagine scenarios where you can’t or don’t express your asynchronous logic that way, instead you just have game systems and objects coupled with events sent between them - suddenly the cognitive load required to follow through the logic becomes challenging, the bigger the scale of game systems and objects interactions, more events sent from unrelated places to objects and systems that will handle all events important to them in a somewhat single place - it gets really messy real quick! It’s really hard to maintain the bigger it gets.
I’m sure you have experienced such event-driven code and maybe even hate the complexity at some point.
Hard to enforce correct and stable flow
At the moment we have very simple flow loop:
… Show dialogue -> Wait for user confirmation …
Let’s say we want to add other dialogue feature such as automatic progression after specified time, blocking input until voice-over is done. In that case we grow in number of states, we need to rework entive dialogue system to incorporate that.
We no longer rely only on conversation points but also on various other states dialogue can be in, effectively turning it into real state machine, making it harder to ensure some received events will only be able to get executed if some properties are in very specific state, like not reacting to show dialogue event if widget is blocked for some time, or to invalidate auto-progressing timer, when user receives user confirmation before.
Similarily, thinking about general event-driven logic, i bet you’ve had experienced or seen cases, where some game systems reacted on some events when they weren’t expecting it to happen, invalidating current state.
For example: your character is in the middle of a cutscene, while game enviroment system spawned a fire next to you, spreading during cutscene, killing the player before cutscene ends - not fun experience, and usually hacked with turning player invincible for duration of cutscene, or generally requiring to carefuly disabling some game systems just to not make it happen.