components/Interactive.js

  1. import {Circle, Polygon, Rectangle} from 'pixi.js';
  2. import AABB from '../AABB.js';
  3. import Data from '../Data.js';
  4. import createComponentClass from '../factory.js';
  5. const
  6. getId = function (event) {
  7. const
  8. data = event.data,
  9. originalEvent = data.originalEvent;
  10. return originalEvent.type.substr(0, 5) + (data.identifier || (originalEvent.changedTouches && originalEvent.changedTouches[0] && originalEvent.changedTouches[0].identifier) || 0);
  11. },
  12. pointerInstances = {},
  13. orphanPointers = [];
  14. export default createComponentClass(/** @lends platypus.components.Interactive.prototype */{
  15. id: 'Interactive',
  16. properties: {
  17. /**
  18. * Sets the container that represents the interactive area.
  19. *
  20. * @property container
  21. * @type PIXI.Container
  22. * @default null
  23. */
  24. "container": null,
  25. /**
  26. * Sets the hit area for interactive responses by describing the dimensions of a clickable rectangle:
  27. *
  28. * "hitArea": {
  29. * "x": 10,
  30. * "y": 10,
  31. * "width": 40,
  32. * "height": 40
  33. * }
  34. *
  35. * Or a circle:
  36. *
  37. * "hitArea": {
  38. * "x": 10,
  39. * "y": 10,
  40. * "radius": 40
  41. * }
  42. *
  43. * Or use an array of numbers to define a polygon: [x1, y1, x2, y2, ...]
  44. *
  45. * "hitArea": [-10, -10, 30, -10, 30, 30, -5, 30]
  46. *
  47. * Defaults to the container if not specified.
  48. *
  49. * @property hitArea
  50. * @type Object
  51. * @default null
  52. */
  53. "hitArea": null,
  54. /**
  55. * Sets whether the entity should respond to mouse hovering.
  56. *
  57. * @property hover
  58. * @type Boolean
  59. * @default false
  60. */
  61. "hover": false,
  62. /**
  63. * Used when returning world coordinates. Typically coordinates are relative to the parent, but when this component is added to the layer level, coordinates must be relative to self.
  64. *
  65. * @property relativeToSelf
  66. * @type String
  67. * @default false
  68. */
  69. "relativeToSelf": false
  70. },
  71. publicProperties: {
  72. /**
  73. * Determines whether hovering over the sprite should alter the cursor.
  74. *
  75. * @property buttonMode
  76. * @type Boolean
  77. * @default false
  78. */
  79. buttonMode: false
  80. },
  81. /**
  82. * This component accepts touches and clicks on the entity. It is typically automatically added by a render component that requires interactive functionality.
  83. *
  84. * @memberof platypus.components
  85. * @uses platypus.Component
  86. * @constructs
  87. * @listens platypus.Entity#camera-update
  88. * @listens platypus.Entity#dispatch-event
  89. * @listens platypus.Entity#handle-render
  90. * @listens platypus.Entity#input-off
  91. * @listens platypus.Entity#input-on
  92. * @listens platypus.Entity#set-hit-area
  93. * @fires platypus.Entity#pressmove
  94. * @fires platypus.Entity#pressup
  95. * @fires platypus.Entity#pointerdown
  96. * @fires platypus.Entity#pointermove
  97. * @fires platypus.Entity#pointertap
  98. * @fires platypus.Entity#pointerout
  99. * @fires platypus.Entity#pointerover
  100. * @fires platypus.Entity#pointerup
  101. * @fires platypus.Entity#pointerupoutside
  102. * @fires platypus.Entity#pointercancel
  103. */
  104. initialize: function () {
  105. this.pressed = false;
  106. this.camera = AABB.setUp();
  107. if (this.hitArea) {
  108. this.container.hitArea = this.setHitArea(this.hitArea);
  109. }
  110. },
  111. events: {
  112. "camera-update": function (camera) {
  113. this.camera.set(camera.viewport);
  114. },
  115. "handle-render": function () {
  116. if (this.buttonMode !== this.container.buttonMode) {
  117. this.container.buttonMode = this.buttonMode;
  118. }
  119. },
  120. /**
  121. * This event dispatches a PIXI.Event on this component's PIXI.Sprite. Useful for rerouting mouse/keyboard events.
  122. *
  123. * @event platypus.Entity#dispatch-event
  124. * @param event {Object | PIXI.Event} The event to dispatch.
  125. */
  126. "dispatch-event": function (event) {
  127. this.sprite.dispatchEvent(this.sprite, event.event, event.data);
  128. },
  129. "input-on": function () {
  130. if (!this.removeInputListeners) {
  131. this.addInputs();
  132. }
  133. },
  134. "input-off": function () {
  135. if (this.removeInputListeners) {
  136. this.removeInputListeners();
  137. }
  138. },
  139. "pointerdown": function () {
  140. this.pressed = true;
  141. },
  142. "pointermove": function (event) {
  143. if (this.pressed && ((pointerInstances[getId(event.pixiEvent)] === this))) {
  144. /**
  145. * This event is triggered on press move (drag).
  146. *
  147. * @event platypus.Entity#pressmove
  148. * @param event {DOMEvent} The original DOM pointer event.
  149. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  150. * @param x {Number} The x coordinate in world units.
  151. * @param y {Number} The y coordinate in world units.
  152. * @param entity {platypus.Entity} The entity receiving this event.
  153. */
  154. this.owner.triggerEvent('pressmove', event);
  155. }
  156. },
  157. "pointerup": function (event) {
  158. if (this.pressed) {
  159. /**
  160. * This event is triggered on press up.
  161. *
  162. * @event platypus.Entity#pressup
  163. * @param event {DOMEvent} The original DOM pointer event.
  164. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  165. * @param x {Number} The x coordinate in world units.
  166. * @param y {Number} The y coordinate in world units.
  167. * @param entity {platypus.Entity} The entity receiving this event.
  168. */
  169. this.owner.triggerEvent('pressup', event);
  170. this.pressed = false;
  171. }
  172. },
  173. "pointerupoutside": function (event) {
  174. if (this.pressed) {
  175. this.owner.triggerEvent('pressup', event);
  176. this.pressed = false;
  177. }
  178. },
  179. "pointercancel": function (event) {
  180. if (this.pressed) {
  181. this.owner.triggerEvent('pressup', event);
  182. this.pressed = false;
  183. }
  184. },
  185. /**
  186. * Sets the hit area for interactive responses by describing the dimensions of a clickable rectangle:
  187. *
  188. * "hitArea": {
  189. * "x": 10,
  190. * "y": 10,
  191. * "width": 40,
  192. * "height": 40
  193. * }
  194. *
  195. * Or a circle:
  196. *
  197. * "hitArea": {
  198. * "x": 10,
  199. * "y": 10,
  200. * "radius": 40
  201. * }
  202. *
  203. * Or use an array of numbers to define a polygon: [x1, y1, x2, y2, ...]
  204. *
  205. * "hitArea": [-10, -10, 30, -10, 30, 30, -5, 30]
  206. *
  207. * Defaults to the container if set to `null`.
  208. *
  209. * @event platypus.Entity#set-hit-area
  210. * @param {Object} shape
  211. */
  212. "set-hit-area": function (shape) {
  213. this.container.hitArea = this.setHitArea(shape);
  214. }
  215. },
  216. methods: {
  217. addInputs: (function () {
  218. var
  219. trigger = function (eventName, event) {
  220. var camera = this.camera,
  221. container = this.container,
  222. msg = null,
  223. matrix = null,
  224. target = this.owner;
  225. if (
  226. !container || //TML - This is in case we do a scene change using an event and the container is destroyed.
  227. !event.data.originalEvent // This is a workaround for a bug in Pixi 3 where phantom hover events are triggered. - DDD 7/20/16
  228. ) {
  229. return;
  230. }
  231. matrix = this.relativeToSelf ? container.transform.worldTransform : container.parent.transform.worldTransform;
  232. msg = Data.setUp(
  233. "event", event.data.originalEvent,
  234. "pixiEvent", event,
  235. "x", event.data.global.x / matrix.a + camera.left,
  236. "y", event.data.global.y / matrix.d + camera.top,
  237. "entity", target
  238. );
  239. target.trigger(eventName, msg);
  240. msg.recycle();
  241. },
  242. triggerPointerDown = function (event) {
  243. const id = getId(event);
  244. if (pointerInstances[id]) { // Hmm, this is a shared identifer - not supposed to happen. We'll save for later to make sure it gets its "pointerup" event.
  245. orphanPointers.push(pointerInstances[id]);
  246. }
  247. pointerInstances[id] = this;
  248. /**
  249. * This event is triggered on pointer down.
  250. *
  251. * @event platypus.Entity#pointerdown
  252. * @param event {DOMEvent} The original DOM pointer event.
  253. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  254. * @param x {Number} The x coordinate in world units.
  255. * @param y {Number} The y coordinate in world units.
  256. * @param entity {platypus.Entity} The entity receiving this event.
  257. */
  258. trigger.call(this, 'pointerdown', event);
  259. event.currentTarget.mouseTarget = true;
  260. },
  261. triggerPointerMove = function (event) {
  262. /**
  263. * This event is triggered on pointer move.
  264. *
  265. * @event platypus.Entity#pointermove
  266. * @param event {DOMEvent} The original DOM pointer event.
  267. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  268. * @param x {Number} The x coordinate in world units.
  269. * @param y {Number} The y coordinate in world units.
  270. * @param entity {platypus.Entity} The entity receiving this event.
  271. */
  272. trigger.call(this, 'pointermove', event);
  273. event.currentTarget.mouseTarget = true;
  274. },
  275. triggerPointerTap = function (event) {
  276. /**
  277. * This event is triggered on pointer tap.
  278. *
  279. * @event platypus.Entity#pointertap
  280. * @param event {DOMEvent} The original DOM pointer event.
  281. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  282. * @param x {Number} The x coordinate in world units.
  283. * @param y {Number} The y coordinate in world units.
  284. * @param entity {platypus.Entity} The entity receiving this event.
  285. */
  286. trigger.call(this, 'pointertap', event);
  287. },
  288. triggerPointerOut = function (event) {
  289. /**
  290. * This event is triggered on pointer out.
  291. *
  292. * @event platypus.Entity#pointerout
  293. * @param event {DOMEvent} The original DOM pointer event.
  294. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  295. * @param x {Number} The x coordinate in world units.
  296. * @param y {Number} The y coordinate in world units.
  297. * @param entity {platypus.Entity} The entity receiving this event.
  298. */
  299. trigger.call(this, 'pointerout', event);
  300. },
  301. triggerPointerOver = function (event) {
  302. /**
  303. * This event is triggered on pointer over.
  304. *
  305. * @event platypus.Entity#pointerover
  306. * @param event {DOMEvent} The original DOM pointer event.
  307. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  308. * @param x {Number} The x coordinate in world units.
  309. * @param y {Number} The y coordinate in world units.
  310. * @param entity {platypus.Entity} The entity receiving this event.
  311. */
  312. trigger.call(this, 'pointerover', event);
  313. },
  314. triggerPointerUp = function (event) {
  315. const
  316. id = getId(event);
  317. let target = null;
  318. if (pointerInstances[id] === this) {
  319. // eslint-disable-next-line consistent-this
  320. target = this;
  321. pointerInstances[id] = null;
  322. } else if (orphanPointers.length) {
  323. target = orphanPointers[orphanPointers.length - 1];
  324. orphanPointers.length -= 1;
  325. } else if (pointerInstances[id]) {
  326. target = pointerInstances[id];
  327. } else {
  328. return;
  329. }
  330. /**
  331. * This event is triggered on pointer up.
  332. *
  333. * @event platypus.Entity#pointerup
  334. * @param event {DOMEvent} The original DOM pointer event.
  335. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  336. * @param x {Number} The x coordinate in world units.
  337. * @param y {Number} The y coordinate in world units.
  338. * @param entity {platypus.Entity} The entity receiving this event.
  339. */
  340. trigger.call(target, 'pointerup', event);
  341. event.currentTarget.mouseTarget = false;
  342. if (event.currentTarget.removeDisplayObject) {
  343. event.currentTarget.removeDisplayObject();
  344. }
  345. },
  346. triggerPointerUpOutside = function (event) {
  347. const
  348. id = getId(event);
  349. let target = null;
  350. if (pointerInstances[id] === this) {
  351. // eslint-disable-next-line consistent-this
  352. target = this;
  353. pointerInstances[id] = null;
  354. } else if (orphanPointers.length) {
  355. target = orphanPointers[orphanPointers.length - 1];
  356. orphanPointers.length -= 1;
  357. } else if (pointerInstances[id]) {
  358. target = pointerInstances[id];
  359. } else {
  360. return;
  361. }
  362. /**
  363. * This event is triggered on pointer up outside.
  364. *
  365. * @event platypus.Entity#pointerupoutside
  366. * @param event {DOMEvent} The original DOM pointer event.
  367. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  368. * @param x {Number} The x coordinate in world units.
  369. * @param y {Number} The y coordinate in world units.
  370. * @param entity {platypus.Entity} The entity receiving this event.
  371. */
  372. trigger.call(target, 'pointerupoutside', event);
  373. event.currentTarget.mouseTarget = false;
  374. if (event.currentTarget.removeDisplayObject) {
  375. event.currentTarget.removeDisplayObject();
  376. }
  377. },
  378. triggerPointerCancel = function (event) {
  379. const
  380. id = getId(event);
  381. let target = null;
  382. if (pointerInstances[id] === this) {
  383. // eslint-disable-next-line consistent-this
  384. target = this;
  385. pointerInstances[id] = null;
  386. } else if (orphanPointers.length) {
  387. target = orphanPointers[orphanPointers.length - 1];
  388. orphanPointers.length -= 1;
  389. } else if (pointerInstances[id]) {
  390. target = pointerInstances[id];
  391. } else {
  392. return;
  393. }
  394. /**
  395. * This event is triggered on pointer cancel.
  396. *
  397. * @event platypus.Entity#pointercancel
  398. * @param event {DOMEvent} The original DOM pointer event.
  399. * @param pixiEvent {PIXI.interaction.InteractionEvent} The Pixi pointer event.
  400. * @param x {Number} The x coordinate in world units.
  401. * @param y {Number} The y coordinate in world units.
  402. * @param entity {platypus.Entity} The entity receiving this event.
  403. */
  404. trigger.call(target, 'pointercancel', event);
  405. event.currentTarget.mouseTarget = false;
  406. if (event.currentTarget.removeDisplayObject) {
  407. event.currentTarget.removeDisplayObject();
  408. }
  409. },
  410. removeInputListeners = function (sprite, pointerdown, pointerup, pointerupoutside, pointercancel, pointermove, pointertap, pointerover, pointerout) {
  411. var key = '';
  412. for (key in pointerInstances) {
  413. if (pointerInstances.hasOwnProperty(key) && (pointerInstances[key] === this)) {
  414. pointerInstances[key] = null;
  415. }
  416. }
  417. sprite.removeListener('pointerdown', pointerdown);
  418. sprite.removeListener('pointerup', pointerup);
  419. sprite.removeListener('pointerupoutside', pointerupoutside);
  420. sprite.removeListener('pointercancel', pointercancel);
  421. sprite.removeListener('pointermove', pointermove);
  422. sprite.removeListener('pointertap', pointertap);
  423. if (this.hover) {
  424. sprite.removeListener('pointerover', pointerover);
  425. sprite.removeListener('pointerout', pointerout);
  426. }
  427. sprite.interactive = false;
  428. this.removeInputListeners = null;
  429. };
  430. return function () {
  431. var sprite = this.container,
  432. pointerdown = null,
  433. pointerover = null,
  434. pointerout = null,
  435. pointermove = null,
  436. pointerup = null,
  437. pointerupoutside = null,
  438. pointercancel = null,
  439. pointertap = null;
  440. // The following appends necessary information to displayed objects to allow them to receive touches and clicks
  441. sprite.interactive = true;
  442. pointerdown = triggerPointerDown.bind(this);
  443. pointermove = triggerPointerMove.bind(this);
  444. pointerup = triggerPointerUp.bind(this);
  445. pointerupoutside = triggerPointerUpOutside.bind(this);
  446. pointercancel = triggerPointerCancel.bind(this);
  447. pointertap = triggerPointerTap.bind(this);
  448. sprite.addListener('pointerdown', pointerdown);
  449. sprite.addListener('pointerup', pointerup);
  450. sprite.addListener('pointerupoutside', pointerupoutside);
  451. sprite.addListener('pointercancel', pointercancel);
  452. sprite.addListener('pointermove', pointermove);
  453. sprite.addListener('pointertap', pointertap);
  454. if (this.hover) {
  455. pointerover = triggerPointerOver.bind(this);
  456. pointerout = triggerPointerOut.bind(this);
  457. sprite.addListener('pointerover', pointerover);
  458. sprite.addListener('pointerout', pointerout);
  459. }
  460. this.removeInputListeners = removeInputListeners.bind(this, sprite, pointerdown, pointerup, pointerupoutside, pointercancel, pointermove, pointertap, pointerover, pointerout);
  461. };
  462. }()),
  463. setHitArea: (function () {
  464. var savedHitAreas = {}; //So generated hitAreas are reused across identical entities.
  465. return function (shape) {
  466. var ha = null,
  467. sav = '';
  468. sav = JSON.stringify(shape);
  469. ha = savedHitAreas[sav];
  470. if (!ha) {
  471. if (Array.isArray(shape)) {
  472. ha = new Polygon(shape);
  473. } else if (shape.radius) {
  474. ha = new Circle(shape.x || 0, shape.y || 0, shape.radius);
  475. } else {
  476. ha = new Rectangle(shape.x || 0, shape.y || 0, shape.width || this.owner.width || 0, shape.height || this.owner.height || 0);
  477. }
  478. savedHitAreas[sav] = ha;
  479. }
  480. return ha;
  481. };
  482. }()),
  483. toJSON: function () { // This component is added by another component, so it shouldn't be returned for reconstruction.
  484. return null;
  485. },
  486. destroy: (function () {
  487. var
  488. removeAfterMouseUp = function () {
  489. this.container.parent.removeChild(this.container);
  490. this.container = null;
  491. };
  492. return function () {
  493. if (this.removeInputListeners) {
  494. this.removeInputListeners();
  495. }
  496. this.camera.recycle();
  497. // This handles removal after the mouseup event to prevent weird input behaviors. If it's not currently a mouse target, we let the render component handle its removal from the parent container.
  498. if (this.container.mouseTarget && this.container.parent) {
  499. this.container.visible = false;
  500. this.container.removeDisplayObject = removeAfterMouseUp.bind(this);
  501. }
  502. };
  503. }())
  504. }
  505. });