Now that I've shat on mutability in prior posts, let's talk about how it pervades my game code.
I'm a fan of the component model for game-objects. Basically, an object is a unique ID, and there is a database of properties associated to IDs. This isn't a disk-based database. Rather, it's an in-memory key-value store, often implemented by hashtable (though anything fitting the
STORE
signature can be provided).This database is where most mutation is, hence the title of this post. So far this has been a nice fusion — functional code for the most part, with game-state held in a database. Updating/setting values in the database is typically done by high-level code, based on calculations performed by all the workhorse functions.
Components (a.k.a. properties)
Here's a character (pruned for brevity):
let make_severin () = Db.get_id_by_name "Severin" |> Characteristics.s {int=3;per=0;str=0;sta=1;pre=0;com=0;dex=0;qik=0} |> Size.s 0 |> Age.s (62,35) |> Confidence.s (2,5) |> Nature.(s human) |> Fatigue.Levels.(s standard) |> Virtue.(s [ TheGift; HermeticMagus; PlaguedBySupernaturalEntity( Db.get_id_by_name "Peripeteia" ); ]) |> Ability.(s [ of_score ArtesLiberales 1 "geometry"; of_score Chirurgy 2 "self"; of_score (AreaLore("Fallen Covenant")) 3 "defences"; of_score MagicTheory 5 "shapeshifting"; of_score (Language(French)) 5 "grogs"; of_score ParmaMagica 5 "Terram"; of_score SingleWeapon 3 "Staff"; of_score Survival 3 "desert"; ]) |> Hermetic.House.(s Tytalus) |> Hermetic.Arts.(s (of_score { cr=8; it=0; mu=6; pe=6; re=20 ; an=0; aq=0; au=0; co=6; he=15 ; ig=0; im=0; me=0; te=8; vi=0 })) |> Hermetic.KnownSpells.s [ Spell.mastery 4 "Acorns for Amusement" Hermetic.([FastCasting;MultipleCasting;QuietCasting;StillCasting]); Spell.name "Carved Assassin"; Spell.name "Wall of Thorns"; Spell.name "Circular Ward Against Demons"; ] |> equip_and_inv ["Wizardly Robes";"Staff"] ["Small Pouch"]
Each of the modules here represents a component which objects may have. So Severin's Age is (62,35) — that is actual age and apparent age (Wizards age slowly).
The function make_severin will return an ID. The first line
Db.get_id_by_name "Severin"
looks up an ID for that name, or generates a new ID if none exists. Each component module has a function "s", which is the same as "set" except it returns the given ID. It's a convenience function for setting multiple component values at once for the same ID — which is exactly what's happening here, with Severin's ID being threaded through all of these property-setting calls.This is the signature common to all components:
module type Sig = sig type t val get : key -> t (* get, obeying table's inheritance setting *) val get_personal : key -> t (* ignore inheritance *) val get_inheritable : key -> t (* permit inheritance (overriding default) *) val get_all : key -> t list (* for a stacked component *) val set : key -> t -> unit (* set value on key; stacking is possible *) val s : t -> key -> key (* set, but an alternate calling signature *) val del : key -> unit (* delete this component from key *) val iter : (key -> t -> unit) -> unit val fold : (key -> t -> 'a -> 'a) -> 'a -> 'a end
I've used first-class modules so I can unpack a table implementation inside a module, making it a "component". For example, in the file hermetic.ml I have this:
module House = struct type s = Bjornaer | Bonisagus | Criamon | ExMiscellanea | Flambeau | Guernicus | Jerbiton | Mercere | Merinita | Tremere | Tytalus | Verditius include (val (Db.Hashtbl.create ()): Db.Sig with type t = s) end
Now the Hermetic.House module has functions to set/get it's values by ID. In the definition of the character, earlier, you can see
Hermetic.House.(s Tytalus)
.This manner of database, organized by modules, has been very pleasant to use. It's easy to create a new component-module, adding it's own types and extra functions. It doesn't need to be centrally defined unless other modules are genuinely dependent on it. In practice, I define these components where they make sense. There's no need to "open" modules, thanks to the relatively recent local-open syntax of:
Module.(in_module_scope)
. Variants and record-fields are neatly accessed while keeping them in their appropriate module.One of the rationales behind a component-model like this is that you can process or collect things by property. This is where the iter and fold are useful. It's easy to grab all entity IDs which have a Wound component (and is therefore able to be injured), for example.
Database
The database, conceptually, is the collection of components. In practice though, I want the components to be non-centrally declared otherwise all datatypes would be centralized as well — so instead, the database is ID management and table-instantiation functors.
First, you create a database of a particular key type...
(* Gamewide 'Db' is based on IDs of type 'int' *) module Db = Database.Make (Database.IntKey)
The resulting signature has functions related to IDs, and table-instantiation, something like this (I've removed the generic table interfaces, for brevity):
module Db : sig type key = Database.IntKey.t val persistent_id : unit -> key val transient_id : unit -> key val find_id_by_name : 'a -> key val get_id_by_name : string -> key val get_name : key -> string val string_of_id : key -> string val delete : key -> unit (* <snip> module type Sig *) module Hashtbl : sig val create : ?size:int -> ?default:'a -> ?nhrt:bool -> unit -> (module Sig with type t = 'a) end module SingleInherit : sig val get_parent : key -> key val set_parent : key -> key -> unit val del_parent : key -> unit (* <snip> Hashtbl.create... *) end module MultiInherit : sig val get_parents : key -> key list val set_parents : key -> key list -> unit val add_parents : key -> key list -> unit val del_parent : key -> key -> unit (* <snip> Hashtbl.create... *) end end
Creating a component (aka table, or property) can be as simple as:
(* A basic property is just a table with an already-known type *) module Size = (val (Db.Hashtbl.create ~default:0 ()): Db.Sig with type t = int)
A common alternative is shown in the
House
example earlier, where the first-class module is unpacked and included within an existing module.Inheritance
There are three kinds of tables which can be instantiated: no inheritance, single inheritance, and multiple inheritance. Oh, did that last one make your hair stand on end? Haha. Well, multiple inheritance of data or properties can be a lot more sensible than the OO notion of it.
The way inheritance works is that an ID may have a parent ID (or a list of IDs for multiple). If a component is not found on ID, it's parent is checked. So if I:
Size.get target
, then target might inherit from a basic_human which has the size (among other "basic human" traits).Multiple inheritance allows for the use of property collections. You might see the value of this when considering the basic_human example... say you wanted to also declare foot_soldier properties which imparted some equipment, skills, and credentials. To make a basic_human foot_soldier with multiple inheritance, they're both parents (list-order gives a natural priority).
On the other hand, if only humans could reasonably be foot_soldiers, then you might be okay with single inheritance for this case — setting basic_human as the parent of foot_soldier.
Currently I'm not using inheritance for the bulk of the game, but the GUI (work in progress) is based on components and uses multiple-inheritance. This is a fragment of the GUI code, so Prop becomes the module used for instantiating components, and it also has the parent-related functions. I've included a few of the components too:
(* GUI uses multiple-inheritance (of data), so style and properties may be * mixed from multiple parents, with parents functioning as property-sets. *) module Prop = Db.MultiInherit let new_child_of parents = let id = new_id () in Prop.set_parents id parents; id module Font = (val (Prop.Hashtbl.create ()): Db.Sig with type t = Fnt2.t) module Pos = (val (Prop.Hashtbl.create ()): Db.Sig with type t = Vec2.t) module Color = (val (Prop.Hashtbl.create ~default:(1.,0.,1.) ()): Db.Sig with type t = float*float*float)
Delete
It's not all roses. One big pain-in-the-tuchus is deleting an ID. This means removing every entry it has in any of these tables. To do this, when a table is instantiated, it's also registered with the database. The delete operation for any table only needs to know ID, the signature being
key -> unit
. The real ugly part is what this means at runtime: doing a find-and-remove on every table - every time an entity is deleted. Various optimizations are possible, but for now I'm keeping it brute force.The Source
If you want to see the source, or to *gasp* use it, here is a gist of database.ml / mli. It's very unpolished, needing fleshing-out and useful documentation. I intend to release it at some future point, along with some other code. But if you have tips or suggestions, please share them! I've been going solo with OCaml and suspect that's limiting me — I could be doing crazy or nonsensical things. Well, I'm sure I am in a few places... maybe right here with this code!
No comments:
Post a Comment