1 /**
  2  * arcade-controls.js
  3  * Copyright (c) 2010-2011,  Martin Wendt (http://wwWendt.de)
  4  *
  5  * Dual licensed under the MIT or GPL Version 2 licenses.
  6  * http://code.google.com/p/arcade-js/wiki/LicenseInfo
  7  *
  8  * A current version and some documentation is available at
  9  *     http://arcade-js.googlecode.com/
 10  *
 11  * @fileOverview Controls and tools for ArcadeJS on mobile devices.
 12  *
 13  * @author Martin Wendt
 14  * @version 0.0.1
 15  */
 16 
 17 function _getTouchWithId(touchList, id){
 18 	// `0` is a valid id on Android!
 19 	if(id !== null && touchList && touchList.length){
 20 		for(var i=0; i<touchList.length; i++) {
 21 			var touch = touchList[i];
 22 			if(touch.identifier === id){
 23 				return touch;
 24 			}
 25 		}
 26 	}
 27 	return null;
 28 }
 29 
 30 /*----------------------------------------------------------------------------*/
 31 
 32 /**Base class for screen controls.
 33  *
 34  * As opposed to a 'normal' Movable, this class sets pos to `undefined` and uses
 35  * posCC instead.
 36  *
 37  * @class
 38  * @extends Movable
 39  */
 40 
 41 var CanvasObject = Movable.extend(
 42 /** @lends CanvasObject.prototype */
 43 {
 44 	init: function(type, opts) {
 45 		this._super(type, $.extend({
 46 			useCC: true,
 47 			isBackground: true,
 48 			onClick: function() { alert("onClick is mandatory"); }
 49 		}, opts));
 50 		// Copy selected options as object attributes
 51 		ArcadeJS.extendAttributes(this, this.opts, "onClick onResize useCC isBackground");
 52 //		ArcadeJS.assertAttributes(this, this.opts, "onClick pos useCC");
 53 	},
 54 	/**Return true, if point hits this object.
 55 	 * @param {Point2} pt Point in canvas coordinates
 56 	 * @returns {boolean}
 57 	 */
 58 	containsCC: undefined,
 59 	contains: undefined, // in useCC mode, this shouldn't be called
 60 	/**@function Called when window is resized (and on start).
 61 	 * The default processing depends on the 'resizeMode' option.
 62 	 * @param {Int} width
 63 	 * @param {Int} height
 64 	 * @param {Event} e
 65 	 * @returns false to prevent default handling
 66 	 */
 67 	onResize: undefined,
 68 	/**Called when button was clicked (i.e. pushed and released). */
 69 	onClick: undefined,
 70 	// --- end of class
 71 	__lastentry: undefined
 72 });
 73 
 74 /*----------------------------------------------------------------------------*/
 75 
 76 /**Button for mouse touch screen devices.
 77  * @class
 78  * @extends CanvasObject
 79  */
 80 
 81 var TouchButton = CanvasObject.extend(
 82 /** @lends TouchButton.prototype */
 83 {
 84 	init: function(opts) {
 85 		this._super("button", $.extend({
 86 			r: 20,
 87 			r3: 40//,
 88 //			onClick: function() { alert("onClick is mandatory"); }
 89 		}, opts));
 90 		// Copy selected options as object attributes
 91 		ArcadeJS.extendAttributes(this, this.opts, "r r3 onClick");
 92 		this.touchDownId = null;
 93 		this.clicked = false;
 94 		this.down = false;
 95 	},
 96 	containsCC: function(ptCC) {
 97 		return this.pos.distanceTo(ptCC) <= this.r3;
 98 	},
 99 	render: function(ctx) {
100 		// Draw gray sphere
101 		var gradient = ctx.createRadialGradient(0, 0, 0, 5, -5, this.r);
102 		gradient.addColorStop(0, "rgba(255, 255, 255, 0.7)");
103 		if(!this.down){
104 			gradient.addColorStop(0.7, "rgba(192, 192, 192, 0.7)");
105 		}
106 		gradient.addColorStop(1, "rgba(80, 80, 80, 0.7)");
107 		ctx.fillStyle = gradient;
108 		ctx.fillCircle2(0, 0, this.r);
109 	},
110 	onMousedown: function(e) {
111 		this.down = this.clicked = this.containsCC(this.game.mousePosCC);
112 	},
113 	onMousemove: function(e) {
114 		this.down = this.clicked && this.containsCC(this.game.mousePosCC);
115 	},
116 	onMouseup: function(e) {
117 		if(this.clicked && this.containsCC(this.game.mousePosCC)){
118 			this.onClick.call(this);
119 		}
120 		this.down = this.clicked = false;
121 	},
122 	onTouchevent: function(e, orgEvent) {
123 		var touch = null,
124 			game = this.game;
125 		if(this.touchDownId !== null){
126 			touch = _getTouchWithId(orgEvent.changedTouches, this.touchDownId);
127 		}else if(e.type == "touchstart" && orgEvent.changedTouches.length == 1) {
128 			touch =  orgEvent.changedTouches[0];
129 		}
130 //		alert("e.type: " + e.type + ", id=" + (touch ? touch.identifier : "?"));
131 		// Ignore event, if touch identifier is different from start event
132 		if(!touch){
133 			return;
134 		}
135 		// Otherwise, prevent default handling
136 		orgEvent.preventDefault();
137 
138 		var touchPos = new Point2(
139 			touch.pageX - game.canvas.offsetLeft,
140 			touch.pageY - game.canvas.offsetTop);
141 		var isInside = this.containsCC(touchPos);
142 
143 		// TODO: seems that we get touchend for both fingers, even if only the other
144 		// finger was lifted!
145 		// http://stackoverflow.com/questions/3695128/webkit-iphone-ipad-issue-with-mutl-touch
146 //		if(e.type!="touchmove"){
147 //			game.debug("button " + e.type + " - isInside: " + isInside + ", drag: " + this.touchDragOffset + ", id=" + touch.identifier);
148 //		}
149 
150 		switch (e.type) {
151 		case "touchstart":
152 		case "touchmove":
153 			this.down = isInside;
154 			if(isInside){
155 				this.touchDownId = touch.identifier;
156 			}
157 			break;
158 		case "touchend":
159 			if(this.down && isInside){
160 				this.onClick.call(this);
161 			}
162 			this.touchDownId = null;
163 			this.down = false;
164 			break;
165 		case "touchcancel":
166 			this.touchDownId = null;
167 			this.down = false;
168 			break;
169 		default:
170 			alert("not handled " + e.type);
171 		}
172 	},
173 	/**Return true if button is down (but mouse key is also still down). */
174 	isDown: function() {
175 		return this.down === true;
176 	},
177 	/**Called when button was clicked (i.e. pushed and released). */
178 	onClick: undefined,
179 	// --- end of class
180 	__lastentry: undefined
181 });
182 
183 /*----------------------------------------------------------------------------*/
184 
185 /**Joystick emulation for mouse and touch screen devices.
186  * @class
187  * @extends CanvasObject
188  */
189 var TouchStick = CanvasObject.extend(
190 /** @lends TouchStick.prototype */
191 {
192 	init: function(opts) {
193 		this._super("joystick", $.extend({
194 			r1: 10,
195 			r2: 30,
196 			r3: 50
197 		}, opts));
198 		// Copy selected options as object attributes
199 		ArcadeJS.extendAttributes(this, this.opts, "r1 r2 r3");
200 		this.active = false;
201 		this.touchDownPos = null;
202 		this.touchDownId = null;
203 		this.touchDragOffset = null;
204 	},
205 	containsCC: function(ptCC) {
206 		return this.pos.distanceTo(ptCC) <= this.r3;
207 	},
208 	render: function(ctx) {
209 		// Draw gray sphere
210 		var gradient = ctx.createRadialGradient(0, 0, this.r1, 5, -5, this.r2);
211 		gradient.addColorStop(0, "rgba(255, 255, 255, 0.7)");
212 		gradient.addColorStop(0.7, "rgba(192, 192, 192, 0.7)");
213 		gradient.addColorStop(1, "rgba(80, 80, 80, 0.7)");
214 		ctx.fillStyle = gradient;
215 		ctx.fillCircle2(0, 0, this.r2);
216 		// with the dragged stick
217 		var pos2 = new Point2(0, 0);
218 		if(this.touchDragOffset){
219 			pos2.translate(this.touchDragOffset.limit(this.r2));
220 		}
221 		var gradient = ctx.createRadialGradient(pos2.x, pos2.y, 0, pos2.x+3, pos2.y-3, this.r1);
222 		gradient.addColorStop(0, "#fff");
223 		gradient.addColorStop(0.7, "#ccc");
224 		gradient.addColorStop(1, "#555");
225 		ctx.fillStyle = gradient;
226 		ctx.fillCircle2(pos2.x, pos2.y, this.r1);
227 	},
228 	onDragstart: function(clickPos) {
229 		if(this.containsCC(this.game.mousePosCC)){
230 			this.touchDownPos = this.pos;
231 			this.touchDragOffset = new Vec2(0, 0);
232 			return true;
233 		}
234 		this.touchDownPos = null;
235 		return false;
236 	},
237 	onDrag: function(dragOffset) {
238 		if(this.touchDownPos){
239 			this.touchDragOffset = dragOffset.copy();
240 		}
241 	},
242 	onDragcancel: function(dragOffset) {
243 		this.touchDownPos = this.touchDownId = this.touchDragOffset = null;
244 	},
245 	onDrop: function(dragOffset) {
246 		this.touchDownPos = this.touchDownId = this.touchDragOffset = null;
247 	},
248 	onTouchevent: function(e, orgEvent) {
249 		// http://developer.apple.com/safari/library/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW1
250 		// http://www.sitepen.com/blog/2008/07/10/touching-and-gesturing-on-the-iphone/
251 		var touch = null,
252 			game = this.game;
253 		if(this.touchDownId !== null){
254 			touch = _getTouchWithId(orgEvent.changedTouches, this.touchDownId);
255 		}else if(e.type == "touchstart" && orgEvent.changedTouches.length == 1) {
256 			touch =  orgEvent.changedTouches[0];
257 		}
258 		// Ignore event, if touch identifier is different from start event
259 		if(!touch){
260 			return;
261 		}
262 
263 		// Otherwise, prevent default handling
264 		orgEvent.preventDefault();
265 
266 		var touchPos = new Point2(
267 			touch.pageX - game.canvas.offsetLeft,
268 			touch.pageY - game.canvas.offsetTop);
269 //		if(e.type!="touchmove"){
270 //			game.debug("stick " + e.type + " - isInside: " + this.contains(touchPos) + ", drag: " + this.touchDragOffset + ", id=" + touch.identifier);
271 //		}
272 		// TODO: seems that we get touchend for both fingers, even if only the other
273 		// finger was lifted!
274 		// http://stackoverflow.com/questions/3695128/webkit-iphone-ipad-issue-with-mutl-touch
275 		switch (e.type) {
276 		case "touchstart":
277 			if(this.containsCC(touchPos)){
278 				this.touchDownPos = touchPos;
279 				this.touchDragOffset = new Vec2(0, 0);
280 				this.touchDownId = touch.identifier;
281 			}
282 			break;
283 		case "touchmove":
284 			// Drag vector is always relative to controls center
285 			this.touchDragOffset = new Vec2(
286 				touchPos.x - this.pos.x,
287 				touchPos.y - this.pos.y);
288 			break;
289 		case "touchend":
290 		case "touchcancel":
291 			this.touchDownPos = this.touchDownId = this.touchDragOffset = null;
292 			break;
293 		}
294 	},
295 	/**Return true if joystick is currently used. */
296 	isActive: function() {
297 		return !!this.touchDownPos;
298 	},
299 	/**Return x deflection [-1.0 .. +1.0]. */
300 	getX: function() {
301 		return this.isActive() ? this.touchDragOffset.dx / this.r2 : 0;
302 	},
303 	/**Return y deflection [-1.0 .. +1.0]. */
304 	getY: function() {
305 		return this.isActive() ? this.touchDragOffset.dy / this.r2 : 0;
306 	},
307 	/**Return deflection vector with length [0..r2]. */
308 	getDeflection: function() {
309 		return this.isActive() ? this.touchDragOffset.copy().normalize() : 0;
310 	},
311 	/**Called when button was clicked (i.e. pushed and released). */
312 	onClick: undefined,
313 	// --- end of class
314 	__lastentry: undefined
315 });
316 
317 /* *Text area with click event.
318  * @class
319  * @extends CanvasObject
320  */
321 /*
322 var TouchArea = CanvasObject.extend(
323 / ** @lends TouchArea.prototype * /
324 {
325 	init: function(opts) {
326 		this._super("touchArea", $.extend({
327 			width: 20,
328 			height: 40,
329 			text: "Ok\nsoweit?",
330 			border: true,
331 			onResize: function(width, height){
332 				this.textWidth = this.game.context.measureText(this.text).width;
333 				this.textHeight = this.game.context.measureText("M").width;
334 				this.box = {
335 						top: 0.5 * (height - this.textHeight) - this.padding,
336 						left: 0.5 * (width - this.textWidth) - this.padding,
337 						width: this.textWidth + 2 *  this.padding,
338 						height: this.textHeight + 2 *  this.padding
339 						};
340 				this.pos.set(this.box.left + this.padding, this.box.top + this.padding + this.textHeight);
341 			},
342 			padding: 5
343 //			onClick: function() { alert("onClick is mandatory"); }
344 		}, opts));
345 		// Copy selected options as object attributes
346 		ArcadeJS.extendAttributes(this, this.opts, "width height padding text onClick");
347 		this.touchDownId = null;
348 		this.clicked = false;
349 		this.down = false;
350 	},
351 	containsCC: function(ptCC) {
352 		var box = this.box;
353 		return ptCC.x >= box.left && ptCC.x <= box.left + box.width
354 			ptCC.y >= box.top && ptCC.y <= box.top + box.height;
355 	},
356 	render: function(ctx) {
357 		ctx.save();
358 		ctx.resetTransform(); // TODO: why is this required?
359 		var box = this.box;
360 		ctx.strokeRect(box.left, box.top, box.width, box.height);
361 		ctx.strokeText(this.text, this.pos.x, this.pos.y);
362 		ctx.restore();
363 	},
364 	onMousedown: function(e) {
365 		this.down = this.clicked = this.containsCC(this.game.mousePosCC);
366 	},
367 	onMousemove: function(e) {
368 		this.down = this.clicked && this.containsCC(this.game.mousePosCC);
369 	},
370 	onMouseup: function(e) {
371 		if(this.clicked && this.containsCC(this.game.mousePosCC)){
372 			this.onClick.call(this);
373 		}
374 		this.down = this.clicked = false;
375 	},
376 	onTouchevent: function(e, orgEvent) {
377 		var touch = null,
378 			game = this.game;
379 		if(this.touchDownId !== null){
380 			touch = _getTouchWithId(orgEvent.changedTouches, this.touchDownId);
381 		}else if(e.type == "touchstart" && orgEvent.changedTouches.length == 1) {
382 			touch =  orgEvent.changedTouches[0];
383 		}
384 		// Ignore event, if touch identifier is different from start event
385 		if(!touch){
386 			return;
387 		}
388 		// Otherwise, prevent default handling
389 		orgEvent.preventDefault();
390 
391 		var touchPos = new Point2(
392 			touch.pageX - game.canvas.offsetLeft,
393 			touch.pageY - game.canvas.offsetTop);
394 		var isInside = this.containsCC(touchPos);
395 
396 		switch (e.type) {
397 		case "touchstart":
398 		case "touchmove":
399 			this.down = isInside;
400 			if(isInside){
401 				this.touchDownId = touch.identifier;
402 			}
403 			break;
404 		case "touchend":
405 			if(this.down && isInside){
406 				this.onClick.call(this);
407 			}
408 			this.touchDownId = null;
409 			this.down = false;
410 			break;
411 		case "touchcancel":
412 			this.touchDownId = null;
413 			this.down = false;
414 			break;
415 		default:
416 			alert("not handled " + e.type);
417 		}
418 	},
419 	/ **Return true if button is down (but mouse key is also still down). * /
420 	isDown: function() {
421 		return this.down === true;
422 	},
423 	/ **Called when button was clicked (i.e. pushed and released). * /
424 	onClick: undefined,
425 	// --- end of class
426 	__lastentry: undefined
427 });
428 */
429 
430 /**HTML overlay, attached to canvas.
431  * @class
432  * @extends Class
433  */
434 
435 var HtmlOverlay = Class.extend(
436 /** @lends HtmlOverlay.prototype */
437 {
438 	_defaultCss: {
439 		position: "absolute",
440 //		display: "none",
441 		left: 0,
442 		top: 0,
443 		display: "none",
444 		padding: 10,
445 		border: "1px solid white",
446 		zIndex: 1000,
447 		color: "black",
448 //		backgroundColor: "transparent",
449 		backgroundColor: "#f1f3f2",
450 //		filter: "alpha(opacity=20)",
451 //		opacity: 0.2,
452 		borderRadius: "5px",
453 		"-moz-border-radius": "5px",
454 		"-webkit-border-radius": "5px",
455 		boxShadow: "3px 3px 10px #bfbfbf",
456 		"-moz-box-shadow": "3px 3px 10px #bfbfbf",
457 		"-webkit-box-shadow": "3px 3px 10px #bfbfbf"
458 	},
459 	init: function(opts){
460 		// Define and override default properties
461 		ArcadeJS.guerrillaDerive(this, {
462 			game: undefined, // (mandatory) parent canvas element
463 			pos: {x: 0, y: 0}, // Centered on screen
464 //			title: null, // Title
465 			html: undefined, // (mandatory) Box content
466 			onClick: null, // Called when box is clicked or touched
467 			onClose: null, // Called after box was closed
468 			closeOnClick: false,
469 			blockClicks: true,
470 			showSpeed: "normal",
471 			hideSpeed: "normal",
472 			timeout: 0 // [ms], 0: never
473 		}, opts);
474 		this.css = $.extend({}, this._defaultCss, opts.css);
475 		var self = this;
476 		this.canvas = this.game.canvas;
477 		this.down = false;
478 		this.inside = false;
479 		this.touchDownId = null;
480 
481 		this.$div = $("<div class='arcadePopup'>" + this.html + "</div>")
482 			.hide(0)
483 			.css(this.css)
484 			.appendTo("body")
485 			.click(function(e){
486 				if(e.target.nodeName == "A"){ return; } // Allow <a> tags to work
487 				self._clicked(e);
488 			}).bind("mousedown", function(e){
489 				if(e.target.nodeName == "A"){ return; } // Allow <a> tags to work
490 				self.inside = self.down = true;
491 				// 'eat' this event, so it isn't dispatched to the parent canvas
492 				return !self.blockClicks;
493 			}).bind("mouseup", function(e){
494 				self.down = false;
495 			}).bind("touchstart", function(e){
496 				self.game.debug("HtmlOverlay got " + e.type + ", node:" + e.target.nodeName);
497 				if(e.target.nodeName == "A"){ return; } // Allow <a> tags to work
498 //				if(e.target.nodeName == "CANVAS"){ return; } // Allow <a> tags to work
499 				if(self.touchDownId !== null && e.originalEvent.changedTouches.length > 1){ return; } // Already have a touch
500 				e.originalEvent.preventDefault();
501 				self.touchDownId = e.originalEvent.changedTouches[0].identifier;
502 				self.game.debug("    nChanged:" + e.originalEvent.changedTouches.length + ", id=" + self.touchDownId);
503 //				$(this).css("backgroundColor", "red");
504 				self.down = true;
505 				self._clicked(e);
506 //			}).bind("touchenter", function(e){
507 //				self.inside = false;
508 //			}).bind("touchleave", function(e){
509 //				self.inside = false;
510 			}).bind("touchend touchcancel", function(e){
511 				self.game.debug("HtmlOverlay got " + e.type + ", node:" + e.target.nodeName);
512 				self.game.debug("    id=" + self.touchDownId + ", t=" + _getTouchWithId(e.originalEvent.changedTouches, self.touchDownId));
513 				e.originalEvent.preventDefault();
514 				if(!_getTouchWithId(e.originalEvent.changedTouches, self.touchDownId)){
515 					return; // touch event was for another target
516 				}
517 				self.touchDownId = null;
518 //				$(this).css("backgroundColor", "white");
519 				self.inside = self.down = false;
520 			});
521 		$(window).resize(function(e){
522 			self._resized(e);
523 		});
524 //		$("button", this.$div).click(function(e){
525 //			alert(e);
526 //		});
527 		this._resized(null);
528 		this.$div.show("normal");
529 	},
530 	/**Hide and remove this box (triggers onClose callback).*/
531 	close: function() {
532 		var self = this;
533 		this.$div.hide(this.hideSpeed, function(){
534 			self.$div.remove();
535 			if(self.onClose){
536 				self.onClose();
537 			}
538 		});
539 	},
540 	/**Return true if button is down (but mouse key / finger is also still down). */
541 	isDown: function() {
542 		return this.down === true;
543 	},
544 	/**Called when element was clicked or touched (triggers onClick callback).*/
545 	_clicked: function(e){
546 		var oe = e.originalEvent,
547 			res = true;
548 		if(this.onClick){
549 			res = this.onClick(e);
550 		}
551 		if(this.closeOnClick){
552 			this.close();
553 		}
554 		return res;
555 	},
556 	/**Called when element was clicked or touched. */
557 	_closed: function(e){
558 		var oe = e.originalEvent;
559 		if(this.closeOnClick){
560 			this.close();
561 		}
562 	},
563 	/**Adjust element relative to canvas center / borders.*/
564 	_resized: function(e){
565 		var x, y,
566 			$c = $(this.canvas),
567 			cx = $c.offset().left,
568 			cy = $c.offset().top;
569 
570 		if(this.pos.x === 0){
571 			x = cx + 0.5 * ($c.width() - this.$div.outerWidth());
572 		}else if(this.pos.x < 0){
573 			x = cx + ($c.width() - this.$div.outerWidth() + this.pos.x + 1);
574 		}else{
575 			x = cx + this.pos.x - 1;
576 		}
577 		if(this.pos.y === 0){
578 			y = cy + 0.5 * ($c.height() - this.$div.outerHeight());
579 		}else if(this.pos.y < 0){
580 //			alert("cy:" + cy + ", ch:" + $c.height() + ", dh:" + this.$div.outerHeight() + ", py:" + this.pos.y);
581 			y = cy + ($c.height() - this.$div.outerHeight() + this.pos.y + 1);
582 		}else{
583 			y = cy + this.pos.y - 1;
584 		}
585 		this.$div.css({left: x, top: y});
586 	},
587 	// --- end of class
588 	__lastentry: undefined
589 });
590