Illumination


h1 7/16/2009 08:13:00 AM

For some reason, I've found myself implementing bits of a text-mode rogue-like game. In a nutshell, this is a turn-based kind of game where the player moves on a grid which usually depicts a dungeon or a cave. Some cells of that grid/map might contain a wall/rock, in which case the player cannot see or walk through them. All this is rather straightforward to implement, but the computation of what the player sees deserves some attention.

I started thinking of casting rays, but only found clumsy, costly solutions. So I did a little research. Some people (including crawl) actually developed algorithms along these lines for computing what is called a permissive field of view:
A destination square is visible from a source square if there is any unobstructed line from some point in the source square to some point in the destination square.

It's apparently a modern notion of field of view, that has one advantage over older techniques: it ensures symmetry, i.e., if I can see you, you can see me.

The same article on roguebasin points to several algorithms, including the one implemented in crawl. They all seemed unsatisfyingly complicated to me. I eventually came up with a different idea that looks good and ensures symmetry. It might have been considered already and discarded for some of its funny aspects, but those are interested to look at.

The basic idea is to forget about those lines. We are talking about a discrete universe, let's try to make its physics discrete too. Geometrical optics is only a convenient metaphor, that is justified by more elementary principles such as Fermat's:
The path taken between two points of a ray of light is the path that can be traversed in the least time.

This is in fact a definition of a ray of light that we can take literally in our discrete rogue world:
A cell is visible from another if one of the shortest paths between them is unobstructed.


Let's look at a simple example. For now, suppose that the movements are only allowed along the axis (no diagonals):
.# 
@#y
.x.

The player is represented by @, and # are walls/rocks. The cell marked y is not visible: it is at distance 2, there is only one path of length 2 that connects it to the player but it is obstructed. Cell x is also at distance 2, but it is visible since one of the two paths of length 2 that connects it to the player is not obstructed.

A funny thing happens when you consider the traditional rogue movements which include diagonals: cell y becomes visible! Indeed, the light can take a path of length 2 through x.

I would tend to adopt rules where the topology is the same for players and light. Either embrace the diagonal movements but accept the surprising effect on light, or remove them both for the light and the player. I chose the less experimental way for now. But it is also possible to use different topologies for the two entities.

To finish, I will give the last reason why I like this solution: the code is stupid simple. Assuming that the radius of the field of view will always be within a fixed bound, we can compute once and for all the map of shortest paths with their dependencies.
(** The maps will be used to associate
* the coordinates of the destination of a path
* to the lists of the coordinates of possible previous step
* in shortest paths leading to that destination. *)
module M = Map.Make (struct type t = int*int let compare = compare end)

let neighbors (x,y) =
[ x-1,y ;
x,y-1 ;
x+1,y ;
x,y+1 ]

let shortest_paths max =
(** Map of distances to the center of the matrix. *)
let dist = Array.make_matrix (2*max+1) (2*max+1) max_int in
(** At each step we compute the shortests paths to cells
* at distance [d=n+1] from those for distance [n]. *)
let step d paths_n =
M.fold
(fun (x,y) _ m ->
List.fold_left
(fun m (x',y') ->
if dist.(max+x').(max+y') < d then
(* Seen in a previous iteration. *)
m
else if dist.(max+x').(max+y') = d then
(* We already found a path of length n+1,
* but we need to keep track of this new one too. *)
M.add (x',y') ((x,y)::M.find (x',y') m) m
else begin
(* First time we see it: first shortest path. *)
dist.(max+x').(max+y') <- d ;
M.add (x',y') [x,y] m
end)
m
(neighbors (x,y)))
paths_n
M.empty
in
let rec aux d frontiers =
if d=max then frontiers else
aux (d+1) (step d (List.hd frontiers) :: frontiers)
in
List.rev (aux 1 [M.add (0,0) [] M.empty])


Then, it only remains to parse dependencies:
(** Takes a radius (d) and returns a field of view function
* for that radius. *)
let fov d =
let frontiers = shortest_paths d in
(* The field of view function,
* which takes a function and calls it on each visible cell.
* The function [f] returns whether a cell is transparent or not. *)
fun f ->
let transparent = Array.make_matrix (2*d+1) (2*d+1) false in
transparent.(d).(d) <- true ;
List.iter
(fun frontier ->
M.iter
(fun (x',y') deps ->
if
List.exists (fun (x,y) -> transparent.(d+x).(d+y)) deps
then
transparent.(d+x').(d+y') <- f x' y')
frontier)
(List.tl frontiers)


Hurray to (impure) functional programming! I leave it to you to glue this to whatever testing code you want. I'll finish with "screenshots".

Without diagonal moves, notice that it is possible to see through a wall on a diagonal:
              .
. ..
.. ##...
#.. ###...# .
#..#......##.#
#.....@......#
#...##........
...#.........
###........
#.......
..####.
.


With diagonal moves, we now have shadows along the diagonals, but we can see around walls placed along the axis:
        ..#..#...
.......
.######.....
...........
.##.......
#.......#
#.......#### #
#..........## ..
.##..##.@....#...
#... ##...##..##
### .......###
...........
............
.............
###...........
............
.##.####.....

Libellés : , ,

Création


h1 6/15/2007 10:01:00 AM

Je ne code plus trop ces temps-ci, après la release de liquidsoap-0.3.3. Déja, je suis pas mal occupé au labo, et ça porte ses fruits. Mais de toute façon, il faut bien qu'il y ait des périodes moins frénétiques: c'est un projet sans deadline ni client. Aucune raison de se faire du mal pour apporter une amélioration montre en main. Me voilà donc dans une période tranquille, où je me demande plutôt ce que je pourrais faire de créatif à la place.

Mes pensées vont par exemple à ce projet de projet de jeu de piste (ou ARG?) radiophonique, parti de la numbers station de Balbinus... Disséminer des pistes, principalement par le biais de quelques webradios amies, vers les éléments d'une histoire/énigme/univers caché et mystérieux. Mais quelle histoire raconter, quelle idée faire passer? Parler de l'excès de communication, de l'usure des mots? des excès de l'individualisme?

En parlant d'alliance création/technique, je suis tombé sur la ménagerie, un studio d'animation amateur sympathique, qui lie developpement open-source et création d'animations en stopmotion (jetez un oeil à la section studio, par exemple cette surprise..) Chapeau bas!

De façon moins intellectuelle, je me demande si j'accrocherais à des jeux à histoire comme un Final Fantasy ou Castlevania sur DS; et si le EEE PC sortira vraiment en Aout, au prix annoncé et en quantité suffisamment énorme :)

Libellés : , , , , ,

En attendant...


h1 2/12/2007 07:37:00 PM

Je n'ai toujours pas reçu ma carte Arduino. Je m'en étonne ce matin et on me répond (comme par hasard) qu'elle partira aujourd'hui, problèmes avec un fournisseur, etc. On verra...

En attendant, le jeu Another World a fêté ses quinze ans. Pour l'occasion, l'éditeur nous "offre" une édition spéciale, avec des décors réhaussés (nombre de couleurs augmenté, détails ajoutés) et (enfin) les graphismes vectoriels rendus en haute résolution. Je dis "offre" parce que c'est payant, uniquement disponible pour Windows. Ya certainement plein de gens qu'aimeraient voir la recette de ce mythe. À la place l'éditeur préfère se faire quelques recettes supplémentaires, qui seront probablement ridicules, d'autant plus que la nouvelle édition est déja craquée. Pour ma part j'étais chaud pour payer, mais j'ai voulu tester d'abord si la démo tournait sous Wine. Le non-émulateur fait tourner Warcraft III et WoW, mais.. pas Another World. Ils ont en effet trouvé le moyen d'utiliser les dernières fonctionnalités de DirectX, pour un jeu qui tourne sans problème sur NES ou GBA...

Au final, j'ai ressorti ma ROM GBA, installé VisualBoyAdvance, et re-fini le jeu. C'est assez court quand on se souvient des ficelles -- et (j'avoue) qu'on zieute la soluce une ou deux fois pour ce dont on ne se souvient pas. Mais ce n'est pas pour ça qu'Another World doit être le seul jeu que j'ai fini plusieurs fois. C'est parceque c'est beau (même tout pixelisé) et c'est bien (même si l'histoire est linéaire) et c'est de la balle.

Allez, un petit site marrant au passage: philosophie.ppt.

Libellés : ,