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/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);