Bogue is a GUI library for the ocaml language, not a game engine... But, well, why not?
In his blog, Florent Monnier nicely demonstrates how to write a simple Snake game in ocaml syntax and compile it to javascript with rescript, see http://decapode314.free.fr/re/tut/ocaml-re-tut.html
Reading this, I wondered: well, Bogue can draw graphics, it has an event loop, so that should work!
It turned out to be completely straightforward to adapt the rescript code to Bogue, using the Sdl_area widget everywhere Javascript graphics were required.
Here is an analysis of the differences between the original snake.ml
code from
http://decapode314.free.fr/re/tut/ocaml-re-tut.html and our new https://github.com/sanette/snake-bogue/blob/master/src/snake1.ml code.
let canvas = Canvas.getElementById Canvas.document "my_canvas"
let ctx = Canvas.getContext canvas "2d"
let widget = W.sdl_area ~w:width ~h:height ()
let area = W.get_sdl_area widget
Here (above code) we create a Widget contaning an Sdl_area, and extract the Sdl_area object itself for further direct access.
let fill_rect color (x, y) =
Canvas.fillStyle ctx color;
Canvas.fillRect ctx x y 20 20
let fill_rect color (x, y) =
let open Sdl_area in
let x, y = to_pixels (x, y) in
let w, h = to_pixels (20, 20) in
fill_rectangle area ~color ~w ~h (x, y)
Bogue mainly works with "logical pixels" coordinates, which are then
scaled taking into account the user's screen size (similar to
javascript/CSS in a browser). However, the Sdl_area widget is special:
it was purposedly designed to access real hardware pixels. This is why
we have to use here the to_pixels
utility.
let display_game state =
let bg_color, snake_color, fruit_color =
if state.game_over
then (red, black, green)
else (black, blue, red)
in
(* background *)
Canvas.fillStyle ctx bg_color;
Canvas.fillRect ctx 0 0 width height;
fill_rect fruit_color state.pos_fruit;
List.iter (fill_rect snake_color) state.seg_snake
Lines 47 to 60 in edfeb0c
It is now easy to understand the changes we made to this function, except maybe for this tiny bit:
Sdl_area.update area
Indeed, for performance reasons, Bogue only updates widgets that are visually modified. In the case of Sdl_areas, one has to explicitely indicate whether the content should be refreshed onscreen or not.
We kept exactly the same code! (except for a small bug correction for
detecting game_over
).
Bogue general strategy for treating events is to connect two widgets (source and target, see here and there): each connection listens to a specific type of event, and executes a specific action. In this case, we have only one widget (the Sdl_area), but that's not a problem: we can connect it to itself!
let keychange_event ev =
req_dir :=
match ev.Canvas.keyCode with
| 37 -> `left
| 38 -> `up
| 39 -> `right
| 40 -> `down
| _ -> (!state.dir_snake)
let keychange_action _area _none ev =
req_dir :=
match E.(get ev keyboard_keycode) with
| x when x = Sdl.K.left -> `left
| x when x = Sdl.K.up -> `up
| x when x = Sdl.K.right -> `right
| x when x = Sdl.K.down -> `down
| _ -> (!state.dir_snake)
A general Bogue action takes 3 arguments: source widget, target widget,
and event. Here the source and the target are the same (the Sdl_area),
but we don't even use them in the code of this function, that's why we
used dummy names _area
and _none
.
This game runs at a constant rate (here 7 frames per seconds). So we need a way to regularly update the game state, and graphics, while listening to keystrokes.
let animate () =
state := update_state !req_dir !state;
display_game !state;
()
in
Canvas.addKeyEventListener Canvas.window "keydown" keychange_event true;
let _ = Canvas.setInterval animate (1000/7) in
()
which requires:
type key_event = {
keyCode: int,
key: string,
}
@send external addKeyEventListener: (Dom.element, string, key_event => unit, bool) => unit = "addEventListener"
type intervalID
@val external setInterval: (unit => unit, int) => intervalID = "setInterval"
let rec animate () =
state := update_state !req_dir !state;
display_game !state;
Update.push widget;
Timeout.add (1000/7) animate |> ignore
in
let c = W.connect_main widget widget keychange_action E.[key_down] in
let layout = L.resident widget in
let board = Bogue.of_layout ~connections:[c] layout in
animate ();
Bogue.run board
Bogue has several ways to execute actions regularly. Here we use the Timeout mechanism in a recursive way.
You can then see how we register the keychange_action
to be executed
when the key_down
event is caught.
Finally we define the complete layout
of our program (which consists
here of the sole Sdl_area widget
), create a Bogue board
with this,
and run
!
In the first version, all graphics are delegated to the Sdl_area: this is very simple to understand, but somehow ignores interesting graphical features provided by other Bogue widgets.
In this new version, we abandon the Sdl_area and build the snake as a list of Box widgets. Although this might seem more involved, it is certainly closer to Bogue spirit. This enables interesting features like animating each snake segment using Bogue internal animation mechanism. (This will be developped in another project).
Here is the code for this second version:
https://github.com/sanette/snake-bogue/blob/master/src/snake2.ml
The final layout is a superposition of 3 layouts:
let area = L.superpose [screen; snake; fruit] in
The screen
contains a unique widget (this is called a resident
in
Bogue terminology) which is a Box. It is used for displaying the
background and listening to keystrokes.
The snake
layout, like screen
, has the size of the whole area. It
is initially empty and is filled at each game iteration by the list of
all snake segments (or "cells"). Each cell itself is a Layout
containing a Box widget. In further versions, it will be easy to
replace this box by an image widget.
The fruit
layout just contains a small box widget, whose position is
updated when necessary.
With the rescript version, you need to write a piece of HTML to host the javascript and run in a browser.
With Bogue, you compile the code once, as a usual ocaml program, which gives you a desktop application that you can run later. You may also compile-and-run in a single stroke with:
dune exec ./snake1.exe
Note that, in the Bogue version, you first need to click inside the window to give it the focus, so that keystrokes are properly detected.
If you see a small gap between the snake segments (boxes) it's because
Bogue detected a non-integer scaling for your screen DPI. You can
force integer scaling by setting the variable INT_SCALE
in your
bogue.conf
file, or directly on the command line like this:
BOGUE_INT_SCALE=true dune exec ./snake1.exe
Yes, of course! It's there: https://github.com/sanette/snoke