ACE is divided into managers and utils.
Utils are for working with small and/or multiple resources of same kind (bitmaps, files, fonts, viewports). Also, there are utils which simplify doing specific tasks (chunky processing, taglists) or simplify hardware access (Amiga chip registers).
When you create a resource using a util, you receive a pointer to it
(e.g. pBm = bitmapCreate(...)
). After finishing work with it, you are required
to free it using dedicated function (e.g. bitmapDestroy(pBm)
). You can use
different functions from util to work with such resource, always passing that
resource as a parameter (e.g. bitmapGetByteWidth(pBm)
).
Every manager is responsible for management of single global resource
(blitter, audio, etc.). You create one with "create" or "open" function
(e.g. blitManagerCreate()
) and close with a "destroy" or "close" counterpart
(blitManagerDestroy()
). Between those calls you may use any of manager's
functions (e.g. blitRect()
, blitCopy()
, blitLine()
). Those are used
usually once per program.
Those are a special case and addition to manager/util system. After constructing a viewport, you need a bitmap buffer to display things on it, scroll it and manage it in many other ways. Viewport managers are doing just that.
- A camera manager keeps track of currently displayed rectangle of a bigger playfield
- A buffer manager creates required bitmap for display and uses camera manager to issue required hardware/software operations to display relevant portion.
Buffer managers are not merely allocate bitmap for you - they do all sorts of heavy lifting - scroll buffer displays background buffer making it fold on Y axis, while tile buffer manages drawing of tiles which are about to display on such buffer.
Every game can be broken into some kind of states. For example:
- "menu" state may be responsible for displaying menu and navigating through it. When player is ready, "menu" should launch proper game and clean up so far used resources.
- "game" state should draw game's screen, process player's events, animate objects on screen, etc. After player wins or loses the game, it should free its resources and move state to menu.
This shows two basic concepts: lifecycle of state and transitions between them.
Each state's life could be split into three phases:
- creation during which needed bitmaps gets allocated, screen is set up, and all initializations take place, e.g. initial state of players, game AI, etc.
- loop which is responsible for processing player's key presses, moving objects on screen, processing game logic: loss of health, win conditions, physics, etc.
- destruction which cleans up resources after state - in practice it does the exact opposite of state's creation code.
States are managed with stack, so besides swapping current state with another state, states can be pushed and popped on stack. See "Pushing and popping states" chapter for more in-depth overview. For fine tunning push/pop transitions between states, ACE provides two additional phases:
- suspend when some child state is about to be pushed
- resume when some child state just popped
Both are usefull when you need run some smaller tweaks without touching creation or destruction of a state.
Suppose you want to implement the logic above. It can be illustrated as below:
OS --stateChange--> menu --stateChange--> game
^ | ^ |
|----stateChange-----| |----stateChange-----|
Function stateChange
does the following:
- calls current state's destruction function,
- sets new state as current one,
- calls new state's create function.
Current state's loop code is called every time game reaches stateProcess
function.
What if you'd like to implement in-game menu which pauses game? You can use state pushing and popping:
OS --stateChange--> menu --stateChange--> game --statePush--> pause
^ | ^ | ^ |
|----stateChange-----| |----stateChange-----| |----statePop-----|
This looks similar to changing state, but there's significant difference:
- when you call
statePush
, game state isn't destroyed. Instead, game's suspend function and then pause's create function are called. From now onstateProcess
will process pause's loop. - when you call
statePop
, pause's state is destroyed, and game's resume function is called, making game's loop the current one. After thatstateProcess
will process game loop.
Suppose your menu state gets bigger and bigger. Suppose you want to allocate common things for whole menu (font, background, display) and then process each part of menu separately. You can implement this as below:
|--POP----------- menuMapSelect
| | ^
| CHANGE| |CHANGE
v v |
OS --CREATE--> menuCommon --PUSH--> menuMain
| ^ | ^
CHANGE| |CHANGE CHANGE| |CHANGE
v | v |
game menuOptions
- menuCommon allocates common parts and at the end of its create function it pushes menuMain.
- In menuMain's create function you draw main menu's background and make its loop function responsible for processing input and navigation to other parts.
- When user wants to navigate to options, you change state to menuOptions, which in its create function draws its background and options list. Loop is reponsible for setting them and navigating back to menuMain.
- menuMapSelect is implemented in same way as menuOptions, with additional ability of popping into menuCommon when map is selected.
- lastly, menuCommon should in its loop examine why its loop got called and
decide what to do next. It can be done by e.g. examining some global variable.
If it was because menuMapSelect requested launching game, you change state
to game, otherwise you call
gameExit
which will effectively close the game.
Most of games have same boilerplate which consists of freezing OS, creating
copper & blitter manager etc. To make initial setup less rudimentary, @approxit
has created ace/generic/main.h file.
Instead of writing main()
function you just #include
this file and define:
genericCreate()
- for creation of additional managersgenericProcess()
- called in a loop until game gets closedgenericDestroy()
- for freeing previously created managers
If you prefer exiting your game in other ways than by calling gameExit
, you
can tune game main loop condition by defining GENERIC_MAIN_LOOP_CONDITION
before #include
. For example:
#define GENERIC_MAIN_LOOP_CONDITION g_pGameStateManager->pCurrent
#include <ace/generic/main.h>
This changes generic main loop's behavior to check if there is any state in selected state manager. The only caveat is to put these lines of code after manager definition.
When building ACE in debug mode, you're enabling 3 main things:
- General logging (
game.log
) - Memory usage logging (
memory.log
) - Sanity checks
When writing apps, it's very convenient to produce logs with debug messages.
Since Amiga is low-spec machine and writing to disk/floppy is resource-intensive,
decision has been made to only enabling debugging in debug mode. To use it
you can use logWrite()
fn, which accepts printf-like arguments.
Note that it will not append new line character for you, so be sure to add \n
code where appropriate. Simple logging is shown below:
logWrite("Hello, my favourite number is %d\n", 8);
// outputs in game.log: "Hello, my favourite number is 8"
Most of ACE functions are logging their actions, hence such kind of log
gets quickly large and illegible. To make it more readable, an indent system
was added, called log blocks. It consists of logBlockBegin()
and logBlockEnd()
functions. logBlockBegin()
also accepts printf-like parameters, while
logBlockEnd()
accepts single string.
void baz(UBYTE x) {
logBlockBegin("bar(x: %hhu)", x);
// Note that there is no logging inside here except block begin/end
// It will fold into one line in log
logBlockEnd("bar()");
}
void bar(UBYTE x) {
logBlockBegin("bar(x: %hhu)", x);
// This will be written with indent
logWrite("this is bar\n");
logBlockEnd("bar()");
}
void foo(UBYTE x) {
logBlockBegin("foo(x: %hhu)", x);
// And this will not be folded since it will contain other blocks
bar(x+1);
baz(x-1);
logBlockEnd("foo()");
}
// somewhere in the code:
foo(7);
Above code will produce following log:
Block begin: foo(x: 7)
This is foo
Block begin: bar(x: 8)
This is bar
Block end: bar(), time: 0.1 us
Block begin: baz(6)...OK, time: 0.1 us
Block end: foo(), time: 0.1 us
As you can see, this can also measure performance in limited manner. Bear in mind that execution time will be most valid for deepest blocks, since those containing any other ones will have added time which was spent on writing internal logBlock messages.
To speed things up, in release builds logWrite()
and logBlock
calls are changed to no-ops.
Each memory allocation and release will be logged into memory.log
file, as seen in example below:
Allocated FAST memory 0@0x2193ec, size 16 (/path/to/game/ACE/src/ace/managers/copper.c:136)
Allocated CHIP memory 1@0x1c614, size 336 (/path/to/game/ACE/src/ace/utils/bitmap.c:59)
freed memory 1@0x1c614, size 336 (/path/to/game/ACE/src/ace/utils/bitmap.c:258)
freed memory 0@0x2193ec, size 16 (/path/to/game/ACE/src/ace/managers/copper.c:207)
Those lines are telling you following things:
- is it allocation (CHIP or FAST mem) or release
- allocation's unique number, address (
idx@addr
) and size in bytes - where allocation/release has been made (
file:line
)
Also, there are some error messages which appear when:
- memory has been freed more than once
- free fn has been called with different size than alloc
- memory has been trashed as in writing to the left or right of allocated space (too big array loop?)
- memory leaks - some allocations has not been freed until the end of program
- and some more things added during ACE's development
It is a good practice to look at this log from time to time in search of lines
starting with ERR:
. Also, at the end of the log you will find summary of allocated
memory such as following one:
=============== MEMORY MANAGER DESTROY ==============
If something is deallocated past here, you're a wuss!
Peak usage: CHIP: 858404, FAST: 96053
If you forgot to free some allocations, you will see them listed beneath this summary. Since all those errors shouldn't take place in finished code, decision has been made to omit all those checks in Release builds. This makes code lighter and less cpu and memory hungry.
Some operations are very painful to debug, hence there are sanity checks scattered here and there in the code. An example of such check is one done in blit functions: they check whether source and destination coords, as well as blit size are fitting inside source and destination bitmaps and even if bitmap pointers are non-null.
Since sanity checks are computational-expensive, decision has been made to make
them only appear by default in Debug builds, since most of the time they shouldn't
be needed. In debug build blitCopy()
function calls blitSafeCopy()
with
sanity checks, while in Release build it uses blitUnsafeCopy()
to squeeze
as much performance as possible. This way, if your code depends on runtime checks,
you can use safe variants where appropriate.