42. Item Drops, Quest Completions
Let's close the loop of quests and items.
By the end of this lesson, you'll be able to pick up the quest from Marcus, fight some rats and 'acquire' their tails, and hand in the quest.
This is where the event system starts to flex its muscles.
Typing the Event Payloads
We need new events types:
Event_Type :: enum {
// ... existing ...
Quest_Start,
Quest_Complete,
Item_Get,
Gold_Get,
Exp_Gain_All,
Load,
}
We'll also remove string as a member of the Event_Payload union and replace it with distinct types--clear and type safe.
Event_Payload_Quest_Start :: distinct string
Event_Payload_Quest_Complete :: distinct string
Event_Payload_Item_Get :: struct {
item_key: string,
quantity: int,
}
Event_Payload_Gold_Get :: distinct int
Event_Payload_Exp_Gain_All :: distinct int
The union now lists them explicitly:
Event_Payload :: union {
Event_Payload_Empty,
Event_Payload_Scene_Change,
Event_Payload_Battle_Start,
Event_Payload_Dialogue_Choice_Select,
Event_Payload_Dialogue_Initiate,
Event_Payload_Quest_Start,
Event_Payload_Quest_Complete,
Event_Payload_Item_Get,
Event_Payload_Gold_Get,
Event_Payload_Exp_Gain_All,
}
A distinct type in Odin is the same as the underlying type, yet provides type safety as if it's a different type.
Gold_GetandExp_Gain_Alldon't yet have listeners. We have the field for gold, and the event is emitted, but we don't yet have an experience system in place.
A Minimal Inventory
Create a new file, inventory.odin.
Inventory is its own type as we want to attach inventories to entities like chests, barrels, etc.
Inventory :: struct {
items: [dynamic]Item_Instance,
gold: int,
}
Item_Instance :: struct {
id: string,
quantity: int,
}
An Item_Instance is what you actually carry--an item key and quantity. The key "rat_tail" matches the item's filename.
A definition describes what an item is--you've seen this many times already.
Item_Definition :: struct {
id: int,
name_text_id: string,
description_text_id: string,
stack_size: int,
type: Item_Type,
consumable_effect: Maybe(Consumable_Effect),
}
Item_Type :: enum {
Consumable,
Key_Item,
Equipment,
}
Consumable_Effect :: struct {
hp_restore: int,
status_cure: bit_set[Status_Effect],
}
Status_Effect :: enum {
Poison,
Sleep,
Stun,
}
In an RPG, a Key Item is (usually) an item that cannot be dropped, sold, or destroyed.
I've added some 'sketched out' code for what a consumable item effect may look like, and hence status effects. These are markers for future work, rather than something we need right now.
Add the two new fields to Game_State:
Game_State :: struct {
// ... existing ...
party_inventory: Inventory,
item_definitions: [dynamic]Item_Definition,
}
Loading Item Definitions
Same code we use for entities, scenes, and quests:
// Load items
{
dir, dir_err := os.open("data/items")
defer os.close(dir)
if dir_err != nil {
log.panicf("%v", dir_err)
}
file_infos, fi_err := os.read_dir(dir, 0, context.temp_allocator)
if fi_err != nil {
log.panicf("%v", fi_err)
}
for file_info in file_infos {
data, read_err := os.read_entire_file(file_info.fullpath, context.temp_allocator)
if read_err != nil {
log.panicf("Failed to read file: %s: %v", file_info.fullpath, read_err)
}
item_def: Item_Definition
json.unmarshal(data, &item_def, .SJSON)
item_def.id = len(gs.item_definitions)
append(&gs.item_definitions, item_def)
}
}
The id is assigned from the current array length, so it doubles as the index into item_definitions.
Why use an array for items when we use a
map[string]<T>_Definitionfor other types?
I'm going to answer this question in an upcoming lesson where we go over all the data in the game, reason about it from first principles, and clean it up.
Our one item, data/items/rat_tail.json:
name_text_id = "items.name.rat_tail"
description_text_id = "items.description.rat_tail"
stack_size = 99
type = "Key_Item"
Receiving Items
The section of code where the events are handled is getting massive.
It's not strictly a problem, and I don't advocate splitting out procedures that are only used in one place.
In this case, the events have been dealing some kind of "psychic damage" to me as I work on the code base.
So, I've moved this event and some other recent events into their corresponding system files.
In inventory.odin:
on_item_get :: proc(event: Event) {
gs := cast(^Game_State)context.user_ptr
payload := event.payload.(Event_Payload_Item_Get)
found := false
for &item in gs.party_inventory.items {
if item.id == payload.item_key {
item.quantity += payload.quantity
found = true
}
}
if !found {
append(&gs.party_inventory.items, Item_Instance {
id = payload.item_key,
quantity = payload.quantity,
})
}
}
stack_sizeisn't enforced yet.
Make sure to use the&item, rather thanitemas it's easy to modify a stack-based copy and wonder what's going on.
Dropping Loot
For an enemy to drop something, its definition has to say what. Add a drops list to Entity_Definition (alongside hand_ins, which we'll use shortly):
Entity_Definition :: struct {
// ... existing ...
drops: []Drop,
hand_ins: []string,
}
Drop :: struct {
item_id: string,
chance: f32,
min: int,
max: int,
}
A Drop is one possible item with a probability and a quantity range. The rat now drops a tail every time:
drops = [
{item_id = "rat_tail", chance = 1.0, min = 1, max = 1},
]
When a battle is won, we were calling battle_finish and parameterising victory.
Instead, we can call battle_victory or battle_defeat.
We guard the call site so these procedures are called once.
// ... in battle_update ...
// Game over happens when all allies are dead, even if somehow
// all units on the field are dead.
any_player_unit_alive := false
for h in bs.player_entity_ids {
if h == nil do continue
e := entity_get(bs.battle_scene, h.?)
if e.hp.curr > 0 {
any_player_unit_alive = true
}
}
if !any_player_unit_alive {
if bs.turn_state != .DEFEAT {
battle_defeat(bs)
}
return
}
any_enemy_unit_alive := false
for h in bs.enemy_entity_ids {
if h == nil do continue
e := entity_get(bs.battle_scene, h.?)
if e.hp.curr > 0 {
any_enemy_unit_alive = true
}
}
if !any_enemy_unit_alive {
if bs.turn_state != .VICTORY {
battle_victory(bs)
}
return
}
// ...
battle_victory :: proc(bs: ^Battle_State) {
bs.turn_state = .VICTORY
for maybe_entity_handle in bs.enemy_entity_ids {
if entity_handle, ok := maybe_entity_handle.?; ok {
entity := entity_get(bs.battle_scene, entity_handle)
entity_def := entity_def_by_id(entity.def_id)
for drop in entity_def.drops {
count := rand.int_max(drop.max - drop.min + 1) + drop.min
chance := rand.float32()
if chance < drop.chance {
emit(
Event {
type = .Item_Get,
payload = Event_Payload_Item_Get{item_key = drop.item_id, quantity = count},
},
)
}
}
}
}
}
battle_defeat :: proc(bs: ^Battle_State) {
bs.turn_state = .DEFEAT
// TODO: Game Over?
}
Quests
When we first linked dialogue to events, a Quest was a single field:
Quest :: struct {
state: Quest_State,
}
Enough to flip a quest to In_Progress and log it.
To track objectives and rewards, we need more data.
Quest :: struct {
id: string,
state: Quest_State,
objectives: []Quest_Objective,
rewards: Quest_Rewards,
}
Quest_Objective :: struct {
type: Quest_Objective_Type,
target_id: string,
current: int,
required: int,
completed: bool,
}
Quest_Objective_Type :: enum {
Kill,
Collect,
Talk,
Visit,
}
Quest_Rewards :: struct {
exp: int,
gold: int,
items: []Item_Instance,
}
The state enum gains a stop between in-progress and done:
Quest_State :: enum {
Not_Started,
In_Progress,
Ready_For_Hand_In, // New
Completed,
Failed,
Failed_Forever,
Invalidated,
}
Ready_For_Hand_In is when requirements have been met, but the player hasn't spoken to the NPC who gives the reward yet.
For some logic later, quests need to hold their own id:
// where we load all quests
name := file_info.name
name = strings.trim_suffix(name, ".json")
name = strings.clone(name)
quest.id = name
gs.quests[name] = quest
In cellar_rats.json, we add objectives and rewards:
display_name_id = "quests.name.cellar_rats"
objectives = [
{type = "Collect", target_id = "rat_tail", required = 5}
]
rewards = {
exp = 100
gold = 5
}
In quests.json, we give the quest a nice name:
"quests.name.cellar_rats" = "Rats in the Grain"
Progress From Pickups
Another event. This time in quests.odin.on_item_get_quest_update is the second listener on Item_Get:
on_item_get_quest_update :: proc(event: Event) {
gs := cast(^Game_State)context.user_ptr
payload := event.payload.(Event_Payload_Item_Get)
for _, &quest in gs.quests {
can_progress := quest.state == .Not_Started
can_progress |= quest.state == .In_Progress
if can_progress {
for &objective in quest.objectives {
objective_matches := objective.type == .Collect
objective_matches &= objective.target_id == payload.item_key
if objective_matches {
objective.current += payload.quantity
quest_revalidate_objectives(&quest)
if quest_check_objective_statuses(quest^) {
quest.state = .Ready_For_Hand_In
}
}
}
}
}
}
// NOTE: Right now this is only used in one place, but I have no doubt
// that it'll be used across many events
quest_revalidate_objectives :: proc(quest: ^Quest) {
for &objective in quest.objectives {
switch objective.type {
case .Collect:
if objective.current >= objective.required {
objective.completed = true
}
case .Kill:
case .Talk:
case .Visit:
}
}
}
quest_check_objective_statuses :: proc(quest: Quest) -> (all_finished: bool) {
for objective in quest.objectives {
if !objective.completed {
return false
}
}
return true
}
Only .Collect does anything yet. .Kill, .Talk, and .Visit are empty cases for now. We'll fill them in later.
can_progressincludes.Not_Startedso quests can be progressed without strictly starting them.
Starting a quest should revalidate to catch the "already had the items" case:
on_quest_start :: proc(event: Event) {
gs := cast(^Game_State)context.user_ptr
payload := string(event.payload.(Event_Payload_Quest_Start))
if quest, ok := &gs.quests[payload]; ok {
quest.state = .In_Progress
if quest_check_objective_statuses(quest^) {
quest.state = .Ready_For_Hand_In
}
log.infof("Quest started: '%s'", payload)
} else {
log.errorf("Tried to start quest that can't be found: '%s'", payload)
}
}
Handing In
The last link is the quest-giver. An NPC declares which quests it accepts hand-ins for via a hand_ins list on its definition:
hand_ins = ["cellar_rats"]
When you interact with an NPC, the Dialogue_Initiate handler checks those hand-ins before it opens any dialogue. If a listed quest is Ready_For_Hand_In and its objectives genuinely check out, it fires Quest_Complete:
entity_def := entity_def_by_id(payload.entity_def_id)
// Complete quest if can hand in and objectives are done
for hand_in in entity_def.hand_ins {
quest := gs.quests[hand_in]
if quest.state == .Ready_For_Hand_In {
if quest_check_objective_statuses(quest) {
emit(Event {
type = .Quest_Complete,
payload = Event_Payload_Quest_Complete(hand_in),
})
}
}
}
The same handler now also guards against opening an empty conversation. Only entities that actually have dialogue nodes open the UI (this was crashing when talking to rats):
if len(entity_def.dialogue) > 0 {
// ... open dialogue as before ...
}
Quest_Complete has its own listener, which closes the quest out and pays the reward, using more events:
on_quest_complete :: proc(event: Event) {
gs := cast(^Game_State)context.user_ptr
payload := string(event.payload.(Event_Payload_Quest_Complete))
if quest, ok := &gs.quests[payload]; ok {
quest.state = .Completed
log.infof("Quest completed: '%s'", payload)
emit(Event{type = .Exp_Gain_All, payload = Event_Payload_Exp_Gain_All(quest.rewards.exp)})
emit(Event{type = .Gold_Get, payload = Event_Payload_Gold_Get(quest.rewards.gold)})
for item in quest.rewards.items {
emit(Event {
type = .Item_Get,
payload = Event_Payload_Item_Get{item_key = item.id, quantity = item.quantity},
})
}
} else {
log.errorf("Tried to complete quest that can't be found: '%s'", payload)
}
}
Here we have events emitting other events such that no systems need be hard coupled.
This is useful for RPG designers as they can create complex interaction chains.
The downside is that tracing mutations through the call graph is now very difficult.
Putting It Together
In game_post_window_create, where other events are subscribed to:
events_subscribe(&gs.events, .Quest_Start, on_quest_start) // replace old inline one (if you want)
events_subscribe(&gs.events, .Quest_Complete, on_quest_complete)
events_subscribe(&gs.events, .Item_Get, on_item_get)
events_subscribe(&gs.events, .Item_Get, on_item_get_quest_update)
Data Housekeeping
A few smaller changes to the data, with more to come in our future data overhaul lesson.
Lower-cased entity keys. Marcus_Town, Marcus_Cellar, and Rat became marcus_town, marcus_cellar, and rat, with the scene spawner type fields and the rat's _battle_entities updated to match. Consistent lower-case keys is the style we'll use from now on.
Named behaviour flags. The rat's behaviour went from an integer behavior_flags = 1 to _behavior_flags = ["Wander"]. We had the behaviour parsing code sitting there for ages, not being used.
More rats. The quest wants five tails, so the cellar got five rats. You could also increase the number of tails dropped per rat, or increase the rats per battle.
Duplicate-text warnings. Dialogue, entity, and quest text all load into one gs.text map. If two files define the same key the data is clobbered. So the loaders now log an error when a key is about to be overwritten.
Marcus' Line. "Perfect. I'll be outside. (TODO): Make Marcus walk outside (fix navgrid, add pathing)"--obviously this breaks the fourth wall, but that's fine for now.
A cosmetic pass on the battle UI. The battle menus were hurting my eyes. I swapped them for a transparent white WHITE_10 :: Vec4{255, 255, 255, 25}. (10% = 25)
The Big Idea
The quest loop is made from a bunch of individual events coming together to give the illusion of a cohesive structure. The battle system drops items, the quest system checks for items, but the battle system and quest system don't directly talk to each other.
We are getting to the point now of being able to add arbitrary content that feels like it all belongs in the same game.