📜 ⬆️ ⬇️

Classic sapper on html5 and libcanvas



In this article I will explain step by step how to write the most common, classic sapper using Html5 Canvas, AtomJS, and the LibCanvas tile engine.

And also see the sequel - " Isometric Sapper on LibCanvas (html5) "
')

We use the standard template for the "start" of our application. It is important not to forget to include js-files after creating the appropriate classes.

<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>LibCanvas :: Mines</title> <link href="/files/styles.css" rel="stylesheet" /> <script src="/files/js/atom.js"></script> <script src="/files/js/libcanvas.js"></script> </head> <body> <p><a href="/">Return to index</a></p> <script> new function () { LibCanvas.extract(); atom.dom(function () { new Mines.Controller(); }); }; </script> <script src="js/controller.js"></script> </body> </html> 


I drew two pictures - mines and flags. Everything else we will do "manually" right in the application. Combined them in one sprite to reduce the number of requests and preload before you start the application. In the code, you can also see the cutting with the help of atom.ImagePreloader :

 /** @class Mines.Controller */ atom.declare( 'Mines.Controller', { initialize: function () { atom.ImagePreloader.run({ flag: 'flag-mine.png [48:48]{0:0}', mine: 'flag-mine.png [48:48]{1:0}' }, this.start.bind(this) ); }, start: function (images) { this.images = images; } }); 


Drawing


I love to visually see what comes up, so I prefer to start with the programming of the drawing, and only then go to the logic. In order for our code to work, we will use LibCanvas.Engines.Tile . Add a class View , in which we create our engine. We also need to create a simple application and bind the engine to the application using TileEngine.Element.app . The default value will be equal to the closed cell. Do not forget to create this View , in our controller.

 /** @class Mines.View */ atom.declare( 'Mines.View', { initialize: function (controller, fieldSize) { this.images = controller.images; this.engine = new TileEngine({ size: fieldSize, cellSize: new Size(24, 24), cellMargin: new Size(0, 0), defaultValue: 'closed' }) .setMethod( this.createMethods() ); this.app = new App({ size : this.engine.countSize(), simple: true }); this.element = TileEngine.Element.app( this.app, this.engine ); }, 


 /** @class Mines.Controller */ // ... start: function (images) { this.images = images; this.view = new Mines.View( this, new Size(15,8) ); } 


Do not rush to run this code, we have not yet defined the createMethods method of the View class. Let's generally define what we can have cell states.

During the game we can see this:

1. Numbers from 1 to 8.
2. Closed cell
3. Open but empty cell
4. Checkbox

After its completion - the following:

1. All mines
2. If you hit one of them, it is highlighted.
3. If the flag is set incorrectly somewhere

Total, 8 + 3 + 3 = 14 different states. We describe them all:

 /** @class Mines.View */ // ... createMethods: function () { return { 1: this.number.bind(this, 1), 2: this.number.bind(this, 2), 3: this.number.bind(this, 3), 4: this.number.bind(this, 4), 5: this.number.bind(this, 5), 6: this.number.bind(this, 6), 7: this.number.bind(this, 7), 8: this.number.bind(this, 8), explode : this.explode.bind(this), closed : this.closed .bind(this), mine : this.mine .bind(this), flag : this.flag .bind(this), empty : this.empty .bind(this), wrong : this.wrong .bind(this) }; }, 


As you can see, we will call the corresponding View methods, bringing them to the current context. In order to see what we have, we need to add the corresponding cells on the field.

 /** @class Mines.Controller */ // ... start: function (images) { // ... // todo: remove after debug '1 2 3 4 5 6 7 8 empty mine flag explode wrong closed' .split(' ') .forEach(function (name, i) { this.view.engine .getCellByIndex(new Point(i, 3)) .value = name; }.bind(this)); 


We simply took all the indexes and assigned them in turn to different cells of the field. Now draw. First of all, we need to create a common method that will “paint” the cell — fill and draw with the necessary color. If a line 1 pixel wide will be drawn in whole coordinates - it will blur (see htmlbook.ru/html5/canvas , the answer to the question "B. Why do we start x and yc 0.5, and not from 0?") , We will use the experimental rectangle method snapToPixel

 /** @class Mines.View */ // ... color: function (ctx, cell, fillStyle, strokeStyle) { var strokeRect = cell.rectangle.clone().snapToPixel(); return ctx .fill( cell.rectangle, fillStyle) .stroke( strokeRect, strokeStyle ); }, 


Now in turn we add rendering methods. Empty cage - just paint:

 /** @class Mines.View */ // ... empty: function (ctx, cell) { return this.color(ctx, cell, '#999', '#aaa'); }, 


Mina and the flag are just pictures on an empty cell:

 /** @class Mines.View */ // ... mine: function (ctx, cell) { return this .empty(ctx, cell) .drawImage( this.images.get('mine'), cell.rectangle ); }, flag: function (ctx, cell) { return this .empty(ctx, cell) .drawImage( this.images.get('flag'), cell.rectangle ); }, 


The mine on which we blew is drawn with a red background:

 /** @class Mines.View */ // ... explode: function (ctx, cell) { return this .color(ctx, cell, '#c00', '#aaa') .drawImage( this.images.get('mine'), cell.rectangle ); }, 


Incorrectly set flag - red cross. It is easy to draw it. First, we restrict the drawing within our rectangle with clip .
Fill it with a background, and then draw two red lines - from the top-left to the bottom-right and from the bottom-left corner to the top-right.

 /** @class Mines.View */ // ... wrong: function (ctx, cell) { var r = cell.rectangle; return this.empty(ctx, cell) .save() .clip( r ) .set({ lineWidth: Math.round(cell.rectangle.width / 8) }) .stroke( new Line( r.from , r.to ), '#900' ) .stroke( new Line( r.bottomLeft, r.topRight ), '#900' ) .restore(); }, 


A closed cell is also quite simple to draw - a gradient from dark to light, from the top-left corner to the bottom-right.

 /** @class Mines.View */ // ... closed: function (ctx, cell) { return ctx.fill( cell.rectangle, ctx.createGradient(cell.rectangle, { 0: '#eee', 1: '#aaa' }) ); }, 


And, actually, numbers. First, add a list of colors for each digit to the prototype. There is no zero, therefore we set zero.
Please note that the first argument of the function is our number . That is what we bind in the createMethods method.
After that, draw the cell as empty, and on top, with the text, write the number.

 /** @class Mines.View */ // ... numberColors: [null, '#009', '#060', '#550', '#808', '#900', '#555', '#055', '#000' ], number: function (number, ctx, cell) { var size = Math.round(cell.rectangle.height * 0.8); return this.empty(ctx, cell) .text({ text : number, color : this.numberColors[number], size : size, lineHeight: size, weight: 'bold', align : 'center', to : cell.rectangle }); } 


Our implementation allows us to change the size of the cells and they will look great anyway:



Min generator


As you can see, the drawing is completely ready. Now we just need to make a simple action and the cell will change its appearance.

Delete our debugging code and create a generator instance:
 /** @class Mines.Controller */ // .. start: function (images) { this.images = images; this.size = new Size(15, 8); this.mines = 20; this.view = new Mines.View( this, this.size ); this.generator = new Mines.Generator( this.size, this.mines ); } 


To begin, learn to scatter mines in the field. Of course, it would be nice to take into account any doubtful situations, but so far we have one requirement for it - to generate the field after the first user click, so that it does not immediately fall on a mine.



We will have a very simple algorithm for generating mines - we create a list of valid points (all except the one we clicked on) - the snapshot method, then “pull” the necessary number of random ones out of them - the createMines method:
 /** @class Mines.Generator */ atom.declare( 'Mines.Generator', { mines: null, initialize: function (fieldSize, minesCount) { this.fieldSize = fieldSize; this.minesCount = minesCount; }, /** @private */ snapshot: function (ignore) { var x, y, point, result = [], size = this.fieldSize; for (y = size.height; y--;) for (x = size.width; x--;) { point = new Point(x, y); if (!point.equals(ignore)) { result.push(point); } } return result; }, /** @private */ createMines: function (count, ignore) { var snapshot = this.snapshot( ignore ); return atom.array.create(count, function () { return snapshot.popRandom(); }); } }); 


The next step is to add an api-method that will be called to generate these mines and index them for quick access. Create a two-dimensional hash with values ​​of 1, where mine is and 0, where there are no mines. It is important for us to use Integer, the reason we will see below. Now we have a quick isMine method to determine if there is a mine by coordinate. The isReady method will be used to tell external classes whether a minefield has already been generated.

 /** @class Mines.Generator */ // .. isReady: function () { return this.mines != null; }, isMine: function (point) { return this.mines[point.y][point.x]; }, generate: function (ignore) { var mines, minesIndex, size = this.fieldSize; mines = this.createMines(this.minesCount, ignore); minesIndex = atom.array.fillMatrix(size.width, size.height, 0); mines.forEach(function (point) { minesIndex[point.y][point.x] = 1; }); this.mines = minesIndex; }, 


The next step is to get the cell value if there is no mine there. The algorithm is very simple - we take all the neighbors that do not go beyond the field, we consider the sum of their values. It is in this place that the mine is an Integer and was useful to us.

 /** @class Mines.Generator */ // .. initialize: function (fieldSize, minesCount) { //       ,      this.bindMethods([ 'isValidPoint', 'isMine' ]); // .. getValue: function (point) { //    return this.getNeighbours(point) //      (1  0) .map(this.isMine) //       .sum(); }, // ,        isValidPoint: function (point) { return point.x >= 0 && point.y >= 0 && point.x < this.fieldSize.width && point.y < this.fieldSize.height; }, //   -   ,  ,     getNeighbours: function (point) { return point.neighbours.filter( this.isValidPoint ); }, 


User interaction


We have a game engine, now you need to make all this a game, not just logic. Create an Action class that will be responsible for all user actions. The first thing we do is react to a user’s click. With TileEngine.Mouse we will listen to mouse events associated with the field. We hang Mouse.prevent on the 'contextmenu' event so that the annoying menu does not pop up. When clicking we check the button. The left mouse button is 0, the middle one is 1, the right one is 2. Recall that in the original game the left click meant opening the cage, the middle shouting revealed all others, and the right click showed mines.

 /** @class Mines.Controller */ // .. start: function (images) { // .. this.action = new Mines.Action(this); } 


 /** @class Mines.Action */ atom.declare( 'Mines.Action', { actions: [ 'open', 'all', 'close' ], initialize: function (controller) { this.controller = controller; this.bindMouse(); }, bindMouse: function () { var view, mouse; view = this.controller.view; mouse = new Mouse(view.app.container.bounds); new App.MouseHandler({ mouse: mouse, app: view.app }) .subscribe( view.element ); mouse.events.add( 'contextmenu', Mouse.prevent ); new TileEngine.Mouse( view.element, mouse ).events .add( 'click', function (cell, e) { this.activate(cell, e.button); }.bind(this)); }, activate: function (cell, actionCode) { console.log( cell.point.dump(), actionCode ); } }); 


Add the first interactivity. We will receive by index the name of the method that needs to be called, and at the same time we will write the simplest method - close . If the cell is closed, then set the flag on it, if the flag is already on the cell, then mark it closed. Now you can see the first interaction - the flag on the cell appears by the right mouse button.

 /** @class Mines.Action */ // ... activate: function (cell, actionCode) { if (typeof actionCode == 'number') { actionCode = this.actions[actionCode]; } this[actionCode](cell); }, close: function (cell) { if (cell.value == 'closed') { cell.value = 'flag'; } else if (cell.value == 'flag') { cell.value = 'closed'; } }, open: function (cell) { }, all: function (cell) { } 


Now we describe the opening of the cell. For starters, open only those cells that are closed. There is nothing to interact with all sorts of static numbers and flags. Second, we check whether our mines are ready and, if not, start the generator.

If the mine is open, we call the lose method, where we mark the cell as exploded.
If there is a number in the cell, then we simply write it, no other actions can be done with this cell.
If the cell is empty, we need to recursively open all the cells around, so while creating a method and mark the cell as empty.

 /** @class Mines.Action */ // ... open: function (cell) { if (cell.value != 'closed') return; var value, gen = this.controller.generator; if (!gen.isReady()) { gen.generate(cell.point); } if (gen.isMine(cell.point)) { this.lose(cell); } else { value = gen.getValue(cell.point); if (value) { cell.value = value; } else { this.openEmpty(cell); } } }, lose: function () { cell.value = 'explode'; }, openEmpty: function (cell) { cell.value = 'empty'; }, 


To open all the cells around the empty one, we just get the neighbors and pass to the open method. We will use this for recursive opening of empty cells and for quick opening by the middle mouse button.

 /** @class Mines.Action */ // ... openNeighbours: function (cell) { this.controller.generator .getNeighbours(cell.point) .forEach(function (point) { this.open( this.getCell(point) ); }.bind(this)); }, openEmpty: function (cell) { cell.value = 'empty'; this.openNeighbours(cell); }, getCell: function (point) { return this.controller.view.engine.getCellByIndex(point); }, all: function (cell) { if (parseInt(cell.value)) { this.openNeighbours(cell); } }, 


We lose the game in the following way - we go through all the cells where we were locked and in fact there was a mine - we draw a mine. Where we had a flag, but in fact there are no mines - we display an error. We also block the open and close methods after losing.

 /** @class Mines.Action */ // ... lost: false, lose: function (cell) { this.lost = true; cell.value = 'explode'; this.controller.view.engine.cells .forEach(this.checkCell.bind(this)); }, checkCell: function (cell) { if (cell.value == 'closed' || cell.value == 'flag') { var isMine = this.controller.generator.isMine(cell.point); if (isMine && cell.value == 'closed') { cell.value = 'mine'; } if (!isMine && cell.value == 'flag') { cell.value = 'wrong'; } } }, // ... close: function (cell) { if (this.lost) return; // ... open: function (cell) { if (this.lost) return; // ... 




Victory!


It remains to display the victory, elapsed time and display the number of mines that are left to open. We will not bother with the appearance, we will use the geeky, but working atom.trace . Get the number of minutes. Calculate the number of empty cells - this is the number of cells just minus the number of minutes. Each time a cell is opened, we will decrease the value of empty cells by one. When they reach zero - the game is won. Let us draw the canvas and with a slight delay display the alert to the user.

 /** @class Mines.Action */ // ... initialize: function (controller) { // ... this.startTime = null; this.minesLeft = controller.mines; this.minesTrace = atom.trace(0); this.changeMines(0); this.emptyCells = controller.size.width * controller.size.height - this.minesLeft; }, changeMines: function (delta) { this.minesLeft += delta; this.minesTrace.value = "Mines: " + this.minesLeft; }, // ... open: function (cell) { // ... if (!gen.isReady()) { // ... this.startTime = Date.now(); } if (gen.isMine(cell.point)) { // ... } else { // ... if (--this.emptyCells == 0) { this.win(); } } }, // ... win: function () { var time = Math.round( (Date.now()-this.startTime) / 1000 ); alert.delay(100, window, ['Congratulations! Mines has been neutralized in '+ time +' sec!']); }, // ... close: function (cell) { // ... if (cell.value == 'closed') { // ... this.changeMines(-1); } else if (cell.value == 'flag') { // ... this.changeMines(+1); } }, 


Play sapper

Source: https://habr.com/ru/post/168435/


All Articles