Last active
November 13, 2024 10:25
-
-
Save perky/f6f84f417170de6228f49912a23627dc to your computer and use it in GitHub Desktop.
Mario State Machine. Showcases the usefulness of switches and tagged unions in Zig.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//! ====== | |
//! file: mario_state_machine.zig | |
//! This is an example of a Mario/Powerup state machine. | |
//! It showcases the usefulness of switches and tagged unions in Zig. | |
//! See state machine diagram: | |
//! https://external-preview.redd.it/TgwKB-bdEWJase06sIDXmVtaGaP7AZTD9YKn0x4yUWo.png?auto=webp&s=c0318b178038bd83212392c8fdd16e1a4b1a0049 | |
//! ====== | |
/// This is a tagged union. | |
/// See tagged union doc: | |
/// https://ziglang.org/documentation/master/#Tagged-union | |
/// Each union "tag" becomes part of an enumeration, and it means we can use the union in a switch expression. | |
/// Combined with Zig's exhaustive switches, this becomes a very useful pattern! | |
/// | |
/// A union can only have a single "tag" active at any time. Attempting to read from an inactive tag throws a compile | |
/// error, making them a good tool for implementing state machines. | |
pub const MarioMode = union(enum) { | |
dead: void, // void tag type makes it just like a basic enumeration. | |
mario: void, | |
super: SuperMario, | |
cape: CapeMario, | |
fire: FireMario, | |
/// We could have had MarioMode as a basic enum, but the tagged union allows us to attach different data payloads | |
/// for each "tag". Note unlike C unions, the in-memory representation isn't guaranteed. For that you use | |
/// "extern union" or "packed union". | |
pub const FireMario = struct { | |
fireballs: u8, | |
/// Putting functions inside a struct/enum/union is an organization tool, effectively namespacing the function. | |
/// We also get some syntatic sugar with the first parameter, effectively making it a method. | |
pub fn fire(self: *FireMario) u8 { | |
if (self.fireballs == 0) unreachable; // The program will panic if execution reaches "unreachable". | |
// Without this we would still get an integer overflow panic | |
// if it attempted to subtract 1 from 0. This just states your | |
// intentions more explicitly. | |
self.fireballs -= 1; | |
return self.fireballs; | |
} | |
}; | |
/// Our payload structs only have a single field each, so the tag types could have used f32/i32 directly, but you | |
/// could imagine how over development iterations the payloads could end up with more complicated data. | |
pub const CapeMario = struct { | |
duration_left: f32 | |
}; | |
pub const SuperMario = struct { | |
bonus_health: i32 | |
}; | |
// You can also declare constants within an union/struct/enum, | |
// another useful namespacing tool. | |
pub const super_default = MarioMode{ .super = .{ .bonus_health = 16 } }; | |
pub const cape_default = MarioMode{ .cape = .{ .duration_left = 4.5 } }; | |
pub const fire_default = MarioMode{ .fire = .{ .fireballs = 10 } }; | |
/// This is the state machine. Select the next mode, based on the current mode and "transition" (the powerup). | |
/// The switch statements here are exhaustive, so if you add new tags to either MarioMode or MarioPowerup you will | |
/// get a compile error if you do not also add switch cases. | |
/// See switch doc: | |
/// https://ziglang.org/documentation/master/#switch | |
/// | |
/// This makes switch expressions much more useful than in other languages, where the danger of using the | |
/// enum+switch paradigm could have programmers easily add new enumeration tags and forget to update switches | |
/// scattered across the codebase. | |
/// | |
/// Note the return type is ?MarioMode, this states that this function will either return MarioMode or null. | |
/// See Optionals doc: | |
/// https://ziglang.org/documentation/master/#Optionals | |
pub fn nextMode(mode: MarioMode, powerup: MarioPowerup) ?MarioMode { | |
// Switches can be used as expressions, making them even more useful. | |
return switch(mode) { | |
.dead => null, | |
.mario => switch(powerup) { | |
.mushroom => super_default, | |
.feather => cape_default, | |
.flower => fire_default | |
}, | |
.super => switch(powerup) { | |
.mushroom => null, | |
.flower => fire_default, | |
.feather => cape_default | |
}, | |
.cape => switch(powerup) { | |
.mushroom, .feather => null, | |
.flower => fire_default | |
}, | |
.fire => switch(powerup) { | |
.mushroom, .flower => null, | |
.feather => cape_default | |
} | |
}; | |
} | |
}; | |
/// A basic enum. | |
/// This will be backed by an unsigned 8-bit integer. You could omit the (u8) to have the compiler infer the backing | |
/// type. | |
/// See enum doc: | |
/// https://ziglang.org/documentation/master/#enum | |
pub const MarioPowerup = enum(u8) { | |
mushroom, | |
feather, | |
flower, | |
}; | |
pub const MarioCharacter = struct { | |
mode: MarioMode = .mario, | |
health: i32, | |
/// Note the "*const" parameter here, this is a pointer to immutable data. Pointers in zig are guarenteed to not be | |
/// null, so we don't need to check for nullptr here. Nullable pointers are expressed as "?*". | |
pub fn getHealth(self: *const MarioCharacter) i32 { | |
return switch(self.mode) { | |
// Switching on a tagged union lets you then capture the tag's payload. The capture is expressed in | |
// "|mode_state|" and gives us const data. For mutable data you would express it as "|*mode_state|". | |
.super => |mode_state| self.health + mode_state.bonus_health, | |
// The "else" case is called for all other tags not counted for, this satisfies the constraint that all | |
// switches must be "exhaustive". | |
else => self.health | |
}; | |
} | |
pub fn givePowerup(character: *MarioCharacter, powerup: MarioPowerup) void { | |
const next_mode_or_null: ?MarioMode = character.mode.nextMode(powerup); | |
// This is the typical way to handle Optionals. If the Optional is not null, the actual data will be captured | |
// into the following scope. | |
if (next_mode_or_null) |next_mode| { | |
character.mode = next_mode; | |
} | |
} | |
pub fn useSpecialPower(character: *MarioCharacter) bool { | |
switch(character.mode) { | |
// This is capturing the union payload as a mutable pointer. "state.fire()" is syntatic sugar for | |
// "MarioMode.FireMario.fire(state)". | |
.fire => |*state| { | |
if (state.fire() == 0) { | |
character.mode = .mario; | |
} | |
return true; | |
}, | |
else => return false | |
} | |
} | |
}; | |
// the "std" (standard) zig library has a lot of useful and common data structures and functions. This is cherrypicking | |
// the assert function out of it. | |
const assert = @import("std").debug.assert; | |
// Zig comes testing system for writing and running unit-tests. You can run all tests in this file on the command line | |
// via: | |
// zig test mario_state_machine.zig. | |
// The std library also has useful functions under std.testing. | |
// See testing doc: | |
// https://ziglang.org/documentation/master/#Zig-Test | |
test "mario state machine" { | |
var mario = MarioCharacter{ .health = 100 }; | |
assert(mario.mode == .mario); | |
assert(mario.getHealth() == 100); | |
// "mario.givePowerup(.mushroom)" is syntatic sugar for "MarioCharacter.givePowerup(&mario, .mushroom)". | |
mario.givePowerup(.mushroom); | |
assert(mario.mode == .super); | |
assert(mario.getHealth() == 116); | |
mario.givePowerup(.flower); | |
// Return values are explicit, if you don't intend to use them you must explicitly discard them with "_". | |
_ = mario.useSpecialPower(); | |
const did_fireball = mario.useSpecialPower(); | |
assert(mario.mode == .fire); | |
assert(mario.getHealth() == 100); | |
assert(did_fireball); | |
assert(mario.mode.fire.fireballs == 8); | |
for (0..7) |_| { | |
_ = mario.useSpecialPower(); | |
} | |
assert(mario.mode == .fire); | |
assert(mario.mode.fire.fireballs == 1); | |
_ = mario.useSpecialPower(); | |
assert(mario.mode == .mario); | |
assert(mario.useSpecialPower() == false); | |
mario.givePowerup(.feather); | |
assert(mario.mode == .cape); | |
mario.givePowerup(.feather); | |
assert(mario.mode == .cape); | |
mario.givePowerup(.mushroom); | |
assert(mario.mode == .cape); | |
mario.givePowerup(.flower); | |
assert(mario.mode == .fire); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment