punter.js 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.nvmrc +1 -0
- package/LICENSE.MD +21 -0
- package/README.MD +731 -0
- package/games/pong.html +260 -0
- package/images/pong/sprites/ball.png +0 -0
- package/images/pong/sprites/paddle.png +0 -0
- package/images/punterjs.png +0 -0
- package/package.json +33 -0
- package/punter.js +1514 -0
- package/tests/fixtures/index.html +30 -0
- package/tests/fixtures/silence.wav +0 -0
- package/tests/input-spec.js +104 -0
- package/tests/interface-spec.js +200 -0
- package/tests/jasmine/config.json +20 -0
- package/tests/jasmine/reporter.js +25 -0
- package/tests/jasmine/teardown.js +7 -0
- package/tests/jasmine/timeout.js +1 -0
- package/tests/scenes-spec.js +87 -0
- package/tests/setup.js +49 -0
- package/tests/sounds-spec.js +83 -0
- package/tests/sprites-spec.js +259 -0
package/punter.js
ADDED
|
@@ -0,0 +1,1514 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Simple 2D game engine
|
|
4
|
+
*/
|
|
5
|
+
(function (global) {
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
if (!window.fetch) throw new Error('window.fetch does not exist, are you missing a polyfill?');
|
|
10
|
+
if (!window.Promise) throw new Error('window.Promise does not exist, are you missing a polyfill?');
|
|
11
|
+
|
|
12
|
+
var _debuggingEnabled = false;
|
|
13
|
+
var _debugBackgroundColor = '';
|
|
14
|
+
var _debugTextColor = '';
|
|
15
|
+
var _debugFont = '';
|
|
16
|
+
var log = (typeof SimpleLog === 'function') ? new SimpleLog('engine', '#6899E1', true) : console.log.bind(console, '[engine]');
|
|
17
|
+
|
|
18
|
+
var images = {};
|
|
19
|
+
var sounds = {};
|
|
20
|
+
var playingSounds = {};
|
|
21
|
+
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
22
|
+
var _isMobile = /Mobi|Android|iPhone/i.test(navigator.userAgent);
|
|
23
|
+
|
|
24
|
+
var _canvas;
|
|
25
|
+
var _canvasCtx;
|
|
26
|
+
var _boundsCanvas;
|
|
27
|
+
var _dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
28
|
+
var _boundsCtx;
|
|
29
|
+
var _initilised = false;
|
|
30
|
+
var _scenes = {};
|
|
31
|
+
var _currentScene = null;
|
|
32
|
+
var _running = false;
|
|
33
|
+
var _paused = false;
|
|
34
|
+
var _frame = 0;
|
|
35
|
+
var _totalFrames = 0;
|
|
36
|
+
var _resized = false;
|
|
37
|
+
var _loopId = null;
|
|
38
|
+
var _sprites = {}; // key: id, value: sprite
|
|
39
|
+
var eventHandlers = {
|
|
40
|
+
ready: function() {},
|
|
41
|
+
update: function() {},
|
|
42
|
+
draw: function() {},
|
|
43
|
+
resize: function() {},
|
|
44
|
+
go: function() {}
|
|
45
|
+
};
|
|
46
|
+
var htmlEl = document.documentElement;
|
|
47
|
+
|
|
48
|
+
htmlEl.setAttribute('data-punter-loading', 'true');
|
|
49
|
+
|
|
50
|
+
var keys = {};
|
|
51
|
+
var mouse = { x: 0, y: 0, clicked: false };
|
|
52
|
+
var skipNextMouse = false;
|
|
53
|
+
|
|
54
|
+
window.addEventListener('keydown', function (e) { keys[e.key] = true; });
|
|
55
|
+
window.addEventListener('keyup', function (e) { keys[e.key] = false; });
|
|
56
|
+
|
|
57
|
+
function registerClick(clientX, clientY) {
|
|
58
|
+
mouse.clicked = true;
|
|
59
|
+
if (!_canvas) { mouse.x = clientX; mouse.y = clientY; return; }
|
|
60
|
+
var rect = _canvas.getBoundingClientRect();
|
|
61
|
+
var canvasScale = _canvas.width / rect.width;
|
|
62
|
+
mouse.x = Math.round((clientX - rect.left) * canvasScale);
|
|
63
|
+
mouse.y = Math.round((clientY - rect.top) * canvasScale);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
document.addEventListener('touchstart', function (e) {
|
|
67
|
+
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
|
|
70
|
+
if (e.touches.length) {
|
|
71
|
+
skipNextMouse = true;
|
|
72
|
+
var touch = e.touches[0];
|
|
73
|
+
registerClick(touch.clientX, touch.clientY);
|
|
74
|
+
}
|
|
75
|
+
}, { capture: true });
|
|
76
|
+
|
|
77
|
+
document.addEventListener('mousedown', function (e) {
|
|
78
|
+
if (skipNextMouse) { skipNextMouse = false; return; }
|
|
79
|
+
registerClick(e.clientX, e.clientY);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* boundingCache - Simple fixed-size cache for sprite bounds
|
|
84
|
+
* Used to avoid re-running getBounds() on sprites
|
|
85
|
+
*/
|
|
86
|
+
var boundingCache = (function () {
|
|
87
|
+
var MAX = 1000;
|
|
88
|
+
var store = Object.create(null);
|
|
89
|
+
var keys = [];
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
get: function (key) {
|
|
93
|
+
return store[key] || null;
|
|
94
|
+
},
|
|
95
|
+
set: function (key, value) {
|
|
96
|
+
if (!store[key]) {
|
|
97
|
+
if (keys.length >= MAX) {
|
|
98
|
+
var oldest = keys.shift();
|
|
99
|
+
delete store[oldest];
|
|
100
|
+
}
|
|
101
|
+
keys.push(key);
|
|
102
|
+
}
|
|
103
|
+
store[key] = value;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Initialises the engine with canvas, sprites, sounds, and buttons
|
|
110
|
+
* @param {Object} config - configuration object
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
function init(config) {
|
|
114
|
+
|
|
115
|
+
config = config || {};
|
|
116
|
+
|
|
117
|
+
_debuggingEnabled = (config.debug === true);
|
|
118
|
+
|
|
119
|
+
if (typeof config.canvas === 'string') {
|
|
120
|
+
_canvas = document.querySelector(config.canvas);
|
|
121
|
+
}
|
|
122
|
+
else if (config.canvas instanceof HTMLCanvasElement) {
|
|
123
|
+
_canvas = config.canvas;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
throw new Error('Invalid config.canvas');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ensure canvas has connect styles
|
|
130
|
+
_canvas.style.position = 'absolute';
|
|
131
|
+
_canvas.style.top = '50%';
|
|
132
|
+
_canvas.style.left = '50%';
|
|
133
|
+
_canvas.style.bottom = '';
|
|
134
|
+
_canvas.style.right = '';
|
|
135
|
+
_canvas.style.transformOrigin = 'center center';
|
|
136
|
+
_canvas.style.imageRendering = 'pixelated';
|
|
137
|
+
_canvas.style.touchAction = 'none';
|
|
138
|
+
_canvas.style.overflow = 'hidden';
|
|
139
|
+
_canvas.style.webkitTouchCallout = 'none';
|
|
140
|
+
_canvas.style.webkitTapHighlightColor = 'transparent';
|
|
141
|
+
_canvas.style.touchAction = 'none';
|
|
142
|
+
_canvas.style.pointerEvents = 'none';
|
|
143
|
+
_canvas.style.contain = 'strict';
|
|
144
|
+
_canvas.style.willChange = 'transform';
|
|
145
|
+
_canvas.style.transform = 'translateZ(0)';
|
|
146
|
+
|
|
147
|
+
setTimeout(resize, 0);
|
|
148
|
+
setupResponsiveResize();
|
|
149
|
+
|
|
150
|
+
// create a background canvas to speed up getBounds
|
|
151
|
+
_boundsCanvas = document.createElement('canvas');
|
|
152
|
+
_boundsCtx = _boundsCanvas.getContext('2d', { willReadFrequently: true });
|
|
153
|
+
|
|
154
|
+
loadSprites(config.sprites || {}).then(function() {
|
|
155
|
+
|
|
156
|
+
// precompute bounds for all sprite frames
|
|
157
|
+
if (config.sprites) {
|
|
158
|
+
for (var key in config.sprites) {
|
|
159
|
+
var img = images[key];
|
|
160
|
+
if (img && img.complete && img.naturalWidth) {
|
|
161
|
+
// this warms up the boundingCache
|
|
162
|
+
var cache = boundingCache.get(key);
|
|
163
|
+
if (!cache) {
|
|
164
|
+
var bounds = getBounds(img, 1);
|
|
165
|
+
boundingCache.set(key, bounds);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
.then(function() {
|
|
172
|
+
return loadSounds(config.sounds || {});
|
|
173
|
+
})
|
|
174
|
+
.then(function() {
|
|
175
|
+
_initilised = true;
|
|
176
|
+
htmlEl.removeAttribute('data-punter-loading');
|
|
177
|
+
eventHandlers.ready();
|
|
178
|
+
})
|
|
179
|
+
.catch(function() {
|
|
180
|
+
_initilised = false;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Loads and decodes all sprite images
|
|
186
|
+
* @param {Object} sprites - Key-value map of sprite keys to image URLs
|
|
187
|
+
* @returns {Promise} - Resolves when all images are loaded and decoded
|
|
188
|
+
*/
|
|
189
|
+
function loadSprites(sprites) {
|
|
190
|
+
|
|
191
|
+
var keys = Object.keys(sprites);
|
|
192
|
+
var total = keys.length;
|
|
193
|
+
|
|
194
|
+
if (!total) return Promise.resolve();
|
|
195
|
+
|
|
196
|
+
return new Promise(function (resolve, reject) {
|
|
197
|
+
var loaded = 0;
|
|
198
|
+
var failed = false;
|
|
199
|
+
|
|
200
|
+
function handleLoad(key) {
|
|
201
|
+
var self = this;
|
|
202
|
+
|
|
203
|
+
self.onload = null;
|
|
204
|
+
self.onerror = null;
|
|
205
|
+
|
|
206
|
+
function finalize() {
|
|
207
|
+
images[key] = self;
|
|
208
|
+
|
|
209
|
+
// precompute and cache bounding box for this sprite key
|
|
210
|
+
if (!boundingCache.get(key)) {
|
|
211
|
+
var bounds = getBounds(self, 1);
|
|
212
|
+
boundingCache.set(key, bounds);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
loaded++;
|
|
216
|
+
if (loaded === total && !failed) resolve();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// only decode after image is fully loaded
|
|
220
|
+
if (typeof self.decode === 'function') {
|
|
221
|
+
self.decode().then(finalize).catch(finalize); // decode may fail, not fatal
|
|
222
|
+
} else {
|
|
223
|
+
finalize();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handleError(key, url) {
|
|
228
|
+
this.onload = null;
|
|
229
|
+
this.onerror = null;
|
|
230
|
+
if (failed) return;
|
|
231
|
+
failed = true;
|
|
232
|
+
reject(new Error('Failed to load sprite "' + key + '" from ' + url));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (var i = 0; i < total; i++) {
|
|
236
|
+
var key = keys[i];
|
|
237
|
+
var url = sprites[key];
|
|
238
|
+
var img = new Image();
|
|
239
|
+
img.key = key; // for debugging
|
|
240
|
+
img.onload = handleLoad.bind(img, key);
|
|
241
|
+
img.onerror = handleError.bind(img, key, url);
|
|
242
|
+
img.src = url;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function loadSounds(audioMap) {
|
|
248
|
+
|
|
249
|
+
var keys = Object.keys(audioMap);
|
|
250
|
+
var total = keys.length;
|
|
251
|
+
if (!total) return Promise.resolve();
|
|
252
|
+
|
|
253
|
+
return new Promise(function (resolve, reject) {
|
|
254
|
+
var loaded = 0;
|
|
255
|
+
var failed = false;
|
|
256
|
+
|
|
257
|
+
function handleSuccess(key, buffer) {
|
|
258
|
+
sounds[key] = buffer;
|
|
259
|
+
loaded++;
|
|
260
|
+
if (loaded === total && !failed) resolve();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function handleError(key, url) {
|
|
264
|
+
if (failed) return;
|
|
265
|
+
failed = true;
|
|
266
|
+
reject(new Error('Failed to load sound "' + key + '" from ' + url));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function decodeAndStore(key, url, buf) {
|
|
270
|
+
audioCtx.decodeAudioData(buf, function (decoded) {
|
|
271
|
+
handleSuccess(key, decoded);
|
|
272
|
+
}, function () {
|
|
273
|
+
handleError(key, url);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (var i = 0; i < total; i++) {
|
|
278
|
+
(function (key, url) {
|
|
279
|
+
fetch(url).then(function (res) {
|
|
280
|
+
return res.arrayBuffer();
|
|
281
|
+
}).then(function (buffer) {
|
|
282
|
+
decodeAndStore(key, url, buffer);
|
|
283
|
+
}).catch(function () {
|
|
284
|
+
handleError(key, url);
|
|
285
|
+
});
|
|
286
|
+
})(keys[i], audioMap[keys[i]]);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Resolves a size from number or percentage string
|
|
293
|
+
* @param {number|string|null} value - Size value (e.g. 100 or '25%')
|
|
294
|
+
* @param {number} base - Base size to use for percentage
|
|
295
|
+
* @returns {number} resolved pixel size or -1 if invalid
|
|
296
|
+
*/
|
|
297
|
+
function resolveSize(value, base) {
|
|
298
|
+
if (typeof value === 'string' && value.indexOf('%') !== -1) {
|
|
299
|
+
var pct = parseFloat(value);
|
|
300
|
+
if (!isNaN(pct)) {
|
|
301
|
+
return Math.floor(base * (pct / 100));
|
|
302
|
+
// return Math.round(base * (pct / 100));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else if (typeof value === 'number') {
|
|
306
|
+
return Math.floor(value);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return -1;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Resolves an x or y position from number or percentage string
|
|
314
|
+
* @param {number|string|null} val - Position value (e.g. 100 or '25%')
|
|
315
|
+
* @param {number} base - Base value (canvas width or height)
|
|
316
|
+
* @param {number} scale - Scale factor for numeric values
|
|
317
|
+
* @param {number} lastValue - Previous pixel value (for scaling)
|
|
318
|
+
* @returns {number} resolved pixel position
|
|
319
|
+
*/
|
|
320
|
+
function resolvePosition(val, base, scale, lastValue) {
|
|
321
|
+
if (typeof val === 'string' && val.indexOf('%') !== -1) {
|
|
322
|
+
return resolveSize(val, base); // re-evaluate based on updated base
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (typeof val === 'number') {
|
|
326
|
+
return Math.floor(val * scale); // scale the original numeric value
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return Math.floor(lastValue); // fallback to previous
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Finalizes sprite size using aspect ratio if needed
|
|
334
|
+
* @param {number|null} w - Initial width or null
|
|
335
|
+
* @param {number|null} h - Initial height or null
|
|
336
|
+
* @param {boolean} preserveAspect - Whether to preserve aspect ratio
|
|
337
|
+
* @param {number} ar - Aspect ratio (width / height)
|
|
338
|
+
* @param {number} imgW - Image natural width
|
|
339
|
+
* @param {number} imgH - Image natural height
|
|
340
|
+
* @returns {{ w: number, h: number }} - Final width and height
|
|
341
|
+
*/
|
|
342
|
+
function finalizeSize(w, h, preserveAspect, ar, imgW, imgH) {
|
|
343
|
+
|
|
344
|
+
var finalW = w;
|
|
345
|
+
var finalH = h;
|
|
346
|
+
|
|
347
|
+
if (preserveAspect) {
|
|
348
|
+
|
|
349
|
+
if (finalW && !finalH) {
|
|
350
|
+
finalH = finalW / ar;
|
|
351
|
+
}
|
|
352
|
+
else if (!finalW && finalH) {
|
|
353
|
+
finalW = finalH * ar;
|
|
354
|
+
}
|
|
355
|
+
else if (!finalW && !finalH) {
|
|
356
|
+
finalW = imgW;
|
|
357
|
+
finalH = imgH;
|
|
358
|
+
}
|
|
359
|
+
// if both are provided, do not override aspect — trust the values
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
if (!finalW) finalW = imgW;
|
|
363
|
+
if (!finalH) finalH = imgH;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
w: Math.floor(finalW),
|
|
368
|
+
h: Math.floor(finalH)
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Draws width x height and x y labels near sprite
|
|
374
|
+
* @param {CanvasRenderingContext2D} ctx - canvas drawing context
|
|
375
|
+
* @param {number} dx - x position of the sprite
|
|
376
|
+
* @param {number} dy - y position of the sprite
|
|
377
|
+
* @param {number} dw - draw width of the sprite
|
|
378
|
+
* @param {number} dh - draw height of the sprite
|
|
379
|
+
* @param {number} canvasH - height of the canvas
|
|
380
|
+
* @returns {void}
|
|
381
|
+
*/
|
|
382
|
+
function drawSpriteLabels(ctx, dx, dy, dw, dh, canvasH) {
|
|
383
|
+
|
|
384
|
+
var label = [
|
|
385
|
+
'x=' + Math.floor(dx) + ' ',
|
|
386
|
+
'y=' + Math.floor(dy) + ' ',
|
|
387
|
+
'(' + Math.floor(dw) + 'x' + Math.floor(dh) + ')'
|
|
388
|
+
].join('');
|
|
389
|
+
|
|
390
|
+
ctx.font = _debugFont;
|
|
391
|
+
var metrics = ctx.measureText(label);
|
|
392
|
+
var textWidth = metrics.width;
|
|
393
|
+
|
|
394
|
+
var ascent = metrics.actualBoundingBoxAscent || 10;
|
|
395
|
+
var descent = metrics.actualBoundingBoxDescent || 4;
|
|
396
|
+
var textHeight = ascent + descent;
|
|
397
|
+
|
|
398
|
+
var textX = Math.floor(dx + (dw - textWidth) / 2);
|
|
399
|
+
var textY;
|
|
400
|
+
|
|
401
|
+
if (dy + dh + textHeight + 2 < canvasH) {
|
|
402
|
+
textY = dy + dh + textHeight;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
textY = dy - 4;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// draw background box
|
|
409
|
+
ctx.fillStyle = _debugBackgroundColor;
|
|
410
|
+
ctx.fillRect(textX - 2, textY - textHeight + 2, textWidth + 4, textHeight);
|
|
411
|
+
|
|
412
|
+
// draw text
|
|
413
|
+
ctx.fillStyle = _debugTextColor;
|
|
414
|
+
ctx.fillText(label, textX, textY);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Renders debug info overlay in bottom-right corner of canvas
|
|
419
|
+
* @param {CanvasRenderingContext2D} ctx - canvas drawing context
|
|
420
|
+
* @param {number} frame - current frame count
|
|
421
|
+
* @param {number} fps - current frames per second
|
|
422
|
+
* @param {number} canvasW - canvas width
|
|
423
|
+
* @param {number} canvasH - canvas height
|
|
424
|
+
* @returns {void}
|
|
425
|
+
*/
|
|
426
|
+
function drawDebugInfo(ctx, frame, fps, canvasW, canvasH) {
|
|
427
|
+
|
|
428
|
+
var pad = 10;
|
|
429
|
+
var textPad = 4;
|
|
430
|
+
|
|
431
|
+
var label = 'Frame: ' + frame + ' | FPS: ' + fps + ' | Canvas: ' + canvasW + 'x' + canvasH + ' | ' + engine.orientation;
|
|
432
|
+
|
|
433
|
+
ctx.font = _debugFont;
|
|
434
|
+
|
|
435
|
+
var metrics = ctx.measureText(label);
|
|
436
|
+
var textW = metrics.width;
|
|
437
|
+
|
|
438
|
+
var ascent = metrics.actualBoundingBoxAscent || 10;
|
|
439
|
+
var descent = metrics.actualBoundingBoxDescent || 4;
|
|
440
|
+
var textH = ascent + descent;
|
|
441
|
+
|
|
442
|
+
var boxW = textW + textPad * 2;
|
|
443
|
+
var boxH = textH + textPad * 2;
|
|
444
|
+
|
|
445
|
+
var boxX = canvasW - boxW - pad;
|
|
446
|
+
var boxY = canvasH - boxH - pad;
|
|
447
|
+
|
|
448
|
+
ctx.fillStyle = _debugBackgroundColor;
|
|
449
|
+
ctx.fillRect(boxX, boxY, boxW, boxH);
|
|
450
|
+
|
|
451
|
+
ctx.fillStyle = _debugTextColor;
|
|
452
|
+
ctx.fillText(label, boxX + textPad, boxY + textPad + ascent);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Gets a CSS variable from :root
|
|
457
|
+
* @param {string} name - css variable name (without --)
|
|
458
|
+
* @param {string} fallback - fallback value if not found
|
|
459
|
+
* @returns {string} resolved css value
|
|
460
|
+
*/
|
|
461
|
+
function getCssVar(name, fallback) {
|
|
462
|
+
var rootStyles = getComputedStyle(document.documentElement);
|
|
463
|
+
return rootStyles.getPropertyValue('--' + name).trim() || fallback;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Creates a sprite with optional animation, scaling, and collision bounds
|
|
468
|
+
* @param {Object} opts - Sprite config
|
|
469
|
+
* @param {string} opts.id - unique id for the sprite
|
|
470
|
+
* @param {string|string[]} opts.key - sprite key used when loading sprite (use array for animations)
|
|
471
|
+
* @param {number} opts.x - x position
|
|
472
|
+
* @param {number} opts.y - y position
|
|
473
|
+
* @param {number} [opts.w] - width
|
|
474
|
+
* @param {number} [opts.h] - height
|
|
475
|
+
* @param {boolean} [opts.preserveAspect=true] - maintain image aspect ratio
|
|
476
|
+
* @param {boolean} [collidable=true] - whether to compute collision bounds (default = true)
|
|
477
|
+
* @returns {Object} new sprite object
|
|
478
|
+
*/
|
|
479
|
+
function Sprite(opts) {
|
|
480
|
+
|
|
481
|
+
if (!opts || typeof opts !== 'object') throw new Error('Sprite: missing opts param');
|
|
482
|
+
if (!opts.id || _sprites[opts.id]) throw new Error('Sprite: id must be unique');
|
|
483
|
+
if (!opts.key) throw new Error('Sprite: missing key');
|
|
484
|
+
if (typeof opts.x === 'undefined') throw new Error('Sprite: missing x');
|
|
485
|
+
if (typeof opts.y === 'undefined') throw new Error('Sprite: missing y');
|
|
486
|
+
|
|
487
|
+
// option values
|
|
488
|
+
this.id = opts.id;
|
|
489
|
+
this.key = opts.key;
|
|
490
|
+
this.preserveAspect = (opts.preserveAspect !== false);
|
|
491
|
+
this.collidable = (opts.collidable !== false);
|
|
492
|
+
this.outline = (typeof opts.outline === 'string') ? opts.outline : null;
|
|
493
|
+
this.frame = null; // optional override by game logic
|
|
494
|
+
this.repeatX = (opts.repeatX === true);
|
|
495
|
+
this.repeatY = (opts.repeatY === true);
|
|
496
|
+
this.clipHeight = (typeof opts.clipHeight === 'number') ? opts.clipHeight : null;
|
|
497
|
+
this.clipFrom = opts.clipFrom === 'top' ? 'top' : 'bottom';
|
|
498
|
+
|
|
499
|
+
// w/h
|
|
500
|
+
this.originalW = (typeof opts.w !== 'undefined') ? opts.w : null;
|
|
501
|
+
this.originalH = (typeof opts.h !== 'undefined') ? opts.h : null;
|
|
502
|
+
this.originalCanvasW = engine.width;
|
|
503
|
+
this.originalCanvasH = engine.height;
|
|
504
|
+
this.w = resolveSize(opts.w, engine.width);
|
|
505
|
+
this.h = resolveSize(opts.h, engine.height);
|
|
506
|
+
if (this.w < 0) this.w = null;
|
|
507
|
+
if (this.h < 0) this.h = null;
|
|
508
|
+
|
|
509
|
+
// x/y
|
|
510
|
+
this.originalX = (typeof opts.x !== 'undefined') ? opts.x : 0;
|
|
511
|
+
this.originalY = (typeof opts.y !== 'undefined') ? opts.y : 0;
|
|
512
|
+
this.x = resolveSize(this.originalX, engine.width);
|
|
513
|
+
this.y = resolveSize(this.originalY, engine.height);
|
|
514
|
+
if (this.x < 0) this.x = 0;
|
|
515
|
+
if (this.y < 0) this.y = 0;
|
|
516
|
+
this.initialX = this.x;
|
|
517
|
+
this.initialY = this.y;
|
|
518
|
+
|
|
519
|
+
if (this.repeatX && this.repeatY) {
|
|
520
|
+
throw new Error('Sprite: cannot set both repeatX and repeatY');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
this._frameIndex = 0;
|
|
524
|
+
this._animated = Array.isArray(this.key);
|
|
525
|
+
|
|
526
|
+
var initialDrawKey = Array.isArray(this.key) ? this.key[0] : this.key;
|
|
527
|
+
var img = images[initialDrawKey];
|
|
528
|
+
|
|
529
|
+
if (!img || !img.complete || !img.naturalWidth) throw new Error('Sprite: image not loaded ' + initialDrawKey);
|
|
530
|
+
|
|
531
|
+
// infer size immediately if needed
|
|
532
|
+
this.aspectRatio = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 1;
|
|
533
|
+
|
|
534
|
+
// now get the final size
|
|
535
|
+
var finalSize = finalizeSize(this.w, this.h, this.preserveAspect, this.aspectRatio, img.naturalWidth, img.naturalHeight);
|
|
536
|
+
|
|
537
|
+
this.w = finalSize.w;
|
|
538
|
+
this.h = finalSize.h;
|
|
539
|
+
|
|
540
|
+
// cache sprite in memory
|
|
541
|
+
_sprites[this.id] = this;
|
|
542
|
+
}
|
|
543
|
+
Sprite.prototype.getFrameKey = function () {
|
|
544
|
+
if (!this._animated) return this.key;
|
|
545
|
+
|
|
546
|
+
var index = (typeof this.frame === 'number' && this.frame >= 0) ? this.frame : this._frameIndex;
|
|
547
|
+
|
|
548
|
+
return this.key[index % this.key.length];
|
|
549
|
+
};
|
|
550
|
+
Sprite.prototype.update = function () {};
|
|
551
|
+
Sprite.prototype.draw = function (ctx) {
|
|
552
|
+
|
|
553
|
+
ctx = ctx || _canvasCtx;
|
|
554
|
+
|
|
555
|
+
if (this.destroyed) return;
|
|
556
|
+
|
|
557
|
+
// always track visibility for seen
|
|
558
|
+
if (!this._seen && this.visible) {
|
|
559
|
+
this._seen = true;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (this.repeatX) return this.drawRepeatX(ctx);
|
|
563
|
+
if (this.repeatY) return this.drawRepeatY(ctx);
|
|
564
|
+
|
|
565
|
+
var drawKey = this.getFrameKey(); // frame key to draw (single or animated)
|
|
566
|
+
var img = images[drawKey]; // loaded image object
|
|
567
|
+
if (!img || !img.complete || !img.naturalWidth) return;
|
|
568
|
+
|
|
569
|
+
var dw = Math.floor(this.w); // draw width (scaled)
|
|
570
|
+
var dh = Math.floor(this.h); // draw height (scaled)
|
|
571
|
+
var dx = Math.floor(this.x); // draw x position
|
|
572
|
+
var dy = Math.floor(this.y); // draw y position
|
|
573
|
+
|
|
574
|
+
var canvasW = engine.width; // canvas width
|
|
575
|
+
var canvasH = engine.height; // canvas height
|
|
576
|
+
|
|
577
|
+
// skip draw if fully offscreen
|
|
578
|
+
if (dx + dw <= 0 || dy + dh <= 0 || dx >= canvasW || dy >= canvasH) return;
|
|
579
|
+
|
|
580
|
+
var sx = 0; // source crop x
|
|
581
|
+
var sy = 0; // source crop y
|
|
582
|
+
var sw = img.naturalWidth; // source width
|
|
583
|
+
var sh = img.naturalHeight; // source height
|
|
584
|
+
|
|
585
|
+
// vertical clipping if clipHeight is set
|
|
586
|
+
if (this.clipHeight != null && this.clipHeight < dh) {
|
|
587
|
+
var ratio = this.clipHeight / dh; // visible ratio
|
|
588
|
+
sh = sh * ratio; // shrink source height
|
|
589
|
+
dh = this.clipHeight; // limit draw height
|
|
590
|
+
|
|
591
|
+
if (this.clipFrom === 'bottom') {
|
|
592
|
+
sy = img.naturalHeight - sh; // shift crop from bottom
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// clip top if above canvas
|
|
597
|
+
if (dy < 0) {
|
|
598
|
+
sy += (-dy / dh) * sh; // shift crop y
|
|
599
|
+
sh -= (-dy / dh) * sh; // reduce source height
|
|
600
|
+
dh += dy; // reduce draw height
|
|
601
|
+
dy = 0;
|
|
602
|
+
}
|
|
603
|
+
else if (dy + dh > canvasH) {
|
|
604
|
+
var overflow = (dy + dh) - canvasH; // overflow bottom
|
|
605
|
+
sh -= (overflow / dh) * sh; // reduce crop height
|
|
606
|
+
dh -= overflow; // reduce draw height
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// clip left if off left edge
|
|
610
|
+
if (dx < 0) {
|
|
611
|
+
sx = (-dx / dw) * sw; // shift crop x
|
|
612
|
+
sw -= sx; // reduce crop width
|
|
613
|
+
dw += dx; // reduce draw width
|
|
614
|
+
dx = 0;
|
|
615
|
+
}
|
|
616
|
+
else if (dx + dw > canvasW) {
|
|
617
|
+
var overflow = (dx + dw) - canvasW; // overflow right
|
|
618
|
+
sw -= (overflow / dw) * sw; // reduce crop width
|
|
619
|
+
dw -= overflow; // reduce draw width
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// draw if everything valid
|
|
623
|
+
if (dw > 0 && dh > 0 && sw > 0 && sh > 0) {
|
|
624
|
+
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
|
|
625
|
+
|
|
626
|
+
if (this.outline) {
|
|
627
|
+
// draw box around the destination area
|
|
628
|
+
ctx.strokeStyle = this.outline; // outline color
|
|
629
|
+
ctx.lineWidth = 1; // outline thickness
|
|
630
|
+
ctx.strokeRect(dx, dy, dw, dh);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (_debuggingEnabled) {
|
|
635
|
+
drawSpriteLabels(ctx, dx, dy, dw, dh, canvasH);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (this.collidable) {
|
|
639
|
+
this._drawWidth = dw;
|
|
640
|
+
this._drawHeight = dh;
|
|
641
|
+
this.computeBounds(img);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
Sprite.prototype.resize = function () {
|
|
645
|
+
|
|
646
|
+
if (this.destroyed) return;
|
|
647
|
+
|
|
648
|
+
var imgKey = this.getFrameKey();
|
|
649
|
+
var img = images[imgKey];
|
|
650
|
+
if (!img || !img.complete || !img.naturalWidth) return;
|
|
651
|
+
|
|
652
|
+
// if (this.originalCanvasW <= 0 || this.originalCanvasH <= 0) return;
|
|
653
|
+
|
|
654
|
+
if (_debuggingEnabled) {
|
|
655
|
+
this._resizeStats = this._resizeStats || [];
|
|
656
|
+
this._resizeStats.push({
|
|
657
|
+
x: this.x,
|
|
658
|
+
y: this.y,
|
|
659
|
+
aw: this.w,
|
|
660
|
+
ah: this.h
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
var scaleW = engine.width / this.originalCanvasW;
|
|
665
|
+
var scaleH = engine.height / this.originalCanvasH;
|
|
666
|
+
|
|
667
|
+
// scale proportionally
|
|
668
|
+
var resolvedW = resolveSize(this.originalW, engine.width);
|
|
669
|
+
var resolvedH = resolveSize(this.originalH, engine.height);
|
|
670
|
+
var resolvedX = resolvePosition(this.originalX, engine.width, scaleW, this.x);
|
|
671
|
+
var resolvedY = resolvePosition(this.originalY, engine.height, scaleH, this.y);
|
|
672
|
+
|
|
673
|
+
this.w = (resolvedW > 0) ? resolvedW : null;
|
|
674
|
+
this.h = (resolvedH > 0) ? resolvedH : null;
|
|
675
|
+
this.x = this.initialX = (resolvedX > 0) ? resolvedX : this.x;
|
|
676
|
+
this.y = this.initialY = (resolvedY > 0) ? resolvedY : this.y;
|
|
677
|
+
|
|
678
|
+
// now get the final size
|
|
679
|
+
var finalSize = finalizeSize(this.w, this.h, this.preserveAspect, this.aspectRatio, img.naturalWidth, img.naturalHeight);
|
|
680
|
+
this.w = finalSize.w;
|
|
681
|
+
this.h = finalSize.h;
|
|
682
|
+
|
|
683
|
+
log('Resize', this.id, {
|
|
684
|
+
orientation: engine.orientation,
|
|
685
|
+
canvasW: engine.width,
|
|
686
|
+
canvasH: engine.height,
|
|
687
|
+
originalW: this.originalW,
|
|
688
|
+
originalH: this.originalH,
|
|
689
|
+
resolvedW: resolvedW,
|
|
690
|
+
resolvedH: resolvedH,
|
|
691
|
+
finalW: this.w,
|
|
692
|
+
finalH: this.h,
|
|
693
|
+
originalX: this.originalX,
|
|
694
|
+
originalY: this.originalY,
|
|
695
|
+
resolvedX: resolvedX,
|
|
696
|
+
resolvedY: resolvedY
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
if (this.collidable && !this.repeatX && !this.repeatY) {
|
|
700
|
+
this.computeBounds(img);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// now update canvas ref so future resize is from here
|
|
704
|
+
this.originalCanvasW = engine.width;
|
|
705
|
+
this.originalCanvasH = engine.height;
|
|
706
|
+
};
|
|
707
|
+
/**
|
|
708
|
+
* Computes bounding box from image and caches relative bounds
|
|
709
|
+
* @param {HTMLImageElement} img - Sprite image
|
|
710
|
+
* @returns {void}
|
|
711
|
+
*/
|
|
712
|
+
Sprite.prototype.computeBounds = function (img) {
|
|
713
|
+
var frameKey = this.getFrameKey();
|
|
714
|
+
|
|
715
|
+
if (!this.relBounds || this._lastBoundsKey !== frameKey) {
|
|
716
|
+
this.relBounds = boundingCache.get(frameKey);
|
|
717
|
+
if (!this.relBounds) {
|
|
718
|
+
this.relBounds = getBounds(img, 1);
|
|
719
|
+
boundingCache.set(frameKey, this.relBounds);
|
|
720
|
+
}
|
|
721
|
+
this._lastBoundsKey = frameKey;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// scale relBounds from image space into sprite draw space
|
|
725
|
+
var scaleX = (this._drawWidth || this.w) / img.naturalWidth;
|
|
726
|
+
var scaleY = (this._drawHeight || this.h) / img.naturalHeight;
|
|
727
|
+
|
|
728
|
+
this.bounds = {
|
|
729
|
+
x: this.x + this.relBounds.x * scaleX,
|
|
730
|
+
y: this.y + this.relBounds.y * scaleY,
|
|
731
|
+
w: this.relBounds.w * scaleX,
|
|
732
|
+
h: this.relBounds.h * scaleY
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
if (_debuggingEnabled) {
|
|
736
|
+
_canvasCtx.strokeStyle = 'red';
|
|
737
|
+
_canvasCtx.lineWidth = 1;
|
|
738
|
+
_canvasCtx.strokeRect(this.bounds.x, this.bounds.y, this.bounds.w, this.bounds.h);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
Sprite.prototype.drawRepeatX = function (ctx) {
|
|
742
|
+
|
|
743
|
+
var imgKey = this.getFrameKey();
|
|
744
|
+
var img = images[imgKey];
|
|
745
|
+
if (!img || !img.complete || !img.naturalWidth) return;
|
|
746
|
+
|
|
747
|
+
var x = Math.floor(this.x);
|
|
748
|
+
var y = Math.floor(this.y);
|
|
749
|
+
var w = this.w;
|
|
750
|
+
var h = this.h;
|
|
751
|
+
|
|
752
|
+
if (w <= 0 || h <= 0) return;
|
|
753
|
+
|
|
754
|
+
var sw = img.naturalWidth;
|
|
755
|
+
var sh = img.naturalHeight;
|
|
756
|
+
|
|
757
|
+
var startX = Math.floor(x % w);
|
|
758
|
+
|
|
759
|
+
for (var px = startX - w; px < engine.width; px += w) {
|
|
760
|
+
ctx.drawImage(img, 0, 0, sw, sh, Math.floor(px), y, w, h);
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
Sprite.prototype.drawRepeatY = function (ctx) {
|
|
764
|
+
|
|
765
|
+
var imgKey = this.getFrameKey();
|
|
766
|
+
var img = images[imgKey];
|
|
767
|
+
if (!img || !img.naturalHeight) return;
|
|
768
|
+
|
|
769
|
+
var x = Math.floor(this.x);
|
|
770
|
+
var y = Math.floor(this.y);
|
|
771
|
+
var w = this.w;
|
|
772
|
+
var h = this.h;
|
|
773
|
+
|
|
774
|
+
if (w <= 0 || h <= 0) return;
|
|
775
|
+
|
|
776
|
+
var sw = img.naturalWidth;
|
|
777
|
+
var sh = img.naturalHeight;
|
|
778
|
+
|
|
779
|
+
var startY = Math.floor(y % h);
|
|
780
|
+
|
|
781
|
+
for (var py = startY - h; py < engine.height; py += h) {
|
|
782
|
+
ctx.drawImage(img, 0, 0, sw, sh, x, Math.floor(py), w, h);
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
Sprite.prototype.animate = function (delayBetweenFrames) {
|
|
786
|
+
if (!this._animated) return;
|
|
787
|
+
|
|
788
|
+
var now = Date.now();
|
|
789
|
+
this._lastFrameTime = this._lastFrameTime || now;
|
|
790
|
+
|
|
791
|
+
if (now - this._lastFrameTime >= delayBetweenFrames) {
|
|
792
|
+
this._lastFrameTime = now;
|
|
793
|
+
this._frameIndex = (this._frameIndex + 1) % this.key.length;
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
Sprite.prototype.moveX = function (dx) {
|
|
797
|
+
this.x = this.x + dx;
|
|
798
|
+
};
|
|
799
|
+
Sprite.prototype.moveY = function (dy) {
|
|
800
|
+
this.y = this.y + dy;
|
|
801
|
+
};
|
|
802
|
+
Sprite.prototype.center = function (offsetX, offsetY) {
|
|
803
|
+
this.centerX(offsetX);
|
|
804
|
+
this.centerY(offsetY);
|
|
805
|
+
};
|
|
806
|
+
Sprite.prototype.centerX = function (offsetX) {
|
|
807
|
+
offsetX = offsetX || 0;
|
|
808
|
+
this.x = Math.floor((engine.width - this.w) / 2) + offsetX;
|
|
809
|
+
};
|
|
810
|
+
Sprite.prototype.centerY = function (offsetY) {
|
|
811
|
+
offsetY = offsetY || 0;
|
|
812
|
+
this.y = Math.floor((engine.height - this.h) / 2) + offsetY;
|
|
813
|
+
};
|
|
814
|
+
Sprite.prototype.bounce = function (range, speed) {
|
|
815
|
+
range = (typeof range === 'number') ? range : 8;
|
|
816
|
+
speed = (typeof speed === 'number') ? speed : 10;
|
|
817
|
+
|
|
818
|
+
this.bounceTick = (this.bounceTick === undefined) ? 0 : this.bounceTick + 1;
|
|
819
|
+
this.y = Math.floor(this.initialY + Math.sin(this.bounceTick / speed) * range);
|
|
820
|
+
};
|
|
821
|
+
/**
|
|
822
|
+
* Scrolls a sprite in the X direction and respawns after delay if offscreen
|
|
823
|
+
* @param {number} speed - horizontal speed in pixels per frame
|
|
824
|
+
* @param {number} respawnAfterMs - delay in ms before respawning
|
|
825
|
+
* @param {number} [offset=50] - extra offset beyond screen edge for respawn
|
|
826
|
+
* @returns {void}
|
|
827
|
+
*/
|
|
828
|
+
Sprite.prototype.parallaxScrollX = function(speed, respawnAfterMs, offset) {
|
|
829
|
+
if (this.destroyed) return;
|
|
830
|
+
|
|
831
|
+
var now = performance.now();
|
|
832
|
+
var extra = (typeof offset === 'number') ? offset : 50;
|
|
833
|
+
|
|
834
|
+
// if waiting to respawn
|
|
835
|
+
if (this.respawnAt) {
|
|
836
|
+
if (now >= this.respawnAt) {
|
|
837
|
+
this.x = (speed < 0)
|
|
838
|
+
? engine.width + extra + Math.floor(Math.random() * 100)
|
|
839
|
+
: -this.w - extra - Math.floor(Math.random() * 100);
|
|
840
|
+
|
|
841
|
+
this.respawnAt = null;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// move sprite
|
|
848
|
+
this.moveX(speed);
|
|
849
|
+
|
|
850
|
+
// start respawn timer if offscreen in current direction
|
|
851
|
+
if ((speed < 0 && this.x + this.w < 0) || (speed > 0 && this.x > engine.width)) {
|
|
852
|
+
this.respawnAt = now + respawnAfterMs;
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
/**
|
|
856
|
+
* Scrolls a sprite in the Y direction and respawns after delay if offscreen
|
|
857
|
+
* @param {number} speed - vertical speed in pixels per frame (negative = up, positive = down)
|
|
858
|
+
* @param {number} respawnAfterMs - delay in ms before respawning
|
|
859
|
+
* @param {number} [offset=50] - extra offset beyond screen edge for respawn
|
|
860
|
+
* @returns {void}
|
|
861
|
+
*/
|
|
862
|
+
Sprite.prototype.parallaxScrollY = function(speed, respawnAfterMs, offset) {
|
|
863
|
+
if (this.destroyed) return;
|
|
864
|
+
|
|
865
|
+
var now = performance.now();
|
|
866
|
+
var extra = (typeof offset === 'number') ? offset : 50;
|
|
867
|
+
|
|
868
|
+
// if waiting to respawn
|
|
869
|
+
if (this.respawnAt) {
|
|
870
|
+
if (now >= this.respawnAt) {
|
|
871
|
+
this.y = (speed < 0)
|
|
872
|
+
? engine.height + extra + Math.floor(Math.random() * 100)
|
|
873
|
+
: -this.h - extra - Math.floor(Math.random() * 100);
|
|
874
|
+
|
|
875
|
+
this.respawnAt = null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// move sprite
|
|
882
|
+
this.moveY(speed);
|
|
883
|
+
|
|
884
|
+
// start respawn timer if offscreen in current direction
|
|
885
|
+
if ((speed < 0 && this.y + this.h < 0) || (speed > 0 && this.y > engine.height)) {
|
|
886
|
+
this.respawnAt = now + respawnAfterMs;
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
/**
|
|
890
|
+
* Scrolls sprite in X direction and loops immediately after leaving screen
|
|
891
|
+
* @param {number} speed - horizontal speed in pixels per frame
|
|
892
|
+
* @returns {void}
|
|
893
|
+
*/
|
|
894
|
+
Sprite.prototype.loopScrollX = function(speed) {
|
|
895
|
+
if (this.destroyed) return;
|
|
896
|
+
|
|
897
|
+
this.moveX(speed);
|
|
898
|
+
|
|
899
|
+
if (speed < 0 && this.x + this.w < 0) {
|
|
900
|
+
this.x += this.w;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (speed > 0 && this.x > engine.width) {
|
|
904
|
+
this.x -= this.w;
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
Sprite.prototype.isCollidingWith = function (target) {
|
|
908
|
+
if (!this.bounds || !target.bounds) return false;
|
|
909
|
+
|
|
910
|
+
var ab = this.bounds;
|
|
911
|
+
var bb = target.bounds;
|
|
912
|
+
|
|
913
|
+
return !(
|
|
914
|
+
ab.x + ab.w <= bb.x ||
|
|
915
|
+
ab.x >= bb.x + bb.w ||
|
|
916
|
+
ab.y + ab.h <= bb.y ||
|
|
917
|
+
ab.y >= bb.y + bb.h
|
|
918
|
+
);
|
|
919
|
+
};
|
|
920
|
+
Sprite.prototype.destroy = function () {
|
|
921
|
+
this.destroyed = true; // let anyone with a reference to this know its dead
|
|
922
|
+
delete _sprites[this.id];
|
|
923
|
+
};
|
|
924
|
+
Object.defineProperties(Sprite.prototype, {
|
|
925
|
+
actualW: {
|
|
926
|
+
get: function () {
|
|
927
|
+
return Math.floor(this.w);
|
|
928
|
+
}
|
|
929
|
+
},
|
|
930
|
+
actualH: {
|
|
931
|
+
get: function () {
|
|
932
|
+
return Math.floor(this.clipHeight != null ? this.clipHeight : this.h);
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
visible: {
|
|
936
|
+
get: function () {
|
|
937
|
+
return (
|
|
938
|
+
this.x + this.actualW > 0 &&
|
|
939
|
+
this.x < engine.width &&
|
|
940
|
+
this.y + this.actualH > 0 &&
|
|
941
|
+
this.y < engine.height
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
},
|
|
945
|
+
seen: {
|
|
946
|
+
get: function () {
|
|
947
|
+
return (this._seen === true);
|
|
948
|
+
},
|
|
949
|
+
set: function (value) {
|
|
950
|
+
this._seen = (value === true);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Computes the tight bounding box around solid (alpha ≥ threshold) pixels
|
|
957
|
+
* @param {HTMLImageElement} img - Image to analyze
|
|
958
|
+
* @param {number} [threshold=1] - Minimum alpha value to consider a pixel solid
|
|
959
|
+
* @returns {object} { x:number, y:number, w:number, h:number }
|
|
960
|
+
*/
|
|
961
|
+
function getBounds(img, threshold) {
|
|
962
|
+
|
|
963
|
+
log('getBounds('+img.key+','+threshold+')');
|
|
964
|
+
|
|
965
|
+
var w = img.naturalWidth;
|
|
966
|
+
var h = img.naturalHeight;
|
|
967
|
+
|
|
968
|
+
_boundsCanvas.width = w;
|
|
969
|
+
_boundsCanvas.height = h;
|
|
970
|
+
_boundsCtx.clearRect(0, 0, w, h);
|
|
971
|
+
_boundsCtx.drawImage(img, 0, 0, w, h);
|
|
972
|
+
|
|
973
|
+
var data = _boundsCtx.getImageData(0, 0, w, h).data;
|
|
974
|
+
|
|
975
|
+
var minX = w, minY = h, maxX = -1, maxY = -1;
|
|
976
|
+
|
|
977
|
+
for (var y = 0; y < h; y++) {
|
|
978
|
+
for (var x = 0; x < w; x++) {
|
|
979
|
+
var alpha = data[(y * w + x) * 4 + 3];
|
|
980
|
+
if (alpha >= threshold) {
|
|
981
|
+
if (x < minX) minX = x;
|
|
982
|
+
if (y < minY) minY = y;
|
|
983
|
+
if (x > maxX) maxX = x;
|
|
984
|
+
if (y > maxY) maxY = y;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (maxX < minX || maxY < minY) {
|
|
990
|
+
return { x: 0, y: 0, w: 0, h: 0 };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
x: minX,
|
|
995
|
+
y: minY,
|
|
996
|
+
w: maxX - minX + 1,
|
|
997
|
+
h: maxY - minY + 1
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Starts the game loop
|
|
1003
|
+
*/
|
|
1004
|
+
function startLoop() {
|
|
1005
|
+
|
|
1006
|
+
if (!_initilised) throw new Error('punter.init must be called first');
|
|
1007
|
+
|
|
1008
|
+
_canvasCtx = _canvas.getContext('2d', { alpha: true, desynchronized: true });
|
|
1009
|
+
_canvasCtx.imageSmoothingEnabled = false;
|
|
1010
|
+
|
|
1011
|
+
_frame = 1;
|
|
1012
|
+
|
|
1013
|
+
var now, last = performance.now(), accumulator = 0, step = 1000 / 60;
|
|
1014
|
+
var fps = 0;
|
|
1015
|
+
var fpsCounter = 0;
|
|
1016
|
+
var fpsTimer = performance.now();
|
|
1017
|
+
|
|
1018
|
+
function loop(timestamp) {
|
|
1019
|
+
now = timestamp;
|
|
1020
|
+
var frameTime = now - last;
|
|
1021
|
+
frameTime = Math.min(frameTime, 100); // max 100ms delay
|
|
1022
|
+
// if (frameTime > 1000) frameTime = step;
|
|
1023
|
+
last = now;
|
|
1024
|
+
accumulator += frameTime;
|
|
1025
|
+
|
|
1026
|
+
while (accumulator >= step) {
|
|
1027
|
+
eventHandlers.update();
|
|
1028
|
+
mouse.clicked = false;
|
|
1029
|
+
_frame++;
|
|
1030
|
+
_totalFrames++;
|
|
1031
|
+
if (_frame > 60) {
|
|
1032
|
+
_frame = 0;
|
|
1033
|
+
}
|
|
1034
|
+
accumulator -= step;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// clear screen
|
|
1038
|
+
_canvasCtx.clearRect(0, 0, engine.width, engine.height);
|
|
1039
|
+
|
|
1040
|
+
// draw everything
|
|
1041
|
+
eventHandlers.draw.call(_canvasCtx);
|
|
1042
|
+
|
|
1043
|
+
// reset the flag after draw
|
|
1044
|
+
_resized = false;
|
|
1045
|
+
|
|
1046
|
+
fpsCounter++;
|
|
1047
|
+
|
|
1048
|
+
// update fps every 1000ms
|
|
1049
|
+
if (now - fpsTimer >= 1000) {
|
|
1050
|
+
fps = fpsCounter;
|
|
1051
|
+
fpsCounter = 0;
|
|
1052
|
+
fpsTimer = now;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// debug overlay
|
|
1056
|
+
if (_debuggingEnabled) {
|
|
1057
|
+
drawDebugInfo(_canvasCtx, _frame, fps, engine.width, engine.height);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
_loopId = requestAnimationFrame(loop);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
_paused = false;
|
|
1064
|
+
_loopId = requestAnimationFrame(loop);
|
|
1065
|
+
_running = true;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function pauseLoop() {
|
|
1069
|
+
|
|
1070
|
+
if (!_initilised) throw new Error('punter.init must be called first');
|
|
1071
|
+
|
|
1072
|
+
if (_loopId !== null) {
|
|
1073
|
+
cancelAnimationFrame(_loopId);
|
|
1074
|
+
_loopId = null;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
_paused = true;
|
|
1078
|
+
_running = false;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Plays a sound from the loaded buffer
|
|
1083
|
+
* @param {string} name - name of the sound buffer
|
|
1084
|
+
* @param {Object} [options] - optional settings
|
|
1085
|
+
* @param {number} [options.volume] - volume from 0 to 1
|
|
1086
|
+
* @param {boolean} [options.loop] - whether to loop the sound
|
|
1087
|
+
* @param {boolean} [options.once] - if true, don't track for stopping
|
|
1088
|
+
* @param {number} [options.speed] - playback speed multiplier (1 = normal)
|
|
1089
|
+
* @returns {void}
|
|
1090
|
+
*/
|
|
1091
|
+
function playSound(name, options) {
|
|
1092
|
+
|
|
1093
|
+
if (!_initilised) throw new Error('punter.init must be called first');
|
|
1094
|
+
|
|
1095
|
+
var buffer = sounds[name];
|
|
1096
|
+
if (!buffer) return;
|
|
1097
|
+
|
|
1098
|
+
try {
|
|
1099
|
+
|
|
1100
|
+
// always stop if sound already playing
|
|
1101
|
+
stopSound(name);
|
|
1102
|
+
|
|
1103
|
+
var source = audioCtx.createBufferSource();
|
|
1104
|
+
source.buffer = buffer;
|
|
1105
|
+
|
|
1106
|
+
var gainNode = audioCtx.createGain();
|
|
1107
|
+
gainNode.gain.value = (options && options.volume != null) ? options.volume : 1;
|
|
1108
|
+
|
|
1109
|
+
source.loop = !!(options && options.loop);
|
|
1110
|
+
|
|
1111
|
+
// apply playback speed if provided
|
|
1112
|
+
source.playbackRate.value = (options && options.speed != null) ? options.speed : 1;
|
|
1113
|
+
|
|
1114
|
+
source.connect(gainNode);
|
|
1115
|
+
gainNode.connect(audioCtx.destination);
|
|
1116
|
+
source.start(0);
|
|
1117
|
+
|
|
1118
|
+
if (!options || !options.once) {
|
|
1119
|
+
if (!playingSounds[name]) playingSounds[name] = [];
|
|
1120
|
+
playingSounds[name].push(source);
|
|
1121
|
+
source.onended = function () {
|
|
1122
|
+
var arr = playingSounds[name];
|
|
1123
|
+
if (arr) {
|
|
1124
|
+
var idx = arr.indexOf(source);
|
|
1125
|
+
if (idx !== -1) arr.splice(idx, 1);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
} catch (e) { }
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function stopSound(name) {
|
|
1133
|
+
|
|
1134
|
+
if (!_initilised) throw new Error('punter.init must be called first');
|
|
1135
|
+
|
|
1136
|
+
var arr = playingSounds[name];
|
|
1137
|
+
if (!arr || !arr.length) return;
|
|
1138
|
+
for (var i = 0; i < arr.length; i++) {
|
|
1139
|
+
try { arr[i].stop(0); } catch (e) { }
|
|
1140
|
+
}
|
|
1141
|
+
playingSounds[name] = [];
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function clearInput() {
|
|
1145
|
+
for (var key in keys) {
|
|
1146
|
+
keys[key] = false;
|
|
1147
|
+
}
|
|
1148
|
+
mouse.clicked = false;
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Get the actual size of the viewport
|
|
1153
|
+
* @returns {object}
|
|
1154
|
+
*/
|
|
1155
|
+
function getViewportSize() {
|
|
1156
|
+
|
|
1157
|
+
if (window.visualViewport) {
|
|
1158
|
+
return {
|
|
1159
|
+
width: Math.floor(window.visualViewport.width),
|
|
1160
|
+
height: Math.floor(window.visualViewport.height)
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return {
|
|
1165
|
+
width: Math.floor(window.innerWidth),
|
|
1166
|
+
height: Math.floor(window.innerHeight)
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Sets HTML/CSS variables for developers
|
|
1172
|
+
* - css --variables
|
|
1173
|
+
* - html[attributes]
|
|
1174
|
+
* @returns {void}
|
|
1175
|
+
*/
|
|
1176
|
+
function setDevVars() {
|
|
1177
|
+
|
|
1178
|
+
var vpSize = getViewportSize();
|
|
1179
|
+
|
|
1180
|
+
// css variables
|
|
1181
|
+
var docStyle = htmlEl.style;
|
|
1182
|
+
docStyle.setProperty('--punter-vpw', vpSize.width + 'px');
|
|
1183
|
+
docStyle.setProperty('--punter-vph', vpSize.height + 'px');
|
|
1184
|
+
|
|
1185
|
+
// html attributes
|
|
1186
|
+
setAttribute(htmlEl, 'data-punter-debug', _debuggingEnabled ? 'true' : '');
|
|
1187
|
+
setAttribute(htmlEl, 'data-punter-device', _isMobile ? 'mobile' : 'desktop');
|
|
1188
|
+
setAttribute(htmlEl, 'data-punter-orientation', engine.orientation);
|
|
1189
|
+
|
|
1190
|
+
// only set scene if we have a value (dev might hard code start scene)
|
|
1191
|
+
if (engine.sceneName) {
|
|
1192
|
+
setAttribute(htmlEl, 'data-punter-scene', engine.sceneName);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// force a CSS reflow
|
|
1196
|
+
void document.body.offsetHeight;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Set attribute: removes if empty, only modifies if value changed
|
|
1201
|
+
* @param {Element} el - target element
|
|
1202
|
+
* @param {string} name - attribute name
|
|
1203
|
+
* @param {string} value - value to set
|
|
1204
|
+
* @returns {void}
|
|
1205
|
+
*/
|
|
1206
|
+
function setAttribute(el, name, value) {
|
|
1207
|
+
|
|
1208
|
+
value = String(value).trim();
|
|
1209
|
+
|
|
1210
|
+
if (value === '') {
|
|
1211
|
+
el.removeAttribute(name);
|
|
1212
|
+
}
|
|
1213
|
+
else if (el.getAttribute(name) !== value) {
|
|
1214
|
+
el.setAttribute(name, value);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function resize() {
|
|
1219
|
+
|
|
1220
|
+
var size = getViewportSize();
|
|
1221
|
+
var screenW = size.width;
|
|
1222
|
+
var screenH = size.height;
|
|
1223
|
+
var screenRatio = screenW / screenH;
|
|
1224
|
+
|
|
1225
|
+
// base internal resolution to maintain consistent game logic
|
|
1226
|
+
var baseW = 375;
|
|
1227
|
+
var baseH = 667;
|
|
1228
|
+
var baseRatio = baseW / baseH;
|
|
1229
|
+
|
|
1230
|
+
var internalW, internalH;
|
|
1231
|
+
|
|
1232
|
+
if (screenRatio > baseRatio) {
|
|
1233
|
+
internalH = baseH;
|
|
1234
|
+
internalW = Math.round(internalH * screenRatio);
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
internalW = baseW;
|
|
1238
|
+
internalH = Math.round(internalW / screenRatio);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
var dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
1242
|
+
_dpr = dpr;
|
|
1243
|
+
|
|
1244
|
+
if (dpr > 1) {
|
|
1245
|
+
_canvas.width = internalW * dpr;
|
|
1246
|
+
_canvas.height = internalH * dpr;
|
|
1247
|
+
}
|
|
1248
|
+
else {
|
|
1249
|
+
_canvas.width = internalW;
|
|
1250
|
+
_canvas.height = internalH;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
var scaleX = screenW / internalW;
|
|
1254
|
+
var scaleY = screenH / internalH;
|
|
1255
|
+
var scale = Math.min(scaleX, scaleY);
|
|
1256
|
+
|
|
1257
|
+
_canvas.style.width = internalW + 'px';
|
|
1258
|
+
_canvas.style.height = internalH + 'px';
|
|
1259
|
+
_canvas.style.transform = 'translate(-50%, -50%) scale(' + scale + ')';
|
|
1260
|
+
|
|
1261
|
+
setDevVars();
|
|
1262
|
+
_resized = true;
|
|
1263
|
+
|
|
1264
|
+
for (var id in _sprites) {
|
|
1265
|
+
if (Object.prototype.hasOwnProperty.call(_sprites, id)) {
|
|
1266
|
+
_sprites[id].resize();
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (typeof eventHandlers.resize === 'function') {
|
|
1271
|
+
eventHandlers.resize();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
engine.redraw();
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Debounced handler for resize and orientationchange events
|
|
1279
|
+
* Waits 5ms after last trigger before calling resize
|
|
1280
|
+
* @returns {void}
|
|
1281
|
+
*/
|
|
1282
|
+
function setupResponsiveResize() {
|
|
1283
|
+
var resizeTimer;
|
|
1284
|
+
|
|
1285
|
+
function handleResizeEvent() {
|
|
1286
|
+
clearTimeout(resizeTimer);
|
|
1287
|
+
resizeTimer = setTimeout(resize, 3);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
window.addEventListener('resize', handleResizeEvent);
|
|
1291
|
+
window.addEventListener('orientationchange', handleResizeEvent);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/* --- public api --- */
|
|
1295
|
+
|
|
1296
|
+
var api = {
|
|
1297
|
+
|
|
1298
|
+
// initialization
|
|
1299
|
+
init: init,
|
|
1300
|
+
|
|
1301
|
+
// scene lifecycle
|
|
1302
|
+
scene: function (name, handler) {
|
|
1303
|
+
_scenes[name] = handler;
|
|
1304
|
+
},
|
|
1305
|
+
go: function (name) {
|
|
1306
|
+
|
|
1307
|
+
if (!_initilised) throw new Error('punter.init must be called first');
|
|
1308
|
+
if (!_scenes[name]) throw new Error('punter.go: unknown scene "' + name + '"');
|
|
1309
|
+
|
|
1310
|
+
// remove existing game loop handlers
|
|
1311
|
+
eventHandlers.update = function () {};
|
|
1312
|
+
eventHandlers.draw = function () {};
|
|
1313
|
+
|
|
1314
|
+
// ensure we clear all input from last scene
|
|
1315
|
+
engine.clearInput();
|
|
1316
|
+
|
|
1317
|
+
// switch scenes
|
|
1318
|
+
_currentScene = name;
|
|
1319
|
+
_scenes[name]();
|
|
1320
|
+
|
|
1321
|
+
setDevVars();
|
|
1322
|
+
|
|
1323
|
+
log('punter.currentScene = ' + _currentScene);
|
|
1324
|
+
|
|
1325
|
+
// auto-start loop if not running
|
|
1326
|
+
if (_loopId === null && _canvas && _initilised) {
|
|
1327
|
+
startLoop();
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// fire engine.on('go', func) handler
|
|
1331
|
+
if (typeof eventHandlers.go === 'function') {
|
|
1332
|
+
eventHandlers.go(_currentScene);
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1335
|
+
pause: pauseLoop,
|
|
1336
|
+
resume: function () {
|
|
1337
|
+
if (_loopId === null && _canvas && _initilised) {
|
|
1338
|
+
startLoop();
|
|
1339
|
+
}
|
|
1340
|
+
},
|
|
1341
|
+
redraw: function () {
|
|
1342
|
+
if (!this.canvas || !this.ctx) return;
|
|
1343
|
+
|
|
1344
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
1345
|
+
|
|
1346
|
+
for (var id in _sprites) {
|
|
1347
|
+
if (Object.prototype.hasOwnProperty.call(_sprites, id)) {
|
|
1348
|
+
var sprite = _sprites[id];
|
|
1349
|
+
if (!sprite.destroyed) {
|
|
1350
|
+
sprite.draw(this.ctx);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
|
|
1356
|
+
// sprite factory
|
|
1357
|
+
createSprite: function(opts) {
|
|
1358
|
+
if (!_initilised) throw new Error('createSprite: punter.init must be called first');
|
|
1359
|
+
return new Sprite(opts);
|
|
1360
|
+
},
|
|
1361
|
+
getSprite: function(id) {
|
|
1362
|
+
return _sprites[id] ? _sprites[id] : null;
|
|
1363
|
+
},
|
|
1364
|
+
|
|
1365
|
+
// input handling
|
|
1366
|
+
clearInput: clearInput,
|
|
1367
|
+
keys: keys,
|
|
1368
|
+
mouse: mouse,
|
|
1369
|
+
|
|
1370
|
+
// event listeners
|
|
1371
|
+
on: function (event, handler) {
|
|
1372
|
+
if (!eventHandlers.hasOwnProperty(event)) throw new Error('punter.on: unknown event "' + event + '"');
|
|
1373
|
+
eventHandlers[event] = handler;
|
|
1374
|
+
},
|
|
1375
|
+
|
|
1376
|
+
// sound control
|
|
1377
|
+
playSound: playSound,
|
|
1378
|
+
stopSound: stopSound
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
Object.defineProperties(api, {
|
|
1382
|
+
sceneName: {
|
|
1383
|
+
get: function () {
|
|
1384
|
+
return _currentScene || '';
|
|
1385
|
+
},
|
|
1386
|
+
enumerable: true
|
|
1387
|
+
},
|
|
1388
|
+
debug: {
|
|
1389
|
+
get: function () {
|
|
1390
|
+
return _debuggingEnabled;
|
|
1391
|
+
},
|
|
1392
|
+
set: function(value) {
|
|
1393
|
+
_debuggingEnabled = (value === true);
|
|
1394
|
+
},
|
|
1395
|
+
enumerable: true
|
|
1396
|
+
},
|
|
1397
|
+
canvas: {
|
|
1398
|
+
get: function () {
|
|
1399
|
+
return _canvas;
|
|
1400
|
+
},
|
|
1401
|
+
enumerable: true
|
|
1402
|
+
},
|
|
1403
|
+
ctx: {
|
|
1404
|
+
get: function () {
|
|
1405
|
+
return _canvasCtx ? _canvasCtx : null;
|
|
1406
|
+
},
|
|
1407
|
+
enumerable: true
|
|
1408
|
+
},
|
|
1409
|
+
width: {
|
|
1410
|
+
get: function () {
|
|
1411
|
+
return _canvas ? _canvas.width : null;
|
|
1412
|
+
},
|
|
1413
|
+
set: function (value) {
|
|
1414
|
+
if (_canvas && typeof value === 'number') {
|
|
1415
|
+
_canvas.width = value;
|
|
1416
|
+
}
|
|
1417
|
+
},
|
|
1418
|
+
enumerable: true
|
|
1419
|
+
},
|
|
1420
|
+
height: {
|
|
1421
|
+
get: function () {
|
|
1422
|
+
return _canvas ? _canvas.height : null;
|
|
1423
|
+
},
|
|
1424
|
+
set: function (value) {
|
|
1425
|
+
if (_canvas && typeof value === 'number') {
|
|
1426
|
+
_canvas.height = value;
|
|
1427
|
+
}
|
|
1428
|
+
},
|
|
1429
|
+
enumerable: true
|
|
1430
|
+
},
|
|
1431
|
+
frame: {
|
|
1432
|
+
get: function () {
|
|
1433
|
+
return _frame;
|
|
1434
|
+
},
|
|
1435
|
+
enumerable: true
|
|
1436
|
+
},
|
|
1437
|
+
totalFrames: {
|
|
1438
|
+
get: function () {
|
|
1439
|
+
return _totalFrames;
|
|
1440
|
+
},
|
|
1441
|
+
enumerable: true
|
|
1442
|
+
},
|
|
1443
|
+
running: {
|
|
1444
|
+
get: function () {
|
|
1445
|
+
return _running;
|
|
1446
|
+
},
|
|
1447
|
+
enumerable: true
|
|
1448
|
+
},
|
|
1449
|
+
paused: {
|
|
1450
|
+
get: function () {
|
|
1451
|
+
return _paused;
|
|
1452
|
+
},
|
|
1453
|
+
enumerable: true
|
|
1454
|
+
},
|
|
1455
|
+
resized: {
|
|
1456
|
+
get: function () {
|
|
1457
|
+
return _resized;
|
|
1458
|
+
},
|
|
1459
|
+
enumerable: true
|
|
1460
|
+
},
|
|
1461
|
+
sprites: {
|
|
1462
|
+
get: function () {
|
|
1463
|
+
var arr = [];
|
|
1464
|
+
for (var key in _sprites) {
|
|
1465
|
+
if (_sprites[key]) arr.push(_sprites[key]);
|
|
1466
|
+
}
|
|
1467
|
+
return arr;
|
|
1468
|
+
},
|
|
1469
|
+
enumerable: true
|
|
1470
|
+
},
|
|
1471
|
+
dpr: {
|
|
1472
|
+
get: function () {
|
|
1473
|
+
return _dpr;
|
|
1474
|
+
},
|
|
1475
|
+
enumerable: true
|
|
1476
|
+
},
|
|
1477
|
+
isMobile: {
|
|
1478
|
+
get: function() {
|
|
1479
|
+
return _isMobile;
|
|
1480
|
+
}
|
|
1481
|
+
},
|
|
1482
|
+
isDesktop: {
|
|
1483
|
+
get: function() {
|
|
1484
|
+
return !_isMobile;
|
|
1485
|
+
}
|
|
1486
|
+
},
|
|
1487
|
+
orientation: {
|
|
1488
|
+
get: function () {
|
|
1489
|
+
return window.innerHeight >= window.innerWidth ? 'portrait' : 'landscape';
|
|
1490
|
+
},
|
|
1491
|
+
enumerable: true
|
|
1492
|
+
}
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
var engine = api;
|
|
1496
|
+
global.punter = api;
|
|
1497
|
+
|
|
1498
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
1499
|
+
_debugBackgroundColor = getCssVar('punter-debug-background', 'rgba(255,255,255,0.7)');
|
|
1500
|
+
_debugTextColor = getCssVar('punter-debug-text', 'red');
|
|
1501
|
+
_debugFont = getCssVar('punter-debug-font', '12px monospace');
|
|
1502
|
+
|
|
1503
|
+
setDevVars();
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
window.addEventListener('error', function (event) {
|
|
1507
|
+
log('[Global Error]', event.message, 'at', event.filename + ':' + event.lineno + ':' + event.colno, event.error);
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
window.addEventListener('unhandledrejection', function (event) {
|
|
1511
|
+
log('[Unhandled Promise Rejection]', event.reason);
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
})(window);
|