forked from marijnh/Eloquent-JavaScript
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy path15_game.txt
1296 lines (1059 loc) · 43.2 KB
/
15_game.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
:chap_num: 15
:prev_link: 14_event
:next_link: 16_FIXME
:load_files: ["js/15_game.js", "js/code/game_levels.js"]
= Practical: A Game =
My initial fascination with computers, like that of many kids,
originated with computer games. I was drawn in by the tiny
computer-simulated worlds which I could manipulate and impact (sort
of). More, I suppose, because of the way I could project my
imagination into them, than because of the possibilities they actually
offered.
I wouldn't wish a career in game programming on anyone—much like in
the music industry, the discrepancy between the many eager young
people wanting to work in it and the actual demand for such people
creates a rather unhealthy atmosphere. But writing games for fun can
be very rewarding. A game world, even when simple, is still a world.
Who doesn't like creating worlds?
This chapter will walk through the implementation of a simple platform
game. Platform games (or “jump and run” games) are games that involve
moving a figure through an (often two-dimensional, side-view) world,
and jumping onto and over things.
== The game ==
Our game will be roughly based on “http://www.lessmilk.com/10[Dark
Blue]”!!tex (http://www.lessmilk.com/10)!! by Thomas Palef. I chose
this game because it is extremely minimal (and thus implementable
without _too_ much code), yet entertaining. It looks like this:
image::img/darkblue.png[alt="The game Dark Blue"]
// FIXME Make this explanation work for the grayscale paper book
The dark box represents the player, whose task it is to collect the
yellow boxes (coins) while avoiding the red stuff (lava?). A level is
completed when all coins have been collected.
The player can walk around with the left and right arrow keys, and
jump with the up arrow. And jumping is something these game
protagonists know how to do, reaching many times their own height,
with ease. They are also able to change direction in mid-air. Though
this may not be entirely realistic, it helps give the player a feeling
of being in direct control of the on-screen avator.
The game's elements consist of a fixed background, laid out like a
grid. Each field on this grid is either empty, solid, or lava.
Overlaid on this background are the moving and changeable elements,
the player, coins, pieces of lava that move. Unlike the artificial
life simulation from Chapter 7, the positions of these elements are
not constrained to the grid—their coordinates may be fractional,
allowing smooth motion (as opposed to jumpy “one field at a time”
motion).
== The technology ==
We will use the browser DOM to display the game, and read user input
by handling key events.
The screen- and keyboard-related code is only a tiny portion of the
work we need to do to implement this game. Since everything looks like
single-color boxes, drawing is uncomplicated: We create DOM elements,
and use styling to give them a background color, size, and position.
We can represent the background, which is a static grid, as a table of
colored cells. The free-moving elements can be overlaid on top of that
using absolutely positioned elements.
In games (and similar programs) that have to respond and animate
without noticeable delay, efficiency is an important concern. Though
the DOM was not originally designed for high-performance graphics, it
is surprisingly effective in that area. We saw some simple animations
in Chapter 13. On a modern machine, drawing a simple game like this
one will not require us to worry about performance.
In the next chapte, we will explore another browser technology, the
`<canvas>` tag, which provides a way to draw graphics that is more
similar to traditional graphics interfaces—working in terms of shapes
and pixels, rather than DOM nodes.
== Levels ==
In Chapter 7 we used arrays of strings to describe a two-dimensional
grid. That approach is useful here as well. It will allow us to easily
design levels without first building a level editor.
This, for example, could specify a simple level:
// include_code
[source,javascript]
----
var simpleLevelPlan = [
" ",
" ",
" x = x ",
" x o o x ",
" x @ xxxxx x ",
" xxxxx x ",
" x!!!!!!!!!!!!x ",
" xxxxxxxxxxxxxx ",
" "
];
----
Both the fixed grid and the moving elements are included in the
strings. We use “x” characters for walls, spaces for empty space, and
exclamation marks for fixed, non-moving lava tiles.
The initial player position is indicated by an “@” character. Coins
will be put wherever there is an “o”, and the equals sign (“=”)
indicates a block of lava that moves back and forth horizontally. Note
that the grid for these positions will be set to contain empty space,
and an additional data structure will be used to track the position of
these non-fixed elements.
We will also support “|” for vertically moving lava, and “v” for
“dripping” lava—vertically moving blocks of lava that don't bounce
back and forth, but move down and jump back to their start position
when they hit the floor.
A whole game will be represented by an array of level arrays, though
which the player must progress one after the other, by collecting all
the coins in each. When the player touches lava, the current level is
simple restored to its starting position, and they can try again.
== Reading a level ==
The following constructor, given an array of strings as seen above,
constructs a level object.
// include_code
[source,javascript]
----
function Level(plan) {
this.width = plan[0].length;
this.height = plan.length;
this.grid = [];
this.actors = [];
for (var y = 0; y < this.height; y++) {
var line = plan[y], gridLine = [];
for (var x = 0; x < this.width; x++) {
var ch = line[x], fieldType = null;
var Actor = actorChars[ch];
if (Actor)
this.actors.push(new Actor(new Vector(x, y), ch));
else if (ch == "x")
fieldType = "wall";
else if (ch == "!")
fieldType = "lava";
gridLine.push(fieldType);
}
this.grid.push(gridLine);
}
this.player = this.actors.filter(function(actor) {
return actor.type == "player";
})[0];
this.status = this.finishDelay = null;
}
----
A level stores its width and height, along with two arrays—one for the
grid, and one for the “actors”, the dynamic elements. The grid is
represented as an array of arrays, where each of the inner arrays
represents a horizontal line of the grid, and holds either null, for
empty squares, or a string indicating the type of the square—either
`"wall"` or `"lava"`.
The actors array holds a number of objects that track the current
position of the dynamic elements in the level. Each of these is
expected to have a `pos` property giving its position (the coordinates
of its top left corner), a `size` property giving its size, and a
`type` property that holds a string that identifies the element
(“lava”, “coin”, or “player”).
After building up the grid, we use the `filter` method to find the
player actor object, and store it in a property of the level. The
`status` and `finishDelay` properties will be used when the player
dies or wins, to show a simple animation rather than immediately
resetting or advancing the level (which would look silly). This method
can be used to determine whether a level is finished:
// include_code
[source,javascript]
----
Level.prototype.isFinished = function() {
return this.status != null && this.finishDelay < 0;
};
----
For brevity, this code assumes that level plans are well-formed—that
each line has the same length and only contains allowed characters,
and that there is a single player start position in it.
== Actors ==
To represent the position and size of an actor, we will return to our
trusty `Point` type, which groups an x and y coordinate into an
object. Only, because sizes are not really points, and we will also
use this to represent speeds, we will call it `Vector` instead (in the
mathematical sense).
// include_code
[source,javascript]
----
function Vector(x, y) {
this.x = x; this.y = y;
}
Vector.prototype.plus = function(other) {
return new Vector(this.x + other.x, this.y + other.y);
};
Vector.prototype.times = function(factor) {
return new Vector(this.x * factor, this.y * factor);
};
----
The `times` method scales a vector by a given factor, and will be
useful when we need to multiply a speed vector by a time interval.
The `actorChars` was used by the `Level` constructor to associate
characters with constructor functions. It looks like this:
// include_code
[source,javascript]
----
var actorChars = {
"@": Player,
"o": Coin,
"=": Lava, "|": Lava, "v": Lava
};
----
There are three characters mapping to `Lava`. The `Level` constructor
passes the actor the character it was based on as an argument, which
the `Lava` constructor uses to determine its behavior (bouncing
horizontally or vertically, or dripping).
The player type is built with this simple constructor. It has a
property `ySpeed` containing its current vertical speed, which we will
use to simulate momentum and gravity (the horizontal speed will be
entirely based on the arrow keys, without any momentum).
// include_code
[source,javascript]
----
function Player(pos) {
this.pos = pos.plus(new Vector(0, -0.5));
this.size = new Vector(0.8, 1.5);
this.ySpeed = 0;
}
Player.prototype.type = "player";
----
When constructing a dynamic `Lava` object, we need to set up some
state based on the character it was based on. Moving lava will simply
move along at its given speed until it hits an obstacle. At that
point, if it has a `repeatPos` property, it will jump back to its
start position (dripping). If it does not, it will invert its speed
and continue merrily on (bouncing). The constructor only sets up the
necessary properties—the method that does the actual moving will be
written later on.
// include_code
[source,javascript]
----
function Lava(pos, ch) {
this.pos = pos;
this.size = new Vector(1, 1);
if (ch == "=") {
this.speed = new Vector(2, 0);
} else if (ch == "|") {
this.speed = new Vector(0, 2);
} else if (ch == "v") {
this.speed = new Vector(0, 3);
this.repeatPos = pos;
}
}
Lava.prototype.type = "lava";
----
`Coin` actors are very simple, and mostly sit statically in their
place. But to liven up the game a little, they are given a “wobble”, a
slight vertical motion back and forth. To track this, a coin object
stores both a current position and a base position (around which the
wobble happens), and has a `wobble` property that tracks its current
position.
// include_code
[source,javascript]
----
function Coin(pos) {
this.basePos = this.pos = pos.plus(new Vector(0.2, 0.1));
this.size = new Vector(0.6, 0.6);
this.wobble = Math.random() * Math.PI * 2;
}
Coin.prototype.type = "coin";
----
To prevent all coins moving up and down synchronously, the starting
phase of each coin is randomized. We will be using `Math.sin` (a sine
wave) to model the motion, so we multiply the value returned by
`Math.random` by 2π, which causes it to take a random position on that
wave. If none of this makes sense to you, don't worry, it is not
necessary to understand it to follow the chapter.
We now have all the parts needed to initialize a level.
// include_code strip_log
[source,javascript]
----
var simpleLevel = new Level(simpleLevelPlan);
console.log(simpleLevel.width, "by", simpleLevel.height);
// → 22 by 9
----
== Encapsulation as a burden ==
Most of the code in this chapter will worry very little about
encapsulation. This has two reasons. Firstly, encapsulation takes
extra effort. It will make programs bigger and require additional
concepts and interfaces to be introduced. Since there is only so much
code you can throw at a reader before their eyes glaze over, I've made
an effort to keep the program small.
Secondly, the various elements in this game are so closely tied
together that if the behavior of one of them changed, it is unlikely
that any of the others would be able to stay the same. Interfaces
between the elements would end up encoding a lot of assumptions about
the way the game works. This makes them a lot less effective—whenever
you make a change to one part of the system, you still have to worry
about the way it impacts the other parts, because their interfaces
wouldn't cover the new situation.
Some “cutting points” in a system lend themselves very well to
separation through rigorous interfaces , others don't. Trying to
encapsulate something that isn't a suitable boundary is a sure way to
waste a lot of energy. When you are making this mistake, you'll
usually notice that your interfaces are getting awkwardly large and
detailed, and that they need to be updated a lot to cover new
situations.
There is one thing that we _will_ encapsulate in this chapter, and
that is the drawing subsystem. The reason for this is that we will
display the same game in a different way in the next chapter. By
putting the drawing behind an interface, we can simply load the same
game program there, but plug in a new display module.
== Drawing ==
The encapsulation of the drawing code is done by defining a “display”
object, which displays a given level. The display type we define in
this chapter is called `DOMDisplay`, because is uses simple DOM nodes
to show the level.
We will be using a sheet to set the actual colors and other fixed
properties of the nodes that make up the game. It would also be
possible to directly assign to the nodes' `style` property when we
create them, but that would produce rather ugly programs.
The following helper function provides a short way to create an
element and give it a class.
// include_code
[source,javascript]
----
function element(name, className) {
var elt = document.createElement(name);
if (className) elt.className = className;
return elt;
}
----
A display is created by giving it a parent node to which it should
append itself, and a level object.
// include_code
[source,javascript]
----
function DOMDisplay(parent, level) {
this.wrap = parent.appendChild(element("div", "game"));
this.level = level;
this.wrap.appendChild(this.drawBackground());
this.actorNode = null;
this.drawFrame();
}
----
We used the fact that `appendChild` returns the appended node to put the
The level's background, which never changes, is drawn once. The actors
are redrawn every time the display is updated. The `actorNode`
property will be used by `drawFrame` to track the node that holds the
actors, so that they can be easily removed and replaced.
Our coordinates and sizes are tracked in units relative to the grid
size, where a size or distance of one means one grid unit. When
setting pixel sizes, we will have to scale these coordinates up—things
would be ridiculously small at a single pixel per square. The `scale`
variable gives the amount of pixels a single unit takes up on the
screen.
// include_code
[source,javascript]
----
var scale = 20;
DOMDisplay.prototype.drawBackground = function() {
var table = element("table", "background");
table.style.width = this.level.width * scale + "px";
this.level.grid.forEach(function(row) {
var rowElt = table.appendChild(element("tr"));
rowElt.style.height = scale + "px";
row.forEach(function(type) {
rowElt.appendChild(element("td", type));
});
});
return table;
};
----
As mentioned earlier, the background is drawn as a `<table>` element.
This nicely corresponds to the structure of the `grid` property in the
level—each row of the grid is turned into a table row (`<tr>`
element). The strings in the grid are used as class names for the
table cell (`<td>`) elements. The following CSS helps the resulting
table look like the background we want:
[source,text/css]
----
.background { background: #34a6fb;
table-layout: fixed;
border-spacing: 0; }
.background td { padding: 0; }
.lava { background: #ff6363; }
.wall { background: white; }
----
Some of these (`table-layout`, `border-spacing`, and `padding`) are
simply used to suppress unwanted default behavior. We don't want space
between the table cells, or padding inside them, and we set the
table's algorithm for computing the width of its columns to a simple,
predictable variant (the way tables are laid out, in HTML, is a very
complicated thing).
The `background` rules set background colors. CSS allows colors to be
specified both as words (`white`) and with a rather cryptic `#RRGGBB`
format, where the hash is followed by pairs of hexadecimal (base-16)
digits giving first the red, then the green, and then the blue
components of the color, given as numbers between zero and `ff` (255).
So in `#34a6fb` the the red component is hexadecimal `34` (3 × 16 + 4
equals decimal 52), green is `a6` (10 × 16 + 6 = 166), and blue `fb`
(15 × 16 + 11 = 251). Since the blue component is the largest, the
resulting color will be blueish. You can see that in the `.lava` rule,
the first part of the number (red) is the largest.
Drawing the actors is quite straightforward:
// include_code
[source,javascript]
----
DOMDisplay.prototype.drawActors = function() {
var wrap = element("div");
this.level.actors.forEach(function(actor) {
var elt = wrap.appendChild(element("div", "actor " + actor.type));
elt.style.width = actor.size.x * scale + "px";
elt.style.height = actor.size.y * scale + "px";
elt.style.left = actor.pos.x * scale + "px";
elt.style.top = actor.pos.y * scale + "px";
});
return wrap;
};
----
Each actor gets an element, and we set the element's position and size
based on the actor's properties, multiplying each value by the display
`scale`.
Giving an element multiple classes is done by separating the class
names with spaces. This is the relevant CSS code. The `actor` class
gives the actors their absolute position, and their type name is used
to give them a color (lava actors use the same type as lava grid
squares, which we defined earlier):
[source,text/css]
----
.actor { position: absolute; }
.coin { background: #f1e559; }
.player { background: #404040; }
----
To update the display when the world changes, the `drawFrame` method
removes the old actor graphics, if any, and redraws them in their new
positions. It may be tempting to try and reuse the DOM nodes, but that
would cause unneccesary interdependency between the level code and the
drawing code—we'd need to have a way to associate actors with nodes,
and the drawing code must know when to remove nodes when their actors
vanish. For a simple game like this, redrawing is fast enough.
// include_code
[source,javascript]
----
DOMDisplay.prototype.drawFrame = function() {
if (this.actorNode)
this.wrap.removeChild(this.actorNode);
this.actorNode = this.wrap.appendChild(this.drawActors());
this.wrap.className = "game " + (this.level.status || "");
this.scrollPlayerIntoView();
};
----
By adding the level's current status as a class name to the wrapper,
we can style the player actor slightly differently when the game is
won or lost. (We could also style other elements, like the background,
differently based on a class in one of their ancestor nodes. CSS is
convenient like that.)
[source,text/css]
----
.lost .player {
background: #a04040;
}
.won .player {
box-shadow: -4px -7px 8px white, 4px -7px 8px white;
}
----
After touching fire, the player's color turns dark red, hopefully
suggesting scorching. When the last coin has been collected, we use a
two white box shadows, one to the top left and one to the top right,
to create a white halo effect.
We don't assume that levels fit into the viewport. That is why the
`scrollPlayerIntoView` call is needed—it ensures that, if the level is
sticking out outside of the viewport, we scroll that viewport to make
sure the player is near its center. The following CSS gives the game's
wrapping DOM node a maximum size, and ensures that anything that
sticks out is not displayed. We also give it a relative position, so
that the absolutely positioned actors are positioned relative to its
top left corner.
[source,text/css]
----
.game {
overflow: hidden;
max-width: 600px;
max-height: 450px;
position: relative;
}
----
In the `scrollPlayerIntoView` method, we find the player's position,
and update the wrapping node's scroll position, by manipulating it's
`scrollLeft` and `scrollTop` properties, when the player is too close
to the edge.
// include_code
[source,javascript]
----
DOMDisplay.prototype.scrollPlayerIntoView = function() {
var width = this.wrap.clientWidth;
var height = this.wrap.clientHeight;
var margin = width / 3;
// The viewport
var left = this.wrap.scrollLeft, right = left + width;
var top = this.wrap.scrollTop, bottom = top + height;
var p = this.level.player;
var center = p.pos.plus(p.size.times(0.5)).times(scale);
if (center.x < left + margin)
this.wrap.scrollLeft = center.x - margin;
else if (center.x > right - margin)
this.wrap.scrollLeft = center.x + margin - width;
if (center.y < top + margin)
this.wrap.scrollTop = center.y - margin;
else if (center.y > bottom - margin)
this.wrap.scrollTop = center.y + margin - height;
};
----
The way the player's center is found shows how the methods on our
`Vector` type allow rather natural-looking computations to be done
with objects. To find the center, we add the player's position (its
top left corner) and half its size. That is the center in level
coordinates, but we need it in pixel coordinates, so we then multiply
the resulting vector by our display scale.
Next, a series of checks verify that the player position isn't outside
of the allowed range. Note that sometimes this will set nonsense
scroll coordinates, below zero or beyond the element's scrollable
area. This is okay—the DOM will constrain them to sane values. Setting
`scrollLeft` to -10 will cause it to become zero.
It would have been slightly simpler to always try to scroll the player
to the center of the viewport. But this creates a rather jarring
effect. As you are jumping, the view will constantly shift up and
down. Having it move up as you jump close to the edge, and then stay
there, is more pleasant.
Finally, we'll need a way to clear a displayed level, for when we
want to move to another level.
// include_code
[source,javascript]
----
DOMDisplay.prototype.clear = function() {
this.wrap.parentNode.removeChild(this.wrap);
};
----
We should now be able to display our tiny level:
[source,text/html]
----
<link rel="stylesheet" href="css/game.css">
<script>
var simpleLevel = new Level(simpleLevelPlan);
var display = new DOMDisplay(document.body, simpleLevel);
</script>
----
ifdef::tex_target[]
image::img/game_simpleLevel.png[alt="Our level rendered"]
endif::tex_target[]
The `<link>` tag, when used with `rel="stylesheet"`, is a way to load
a CSS file into a page. In this case, `game.css` contains the styles
necessary for our game.
== Motion and collision ==
Now we get to the point where we can start adding motion—the most
interesting aspect of this game. The basic approach, which most games
like this take, is to split time into small steps, and for each step,
move the actors by a distance corresponding to their speed (distance
moved per second) multiplied by the size of the time step (in
seconds).
That is easy. The difficult part is to deal with the interactions
between the elements. When the player hits a wall or floor, they
should not simply move through it. The game must notice when the
planned motion causes an object to hit another object, and respond
accordingly—for walls, the motion must be stopped, for coins, the coin
collected, and so on.
Solving this in the general case is quite a big undertaking. You can
find libraries, usually called _physics engines_, that simulate
interaction between physical objects, in two or three dimensions.
We'll take a more modest approach in this chapter, and only handle a
very narrow set of collisions between boxes, in a rather simplistic
way.
That is, before moving the player or a block of lava, we test whether
the motion would take it inside of a non-empty part of the background.
If it does, we simply cancel the motion altogether. The response to
such a collision depends on the type of actor—the player will stop,
whereas a lava block will bounce back.
This approach requires our time steps to be rather small, since it
will cause motion to stop before the objects actually touch. If the
time steps, and thus the motion steps, are too big, the player would
end up hovering a noticeable distance above the ground. Another
approach, arguably better but more complicated, would be to find the
exact collision spot, and move there. Since this introduces quite a
bit of complexity, we will stick to the simple approach and hide its
problems by ensuring the animation proceeds in small steps.
This method tells us whether a rectangle (specified by a position and
a size) overlaps with any non-empty space from the background grid:
// include_code
[source,javascript]
----
Level.prototype.obstacleAt = function(pos, size) {
var yStart = Math.floor(pos.y);
var yEnd = Math.ceil(pos.y + size.y);
var xStart = Math.floor(pos.x);
var xEnd = Math.ceil(pos.x + size.x);
if (xStart < 0 || xEnd > this.width || yStart < 0)
return "wall";
if (yEnd > this.height)
return "lava";
for (var y = yStart; y < yEnd; y++) {
for (var x = xStart; x < xEnd; x++) {
var fieldType = this.grid[y][x];
if (fieldType) return fieldType;
}
}
};
----
It computes the set of grid squares that the body overlaps with by
using `Math.floor` and `Math.ceil` on its coordinates. Remember that
grid squares are one by one unit in size. If the body sticks out of
the level, we return a predetermined obstacle type—`"wall"` for the
sides and top, `"lava"` for the bottom. Otherwise, we loop over the
block of grid squares, and return the first non-empty content type we
find.
Collisions between the player and other dynamic actors (coins, moving
lava) are handled _after_ the player moved. When the motion would take
them into the other actor, the appropriate effect (collecting the
coin, or dying) is activated.
This method scans through the array of actors, looking for an actor
that overlaps the one given as an argument, without actually being the
same actor.
// include_code
[source,javascript]
----
Level.prototype.actorAt = function(actor) {
for (var i = 0; i < this.actors.length; i++) {
var other = this.actors[i];
if (other != actor &&
actor.pos.x + actor.size.x > other.pos.x &&
actor.pos.x < other.pos.x + other.size.x &&
actor.pos.y + actor.size.y > other.pos.y &&
actor.pos.y < other.pos.y + other.size.y)
return other;
}
};
----
== Actors and actions ==
The `animate` method, which we add to the `Level` type below, is
responsible for giving the actors in the level a chance to move.
// include_code
[source,javascript]
----
var maxStep = 0.05;
Level.prototype.animate = function(step, keys) {
if (this.status != null)
this.finishDelay -= step;
while (step > 0) {
var thisStep = Math.min(step, maxStep);
for (var i = 0; i < this.actors.length; i++)
this.actors[i].act(thisStep, this, keys);
step -= thisStep;
}
};
----
When the level's `status` property has a non-null value (which is the
case when the player has won or lost), we must count down the
`finishDelay` property, which tracks the time between the point where
winning or losing happens, and the point where we actually want to
stop showing the level.
The `while` loop is responsible for cutting the time step we are
animating into suitably small pieces. It ensures that no step larger
than `maxStep` is taken. For example, a `step` of 0.12 second would be
cut into two steps of 0.05 second, and one of 0.02.
Actor objects are supposed to implement an `act` method, which is
given as arguments the time step, the level object, and an object with
information about the keys the player is holding down. Here is a
simple one, for the `Lava` actor type:
// include_code
[source,javascript]
----
Lava.prototype.act = function(step, level) {
var newPos = this.pos.plus(this.speed.times(step));
if (!level.obstacleAt(newPos, this.size))
this.pos = newPos;
else if (this.repeatPos)
this.pos = this.repeatPos;
else
this.speed = this.speed.times(-1);
};
----
It computes a new position by adding the product of the time step and
its current speed to its old position. If no obstacle blocks that new
position, it moves there. If there is an obstacle, the behavior
depends on the type of moving lava this is—dripping lava has a
`repeatPos` property, to which it skips back when it hits something.
Bouncing lava simply inverts its speed (multiplies it by -1) in order
to start moving in the other direction.
Coins need to update their wobble in their `act` method:
// include_code
[source,javascript]
----
var wobbleSpeed = 8, wobbleDist = 0.07;
Coin.prototype.act = function(step) {
this.wobble += step * wobbleSpeed;
var wobblePos = Math.sin(this.wobble) * wobbleDist;
this.pos = this.basePos.plus(new Vector(0, wobblePos));
};
----
The `wobble` property is updated to track time, and then used as
argument to `Math.sin` to create a wave, which is used to compute a
new position. Coins don't detect collisions. Their motion is defined
in such a way that it stays within their starting square, and
collision with the player is handled by the player's `act` method.
Which is the next things we get to. Player motion is separated per
axis, because hitting the floor should not prevent horizontal motion,
and hitting a wall should not stop falling or jumping motion. The
method below implements the horizontal part.
// include_code
[source,javascript]
----
var playerXSpeed = 7;
Player.prototype.moveX = function(step, level, keys) {
var speed = 0;
if (keys.left) speed -= playerXSpeed;
if (keys.right) speed += playerXSpeed;
var newPos = this.pos.plus(new Vector(speed * step, 0));
var obstacle = level.obstacleAt(newPos, this.size);
if (obstacle)
level.playerTouched(obstacle);
else
this.pos = newPos;
};
----
The motion is computed based on the state of the left and right arrow
keys. When it would cause the player to hit something, the
`playerTouched` method for the level, which handles things like dying
in lava and collecting coins, is called. Otherwise, the object updates
its position.
The vertical motion works in a comparable way, but has to take jumping
and gravity into account.
// include_code
[source,javascript]
----
var gravity = 30;
var jumpSpeed = 17;
Player.prototype.moveY = function(step, level, keys) {
this.ySpeed += step * gravity;
var newPos = this.pos.plus(new Vector(0, this.ySpeed * step));
var obstacle = level.obstacleAt(newPos, this.size);
if (obstacle) {
level.playerTouched(obstacle);
if (keys.up && this.ySpeed > 0)
this.ySpeed = -jumpSpeed;
else
this.ySpeed = 0;
} else {
this.pos = newPos;
}
};
----
At the start of the method, the player's vertical speed is updated for
gravity. The gravity, jumping speed, and pretty much all other
constants in this game, have been set by trial-and-error. I tried out
various values to see how they felt.
We then check for obstacles again. If we are hitting an obstacle,
there are two possible outcomes. When the up arrow is pressed, _and_
we are moving down (meaning the thing we hit is below us), the speed
is set to a relatively large, negative value. This causes the player
to jump. If that is not the case, we simply hit something, and the
speed is reset to zero.
The actual `act` method looks like this:
// include_code
[source,javascript]
----
Player.prototype.act = function(step, level, keys) {
this.moveX(step, level, keys);
this.moveY(step, level, keys);
var otherActor = level.actorAt(this);
if (otherActor)
level.playerTouched(otherActor.type, otherActor);
// Losing animation
if (level.status == "lost") {
this.pos.y += step;
this.size.y -= step;
}
};
----
After moving, it checks for other actors that it is colliding with,
and again calls `playerTouched` when it finds one. This time, it
passes the actor object as second argument, because if the other actor
is a coin, `playerTouched` needs to know which one is being collected.
Finally, when the player died (touched lava) we set up a little
animation that causes them to “shrink” or “sink” down, by reducing the
height of the player object.
And here is the method that handles collisions between the player and
other objects:
// include_code
[source,javascript]
----
Level.prototype.playerTouched = function(type, actor) {
if (type == "lava" && this.status == null) {
this.status = "lost";
this.finishDelay = 1;
} else if (type == "coin") {
this.actors.splice(this.actors.indexOf(actor), 1);
if (!this.actors.some(function(actor) {
return actor.type == "coin";
})) {
this.status = "won";
this.finishDelay = 1;
}
}
};
----
When lava is touched, the game's status is set to `"lost"`. When a
coin is touched, that coin is removed from the array of actors, and if
it was the last one, the game's status is set to `"won"`.
The `splice` method is used to cut a piece out of an array. It is
given an index and a number of elements, and removes that many
elements starting at the given index. In this case, we remove a single
element, our coin actor, whose index we found by calling `indexOf`.
Additional arguments can be given to `splice`, and they will be
inserted into the array at the given position, replacing the removed
elements.
That gives us a level that can actually be animated. All that's
missing now is the code that “drives” the animation.
== Tracking keys ==
For a game like this, we do not want keys to take effect once per
press. Rather, we want their effect (moving the player figure) to
continue happening as long as they are pressed.
We need to set up a key handler that stores the current state of the
left, right, and up keys. We will also want to call `preventDefault`
on events for those keys, so that they don't end up scrolling the
page.
The function below, when given an object with key codes as property
names and key names as value, will return an object that tracks the
current position of those keys. It registers event handlers for
`"keydown"` and `"keyup"` events, and when the key code in the event
is present in the set of codes that it is tracking, update the object.
// include_code
[source,javascript]
----
var arrowCodes = {37: "left", 38: "up", 39: "right"};
function trackKeys(codes) {
var pressed = Object.create(null);