1 /**
  2  * Renderer object.
  3  *
  4  * @author Andrew J. Baker
  5  */
  6 
  7 /** @namespace Steppe */
  8 var Steppe = (function(Steppe) {
  9     /** @class */
 10     Steppe.Renderer = function(canvas) {
 11         var _CANVAS_WIDTH        = 320,	// 320 pixels
 12             _CANVAS_HEIGHT       = 200,	// 200 pixels
 13             _ANGLE_OF_VIEW       = 60,	// 60 degrees
 14             _ONE_DEGREE_ANGLE    = 1 / _ANGLE_OF_VIEW * _CANVAS_WIDTH,
 15             _THIRTY_DEGREE_ANGLE = _ONE_DEGREE_ANGLE * 30,
 16             _THREE_HUNDRED_AND_SIXTY_DEGREE_ANGLE = _ONE_DEGREE_ANGLE * 360,
 17             _ANGULAR_INCREMENT   = _ANGLE_OF_VIEW / _CANVAS_WIDTH,
 18             _DEGREES_TO_RADIANS  = Math.PI / 180,
 19             _FAKE_DEGREES_TO_RADIANS = (2 * Math.PI) /
 20                 ((360 / _ANGLE_OF_VIEW) * _CANVAS_WIDTH),
 21             _RADIANS_TO_DEGREES  = 180 / Math.PI,
 22             _RADIANS_TO_FAKE_DEGREES = ((360 / _ANGLE_OF_VIEW) *
 23                 _CANVAS_WIDTH) / (2 * Math.PI),
 24             _SCALE_FACTOR = 35,
 25             _CAMERA_Y     = 200,
 26             _DISTANCE     = 75,
 27             _MAXIMUM_ROW  = _CANVAS_HEIGHT + _CANVAS_HEIGHT / 2 - 1,
 28             _WATER_HEIGHT = 64;
 29 
 30         var _FASTEST   = 4,
 31             _DONT_CARE = 2,
 32             _NICEST    = 1;
 33 
 34         var _camera = { angle: 0, x: 0, y: _CAMERA_Y, z: 0 },
 35             _cosineLookupTable = [],
 36             _fogColor = 0x7f7f7fff,
 37             _framebuffer,
 38             _heightmap = [],
 39             _inverseDistortionLookupTable = [],
 40             _outOfBoundsHeightmap = [],
 41             _outOfBoundsTexturemap,
 42             _rayLengthLookupTable = [],
 43             _sineLookupTable = [],
 44             _sky,
 45             _skyData,
 46             _spriteList = [],
 47             _temporaryFramebuffer,
 48             _texturemap,
 49             _visibleSpriteList = [],
 50             _zBuffer = [];
 51 
 52         var _fog = false,		// disabled (default)
 53             _quality = _DONT_CARE,	// medium quality (default)
 54             _smooth = 0,		// disabled (default)
 55             _waterHeight = -1;		// disabled (default)
 56 
 57         /**
 58          * Blend two colours together using an alpha value.
 59          *
 60          * @param {number} firstColor First, or source, colour (RGBA).
 61          * @param {number} secondColor Second, or destination, colour (RGBA).
 62          * @param {number} alpha Alpha value in the range 0..255.
 63          * @return {number} Mixed colour (RGBA).
 64          */
 65         var _alphaBlend = function(firstColor, secondColor, alpha) {
 66             if (alpha < 0) {
 67                 alpha = 0;
 68             } else if (alpha > 255) {
 69                 alpha = 255;
 70             }
 71 
 72             var normalisedAlpha = alpha / 255,
 73                 adjustedAlpha   = 1 - normalisedAlpha;
 74 
 75             var mixedRed   = ((firstColor >> 24) & 0xff) * normalisedAlpha | 0,
 76                 mixedGreen = ((firstColor >> 16) & 0xff) * normalisedAlpha | 0,
 77                 mixedBlue  = ((firstColor >>  8) & 0xff) * normalisedAlpha | 0;
 78 
 79             mixedRed   += Math.floor(((secondColor >> 24) & 0xff) *
 80                 adjustedAlpha);
 81             mixedGreen += Math.floor(((secondColor >> 16) & 0xff) *
 82                 adjustedAlpha);
 83             mixedBlue  += Math.floor(((secondColor >> 8)  & 0xff) *
 84                 adjustedAlpha);
 85 
 86             return (mixedRed << 24) |
 87                 (mixedGreen << 16)  |
 88                 (mixedBlue  << 8)   | 0xff;
 89         };
 90 
 91         /**
 92          * Get a pixel from the out-of-bounds texturemap.
 93          *
 94          * @param {number} x The x-coordinate; must be in the range
 95          *                   0..out-of-bounds-texturemap-width - 1.
 96          * @param {number} y The y-coordinate; must be in the range
 97          *                   0..out-of-bounds-texturemap-height - 1.
 98          * @return {number} An integer composed of RGBA components for the
 99          *                  corresponding pixel.
100          */
101         var _getPixelFromOutOfBoundsTexturemap = function(x, y) {
102             if (typeof _outOfBoundsTexturemap !== 'undefined') {
103                 var index = (y << 12) + (x << 2);
104 
105                 return (_outOfBoundsTexturemap[index] << 24)  |
106                     (_outOfBoundsTexturemap[index + 1] << 16) |
107                     (_outOfBoundsTexturemap[index + 2] <<  8) | 0xff;
108             } else {
109                 return 0x7f7f7fff;
110             }
111         };
112 
113         /**
114          * Get a pixel from the sky canvas.
115          *
116          * @param {number} x The x-coordinate; should be in the range
117          *                   0..sky-width - 1.
118          * @param {number} y The y-coordinate; should be in the range
119          *                   0..sky-height - 1.
120          * @return {number} An integer composed of RGBA components for the
121          *                  corresponding pixel.
122          */
123         var _getPixelFromSky = function(x, y) {
124             var currentAngle = _camera.angle - _THIRTY_DEGREE_ANGLE;
125 
126             if (currentAngle < 0) {
127                 currentAngle += _THREE_HUNDRED_AND_SIXTY_DEGREE_ANGLE;
128             }
129 
130             if (y < 0) {
131                 y = 0;
132             } else if (y >= 100) {
133                 y = 100 - 1;
134             }
135 
136             var index = (y * 1920 + (currentAngle + x | 0) % 1920) * 4;
137 
138             return (_skyData[index] << 24)  |
139                 (_skyData[index + 1] << 16) |
140                 (_skyData[index + 2] <<  8) | 0xff;
141         };
142 
143         /**
144          * Get a pixel from the texturemap.
145          *
146          * @param {number} x The x-coordinate; must be in the range
147          *                   0..texturemap-width - 1.
148          * @param {number} y The y-coordinate; must be in the range
149          *                   0..texturemap-width - 1.
150          * @return {number} An integer composed of RGBA components for the
151          *                  corresponding pixel.
152          */
153         var _getPixelFromTexturemap = function(x, y) {
154             if (typeof _outOfBoundsTexturemap !== 'undefined') {
155                 var index = (y << 12) + (x << 2);
156 
157                 return (_texturemap[index] << 24)  |
158                     (_texturemap[index + 1] << 16) |
159                     (_texturemap[index + 2] <<  8) | 0xff;
160             } else {
161                 return 0xffffffff;
162             }
163         };
164 
165         /**
166          * Get the row at which a sprite should be rendered. This is a private
167          * helper method.
168          *
169          * @param {number} x ...
170          * @param {number} z ...
171          * @param {number} ray ...
172          * @return {number} ...
173          */
174         var _getRow = function(x, z, ray) {
175             var cameraVectorX = Math.cos(_camera.angle * _ANGULAR_INCREMENT *
176                 _DEGREES_TO_RADIANS);
177             var cameraVectorZ = Math.sin(_camera.angle * _ANGULAR_INCREMENT *
178                 _DEGREES_TO_RADIANS);
179 
180             var spriteVectorX = x - _camera.x;
181             var spriteVectorZ = z - _camera.z;
182 
183             var vectorLength = Math.sqrt(spriteVectorX * spriteVectorX +
184                 spriteVectorZ * spriteVectorZ);
185 
186             var newVectorLength = vectorLength /
187                 _inverseDistortionLookupTable[ray];
188 
189             var y = Math.round(_DISTANCE * _camera.y / newVectorLength);
190             var row = y + _CANVAS_HEIGHT - 1 - _camera.y;
191 
192             return row;
193         };
194 
195         /**
196          * Initialise the inverse distortion lookup table (for removing
197          * fisheye).
198          */
199         var _initInverseDistortionLookupTable = function() {
200             for (var angleOfRotation = 0;
201                 angleOfRotation < _THIRTY_DEGREE_ANGLE;
202                 ++angleOfRotation) {
203                 var angleOfRotationInRadians = angleOfRotation *
204                     _ANGULAR_INCREMENT * _DEGREES_TO_RADIANS;
205 
206                 _inverseDistortionLookupTable[angleOfRotation +
207                     _THIRTY_DEGREE_ANGLE] = 1 /
208                     Math.cos(angleOfRotationInRadians);
209 
210                 var cosine = Math.cos(angleOfRotationInRadians);
211                 if (cosine !== 0) {
212                     _inverseDistortionLookupTable[
213                         _THIRTY_DEGREE_ANGLE - angleOfRotation] = 1 / cosine;
214                 }
215             }
216 
217             _inverseDistortionLookupTable[0]   = 2;
218             _inverseDistortionLookupTable[160] = 1;
219         };
220 
221         /**
222          * Initialise (or recalculate) the ray-length lookup table.
223          *
224          * @param {number} distance The distance from the camera to the
225          *                          projection plane.
226          */
227         var _initRayLengthLookupTable = function(distance) {
228             for (var y = 200; y <= 300; ++y) {
229                 _rayLengthLookupTable[y] = [];
230 
231                 for (var ray = 1; ray < _CANVAS_WIDTH; ++ray) {
232                     for (var row = 0; row <= _MAXIMUM_ROW; ++row) {
233                         var invertedRow = _CANVAS_HEIGHT - 1 - row;
234 
235                         var rayLength = _inverseDistortionLookupTable[ray] *
236                             ((distance * y) / (y - invertedRow));
237 
238                         _rayLengthLookupTable[y][row * _CANVAS_WIDTH + ray] =
239                             rayLength;
240                     }
241                 }
242             }
243         };
244 
245         /**
246          * Initialise the sine and cosine lookup tables.
247          */
248         var _initSineAndCosineLookupTables = function() {
249             for (var angleOfRotation = 0;
250                 angleOfRotation < _THREE_HUNDRED_AND_SIXTY_DEGREE_ANGLE;
251                 ++angleOfRotation) {
252                 var angleOfRotationInRadians = angleOfRotation *
253                     _ANGULAR_INCREMENT * _DEGREES_TO_RADIANS;
254 
255                 _sineLookupTable[angleOfRotation]   = Math.sin(
256                     angleOfRotationInRadians);
257                 _cosineLookupTable[angleOfRotation] = Math.cos(
258                     angleOfRotationInRadians);
259             }
260         };
261 
262         /**
263          * Render the terrain (landscape).
264          *
265          * @param {number} initialAngle ...
266          */
267         var _renderFrontToBack = function(initialAngle) {
268             var currentAngle = initialAngle;
269 
270             var framebufferImageData = _temporaryFramebuffer.createImageData(
271                 320, 200);
272             var framebufferData      = framebufferImageData.data;
273 
274             for (var ray = _quality; ray < _CANVAS_WIDTH; ray += _quality) {
275                 var previousTop = _MAXIMUM_ROW;
276 
277                 for (var row = _MAXIMUM_ROW; row >= 0; --row) {
278                     var rayLength = _rayLengthLookupTable[_camera.y][
279                         (row << 8) + (row << 6) + ray];
280 
281                     var rayX = _camera.x + rayLength *
282                         _cosineLookupTable[currentAngle] | 0,
283                         rayZ = _camera.z + rayLength *
284                         _sineLookupTable[currentAngle]   | 0;
285 
286                     var u = rayX & 1023;
287                     var v = rayZ & 1023;
288 
289                     var height;
290                     if ((rayX < 1024 || rayX >= 1024 + 1024 ||
291                         rayZ < 1024 || rayZ >= 1024 + 1024) &&
292                         _outOfBoundsHeightmap.length > 0) {
293                         height = _outOfBoundsHeightmap[(v << 10) + u];
294                     } else {
295                         height = _heightmap[(v << 10) + u];
296                     }
297 
298                     var scale = height * _SCALE_FACTOR / (rayLength + 1) | 0;
299 
300                     var top    = (_CANVAS_HEIGHT >> 1) -
301                         (_camera.y - _CANVAS_HEIGHT) + row - scale,
302                         bottom = top + scale;
303 
304                     if (top < previousTop) {
305                         bottom = previousTop;
306                         previousTop = top;
307 
308                         var color = 0x000000ff;
309 
310                         var texel;
311                         if (rayX < 1024 || rayX >= 1024 + 1024 ||
312                             rayZ < 1024 || rayZ >= 1024 + 1024) {
313                             texel =
314                                 _getPixelFromOutOfBoundsTexturemap(u, v);
315 
316                             color = texel;
317                         } else {
318                             if (height < _waterHeight) {
319                                 var data = _getPixelFromSky(ray,
320                                     200 - top);
321 
322                                 texel =
323                                     _getPixelFromTexturemap(u, v);
324 
325                                 var mixedColor = _alphaBlend(data,
326                                     texel, (_waterHeight - height) /
327                                     _waterHeight * 255 * 2 | 0);
328 
329                                 texel = mixedColor;
330 
331                                 height = _waterHeight;
332 
333                                 color = texel;
334                             } else {
335                                 texel =
336                                     _getPixelFromTexturemap(u, v);
337 
338                                 color = texel;
339                             }
340                         }
341 
342                         if (_fog) {
343                             var foggedTexel = _alphaBlend(texel,
344                                 _fogColor, row / 100 * 255 | 0);
345 
346                             color = foggedTexel;
347                         }
348 
349                         if (bottom > 199) {
350                             bottom = 199;
351                         }
352 
353                         var index, i, j;
354                         if (ray > _quality) {
355                             // Not the left-most ray...
356                             index =
357                                 (top * (framebufferImageData.width << 2)) +
358                                 (ray << 2);
359 
360                             var red   = (color >> 24) & 0xff,
361                                 green = (color >> 16) & 0xff,
362                                 blue  = (color >>  8) & 0xff;
363 
364                             for (j = 0; j < bottom - top + 1; ++j) {
365                                 for (i = 0; i < _quality; ++i) {
366                                     framebufferData[index++]     = red;
367                                     framebufferData[index++] = green;
368                                     framebufferData[index++] = blue;
369                                     framebufferData[index++] = 0xff;
370                                 }
371 
372                                 index += (framebufferImageData.width << 2) -
373                                     (_quality << 2);
374                             }
375                         } else {
376                             // Left-most ray: we don't cast rays for column 0!
377                             index =
378                                 (top * (framebufferImageData.width << 2)) +
379                                 (ray << 2);
380 
381                             red   = (color >> 24) & 0xff;
382                             green = (color >> 16) & 0xff;
383                             blue  = (color >>  8) & 0xff;
384 
385                             for (j = 0; j < bottom - top + 1; ++j) {
386                                 for (i = 0; i < _quality; ++i) {
387                                     framebufferData[index -
388                                         (_quality << 2)]     = red;
389                                     framebufferData[index -
390                                         (_quality << 2) + 1] = green;
391                                     framebufferData[index -
392                                         (_quality << 2) + 2] = blue;
393                                     framebufferData[index -
394                                         (_quality << 2) + 3] =
395                                         0xff;
396 
397                                     framebufferData[index++] = red;
398                                     framebufferData[index++] = green;
399                                     framebufferData[index++] = blue;
400                                     framebufferData[index++] = 0xff;
401                                 }
402 
403                                 index += (framebufferImageData.width << 2) -
404                                     (_quality << 2);
405                             }
406                         }
407                     }
408                 }
409 
410                 currentAngle += _quality;
411                 if (currentAngle >= _THREE_HUNDRED_AND_SIXTY_DEGREE_ANGLE) {
412                     currentAngle = 0;
413                 }
414             }
415 
416             _temporaryFramebuffer.putImageData(framebufferImageData, 0, 0);
417 
418             _framebuffer.drawImage(_temporaryFramebuffer.canvas,
419                 0, 0 - _smooth,
420                 _temporaryFramebuffer.canvas.width,
421                 _temporaryFramebuffer.canvas.height);
422         };
423 
424         /**
425          * Render the 360-degree panoramic sky based on the camera's angle
426          * of rotation.
427          */
428         var _renderSky = function() {
429             var angleOfRotation = _camera.angle - _THIRTY_DEGREE_ANGLE;
430 
431             if (angleOfRotation < 0) {
432                 angleOfRotation += _THREE_HUNDRED_AND_SIXTY_DEGREE_ANGLE;
433             }
434 
435             angleOfRotation |= 0; 
436 
437             var skyWidth  = _sky.width;
438             var skyHeight = (_CANVAS_HEIGHT >> 1) -
439                 (_camera.y - _CANVAS_HEIGHT);
440 
441             if (skyHeight > _sky.height) {
442                 skyHeight = _sky.height;
443             }
444 
445             var sy = _camera.y - _CANVAS_HEIGHT;
446 
447             if (sy < 0) {
448                 sy = 0;
449             }
450 
451             if (angleOfRotation + 320 <= skyWidth) {
452                 _framebuffer.drawImage(_sky, angleOfRotation, sy, 320,
453                     skyHeight, 0, 0, 320, skyHeight);
454             } else {
455                 _framebuffer.drawImage(_sky, angleOfRotation, sy,
456                     skyWidth - angleOfRotation, skyHeight, 0, 0,
457                     skyWidth - angleOfRotation, skyHeight);
458                 _framebuffer.drawImage(_sky, 0, sy,
459                     320 - (skyWidth - angleOfRotation),
460                     skyHeight, skyWidth - angleOfRotation, 0,
461                     320 - (skyWidth - angleOfRotation), skyHeight);
462             }
463 
464             if (_fog) {
465                 var skyContext = _sky.getContext('2d');
466 
467                 var skyGradient = skyContext.createLinearGradient(0, 0, 0,
468                     skyHeight - 1);
469                 skyGradient.addColorStop(0, 'rgba(' +
470                     ((_fogColor >> 24) & 0xff) + ', ' +
471                     ((_fogColor >> 16) & 0xff) + ', ' +
472                     ((_fogColor >>  8) & 0xff) + ', ' +
473                     (1 - (1 / (100 / skyHeight))) + ')');
474                 skyGradient.addColorStop(1, 'rgba(' +
475                     ((_fogColor >> 24) & 0xff) + ', ' +
476                     ((_fogColor >> 16) & 0xff) + ', ' +
477                     ((_fogColor >>  8) & 0xff) + ', 1)');
478 
479                 _framebuffer.fillStyle = skyGradient;
480                 _framebuffer.fillRect(0, 0, 320, skyHeight);
481             }
482         };
483 
484         /**
485          * ...
486          */
487         var _renderSprites = function() {
488             // For each visible sprite...
489             for (var i = 0; i < _visibleSpriteList.length; ++i) {
490                 // If the current sprite has been removed...
491                 if (typeof _visibleSpriteList[i] === 'undefined') {
492                     // Move to the next sprite.
493                     continue;
494                 }
495 
496                 var sprite = _visibleSpriteList[i];
497 
498                 // Draw the sprite.
499                 _framebuffer.drawImage(
500                     sprite.image,
501                     sprite.x,
502                     sprite.y - _smooth,
503                     sprite.width,
504                     sprite.height);
505 
506                 // Remove the sprite from the list of visible sprites.
507                 delete _visibleSpriteList[i];
508             }
509         };
510 
511         /**
512          * Sort the list of currently visible sprites.
513          */
514         var _sortVisibleSpriteList = function() {
515             var length = _visibleSpriteList.length;
516 
517             for (var i = 0; i < length - 1; ++i) {
518                 for (var j = i + 1; j < length; ++j) {
519                     if (_visibleSpriteList[j].vectorLength >
520                         _visibleSpriteList[i].vectorLength) {
521                         var temp = _visibleSpriteList[i];
522                         _visibleSpriteList[i] = _visibleSpriteList[j];
523                         _visibleSpriteList[j] = temp;
524                     }
525                 }
526             }
527         };
528 
529         if (arguments.length > 1) {
530             throw('Too many arguments passed to constructor');
531         }
532 
533         if (canvas.width !== _CANVAS_WIDTH) {
534             throw('Canvas width not equal to ' + _CANVAS_WIDTH);
535         }
536         if (canvas.height !== _CANVAS_HEIGHT) {
537             throw('Canvas height not equal to ' + _CANVAS_HEIGHT);
538         }
539 
540         _framebuffer = canvas.getContext('2d');
541 
542         var temporaryFramebufferCanvas    = document.createElement('canvas');
543         temporaryFramebufferCanvas.width  = canvas.width;
544         temporaryFramebufferCanvas.height = canvas.height;
545 
546         _temporaryFramebuffer = temporaryFramebufferCanvas.getContext('2d');
547 
548         _initSineAndCosineLookupTables();
549         _initInverseDistortionLookupTable();
550         _initRayLengthLookupTable(_DISTANCE);
551 
552         return {
553             /**
554              * Add a 2D sprite, at the specified world coords, to the sprite
555              * list.
556              *
557              * @param {HTMLImageElement} image The 2D sprite as an image.
558              * @param {number} x The x-coordinate in world space.
559              * @param {number} y The y-coordinate in world space.
560              * @param {number} z The z-coordinate in world space.
561              * @return {Renderer} This (chainable).
562              */
563             addSprite: function(image, x, y, z) {
564                 if ( !(image instanceof HTMLImageElement)) {
565                     throw('Invalid image: not an instance of HTMLImageElement');
566                 }
567                 if (typeof(x) != 'number') {
568                     throw('Invalid x: not a number');
569                 }
570                 if (typeof(y) != 'number') {
571                     throw('Invalid y: not a number');
572                 }
573                 if (typeof(z) != 'number') {
574                     throw('Invalid z: not a number');
575                 }
576 
577                 if (x < 1024 || x >= 1024 + 1024) {
578                     throw('Invalid x: must be in the range 1024..2047');
579                 }
580                 if (y < 0 || y >= 1024) {
581                     throw('Invalid y: must be in the range 0..1023');
582                 }
583                 if (z < 1024 || z >= 1024 + 1024) {
584                     throw('Invalid z: must be in the range 1024..2047');
585                 }
586 
587                 _spriteList.push({
588                     image: image,
589                     x: x,
590                     y: y & 1023,
591                     z: z
592                 });
593 
594                 return this;
595             },
596 
597             /**
598              * Disable a Steppe capability.
599              *
600              * @param {string} capability Specifies a string indicating a
601              *                            Steppe capability; 'fog',
602              *                            'reflection-map' and 'smooth' are
603              *                            currently implemented.
604              * @return {Renderer} This (chainable).
605              */
606             disable: function(capability) {
607                 if (capability === 'fog') {
608                     _fog = false;
609                 } else if (capability === 'reflection-map') {
610                     _waterHeight = -1;
611                 } else if (capability === 'smooth') {
612                     _smooth = 0;
613                 } else {
614                     throw("Can't disable unknown capability");
615                 }
616 
617                 return this;
618             },
619 
620             /**
621              * Enable a Steppe capability.
622              *
623              * @param {string} capability Specifies a string indicating a
624              *                            Steppe capability; 'fog',
625              *                            'reflection-map' and 'smooth' are
626              *                            currently implemented.
627              * @return {Renderer} This (chainable).
628              */
629             enable: function(capability) {
630                 if (capability === 'fog') {
631                     _fog = true;
632                 } else if (capability === 'reflection-map') {
633                     _waterHeight = _WATER_HEIGHT;
634                 } else if (capability === 'smooth') {
635                     _smooth = 0.5;
636                 } else {
637                     throw("Can't enable unknown capability");
638                 }
639 
640                 return this;
641             },
642 
643             /**
644              * Get the current camera.
645              *
646              * @return {object} An object composed of an angle-of-rotation (in
647              *                  degrees about the y-axis) and a 3D point in
648              *                  world space.
649              */
650             getCamera: function() {
651                 return {
652                     angle: _camera.angle /
653                         _THREE_HUNDRED_AND_SIXTY_DEGREE_ANGLE * 360 +
654                         0.5 | 0,
655                     x:     _camera.x,
656                     y:     _camera.y,
657                     z:     _camera.z
658                 };
659             },
660 
661             /**
662              * Get the height (from the heightmap) of a single unit of terrain,
663              * in world space.
664              *
665              * @param {number} x The x-coordinate of the unit of terrain.
666              * @param {number} z The z-coordinate of the unit of terrain.
667              * @return {number} The corresponding y-coordinate of the specified
668              *                  unit of terrain.
669              */
670             getHeight: function(x, z) {
671                 if (typeof(x) != 'number') {
672                     throw('Invalid x: not a number');
673                 }
674                 if (typeof(z) != 'number') {
675                     throw('Invalid z: not a number');
676                 }
677 
678                 if (x < 1024 || x >= 1024 + 1024) {
679                     throw('Invalid x: must be in the range 1024..2047');
680                 }
681                 if (z < 1024 || z >= 1024 + 1024) {
682                     throw('Invalid z: must be in the range 1024..2047');
683                 }
684 
685                 var u = x & 1023;
686                 var v = z & 1023;
687 
688                 return _heightmap[(v << 10) + u];
689             },
690 
691             /**
692              * Test whether a capability is enabled.
693              *
694              * @param {string} capability Specifies a string indicating a
695              *                            Steppe capability; 'smooth' and
696              *                            'reflection-map' are currently
697              *                            implemented.
698              * @return {boolean} Returns true if capability is an enabled
699              *                   capability and returns false otherwise.
700              */
701             isEnabled: function(capability) {
702                 if (capability === 'fog') {
703                     return _fog;
704                 } else if (capability === 'reflection-map') {
705                     return (_waterHeight > -1);
706                 } else if (capability === 'smooth') {
707                     return (_smooth === 0.5);
708                 }
709                 throw('Unknown capability');
710             },
711 
712             /**
713              * Render the terrain (landscape) including the sky and any visible
714              * sprites.
715              */
716             render: function() {
717                 // Fill the upper region of the framebuffer with the
718                 // fog-colour.
719                 _framebuffer.fillStyle = '#7f7f7f';
720                 _framebuffer.fillRect(0, 100, 320, 25);
721 
722                 _renderSky();
723 
724                 // Empty the list of visible sprites.
725                 _visibleSpriteList.length = 0;
726 
727                 if (_spriteList.length > 0) {
728                     // Calculate the unit vector of the camera.
729                     var cameraVectorX = Math.cos(_camera.angle *
730                         _ANGULAR_INCREMENT * _DEGREES_TO_RADIANS);
731                     var cameraVectorZ = Math.sin(_camera.angle *
732                         _ANGULAR_INCREMENT * _DEGREES_TO_RADIANS);
733 
734                     // For each sprite...
735                     for (var i = 0; i < _spriteList.length; ++i) {
736                         var sprite = _spriteList[i];
737 
738                         // Calculate the vector of the sprite.
739                         var spriteVectorX = sprite.x - _camera.x,
740                             spriteVectorZ = sprite.z - _camera.z;
741 
742                         // Calculate the magnitude (length of the vector) to
743                         // determine the distance from the camera to the
744                         // sprite.
745                         var vectorLength = Math.sqrt(
746                             spriteVectorX * spriteVectorX +
747                             spriteVectorZ * spriteVectorZ);
748 
749                         // If the distance from the camera to the sprite is
750                         // outside the viewing frustum...
751                         if (vectorLength > 400) {
752                             // Move to the next sprite.
753                             continue;
754                         }
755 
756                         // Normalise the sprite vector to become the unit
757                         // vector.
758                         spriteVectorX /= vectorLength;
759                         spriteVectorZ /= vectorLength;
760 
761                         // Calculate the dot product of the camera and sprite
762                         // vectors.
763                         var dotProduct = cameraVectorX * spriteVectorX +
764                             cameraVectorZ * spriteVectorZ;
765 
766                         // If the dot product is negative...
767                         if (dotProduct < 0) {
768                             // The sprite is behind the camera, so clearly not
769                             // in view. Move to the next sprite.
770                             continue;
771                         }
772 
773                         // Calculate the angle (theta) between the camera vector
774                         // and the sprite vector.
775                         var theta = Math.acos(dotProduct);
776 
777                         // If the angle (theta) is less than or equal to 30
778                         // degrees...
779                         // NOTE: We do NOT need to check the lower bound (-30
780                         // degrees) because theta will /never/ be negative.
781                         if (theta <= _THIRTY_DEGREE_ANGLE *
782                             _FAKE_DEGREES_TO_RADIANS) {
783                             var scale = _SCALE_FACTOR / (vectorLength + 1);
784 
785                             // Scale the projected sprite.
786                             var width  = scale * sprite.image.width  | 0;
787                             var height = scale * sprite.image.height | 0;
788 
789                             // Calculate the cross product. The cross product
790                             // differs from the dot product in a crucial way:
791                             // the result is *signed*!
792                             var crossProduct = cameraVectorX * spriteVectorZ -
793                                 spriteVectorX * cameraVectorZ;
794 
795                             // Calculate the projected x coord relative to the
796                             // horizontal centre of the canvas. We add or
797                             // subtract the value dependent on the sign of the
798                             // cross product.
799                             var x;
800                             if (crossProduct < 0) {
801                                 x = _CANVAS_WIDTH / 2 - theta *
802                                     _RADIANS_TO_FAKE_DEGREES | 0;
803                             } else {
804                                 x = _CANVAS_WIDTH / 2 + theta *
805                                     _RADIANS_TO_FAKE_DEGREES | 0;
806                             }
807 
808                             // Calculate the 3D coords of the sprite.
809                             var spriteX = sprite.x,
810                                 spriteY = _heightmap[((sprite.z & 1023) << 10) +
811                                     (sprite.x & 1023)],
812                                 spriteZ = sprite.z;
813 
814                             var row = _getRow(spriteX, spriteZ, x);
815 
816                             // Centre the scaled sprite.
817                             x -= (width >> 1);
818 
819                             var rayX = spriteX,
820                                 rayZ = spriteZ;
821 
822                             var u = rayX & 1023,
823                                 v = rayZ & 1023;
824 
825                             var projectedHeight;
826                             if ((rayX < 1024 || rayX >= 1024 + 1024 ||
827                                 rayZ < 1024 || rayZ >= 1024 + 1024) &&
828                                 _outOfBoundsHeightmap.length > 0) {
829                                 projectedHeight = _outOfBoundsHeightmap[
830                                     (v << 10) + u];
831                             } else {
832                                 projectedHeight = _heightmap[(v << 10) + u];
833                             }
834 
835                             var projectedScale = projectedHeight * scale;
836 
837                             var top = (_CANVAS_HEIGHT >> 1) -
838                                 (_camera.y - _CANVAS_HEIGHT) + row -
839                                 projectedScale,
840                                 bottom = top + projectedScale;
841 
842                             // Add the projected sprite to the list of visible
843                             // sprites.
844                             _visibleSpriteList.push({
845                                 height:       height,
846                                 image:        sprite.image,
847                                 row:          bottom | 0,
848                                 vectorLength: vectorLength,
849                                 width:        width,
850                                 x:            x | 0,
851                                 y:            top - height | 0
852                             });
853                         }
854                     }
855                 }
856 
857                 _sortVisibleSpriteList();
858 //                for (var k = 0; k < _visibleSpriteList.length; ++k) {
859 //                    console.log(_visibleSpriteList[k].vectorLength);
860 //                }
861 
862                 var initialAngle = _camera.angle - _THIRTY_DEGREE_ANGLE;
863 
864                 if (initialAngle < 0) {
865                     initialAngle += _THREE_HUNDRED_AND_SIXTY_DEGREE_ANGLE;
866                 }
867 
868                 initialAngle |= 0;
869 
870 //                var date = new Date();
871 //                var startTime = date.getTime();
872 
873                 /*
874                  * 1. Construct a list of visible sprites; sprites are visible
875                  *    where they fall within the -30..30 (60-degree) horizontal
876                  *    field-of-view based on the direction that the camera is
877                  *    pointing in.
878                  * 2. For each visible sprite, determine its projected 2D
879                  *    coords (x and y) and its scaled width and height based on
880                  *    distance from the camera. The y coord corresponds to the
881                  *    bottom of the sprite and the x coord is the centre of the
882                  *    width of the sprite.
883                  * 3. After drawing each row of terrain, draw any sprites where
884                  *    the sprite's 'projected' row equals the current row.
885                  *    Remove the sprite from the list of visible sprites.
886                  * 4. Rinse and repeat.
887                  */
888 
889                 // Render the terrain front-to-back.
890                 _renderFrontToBack(initialAngle);
891 
892                 // If there are sprites in view...
893                 if (_visibleSpriteList.length > 0) {
894                     _renderSprites();
895                 }
896 
897 //                var date2 = new Date();
898 //                var endTime = date2.getTime();
899 //                console.log(endTime - startTime);
900             },
901 
902             /**
903              * Set the current camera.
904              *
905              * @param {object} camera The object representing the current
906              *                        camera.
907              * @return {Renderer} This (chainable).
908              */
909             setCamera: function(camera) {
910                 if (typeof(camera) != 'object') {
911                     throw('Invalid camera: not an object');
912                 }
913 
914                 _camera.angle = (typeof camera.angle === 'number') ?
915                     (Math.abs(~~(camera.angle + 0.5)) % 360 /
916                     _ANGLE_OF_VIEW * 320) : (_camera.angle);
917                 _camera.x = (typeof camera.x === 'number') ?
918                     (~~(camera.x + 0.5)) : (_camera.x);
919                 if (typeof camera.y === 'number' && camera.y >= 200 &&
920                     camera.y <= 300) {
921                     _camera.y = ~~(camera.y + 0.5);
922                 }
923                 _camera.z = (typeof camera.z === 'number') ?
924                     (~~(camera.z + 0.5)) : (_camera.z);
925 
926                 return this;
927             },
928 
929             /**
930              * Set the fog colour.
931              *
932              * @param {string} cssColor ...
933              * @return {Renderer} This (chainable).
934              */
935             setFogColor: function(cssColor) {
936                 if ( !_fog) {
937                     throw('Capability not enabled');
938                 }
939 
940                 var re = /#([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/;
941                 var matches = re.exec(cssColor);
942 
943                 if (matches.length != 4) {
944                     throw('Invalid cssColor: must be in fully-qualified ' +
945                         'hexadecimal CSS format (#rrggbb)');
946                 }
947 
948                 var red   = matches[1],
949                     green = matches[2],
950                     blue  = matches[3];
951 
952                 _fogColor = ((parseInt(red,   16) << 24) |
953                              (parseInt(green, 16) << 16) |
954                              (parseInt(blue,  16) <<  8) | 0xff);
955 
956                 return this;
957             },
958 
959             /**
960              * Set the heightmap to use for terrain rendering.
961              *
962              * @param {array} heightmap The heightmap canvas as an array of
963              *                          values in the range 0..255.
964              * @return {Renderer} This (chainable).
965              */
966             setHeightmap: function(heightmap) {
967                 if ( !(heightmap instanceof Array)) {
968                     throw('Invalid heightmap: not an array');
969                 }
970 
971                 if (heightmap.length != 1024 * 1024) {
972                     throw('Invalid heightmap: number of array elements ' +
973                         'incorrect');
974                 }
975 
976                 _heightmap = heightmap;
977 
978                 return this;
979             },
980 
981             /**
982              * Set the out-of-bounds heightmap.
983              *
984              * @param {array} outOfBoundsHeightmap The out-of-bounds heightmap
985              *                                     canvas as an array.
986              * @return {Renderer} This (chainable).
987              */
988             setOutOfBoundsHeightmap: function(outOfBoundsHeightmap) {
989                 if ( !(outOfBoundsHeightmap instanceof Array)) {
990                     throw('Invalid outOfBoundsHeightmap: not an array');
991                 }
992 
993                 if (outOfBoundsHeightmap.length != 1024 * 1024) {
994                     throw('Invalid outOfBoundsHeightmap: number of array ' +
995                         'elements incorrect');
996                 }
997 
998                 _outOfBoundsHeightmap = outOfBoundsHeightmap;
999 
1000                 return this;
1001             },
1002 
1003             /**
1004              * Set the out-of-bounds texturemap.
1005              *
1006              * @param {HTMLCanvasElement} outOfBoundsTexturemapCanvas The
1007              *                            out-of-bounds texturemap canvas.
1008              * @return {Renderer} This (chainable).
1009              */
1010             setOutOfBoundsTexturemap: function(
1011                 outOfBoundsTexturemapCanvas) {
1012                 if ( !(outOfBoundsTexturemapCanvas instanceof
1013                     HTMLCanvasElement)) {
1014                     throw('Invalid outOfBoundsTexturemapCanvas: not an ' +
1015                         'instance of HTMLCanvasElement');
1016                 }
1017 
1018                 if (outOfBoundsTexturemapCanvas.width != 1024) {
1019                     throw('outOfBoundsTexturemapCanvas width not equal ' +
1020                         'to 1024');
1021                 }
1022                 if (outOfBoundsTexturemapCanvas.height != 1024) {
1023                     throw('outOfBoundsTexturemapCanvas height not equal ' +
1024                         'to 1024');
1025                 }
1026 
1027                 _outOfBoundsTexturemap =
1028                     outOfBoundsTexturemapCanvas.getContext('2d')
1029                     .getImageData(0, 0, outOfBoundsTexturemapCanvas.width,
1030                     outOfBoundsTexturemapCanvas.height).data;
1031 
1032                 return this;
1033             },
1034 
1035             /**
1036              * Set render quality.
1037              *
1038              * @param {string} quality Specifies a string indicating the
1039              *                         render quality from 'low', through
1040              *                         'medium', to 'high'.
1041              * @return {Renderer} This (chainable).
1042              */
1043             setQuality: function(quality) {
1044                 if (quality === 'medium') {
1045                     _quality = _DONT_CARE;
1046                 } else if (quality === 'low') {
1047                     _quality = _FASTEST;
1048                 } else if (quality === 'high') {
1049                     _quality = _NICEST;
1050                 } else {
1051                     throw("Invalid quality: must be 'low', 'medium', " +
1052                         "or 'high'");
1053                 }
1054 
1055                 return this;
1056             },
1057 
1058             /**
1059              * Set the canvas to use for 360-degree panoramic sky.
1060              *
1061              * @param {HTMLCanvasElement} skyCanvas The sky canvas; must be
1062              *                                      1920x100.
1063              * @return {Renderer} This (chainable).
1064              */
1065             setSky: function(skyCanvas) {
1066                 if ( !(skyCanvas instanceof HTMLCanvasElement)) {
1067                     throw('Invalid skyCanvas: not an instance of ' +
1068                         'HTMLCanvasElement');
1069                 }
1070 
1071                 if (skyCanvas.width != 1920) {
1072                     throw('skyCanvas width not equal to 1920');
1073                 }
1074                 if (skyCanvas.height != 100) {
1075                     throw('skyCanvas height not equal to 100');
1076                 }
1077 
1078                 _sky = skyCanvas;
1079                 _skyData = skyCanvas.getContext('2d').getImageData(0, 0,
1080                     skyCanvas.width, skyCanvas.height).data;
1081 
1082                 return this;
1083             },
1084 
1085             /**
1086              * Set the texturemap.
1087              *
1088              * @param {HTMLCanvasElement} texturemapCanvas The texturemap
1089              *                                             canvas.
1090              * @return {Renderer} This (chainable).
1091              */
1092             setTexturemap: function(texturemapCanvas) {
1093                 if ( !(texturemapCanvas instanceof HTMLCanvasElement)) {
1094                     throw('Invalid texturemapCanvas: not an instance of ' +
1095                         'HTMLCanvasElement');
1096                 }
1097 
1098                 if (texturemapCanvas.width != 1024) {
1099                     throw('texturemapCanvas width not equal to 1024');
1100                 }
1101                 if (texturemapCanvas.height != 1024) {
1102                     throw('texturemapCanvas height not equal to 1024');
1103                 }
1104 
1105                 _texturemap = texturemapCanvas.getContext('2d')
1106                     .getImageData(0, 0, texturemapCanvas.width,
1107                     texturemapCanvas.height).data;
1108 
1109                 return this;
1110             },
1111 
1112             /**
1113              * Set height of the reflection-mapped water.
1114              *
1115              * @param {number} height Globally-defined height of the
1116              *                        reflection-mapped water. It must be
1117              *                        in the range 0..255.
1118              * @return {Renderer} This (chainable).
1119              */
1120             setWaterHeight: function(height) {
1121                 if (_waterHeight == -1) {
1122                     throw('Capability not enabled');
1123                 }
1124 
1125                 if (typeof(height) != 'number') {
1126                     throw('Invalid height: not a number');
1127                 }
1128 
1129                 if (height < 0 || height > 255) {
1130                     throw('Invalid height: must be in the range 0..255');
1131                 }
1132 
1133                 _waterHeight = height;
1134 
1135                 return this;
1136             }
1137         };
1138     };
1139 
1140     return Steppe;
1141 } (Steppe || { }) );
1142