Beast aims to be a thin layer over CLOS, and so has a fairly small user-facing API.
Aspects are facets/traits of your game objects that you want to model. Some examples could be things like: location, moveable, visible, edible, sentient.
To define an aspect you use define-aspect
:
(define-aspect location x y) (define-aspect edible nutrition-value)
define-aspect
takes the name of the aspect and zero or more slot definitions.
The names of aspect slots will have the aspect name prepended to them with
a slash to avoid clashing between aspects, and the initargs
and accessors
will be added for you. So for example, this:
(define-aspect inspectable text) (define-aspect readable text)
Would macroexpand into something roughly like:
(defclass inspectable () ((inspectable/text :initarg :inspectable/text :accessor inspectable/text))) (defclass readable () ((readable/text :initarg :readable/text :accessor readable/text)))
You can include extra slot options when defining an aspect's slots, and they'll
be passed along to the defclass
. This is especially handy for :initform
and :type
.
(define-aspect container (contents :initform nil)) (define-aspect throwable (accuracy :type single-float) (damage :type integer))
In the end it's just CLOS though, so if you want to add some :documentation
or
use :allocation :class
then go nuts!
When you define an aspect named foo
Beast also defines a foo?
predicate that
returns (typep object 'foo)
, which comes in handy when using higher-order
functions:
(defun whats-for-dinner? () (remove-if-not #'edible? (container/contents *fridge*)))
Once you've got some aspects you'll want to define some entity classes that mix them together.
You define entity classes using define-entity
:
(define-entity dart (throwable)) (define-entity bread (edible)) (define-entity pie (edible throwable)) (define-entity icebox (container))
The resulting classes will inherit from entity
and each of the aspects (in
order). This example would expand (roughly) into:
(defclass dart (entity throwable) ()) (defun dart? (object) (typep object 'dart)) (defclass bread (entity edible) ()) (defun bread? (object) (typep object 'bread)) (defclass pie (entity edible throwable) ()) (defun pie? (object) (typep object 'pie)) (defclass icebox (entity container) ()) (defun icebox? (object) (typep object 'icebox))
You can also specify slot definitions at the entity level, and they'll be passed
along to defclass
:
(define-entity cheese (edible) (variety :type (member :swiss :cheddar :feta) :initarg :variety :reader :cheese-variety))
Note that slot definitions on entities are passed along raw, without the name-mangling or default-slot-option-adding that's done for aspects. This may change in the future.
After you've defined your entity classes you can can create some entities using
create-entity
:
(defparameter *my-fridge* (create-entity 'icebox)) (dotimes (i 30) (push (create-entity 'cheese :edible/nutrition-value 10 :variety (nth (random 3) '(:swiss :cheddar :feta))) (container/contents *my-fridge*)))
create-entity
is a thin wrapper around make-instance
that handles some extra
bookkeeping. When you create an entity, Beast will keep track of it in a global
index. We'll see the reason for this in the next section.
To destroy an entity (i.e. remove it from Beast's index) you can use
(destroy-entity the-entity)
. You can wipe the slate clean and remove all
entities at once with (clear-entities)
.
Beast also defines two generic functions called entity-created
and
entity-destroyed
which don't do anything by default, but are there for you to
add methods on if you want. For example:
(define-aspect location x y) (defvar *world* (make-array (100 100) :initial-element nil)) (defmethod entity-created :after ((e location)) (push e (aref *world* (location/x e) (location/y e)))) (defmethod entity-destroyed :after ((e location)) (with-slots ((x location/x) (y location/y)) e (setf (aref *world* x y) (delete e (aref *world* x y)))))
Beast's aspects and entities are just very thin sugar over CLOS, but systems provide extra functionality that comes in handy when writing games.
A system is essentially a function that takes an entity as an argument with zero or more aspects as type specifiers. When you run a system the function will be run on every entity that meet the requirements. For example:
; No specifiers, this just runs on every entity. (define-system log-all-entities (entity) (print entity)) ; Runs on entities with the lifetime aspect. (define-system age ((entity lifetime)) (when (> (incf (lifetime/age entity)) (lifetime/lifespan entity)) (destroy-entity entity))) ; Run on entities with both the visible and location aspects. (define-system render ((entity visible location)) (draw entity (location/x entity) (location/y entity) (visible/color entity)))
Systems with more than one argument are currently supported, but should be considered experimental. The API may change in the future.
; Run on all PAIRS of entities that have the appropriate aspects. (define-system detect-collisions ((e1 location collidable) (e2 location collidable)) ; ... )
define-system
defines a function with the same name as the system, and
a run-...
function that will do the actual running for you:
(define-system log-all-entities (entity) (print entity)) (run-log-all-entities)
You should always use the run-...
function, but the other one can be handy to
have around for tracing/debugging/disassembling purposes.
That's most of Beast in a nutshell. If you've gotten this far you can dive in and make something, or take a look at the API Reference.
Beast also does some stuff not discussed here like caching entities by aspect/system and type-hinting system functions. If you're curious about how it works you can read the source.