📜 ⬆️ ⬇️

Isometric Sapper on LibCanvas (html5)


This topic will be different from the previous topic Classic sapper on html5 and LibCanvas . It may even be called a continuation. And the first part contained step-by-step and detailed instructions on how to make a toy work, then in this part there will be a couple of interesting techniques, how to “render” it.

Play Isometric Minesweeper





')
If you are new to this business, you should start with the first part. For those who want to go deep, I will consider the following topics on the example of an isometric sapper built on the basis of LibCanvas:



Lyrical digression


I still propose not to go into the senseless criticism a la “classical sapper is better”, “gates of this form are nonsense” and “you have a little wrong formula there”. The goal was to make a cute toy in a short time (about 8 hours for the full implementation of the art part and about 4 hours for the full implementation of the code at a leisurely pace in your free time). And reveal on the basis of this toy some approaches.

Isometric view


Let's start with the easiest and most interesting. Isometric games on LibCanvas are implemented by combining two tools - the LibCanvas.App framework, which was described in the previous two topics and the IsometricEngine.Projection class.

The peculiarity of IsometricEngine.Projection is that it does not implement anything by itself. It only provides a convenient and fast way to convert 3d coordinates in space to 2d isometric coordinates and back.

 var projection = new IsometricEngine.Projection({ factor: new Point3D(1, 0.5, 1) }); //   3d  2d var point2d = projection.toIsometric(new Point3D(100, 50, 10)); //   2d  3d var point3d = projection.to3d( mouse.point, 0 ); 

When translating from 2d coordinates, it is always unclear in which plane to count the point, therefore, in addition, you have to specify the future z-coordinate

By the way, what is this factor ? In theory, the correct isometric projection is a projection with an angle of 120 °, but in practice, in toys, a projection with an angle of ~ 117 degrees is used so that the lines fall into the pixel grid.



That's just the factor and allows you to set the aspect ratio. You can choose any of the approaches:
 //   factor: new Point3D( 1, 0.5, 1) //   factor: new Point3D(0.866, 0.5, 0.866) 


I didn’t give a damn about all the rules in my application and just made the factor equal to the size of the picture, so I have the left corner of the picture in the coordinates [0;0;0] , and the right one in [1;1;0]
 factor: new Point3D(90, 52, 54) 




So how did I adapt this tool to the requirements of the application? A very simple. Create a cell element like this, where the cell is simply drawn around the center of the shape.

 /** @class IsoMines.Cell */ atom.declare( 'IsoMines.Cell', App.Element, { renderTo: function (ctx, resources) { ctx.drawImage({ image : resources.get('images').get('static-carcass'), center: this.shape.center }); } }); 


And then, using the projection, I created the necessary number of cells, passing them polygons:

 createPolygon: function (point) { var p = this.projection; return new Polygon( p.toIsometric(new Point3D(point.x , point.y , 0)), p.toIsometric(new Point3D(point.x+1, point.y , 0)), p.toIsometric(new Point3D(point.x+1, point.y+1, 0)), p.toIsometric(new Point3D(point.x , point.y+1, 0)) ); }, createCells: function () { var size = this.fieldSize, x, y, point; for (x = size.width; x--;) for (y = size.height; y--;) { point = new Point(x, y); new IsoMines.Cell(this.layer, { point : point, shape : this.createPoly(point) }); } 


I highly recommend reading the documentation - it's quite interesting there, IMHO.

Rendering speed optimization

And now we will look at our application and pay attention to its feature. The beauty is that our objects do not move relative to each other and never intersect, they just touch. Moreover, the objects themselves are completely opaque. Therefore, when changing, you can act very brazenly - do not erase anything, but simply draw a new cell image on top of the old one.

To do this, firstly, you need to disable the intersection check in LibCanvas.App when creating a layer

 this.layer = this.app.createLayer({ intersection: 'manual' }); 


Second, override the cleanup method by making it empty:

 /** @class IsoMines.Cell */ atom.declare( 'IsoMines.Cell', App.Element, { clearPrevious: function () {} }); 


Suppose we cannot completely abandon cleaning for some reason. For example ... Our image has translucent areas or something like that. As a result, you will encounter a problem like a kazmiruk user:



This comes from the two “defaults” of LibCanvas.App - the boundingRectangle is considered the bounding figure (boundingShape), and it erases it precisely by it. It is enough to override any of these behaviors (only one of them, both are meaningless):

 /** @class IsoMines.Cell */ atom.declare( 'IsoMines.Cell', App.Element, { //     ,    boundingRectangle get currentBoundingShape () { return this.shape; } //    , .      clearPrevious: function (ctx) { ctx.clear( this.shape ); } }); 


In fact, this optimization can be used for all static or semi-static layers. Do not forget that we can have several different Layers and each of them has its own behavior strategy:

 this.layerStatic = this.app.createLayer({ intersection: 'manual' }); this.layerDynamic = this.app.createLayer({ intersection: 'auto' }); 


Sprite animations



The next interesting topic is how to insert and make animation work — beautiful pictures a la gifs. For these purposes, there is one of my favorite plugins - Animation .

The basic idea is that we transfer a png-file, which contains a lot of animation frames, which we then assemble into “videos”. It looks something like this, but with translucency instead of squares in the background, twice as many frames and the size of each frame:
.

Creating an animation is divided into three stages:

1. Cutting frames

Using Animation.Frames we cut our grid image into many small pictures:

 var frames = new Animation.Frames( image, 180, 104 ); 


You can have many different animations in one sprite. Cutting should be done only once, and then use it for all prototypes.

2. Prototype animation

Using Animation.Sheet we create a general description of the animation - frame order, delay, obsession and give a link to the frames that were cut above. Each animation prototype should occur only once per application. For example, if you have an explosion animation that occurs many times per application, you only need to create its Animation.Sheet once.

In the isometric sapper, I needed three animations - opening and closing the lock, opening and closing the doors. They had the same settings, frames, only the name and the frame order differed, therefore I did it all short and beautifully:

 this.animationSheets = atom.object.map({ opening : atom.array.range( 12, 23), closing : atom.array.range( 23, 12), locking : atom.array.range( 0, 11), unlocking: atom.array.range( 11, 0) }, function (sequence) { return new Animation.Sheet({ frames: frames, delay : 40, sequence: sequence }); }); console.log( this.animationSheets ); 




3. The essence of animation


But for the immediate launch, every time at the start, an Animation object is created, where we transfer the callbacks and we can hang on to the events. In the sapper, I organized the launch of animations by switching the state:

Animated state switching in IsoMines.Cell
 /** @class IsoMines.Cell */ atom.declare( 'IsoMines.Cell', App.Element, { preStates: { opened: 'opening', closed: 'unlocking', locked: 'locking' }, changeState: function (state, callback) { this.state = this.preStates[state]; this.animation = new Animation({ sheet : this.sheets[this.state], onUpdate: this.redraw, onStop : function () { this.state = state; this.animation = null; this.redraw(); callback && callback.call(this); }.bind(this) }); }, getGatesImage: function () { return (this.animation && this.animation.get()) || 'gates-' + this.state; }, renderTo: function (ctx) { this.drawImage(ctx, this.getGatesImage()); this.drawImage(ctx, 'static-carcass'); }, drawImage: function (ctx, image) { if (typeof image == 'string') { image = this.layer.app.resources.get('images').get(image); } ctx.drawImage({ image : image, center: this.shape.center }); }, }); 



Draggable layers


Our application turned out quite large and even with a full-screen does not fit into the computer screen. It is important for us that the user can reach any cell. Therefore we will fasten the “draggable” layer. T.K. The left and right mouse buttons are busy with us - we will fasten the scroll by pressing and dragging the mouse wheel, and especially for the operatives we will have to make an alternative option through shift + click. Not very convenient, but quite suitable for the demo. I think that in full-screen mode it would be ideal to scroll if the mouse is near the borders, but this was beyond the scope of the article.

The principle is very simple, although it is one of the few classes not covered in the documentation. Hope to fix it soon).

So, first we will use App.LayerShift, which allows you to move a layer along with all its elements and set frames for shifting (if we don’t want to be dragged off to infinity somewhere, and then we could not find it).

After that we will use the built-in class App.Dragger , which allows dragging our layer. In the start-up callback, we define under what conditions this drag should be started.

Well and, depending on the mode, we will define the boundaries of the dredge - in the full screen and minimized state they will be different.

Make the layer shake
  initDragger: function () { this.shift = new App.LayerShift(this.layer); this.updateShiftLimit(); new App.Dragger( this.mouse ) .addLayerShift( this.shift ) .start(function (e) { return e.button == 1 || e.shiftKey; }); }, updateShiftLimit: function () { var padding = new Point(64, 64); this.shift.setLimitShift(new Rectangle( new Point(this.app.container.size) .move(this.layerSize, true) .move(padding, true), padding )); }, 



It is worth noting that by default the drawing is frozen during a drag. I spotted this optimization in old toys, such as the Pharaon and Caesar 3 - when I shifted the map there - the animations stopped, and you could notice this clearly by “leaning into the wall”. This behavior is quite easy to change, but it will require its own heir App.Dragger

Mouse handler optimization


A topic that is affected at the very last moment of developing an application is optimization with a profiler.

The fact is that the default handler initially works according to a rather slow algorithm, but a very universal algorithm - it checks all the shapes of the elements to see if the mouse belongs, and if it doesn’t feel at all on the sizes 5 * 5, then the professional size 30 * 16 is half a thousand elements that need to go through each mouse movement, and at a size of 100 * 100 will be unrealistic 10,000 objects . The square growth on the face (

But each application has its own optimization methods - fast algorithms and caching. To do this, MouseHandler has the opportunity to transfer its own “search engine of elements”, to the constructor of which we can transfer all the necessary data:

 this.mouseHandler = new App.MouseHandler({ mouse: this.mouse, app: this.app, search: new IsoMines.FastSearch(this.shift, this.projection) }); 


In the case of our game, we will put the entire element in an indexed hash, and then search through the method IsometricEngine.Projection.to3D - so instead of the complexity O(N 2 ) we get the complexity O(C) - the constant search speed of the element.

Quick search for the cell that was clicked
 /** @class IsoMines.FastSearch */ atom.declare( 'IsoMines.FastSearch', App.ElementsMouseSearch, { initialize: function (shift, projection) { this.projection = projection; this.shift = shift; this.cells = {}; }, add: function (cell) { return this.set(cell, cell); }, remove: function (cell) { return this.set(cell, null); }, set: function (cell, value) { this.cells[cell.point.y + '.' + cell.point.x] = value; return this; }, findByPoint: function (point) { point = point.clone().move(this.shift.getShift(), true); var path = this.projection.to3D(point), cell = this.cells[Math.floor(path.y) + '.' + Math.floor(path.x)]; return cell ? [ cell ] : []; } }); 



To check the quality of the optimization, I made a map of about 1000 elements (33x33), first turned off the quick search, drove the mouse over the field under the profiler for a long time, and then turned on the quick search and drove the mouse over the field again. Result:

Before:


After:


Application load dropped from 74.4% to 3.4% - more than twenty times in such a simple way. In my opinion, the particular advantage of this method is that it allows you to quickly prototype an application using the default algorithm and transfer the optimization to a later date.

Play Isometric Minesweeper



P.S


In a couple of weeks I will be speaking on the JavaScript frameworks day in Kiev with the theme “AtomJS and LibCanvas”. While I haven’t decided exactly what I’m going to tell you, your opinion is interesting about the two points in this survey, thanks in advance for the answers.

And yes, if you have questions, but for some reason you cannot ask them here on Habré - write to email: shocksilien@gmail.com

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


All Articles