Constructing the Maze
The usual way
When people do their first Pacman game in Director they tend
to implement that by building the walls of maze from different
sizes of square sprites. The player sprite is moved freely
and checked for collisions against these walls. This kind of
"free range" maze might first look like an easy solution but
the truth is (in my opinion) different.
First of all it's quite hard to align the wall so that player
is able to move only horizontally or vertically and
not both in a hallway. The result is usually awkward movement.
For example when a player presses up in a vertical hallway,
moves one or two pixels up, collides with a wall and stops.
This is not very playable. The enemy AI (artificial intelligence) would also be quite
a pain to do.
My advice would be to forget freerange mazes.
Tile based approach
In many arcade games the graphics are still built from tiles.
In fact in some systems (Nintendo Gameboy for example) everything
is built from tiles. This is the approach I'll be discussing here.
The 2d table
On my approach all the logic is done on a 2d table and its
coordinates. These coordinates are not to be confused with stage
coordinates. The table can be virtually any size. Only the
visible part is limited by the number of available sprite channels.
The basis of the playfield is visualized by constructing a grid
from a cast member called "emptytile". The tile corresponding to
location x=1, y=1 on 2d table is in sprite channel 1; x=2,
y=1 is in sprite channel 2 continuing to the last tile, x=10,
y=10 in sprite channel 100.
The downside for this approach is that it needs one channel
for each tile in the 2d table. The big advantage is the
possibility of easily creating a level editor. This brings
out many possibilities such as users creating their own
levels which they can save on your server or send to their
friends by email.
A primitive level editor is included in the example source.
You can try it by dragging some tiles to the grid. The
editor is dumb and allows you to build illegal combinations
of tiles side by side (someone might call this a feature,
since it enables oneway doors ;).
Gametable data is stored in global gtable. Global stores
width and height of the table and all the tiles for the
gametable row by row. After calling inittable() it looks
like this:
put gtable
-- [#w: 10, #h: 10, #data: [
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["0", "0", "0", "0", "0", "0", "0", "0", "0", "0"] ] ]
An individual tile in gametable data can be addressed with and
set with:
tile = gtable.data[y][x]
gtable.data[y][x] = tile
We obviously need to be able to convert table coordinates to
spritechannels:
channel = (y-1) * gtable.w + x
And vice versa. These are a bit kludgish since first sprite
channel is 1 and I used coordinates starting at (1,1) instead
of (0,0). Please see channel2table() in the source:
x = channel mod gtable.w
y = channel / gtable.w + 1
Every function dealing with the 2d gametable such as settable(),
table2channel() or drawtile() are based on these formulas.
Again, please see Game table in source.
The editor
The editor part is really simple. Tile builder and modified
Drag Snap (which came with Director) behaviours are attached
to the builder tiles. The tiles snap to all the basetiles
from which the grid is built.
When builder tile is dragged over and the mouse button is released
the behaviour checks which basetile it intersects (it intersects
only one because Drag Snap aligns it with one tile only) and
calculates basetiles gametable coordinates:
base = channel2table(chan)
Then it updates gametable data with the name of the dragged
tile and draws the tile to stage:
tile = pspriteref.member.name
settable(base.x, base.y, tile)
drawtile(base.x, base.y, tile)
Editor can be as simple as that. The rest of the code is just
cosmetics. Check Base tile and Tile builder behaviours for
more.
The tiles
In this example the maze is built from 32x32 pixel tiles. I
use this size because of an old habit. (Accessing tile sizes as
8x8, 16x16 or 32x32 from memory is fast in programming languages
like C or Assembler. I'm pretty sure it doesn't make the Lingo
code any faster though). There are 16 different tiles each having a
unique combination of number and direction of possible exits
from the tile. Tiles are named from letter a to letter p.
Next we construct a list which holds attributes for each
direction (up, down, left, right) on each tile: 1 (or TRUE) if
exit is possible and 0 (or FALSE) if the exit is blocked. These
values are held in global gmoves. I chose to use rect as datatype
because it just happened to have four entries. I of course have
used a property list of my own. After calling initblockmoves()
the list looks like this:
put gmoves
-- [ "a": rect(0, 1, 1, 1), "b": rect(1, 0, 1, 1),
"c": rect(1, 1, 0, 1), "d": rect(1, 1, 1, 0),
"e": rect(0, 0, 1, 1), "f": rect(1, 0, 0, 1),
"g": rect(1, 1, 0, 0), "h": rect(0, 1, 1, 0),
"i": rect(0, 0, 0, 1), "j": rect(1, 0, 0, 0),
"k": rect(0, 1, 0, 0), "l": rect(0, 0, 1, 0),
"m": rect(0, 1, 0, 1), "n": rect(1, 0, 1, 0),
"o": rect(1, 1, 1, 1), "p": rect(0, 0, 0, 0),
"x": rect(1, 1, 1, 1)]
These values are used everytime player or enemy sprite tries
to move away from a tile using canigo(). For example, to check
out if it's possible to go down from location x=8, y=6 we
would ask:
channel = table2channel(8,6)
possible = sprite(channel).canigo(#down)
The purpose of gmoves list, how and why it is used, is one of
the most important things to understand when figuring out
how this game works.
Movement in gametable
All the logic happens in the gametable. If you check the
Player and Enemy AI behaviours you can find the following
if statement:
if (pstatus = #there) then
...
end if
There are two possible pstatus values; #there and #moving.
If pstatus is #moving, it means the sprite is still animating
between two tiles. The animation is handled by a behaviour
called Animation.
Behaviours Player and Enemy AI take care of what is the next
tile where a sprite goes, nothing else. They work in
gametable coordinates. The Animation behaviour however
works in stage coordinates. It calculates what are the
stage coordinates in the current gametable location of the sprite
and what are the stage coordinates in the target location
where it is going. It then animates the sprite between these
stage coordinates. When the animation is done it gives the
control back to Player or Enemy AI behaviour to decide the
next target location again.
If you have trouble visualizing this, remove the Animation
behaviour from player and enemy sprites. Drop the frame
rate to 3 fps and attach No Animation behaviour to
both sprites again. Now you can see how the logic works
behind the scenes.
Navigating the Player in the Maze
Keypress is sent to player sprite in the usual way just
by calling keydown(). You can find examples of this almost
from every Director primer. To make the game more playable, the
player sprite stores the last key pressed in property plastkey.
Always when it is finished animating between tiles:
if (pstatus = #there) then
... the behaviour checks if its possible to turn to direction
of the last keypress:
channel = table2channel(pcurrent.x, pcurrent.y)
canturn = sprite(channel).canimove(plastkey)
... and updates all needed variables. If the turn was not
possible behaviour checks if it can continue to the old
direction:
possible = sprite(channel).canimove(pwannago)
And again, if it was possible to continue, it updates all
the needed variables. If a special case where neither
movement to plastkey nor pwannago was possible the sprite
just sits in the corner / deadend and waits for user input.
Check the Player behaviour for full sourcecode.
Enemy AI in maze
Crash 'n turn algorithm
Even though this algorithm is not very effective it is perfect
for this example because it is one of the easiest to implement
and it is adequate for pacman style games since they themselves
aren't too complicated (if comparing to real-time hex based
strategy games or alike).
In the most basic form this algorithm goes like this:
- Calculate straight line from current location to target
location and try to move to the next tile in that direction.
- If it collides with an obstacle turn right until free passage
is found and then move to the tile that direction.
- Repeat 1 - 2 until we have reached the target location or we
have collided with previous tile.
- If we collided with previous tile (the tile we came from)
change the direction of turning and go through same 1 - 2 loop
(this time just turning to another direction)
There are some concerns you should note in here. What happens if
enemy walks into dead end? Yup. The algorithm locks since it is
not allowed to go back to the tile it previously came from (there
is a reason for this. If it was allowed to return to previous
tile, there would be situations where enemy would start to run
aimlessly back and forth between two tiles). So what we need
to do is to modify step 3 a bit.
- Repeat 1 - 2 until we have reached the target location or we
have collided with previous tile and we have not collided with
previous tile before in this turn.
Note: Since the player sprite is not stationary we need
to calculate new direction (step 1) everytime we move to the
next tile.
The behaviour first finds the direction to the player using
finddirection() function. I made a shortcut here and didn't
implement any real linedrawing algorithm. Instead
it compares difference between x and y coordinates between
enemy and player (target) sprites:
xdiff = pcurrent.x - sprite(target).pcurrent.x
ydiff = pcurrent.y - sprite(target).pcurrent.y
Then, whichever is bigger, the function returns a direction
depending whether the difference is positive or negative.
For example if abs(ydiff) is bigger than abs(xdiff) and ydiff
is negative (player y is bigger than enemy y) the direction
to go is down.
The behaviour then tries to move where finddirection() told
it to. If that was not possible:
if ((not possible) or (previous)) then
...
end if
It jumps into the mainloop:
repeat while ((not possible) or (previous))
...
end repeat
The code then turns where the enemy is heading. It also updates
the needed variables and makes the possible / previous
checks again. If it collides with the previous tile and
it is the first time it collides with it, the behaviour
just changes the direction of turning. If it collides
with previous tile second time, the behaviour notices
that it is in a dead end and forces the enemy to
make a u-turn.
When the loop has exited and the new direction is found,
pstatus is updated again to #moving and animation behaviour
takes control.
Homework
As I said, this is not a finished game. However, it should be
enough to help anyone finish their own Pacman game. A few things
just need to be added: the eatable dots, powerups, more enemies
etc.
The level editor can be improved quite a lot. Autojunctioning
would be a nice feature. It also shouldn't allow impossible
combinations of tiles.
You should also make different kind of behaviour models
for the enemy AI to make the game more interesting. Here
are some possible ideas:
- Waypoint patroling. Enemies just patrol between some
predefined coordinates.
- Bloodhounds. Enemies just wonder aimlessly until they get a
'scent' (get close enough) to the player and then chase player
at double speed.
- Roadblockers. They calculate location of the next junction in
maze where player is heading and move there trying to 'block'
the road and thus effectively trapping the player.
- Any combination of the above (1+2 is quite effective)
Have fun! If you use this, drop me an e-mail.
|