|
|
# Creating a basic game with Cobblestone
|
|
|
|
|
|
This tutorial will explain the process of creating a basic snake game from start to finish.
|
|
|
|
|
|
Basic knowledge of [Dart syntax](https://dart.dev/guides/language/language-tour)
|
|
|
and the [webdev](https://dart.dev/tools/webdev) command are assumed.
|
|
|
|
|
|
Complete source code is available at the end of the article.
|
|
|
|
|
|
## Step 1: Project Setup
|
|
|
|
|
|
Cobblestone projects can easily be based off of the [Stagehand](https://pub.dev/packages/stagehand) bare-bones
|
|
|
web app template, or any project with Dart on a webpage.
|
|
|
|
|
|
Add a canvas to your HTML file, and use some CSS to center it on the page. The following snippet will do that for you,
|
|
|
if you don't want to mess with CSS (this is the last of it).
|
|
|
|
|
|
```css
|
|
|
canvas {
|
|
|
position: absolute;
|
|
|
left: 0; right: 0;
|
|
|
top: 0; bottom: 0;
|
|
|
margin: auto;
|
|
|
max-width: 100%;
|
|
|
max-height: 100%;
|
|
|
overflow: auto;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Add cobblestone to your dependencies. Import it in your main Dart file.
|
|
|
|
|
|
Create a new class that extends ```BaseGame```. Override the abstract methods ```preload```, ```create```,
|
|
|
```update(double delta)```, and ```render(double delta)```. Create a new instance of that class in your main method.
|
|
|
|
|
|
Add the line ```gl.clearScreen(Colors.paleGoldenrod);``` in the render method.
|
|
|
This will fill the canvas with the given color.
|
|
|
|
|
|
Serving this page should yield a sandy yellow rectangle with black bars surrounding it on the side. See the article
|
|
|
[Application Lifecycle](Application-Lifecylce) for details on the flow between all the ```BaseGame``` methods.
|
|
|
For now, just know that ```preload``` is called to start loading assets, ```create``` is called after assets are loaded,
|
|
|
```update``` is called each frame for game logic, and ```render``` is called each frame for rendering.
|
|
|
|
|
|
## Step 2: Asset Loading & World Setup
|
|
|
|
|
|
Rendering your will require assets. Simple sprites like these can be made fairly quickly:
|
|
|
|
|
|
![head](uploads/1c4dce126ba57b5791da12e6b28b70f0/head.png)
|
|
|
![body](uploads/0f2ff404437df598305ece8f18e85a3f/body.png)
|
|
|
![fruit](uploads/bffa966c8ca85a30cd22bd6ecf7bba41/fruit.png)
|
|
|
|
|
|
Textures can be loaded with the line ```assetManager.load("<alias>", loadTexture(gl, "img/<filename>"));```
|
|
|
in the ```preload``` function. They can be retrieved again in ```create``` by calling ```assetManager.get("<alias>")```.
|
|
|
Assign your wall, body, head, and fruit textures to variables on your game class.
|
|
|
|
|
|
Create a ```List``` of ```Vector2```s called ```snake``` in your game class.
|
|
|
Initialize it to contain a ```Vector2(2, 2)```.
|
|
|
|
|
|
Create a ```const int``` called ```gridSize``` and set it to the size of your world grid; ```20``` works well.
|
|
|
Create a ```const double``` called ```moveTime```. This will be the amount of time it takes the snake to move one space,
|
|
|
the example code uses ```0.2```.
|
|
|
|
|
|
## Step 3: Rendering the world
|
|
|
|
|
|
Add another method to your game class, called config. There set the variable ```scaleMode``` to ```ScaleMode.fit```,
|
|
|
```requestedWith``` to ```gridSize``` and ```requestedHeight``` to ```gridSize```.
|
|
|
This will make your game view a square, scaled up within the boundaries of the page.
|
|
|
|
|
|
Also, create a ```SpriteBatch``` variable and initialize it to ```SpriteBatch.defaultShader(gl)```.
|
|
|
Finally, create a ```Camera2D``` and initialize it to ```Camera2D.originBottomLeft(gridSize, gridSize)```.
|
|
|
|
|
|
That may seem like a lot of objects, but they're all needed for rendering. The textures store the image data.
|
|
|
The SpriteBatch provides functions for drawing texture on the screen. The camera transforms the game coordinates into
|
|
|
WebGL coordinate space.
|
|
|
|
|
|
> Note: The game coordinates here are not the same as the screen resolution. ```ScaleMode.fit``` will create a canvas
|
|
|
with the same aspect ratio, and the camera will scale the game to fill that canvas.
|
|
|
|
|
|
In your render function, set the ```projection``` variable on the batch to ```camera.combined```.
|
|
|
Then, call ```batch.begin()``` to start rendering.
|
|
|
|
|
|
Iterate over your snake, rendering each segment with the line
|
|
|
```batch.draw(bodyTex, snake[i].x, snake[i].y, width: 1, height: 1);```.
|
|
|
The first segment should instead be rendered with the head texture.
|
|
|
|
|
|
The whole rendering block should look something like:
|
|
|
```dart
|
|
|
gl.clearScreen(Colors.paleGoldenrod);
|
|
|
|
|
|
batch.projection = camera.combined;
|
|
|
batch.begin();
|
|
|
|
|
|
batch.draw(headTex, snake[0].x, snake[0].y, width: 1, height: 1);
|
|
|
for(int i = 1; i < snake.length; i++) {
|
|
|
batch.draw(bodyTex, snake[i].x, snake[i].y, width: 1, height: 1);
|
|
|
}
|
|
|
batch.end();
|
|
|
```
|
|
|
|
|
|
Running the game now should show the snake head on the screen.
|
|
|
|
|
|
## Step 4: Controlling the snake
|
|
|
|
|
|
Now turn to the update function, where we will move the snake.
|
|
|
|
|
|
Create a ```Vector2``` variable called ```direction``` and initialize it to zero.
|
|
|
Player input can be polled via ```keyboard.keyPressed(KeyCode.LEFT)```
|
|
|
If the player presses an arrow key, set direction to a vector in that direction.
|
|
|
|
|
|
The input block should look something like:
|
|
|
```dart
|
|
|
if(keyboard.keyPressed(KeyCode.LEFT)) direction = Vector2(-1, 0);
|
|
|
if(keyboard.keyPressed(KeyCode.RIGHT)) direction = Vector2(1, 0);
|
|
|
if(keyboard.keyPressed(KeyCode.UP)) direction = Vector2(0, 1);
|
|
|
if(keyboard.keyPressed(KeyCode.DOWN)) direction = Vector2(0, -1);
|
|
|
```
|
|
|
|
|
|
Add a ```double``` variable called ```moveCountdown``` and initialize it to ```moveTime```.
|
|
|
Subtract ```delta``` from it on each update.
|
|
|
|
|
|
If ```moveCountdown``` is less than zero, then the snake should be moved in ```direction```. This can be done by
|
|
|
adding a new ```Vector2``` in front of the first in the list, and then removing the last element of the list.
|
|
|
|
|
|
```moveCountdown``` should be reset after this call.
|
|
|
|
|
|
The movement block should look like
|
|
|
```dart
|
|
|
if(moveCountdown < 0) {
|
|
|
snake.insert(0, snake[0] + direction);
|
|
|
snake.removeLast();
|
|
|
moveCountdown= moveTime;
|
|
|
if(Vector2.random().x < 0.1) {
|
|
|
snake.add(snake.last);
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Running the game at this point should allow you to move the snake's head around the screen.
|
|
|
|
|
|
## Step 5: Fruit and Extending the Snake
|
|
|
|
|
|
Create another Vector2, ```fruitPos```. Write a function to set it to a position outside of the snake. Something like:
|
|
|
```dart
|
|
|
spawnFruit() {
|
|
|
Vector2 pos = Vector2(random.nextInt(gridSize).toDouble(),
|
|
|
random.nextInt(gridSize).toDouble());
|
|
|
for(var segment in snake) {
|
|
|
if(segment.x == pos.x && segment.y == pos.y) {
|
|
|
spawnFruit();
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
fruitPos = pos;
|
|
|
}
|
|
|
```
|
|
|
|
|
|
Call that function once in create.
|
|
|
|
|
|
If the snake head moves into the same space as the fruit, add three copies of the last element in the snake to it.
|
|
|
(Hint: you can copy a vector via ```.clone()```). Then, spawn a new fruit.
|
|
|
|
|
|
The fruit pickup code should look like:
|
|
|
```dart
|
|
|
if(snake[0] == fruitPos) {
|
|
|
spawnFruit();
|
|
|
for(int i = 0; i < 3; i++) {
|
|
|
snake.add(snake.last.clone());
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
The game should now spawn fruit and extend the snake when they are picked up.
|
|
|
|
|
|
## Step 6: Ending the Game
|
|
|
|
|
|
The game should end when the snake hits itself.
|
|
|
|
|
|
When the snake head moves into a space, check if any other part of the snake occupies that space. If so, reset
|
|
|
```direction``` to zero, shrink the snake back to a single segment, and move the fruit to a new position.
|
|
|
|
|
|
> Note: This should be done before extending the snake, to avoid colliding with new segments.
|
|
|
|
|
|
That code could look like:
|
|
|
```dart
|
|
|
for(int i = 1; i < snake.length; i++) {
|
|
|
if(snake[i] == snake[0]) {
|
|
|
snake = [Vector2(2, 2)];
|
|
|
direction = Vector2.zero();
|
|
|
spawnFruit();
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
It's generally not a good idea to compare vectors like that because of floating point imprecision, but it works for this
|
|
|
game because they're only filled with whole numbers.
|
|
|
|
|
|
That's it! The game should now restart when the snake runs over itself.
|
|
|
|
|
|
## Step 7: Extensions
|
|
|
|
|
|
There's lots more that can be done with this game! If you're interested, try:
|
|
|
- Adding walls on the edges or wrapping around edges
|
|
|
- Adding a variety of fruits that add different amounts to the length
|
|
|
- Adding sound effects when a fruit is collected or when the snake dies (see Sound API)
|
|
|
- Displaying the current length of the snake and tracking a high score (see BitmapFont)
|
|
|
- Improving graphics by representing the direction each segment is facing
|
|
|
(this many textures should probably be handled by a Texture Atlas)
|
|
|
|
|
|
You could also reimplement the game using a different methods; e.g. creating a multidimensional grid that stores both
|
|
|
the snake and the fruit. Cobblestone leaves such design decisions up to the user; different setups will work better
|
|
|
for different games.
|
|
|
|
|
|
## Full Source Code
|
|
|
|
|
|
```dart
|
|
|
import 'package:cobblestone/cobblestone.dart';
|
|
|
|
|
|
main() {
|
|
|
SnakeGame();
|
|
|
}
|
|
|
|
|
|
const int gridSize = 20;
|
|
|
const double moveTime = 0.2;
|
|
|
|
|
|
class SnakeGame extends BaseGame {
|
|
|
|
|
|
Texture bodyTex;
|
|
|
Texture headTex;
|
|
|
Texture fruitTex;
|
|
|
|
|
|
SpriteBatch batch;
|
|
|
Camera2D camera;
|
|
|
|
|
|
Random random;
|
|
|
|
|
|
List<Vector2> snake;
|
|
|
Vector2 direction;
|
|
|
|
|
|
Vector2 fruitPos;
|
|
|
|
|
|
double moveCountdown = moveTime;
|
|
|
|
|
|
@override
|
|
|
create() {
|
|
|
bodyTex = assetManager.get("body");
|
|
|
headTex = assetManager.get("head");
|
|
|
fruitTex = assetManager.get("fruit");
|
|
|
|
|
|
batch = SpriteBatch.defaultShader(gl);
|
|
|
camera = Camera2D.originBottomLeft(gridSize, gridSize);
|
|
|
|
|
|
random = Random();
|
|
|
|
|
|
snake = [Vector2(2, 2)];
|
|
|
direction = Vector2.zero();
|
|
|
|
|
|
spawnFruit();
|
|
|
}
|
|
|
|
|
|
@override
|
|
|
render(double delta) {
|
|
|
gl.clearScreen(Colors.paleGoldenrod);
|
|
|
|
|
|
batch.projection = camera.combined;
|
|
|
batch.begin();
|
|
|
|
|
|
for(int i = 1; i < snake.length; i++) {
|
|
|
batch.draw(bodyTex, snake[i].x, snake[i].y, width: 1, height: 1);
|
|
|
}
|
|
|
batch.draw(headTex, snake[0].x, snake[0].y, width: 1, height: 1);
|
|
|
batch.draw(fruitTex, fruitPos.x, fruitPos.y, width: 1, height: 1);
|
|
|
batch.end();
|
|
|
}
|
|
|
|
|
|
@override
|
|
|
update(double delta) {
|
|
|
moveCountdown -= delta;
|
|
|
|
|
|
if(keyboard.keyPressed(KeyCode.LEFT)) direction = Vector2(-1, 0);
|
|
|
if(keyboard.keyPressed(KeyCode.RIGHT)) direction = Vector2(1, 0);
|
|
|
if(keyboard.keyPressed(KeyCode.UP)) direction = Vector2(0, 1);
|
|
|
if(keyboard.keyPressed(KeyCode.DOWN)) direction = Vector2(0, -1);
|
|
|
|
|
|
if(moveCountdown < 0) {
|
|
|
snake.insert(0, snake[0] + direction);
|
|
|
snake.removeLast();
|
|
|
moveCountdown = moveTime;
|
|
|
for(int i = 1; i < snake.length; i++) {
|
|
|
if(snake[i] == snake[0]) {
|
|
|
snake = [Vector2(2, 2)];
|
|
|
direction = Vector2.zero();
|
|
|
spawnFruit();
|
|
|
}
|
|
|
}
|
|
|
if(snake[0] == fruitPos) {
|
|
|
spawnFruit();
|
|
|
for(int i = 0; i < 3; i++) {
|
|
|
snake.add(snake.last.clone());
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
spawnFruit() {
|
|
|
Vector2 pos = Vector2(random.nextInt(gridSize).toDouble(),
|
|
|
random.nextInt(gridSize).toDouble());
|
|
|
for(var segment in snake) {
|
|
|
if(segment == pos) {
|
|
|
spawnFruit();
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
fruitPos = pos;
|
|
|
}
|
|
|
|
|
|
@override
|
|
|
preload() {
|
|
|
assetManager.load("body", loadTexture(gl, "img/body.png", nearest));
|
|
|
assetManager.load("head", loadTexture(gl, "img/head.png", nearest));
|
|
|
assetManager.load("fruit", loadTexture(gl, "img/fruit.png", nearest));
|
|
|
}
|
|
|
|
|
|
@override
|
|
|
config() {
|
|
|
scaleMode = ScaleMode.fit;
|
|
|
requestedWidth = gridSize;
|
|
|
requestedHeight = gridSize;
|
|
|
}
|
|
|
|
|
|
}
|
|
|
``` |
|
|
\ No newline at end of file |