1 module snake.app; 2 3 import pixelperfectengine.graphics.outputscreen; 4 import pixelperfectengine.graphics.raster; 5 import pixelperfectengine.graphics.layers; 6 7 import pixelperfectengine.graphics.bitmap; 8 9 import pixelperfectengine.system.input; 10 import pixelperfectengine.system.input.scancode; 11 import pixelperfectengine.system.file; 12 import pixelperfectengine.system.etc; 13 import pixelperfectengine.system.config; 14 import pixelperfectengine.system.timer; 15 import pixelperfectengine.system.rng; 16 17 import pixelperfectengine.system.common; 18 /** 19 * The main entry point. Contains essential calls to initializing and running the program. 20 */ 21 int main() { 22 //SDL initialization call. Once the library `iota` is mature enough for input/output, 23 //it'll be removed. 24 initialzeSDL(); 25 //Initialize our game. 26 SnakeGame game = new SnakeGame(); 27 //Run the game. 28 game.whereTheMagicHappens(); 29 return 0; 30 } 31 /** 32 * A simple snake game. 33 * Uses code-generated graphics to avoid relying on external assets. 34 * 35 * Game rules: 36 * * The player controls a snake. 37 * * The player must collect apples to get score, which also makes the snake grow. 38 * * Every time an apple is collected, a new one is randomly spawned on the map at a point outside of the snake. 39 * * If the player either hits the snake or wall, they lose. 40 * * If the player somehow makes the snake to grow so big a new apple cannot be spawned, then the player wins. 41 * 42 * Design guidelines: 43 * * No much external assets. (except for engine essentials). 44 * * Snake tail follows past directions 45 * 46 * Some possible assignments to practice using the engine: 47 * * Use some external assets. 48 * * Add some obstacles. 49 * * Add a second, third, etc. player. 50 */ 51 public class SnakeGame : InputListener, SystemEventListener { 52 /** 53 * Used for direction tracing in the API. 54 */ 55 enum Direction : ubyte { 56 init = 0, 57 North = 1, 58 South = 2, 59 East = 4, 60 West = 8, 61 } 62 /** 63 * Defines tile type codes. 64 * 65 * Can also be used to track the snake itself. 66 */ 67 enum TileTypes : wchar { 68 init, 69 Empty = 0x00, 70 Apple = 0x01, 71 SnakeH = 0x11, 72 SnakeV = 0x12, 73 SnakeNE = 0x13, 74 SnakeSE = 0x14, 75 SnakeNW = 0x15, 76 SnakeSW = 0x16, 77 } 78 ///The position of the head of the snake. 79 ///We can track the rest of the snake by tracking the directions. 80 Point snakeHead; 81 ///Counts how much score the player has. 82 int score; 83 ///Program and game state. 84 ///4: program exit 85 ///5: game start 86 ///6: game over 87 ///7: game won 88 ubyte state; 89 ///Contains the current direction. 90 ubyte dir; 91 ///Contains the previous direction. 92 ubyte prevDir; 93 ///The output screen, where the output is being displayed. 94 OutputScreen output; 95 ///Handles all the inputs. On every keypress/buttonpress, a new event is created, which changes the internal state 96 ///of the program. 97 InputHandler ih; 98 ///The raster handles frame buffer and layer priority management. Even though we currently only have a single 99 ///tilelayer, we will still needing it. 100 Raster raster; 101 ///The TileLayer, that: 102 /// * Displays the current game state 103 /// * Stores playfield data (snaketail, apple's position, etc) 104 TileLayer playfield; 105 ///These are the tiles, we're going to use in our game 106 Bitmap8Bit empty, apple, snakeH, snakeV, snakeNE, snakeSE, snakeNW, snakeSW; 107 ///A random number generator 108 RandomNumberGenerator rng; 109 ///Constructor to initialize things. 110 ///It is important to keep initialization and game logic separate. 111 public this() { 112 //Create all the tiles needed for the game. 113 114 //The easiest is the empty tile, all we need is to create an empty bitmap (all indexes are 0). 115 empty = new Bitmap8Bit(8,8); 116 //The apple uses all red color. Initialize an empty bitmap, then set all the pixels to index 2. 117 apple = new Bitmap8Bit(8,8); 118 for (int y ; y < 8 ; y++) { //Use some iterations to easily set all indexes. Top-left is the origin point, and indexing starts at 0. 119 for (int x ; x < 8 ; x++) { 120 //NOTE: it's faster to write to the bitmap by directly accessing its data, but for this purpose, it's 121 //more than enough. 122 apple.writePixel(x, y, 0x2); 123 } 124 } 125 //The snakeH is a horizontal piece, and our snake is 4 pixels thick. Let's draw it with similar method! 126 snakeH = new Bitmap8Bit(8,8); 127 for (int y = 2 ; y < 6 ; y++) { 128 for (int x ; x < 8 ; x++) { 129 snakeH.writePixel(x, y, 0x1); 130 } 131 } 132 //The snakeV is the straight vertical piece. 133 snakeV = new Bitmap8Bit(8,8); 134 for (int y ; y < 8 ; y++) { 135 for (int x = 2 ; x < 6 ; x++) { 136 snakeV.writePixel(x, y, 0x1); 137 } 138 } 139 //The corner pieces are more complicated, but not too hard. 140 //This piece is the north-east piece, it connects the top (north) with the right (east). The rest of the corner 141 //tiles use the same system, so I won't annotate their creation. 142 snakeNE = new Bitmap8Bit(8,8); 143 for (int y ; y < 2 ; y++) { 144 for (int x = 2 ; x < 6 ; x++) { 145 snakeNE.writePixel(x, y, 0x1); 146 } 147 } 148 for (int y = 2 ; y < 6 ; y++) { 149 for (int x = 2 ; x < 8 ; x++) { 150 snakeNE.writePixel(x, y, 0x1); 151 } 152 } 153 snakeSE = new Bitmap8Bit(8,8); 154 for (int y = 6 ; y < 8 ; y++) { 155 for (int x = 2 ; x < 6 ; x++) { 156 snakeSE.writePixel(x, y, 0x1); 157 } 158 } 159 for (int y = 2 ; y < 6 ; y++) { 160 for (int x = 2 ; x < 8 ; x++) { 161 snakeSE.writePixel(x, y, 0x1); 162 } 163 } 164 snakeNW = new Bitmap8Bit(8,8); 165 for (int y ; y < 2 ; y++) { 166 for (int x = 2 ; x < 6 ; x++) { 167 snakeNW.writePixel(x, y, 0x1); 168 } 169 } 170 for (int y = 2 ; y < 6 ; y++) { 171 for (int x ; x < 6 ; x++) { 172 snakeNW.writePixel(x, y, 0x1); 173 } 174 } 175 snakeSW = new Bitmap8Bit(8,8); 176 for (int y = 6 ; y < 8 ; y++) { 177 for (int x = 2 ; x < 6 ; x++) { 178 snakeSW.writePixel(x, y, 0x1); 179 } 180 } 181 for (int y = 2 ; y < 6 ; y++) { 182 for (int x ; x < 6 ; x++) { 183 snakeSW.writePixel(x, y, 0x1); 184 } 185 } 186 187 //We now have all the necessary graphics elements, let's create the rest! 188 //First, we need an output screen. It's 4:3, which isn't very modern, but will be useful for demo purposes. 189 output = new OutputScreen("Snek game", 320 * 4, 240 * 4); 190 //Next, we have to create the raster, with 320x240 resolution, and 256 colors. Technically we will only use 3 191 //colors, however one should overprovision the palette length to the multiple of the maximum color of the used 192 //indexed bitmap. (16 bit is mainly there to directly access all colors of the palette) 193 raster = new Raster(320, 240, output, 256); 194 //Set up the color palette 195 raster.setPaletteIndex(0, Color(0,0,0,255)); 196 raster.setPaletteIndex(1, Color(255,255,255,255)); 197 raster.setPaletteIndex(2, Color(255,0,0,255)); 198 //Let's create our playfield, where the action will happen. 199 //The `Copy` rendering mode does not support transparency or anything fancy like that, but is very fast and 200 //harder to make things go bad. 201 playfield = new TileLayer(8, 8, RenderingMode.Copy); 202 //Add the tile layer to the raster. 203 raster.addLayer(playfield, 0); 204 //Load an empty mapping into our playfield, which we will be modifying later through the `readMapping` and 205 //`writeMapping` functions. 206 { 207 MappingElement[] emptyMap; 208 emptyMap.length = 40 * 30; 209 playfield.loadMapping(40, 30, emptyMap); 210 } 211 //Add the tiles to the playfield 212 playfield.addTile(empty, TileTypes.Empty); 213 playfield.addTile(apple, TileTypes.Apple); 214 playfield.addTile(snakeH, TileTypes.SnakeH); 215 playfield.addTile(snakeV, TileTypes.SnakeV); 216 playfield.addTile(snakeNE, TileTypes.SnakeNE); 217 playfield.addTile(snakeSE, TileTypes.SnakeSE); 218 playfield.addTile(snakeNW, TileTypes.SnakeNW); 219 playfield.addTile(snakeSW, TileTypes.SnakeSW); 220 //Initialize input handler 221 ih = new InputHandler(); 222 ih.systemEventListener = this; 223 ih.inputListener = this; 224 //Register key bindings. 225 ih.addBinding(BindingCode(ScanCode.UP, 0, Devicetype.Keyboard, 0, KeyModifier.All), InputBinding("up")); 226 ih.addBinding(BindingCode(ScanCode.DOWN, 0, Devicetype.Keyboard, 0, KeyModifier.All), InputBinding("down")); 227 ih.addBinding(BindingCode(ScanCode.LEFT, 0, Devicetype.Keyboard, 0, KeyModifier.All), InputBinding("left")); 228 ih.addBinding(BindingCode(ScanCode.RIGHT, 0, Devicetype.Keyboard, 0, KeyModifier.All), InputBinding("right")); 229 ih.addBinding(BindingCode(ScanCode.ENTER, 0, Devicetype.Keyboard, 0, KeyModifier.All), InputBinding("start")); 230 ih.addBinding(BindingCode(ScanCode.ESCAPE, 0, Devicetype.Keyboard, 0, KeyModifier.All), InputBinding("quit")); 231 //Register an initial timer event 232 timer.register(&timerEvent, msecs(200)); 233 } 234 /// Main loop cycle, where all the good things happen. 235 public void whereTheMagicHappens() { 236 while (state != 4) { 237 if (state == 5) 238 timer.test(); 239 ih.test(); 240 raster.refresh(); 241 rng.seed(); 242 } 243 } 244 /// Places the next apple to a random empty location. 245 /// If it can no longer put any new apples anywhere, then the game have been won. 246 public void placeNextApple() { 247 const int x = cast(int)(rng() % 40), y = cast(int)(rng() & 30); 248 for (int yi ; yi < 30 ; yi++) { 249 for (int xi ; xi < 40 ; xi++) { 250 if (playfield.readMapping((xi + x) % 40, (yi + y) % 30).tileID == TileTypes.Empty) { 251 playfield.writeMapping((xi + x) % 40, (yi + y) % 30, MappingElement(TileTypes.Apple)); 252 return; 253 } 254 } 255 } 256 //If didn't escaped from the loop, then it means we can place no more apples, and the player have won. 257 state = 7; 258 } 259 ///Moves the snake in the given direction. 260 public void moveSnake() { 261 prevDir = dir; 262 Point curr, prev = snakeHead; 263 bool appleFound; 264 switch (dir) { 265 default: 266 return; 267 case Direction.North: 268 snakeHead.relMove(0, -1); 269 break; 270 case Direction.South: 271 snakeHead.relMove(0, 1); 272 break; 273 case Direction.East: 274 snakeHead.relMove(1, 0); 275 break; 276 case Direction.West: 277 snakeHead.relMove(-1, 0); 278 break; 279 } 280 curr = snakeHead; 281 // Check for collision 282 MappingElement me = playfield.readMapping(curr.x, curr.y); 283 if (me.tileID == TileTypes.Apple) { //Player has collected an apple, let's reward them! 284 score++; 285 placeNextApple(); 286 appleFound = true; 287 } else if (me.tileID != TileTypes.Empty || !Box(0, 0, 39, 29).isBetween(curr)) { //Player has died, stop game. 288 raster.setPaletteIndex(0, Color(255,0,0,255)); 289 state = 6; 290 return; 291 } 292 // Draw the head to the new place 293 if (dir == Direction.East || dir == Direction.West) 294 playfield.writeMapping(curr.x, curr.y, MappingElement(TileTypes.SnakeH)); 295 else 296 playfield.writeMapping(curr.x, curr.y, MappingElement(TileTypes.SnakeV)); 297 // Move the snake. 298 // This iteration finds the endpiece of the snake, by using the shape identifier codes. 299 for (int i ; i <= score ; i++) { 300 //MappingElement currT = playfield.readMapping(curr.x, curr.y); 301 MappingElement prevT = playfield.readMapping(prev.x, prev.y); 302 switch (prevT.tileID) { 303 case TileTypes.SnakeH: 304 const int cX = curr.x, pX = prev.x; 305 curr.x = prev.x; 306 if (pX < cX) 307 prev.x -= 1; 308 else 309 prev.x += 1; 310 break; 311 case TileTypes.SnakeV: 312 const int cY = curr.y, pY = prev.y; 313 curr.y = prev.y; 314 if (pY < cY) 315 prev.y -= 1; 316 else 317 prev.y += 1; 318 break; 319 case TileTypes.SnakeNE: 320 const int cX = curr.x, pX = prev.x; 321 curr = prev; 322 if (cX == pX) 323 prev.x += 1; 324 else 325 prev.y -= 1; 326 break; 327 case TileTypes.SnakeSE: 328 const int cX = curr.x, pX = prev.x; 329 curr = prev; 330 if (cX == pX) 331 prev.x += 1; 332 else 333 prev.y += 1; 334 break; 335 case TileTypes.SnakeNW: 336 const int cX = curr.x, pX = prev.x; 337 curr = prev; 338 if (cX == pX) 339 prev.x -= 1; 340 else 341 prev.y -= 1; 342 break; 343 case TileTypes.SnakeSW: 344 const int cX = curr.x, pX = prev.x; 345 curr = prev; 346 if (cX == pX) 347 prev.x -= 1; 348 else 349 prev.y += 1; 350 break; 351 default: 352 break; //Not nice I know, but there's no better way currently to break from multiple levels 353 } 354 } 355 356 // Remove end if growth doesn't happen. 357 if (!appleFound) 358 playfield.writeMapping(curr.x, curr.y, MappingElement(TileTypes.Empty)); 359 } 360 /// Since tilemaps get initialized with 0xFFFF (none) tiles, we need to set it to 0x0000 361 public void clearTilemap() { 362 for (int y ; y < 30 ; y++) { 363 for (int x ; x < 40 ; x++) { 364 playfield.writeMapping(x,y, MappingElement(TileTypes.Empty)); 365 } 366 } 367 } 368 /// A timer event, to make the game run stable regardless of the framerate. 369 public void timerEvent(Duration jitter) { 370 //Reregister timer event 371 timer.register(&timerEvent, msecs(200)); 372 //Move snake if needed 373 if (dir) { 374 moveSnake(); 375 } 376 } 377 /// Key event data is received here. 378 public void keyEvent(uint id, BindingCode code, uint timestamp, bool isPressed) { 379 switch (id) { 380 case hashCalc("up"): 381 if (prevDir != Direction.South) { 382 dir = Direction.North; 383 } 384 switch (prevDir) { 385 case Direction.East: 386 playfield.writeMapping(snakeHead.x, snakeHead.y, MappingElement(TileTypes.SnakeNW)); 387 break; 388 case Direction.West: 389 playfield.writeMapping(snakeHead.x, snakeHead.y, MappingElement(TileTypes.SnakeNE)); 390 break; 391 default: break; 392 } 393 break; 394 case hashCalc("down"): 395 if (prevDir != Direction.North) { 396 dir = Direction.South; 397 } 398 switch (prevDir) { 399 case Direction.East: 400 playfield.writeMapping(snakeHead.x, snakeHead.y, MappingElement(TileTypes.SnakeSW)); 401 break; 402 case Direction.West: 403 playfield.writeMapping(snakeHead.x, snakeHead.y, MappingElement(TileTypes.SnakeSE)); 404 break; 405 default: break; 406 } 407 break; 408 case hashCalc("left"): 409 if (prevDir != Direction.East) { 410 dir = Direction.West; 411 } 412 switch (prevDir) { 413 case Direction.North: 414 playfield.writeMapping(snakeHead.x, snakeHead.y, MappingElement(TileTypes.SnakeSW)); 415 break; 416 case Direction.South: 417 playfield.writeMapping(snakeHead.x, snakeHead.y, MappingElement(TileTypes.SnakeNW)); 418 break; 419 default: break; 420 } 421 break; 422 case hashCalc("right"): 423 if (prevDir != Direction.West) { 424 dir = Direction.East; 425 } 426 switch (prevDir) { 427 case Direction.North: 428 playfield.writeMapping(snakeHead.x, snakeHead.y, MappingElement(TileTypes.SnakeSE)); 429 break; 430 case Direction.South: 431 playfield.writeMapping(snakeHead.x, snakeHead.y, MappingElement(TileTypes.SnakeNE)); 432 break; 433 default: break; 434 } 435 break; 436 case hashCalc("start"): 437 raster.setPaletteIndex(0, Color(0,0,0,255)); 438 clearTilemap(); 439 state = 5; 440 snakeHead = Point(20, 15); 441 score = 0; 442 dir = 0; 443 prevDir = 0; 444 placeNextApple(); 445 playfield.writeMapping(snakeHead.x, snakeHead.y, MappingElement(TileTypes.SnakeH)); 446 break; 447 case hashCalc("quit"): 448 state = 4; 449 break; 450 default: break; 451 } 452 } 453 454 public void axisEvent(uint id, BindingCode code, uint timestamp, float value) { 455 456 } 457 /// Makes it possible to close the game the `proper` way. 458 public void onQuit() { 459 state = 4; 460 } 461 462 public void controllerAdded(uint id) { 463 464 } 465 466 public void controllerRemoved(uint id) { 467 468 } 469 }