When I wrote my sdlBasic programming guide (which you should read first, by the way), I had definite plans for a third game made with it. Even after those plans fell through, sdlBasic still remains my favorite game development tool nobody's heard of. And as it turns out, in time I gathered a nice little collection of routines that could be useful in all kinds of places. Warning, lots of code ahead — in my defense, it's all fully functional, tested demos, and while they can get long, it's all straightforward. I wouldn't have it any other way.
2.5D camera
2D games are all good and well, and sdlBasic has excellent support for them. But sometimes you want to add just a little depth, without going full 3D. While for a parallax background you can just wing it, when you have to track dozens or hundreds of objects moving at different depths, you pretty much need the real deal — actual perspective projection.
Mind you, my camera is pretty simplistic: it can't tilt or rotate, and you have to fudge the "focal" distance — actually more like the body depth of a pinhole camera. (Tip: keep it about equal to the height of your window, more or less. Here it should be less.) I still used it successfully in many games, across four different programming languages.
option explicit
const scrwidth = 1024
const scrheight = 768
setdisplay(scrwidth, scrheight, 32, 1)
dim common cam_x = 0, cam_y = 0, cam_z = -512
dim common cam_f = scrheight
dim common cam_px = 0, cam_py = 0, cam_scale = 0
sub cam_project(x, y, z)
dim x1 = x - cam_x
dim y1 = y - cam_y
dim z1 = z - cam_z
cam_scale = cam_f / z1
cam_px = x1 * cam_scale
cam_py = y1 * cam_scale
end sub
dim x, y, z
for z = 256 to 32 step -32
for y = -256 to 256 step 64
for x = -256 to 256 step 64
cam_project(x, y, z)
ink(rgb(256 - z, 256 - z, 256 - z))
fillcircle(cam_px + 512, cam_py + 384, cam_scale * 24)
next
next
next
waitkey
For the sdlBasic implementation, my first instinct was to do it the object-oriented way, storing all those variables in an associative array passed by reference to cam_project
, but that was very slow, and not worth the trouble when you have just one camera anyway, as you're likely to. So for this article I switched to a procedural style. No need to try and force things.
A few more tips:
- Don't try to project a point with the same Z coordinate as the camera.
- Do remember to invert Y (the vertical) after projecting. Here I could skip that step thanks to symmetry.
- Speaking of coordinates, the camera uses a left-hand system, with the Z axis going into the screen, and Y going up.
Game menus
This one involves a lot more code, but on the plus side it's much easier to explain. In fact, to some programmers it may seem obvious. But remember that one person's obvious is another's inscrutable. So here's how to set up a typical game menu, that you can navigate with up-down-enter.
const scrwidth = 800
const scrheight = 600
const centerx = 400
const centery = 300
dim common main_menu[0 to 7]
dim common active_item = 1
dim common game_title
sub prerender_text()
ink(rgb(255, 255, 255))
game_title = textrender("Game menu demo", 60)
main_menu[0] = textrender("> <", 30)
main_menu[1] = textrender("Start", 30)
main_menu[2] = textrender("Resume", 30)
main_menu[3] = textrender("Scores", 30)
main_menu[4] = textrender("Options", 30)
main_menu[5] = textrender("Help", 30)
main_menu[6] = textrender("Credits", 30)
main_menu[7] = textrender("Quit", 30)
dim i
for i = 0 to 7
hotspot(main_menu[i], 1, 1) ' Anchor to image center
next
hotspot(game_title, 1, 1)
end sub
sub paint_title_screen()
dim i
cls()
pastebob(centerx, scrheight \ 5, game_title)
for i = 1 to 7
pastebob(centerx, scrheight \ 3 + i * 30, main_menu[i])
next
pastebob(centerx, scrheight \ 3 + active_item * 30, main_menu[0])
end sub
sub loop_title_screen()
dim dirty = true
do
if dirty then
paint_title_screen
dirty = false
end if
if key(k_escape) then
exit do
elseif key(k_down) then
active_item += 1
if active_item > 7 then
active_item = 1
end if
dirty = true
wait(100)
elseif key(k_up) then
active_item -= 1
if active_item < 1 then
active_item = 7
end if
dirty = true
wait(100)
elseif key(k_return) or key(k_space) then
select case active_item
case 1
wait(100)
print("Start selected")
case 2
wait(100)
print("Resume selected")
case 7
exit do
case else
wait(100)
print("Some option selected")
end select
end if
waitvbl
loop
end sub
setdisplay(scrwidth, scrheight, 32, 1)
prerender_text
loop_title_screen
This is probably overengineered. I could easily get away with rendering the menu again on every frame, whether it's needed or not. Maybe even with rendering all the text again on the fly. I just don't like waste.
Note that the menu demo relies on sdlBasic loading a default font from the operating system. It no longer does that as of the 2016 build, so you may have to bring your own.
Turtle graphics in one function
I saved the longest and most complex code for the end. You see, sdlBasic is optimized for use with sprites; in fact, its drawing primitives are noticeably slow. But many of my games have vector graphics, for various reasons. And vector graphics have a bit of a problem: all but the simplest shapes take a fair bit of code to generate, code that makes your game bigger and buggier.
The solution would be a miniature drawing language embedded in your game, similar to the DRAW statement in QBasic and GWBasic, or the path syntax in SVG. Trouble is, I don't like either. Too big, with many codes to remember and not so easy to parse. So what to do?
Turtle graphics, that's what. Having coded a simple version already, I knew it didn't take much. The trick is implementing a tiny interpreter, for an equally tiny language. All in one function! Explanations after the demo.
option explicit
function turtle_draw(x, y, drawing$)
' Preserve the origin for the x and y commands.
dim tx = x
dim ty = y
dim heading = 0
dim is_pen_down = true
dim data_stack[1 to 10]
dim ret_stack[1 to 10]
dim d_sp = 0
dim r_sp = 0
dim pc = 1
dim buffer = ""
dim signum = 1
dim angle = 0
dim dx = 0
dim dy = 0
while pc <= len(drawing$)
if mid$(drawing$, pc, 1) = "-" then
signum = -1
pc += 1
if pc > len(drawing$) then
print "Number expected at position " + str$(pc)
return false
end if
else
signum = 1
end if
buffer = ""
dim tmp_ch = mid$(drawing$, pc, 1)
while tmp_ch >= "0" and tmp_ch <= "9"
buffer += tmp_ch
pc += 1
if pc > len(drawing$) then
return false
else
tmp_ch = mid$(drawing$, pc, 1)
end if
wend
if len(buffer) > 0 then
buffer = val(buffer) * signum
else
buffer = 0
end if
select case mid$(drawing$, pc, 1)
case "f"
angle = (heading - 90) * (3.141592 / 180)
dx = cos(angle) * buffer
dy = sin(angle) * buffer
if is_pen_down then
line(tx, ty, tx + dx, ty + dy)
end if
tx += dx
ty += dy
case "b"
angle = (heading - 90) * (3.141592 / 180)
dx = cos(angle) * buffer
dy = sin(angle) * buffer
if is_pen_down then
line(tx, ty, tx - dx, ty - dy)
end if
tx -= dx
ty -= dy
case "l"
heading = (heading - buffer) mod 360
case "r"
heading = (heading + buffer) mod 360
case "h"
heading = buffer mod 360
case "u"
is_pen_down = false
case "d"
is_pen_down = true
case "x"
' Move relatively to origin, not turtle.
if is_pen_down then
line(tx, ty, x + buffer, ty)
end if
tx = x + buffer
case "y"
' Move relatively to origin, not turtle.
if is_pen_down then
line(tx, ty, x, ty + buffer)
end if
tx = x + buffer
case "["
if d_sp >= 10 or r_sp >= 10 then
print "Stack overflow."
return false
end if
d_sp += 1
data_stack[d_sp] = buffer
r_sp += 1
ret_stack[r_sp] = pc
case "]"
if d_sp < 1 or d_sp < 1 then
print "Stack underflow."
return false
end if
if data_stack[d_sp] <= 1 then
d_sp -= 1
r_sp -= 1
else
data_stack[d_sp] -= 1
pc = ret_stack[r_sp]
end if
case else
print "Unknown drawing instruction: " +
mid$(drawing$, pc, 1)
return false
end select
pc += 1
wend
return true
end function
setdisplay(1024, 768, 32, 1)
turtle_draw(128, 32, "160r5[128f144r]")
turtle_draw(512, 384, "10[4[256f90r]36r]")
turtle_draw(1024-128, 768-160, "160r5[128f144r]")
waitkey
To figure out the code, first look at the bottom. Each drawing consists mainly of commands in the form "NNNc": an integer with optional sign followed by a letter. Square brackets mark loops, like the REPEAT statement in Logo; a number before the opening square bracket says how many times to go around. And because loops can have loops, like the second example, it needs a data stack and a return stack, to hold the loop counter and where in drawing$
each loop begins, respectively. The pc
variable tracks where we are in drawing$
at any one point — which character we'll read next.
Now for how it works: the first part of the while
, before the select
(lines 24-51), parses a signed integer. You could get rid of the whole mess by expecting just one digit there, if you don't mind tiny, blocky drawings. The real magic happens in the handling of "f" and "b" commands ("forward" and "back"). You might want to brush up on your trigonometry 101, but in essence they start from a point and an angle, draw a line of given length (held in buffer
), and wherever the line ends becomes the new starting point. Most of the complications arise from two sources:
- converting angles from degrees to radians for
sin
and cos
to use;
- rotating the whole thing 90 degrees counterclockwise, because
sin
and cos
think the default, "zero" angle points at three o'clock, while we want it at twelve o'clock.
The rest is mostly bookkeeping, apart from loop handling. And that's all of two steps:
- "[" writes down where the loop starts (right after itself) and how many times to loop;
- "]" first checks to see if the loop counter has wound down to zero, then it just moves on. Otherwise it deducts one and goes through the loop again.
Okay, this last one was maybe too much for a mere bag of tricks. But tell me it isn't cool! Note how you don't need to worry about color because you can set it with ink
before calling turtle_draw
. And while you can't set the line thickness, you can draw the same thing repeatedly, moving it by one pixel every time.
In conclusion, you don't need a fancy programming language to pull off some nice special effects. The humble sdlBasic can take you surprisingly far. So have fun with it.