Allow empty attribute syntax to mean "same command as ID".
[yuu.git] / src / yuu / director.js
1 /* Copyright 2014 Yukkuri Games
2 Licensed under the terms of the GNU GPL v2 or later
3 @license http://www.gnu.org/licenses/gpl-2.0.html
4 @source: http://yukkurigames.com/yuu/
5 */
6
7 (function (yuu) {
8 "use strict";
9
10 var yT = this.yT || require("./yT");
11 var yf = this.yf || require("./yf");
12
13 // It's vaguely plausible to want a director without any scenes
14 // (only entity0 and the canvas), which means the renderer is not
15 // required.
16 if (!yuu.E) require("./ce");
17 if (!yuu.InputState) require("./input");
18 if (!yuu.Material) require("./gfx");
19
20 function getCommand (element) {
21 var command = element.getAttribute('data-yuu-command');
22 if (!command || command === 'data-yuu-command')
23 element.setAttribute('data-yuu-command', command = element.id);
24 return command;
25 }
26
27 yuu.Director = yT({
28 constructor: function (commandStack, input, tickHz) {
29 /** Manage and update a set of Scenes
30
31 The director is responsible for calling two functions
32 regularly on the Scene instances it controls, `tick` and
33 `render`. `tick` is called at a regular interval (or at
34 least pretends to be called at one, browser scheduler
35 permitting), and `render` is called when the browser asks
36 for a new display frame.
37 */
38 this._scenes = [];
39 this.entity0 = new yuu.E();
40 this._commandStack = commandStack || yuu.commandStack;
41 this.input = input || new yuu.InputState([yuu.defaultKeybinds]);
42 this._events = {};
43 this._tickCount = 0;
44 this._timerStart = 0;
45 this._audioOffset = 0;
46 this._rafId = null;
47 this._tickHz = tickHz || 60;
48 this._afterRender = [];
49
50 this.commands = yuu.extractCommands(this);
51 this._commandStack.push(this.commands);
52 this._dogesture = this.__dogesture.bind(this);
53 this._gesture = null;
54 this._resized = false;
55 this._toasts = {};
56 this._devices = {};
57 },
58
59 pushScene: function (scene) {
60 /** Add a Scene onto the director's stack */
61 this.insertScene(scene, this._scenes.length);
62 },
63
64 popScene: function () {
65 /** Remove the top scene from the director's stack */
66 this.removeScene(yf.last(this._scenes));
67 },
68
69 pushPopScene: function (scene) {
70 /** Replace the top scene on the stack */
71 this.popScene();
72 this.pushScene(scene);
73 },
74
75 insertScene: function (scene, idx) {
76 var scenes = this._scenes.slice();
77 scenes.splice(idx, 0, scene);
78 this._scenes = scenes;
79 this._commandStack.insertBefore(
80 scene.commands,
81 this._scenes[idx + 1] && this._scenes[idx + 1].commands);
82 this.input.insertBefore(
83 scene.keybinds,
84 this._scenes[idx + 1] && this._scenes[idx + 1].keybinds);
85 scene.init(this);
86 if (scene.inputs.resize)
87 scene.inputs.resize.call(scene, yuu.canvas);
88 },
89
90 insertUnderScene: function (scene, over) {
91 return this.insertScene(scene, this._scenes.indexOf(over));
92 },
93
94 removeScene: function (scene) {
95 /** Remove a Scene onto the director's stack */
96 this._scenes = yf.without(this._scenes, scene);
97 scene.done();
98 this.input.remove(scene.keybinds);
99 this._commandStack.remove(scene.commands);
100 },
101
102 DOCUMENT_EVENTS: [ "keydown", "keyup", "visibilitychange" ],
103
104 CANVAS_EVENTS: [ "mousemove", "mousedown", "mouseup" ],
105
106 WINDOW_EVENTS: [ "popstate", "resize", "pageshow",
107 "yuugamepadbuttondown", "yuugamepadbuttonup" ],
108
109 GESTURES: [
110 "touch", "release", "hold", "tap", "doubletap",
111 "dragstart", "drag", "dragend", "dragleft", "dragright",
112 "dragup", "dragdown", "swipe", "swipeleft", "swiperight",
113 "swipeup", "swipedown", "pinch", "pinchin", "pinchout"
114 ],
115
116 _dispatchSceneInput: function (name, args) {
117 var scenes = this._scenes;
118 for (var i = scenes.length - 1; i >= 0; --i) {
119 var scene = scenes[i];
120 var handler = scene.inputs[name];
121 if (handler && handler.apply(scene, args))
122 return true;
123
124 // FIXME: This may be a heavy ad hoc solution for the
125 // multiple input layer problems in pwl6. Something
126 // like this isn't required or allowed for e.g.
127 // individual keys, why not?
128 //
129 // REALLY FIXME: This doesn't even work correctly for
130 // joystick events, a) because they're prefixed and b)
131 // because they are in WINDOW_EVENTS so you have to
132 // manually enumerate them or you also ignore e.g.
133 // resize.
134 else if (scene.inputs.consume
135 && yf.contains(scene.inputs.consume, name))
136 return false;
137 }
138 return false;
139 },
140
141 // Aside from the performance considerations, deferring
142 // resizing by multiple frames fixes mis-sizing during startup
143 // and fullscreen transition in node-webkit on Windows. (And
144 // probably similar bugs in other configurations.)
145 _doresize: yf.debounce(function () {
146 this._resized = true;
147 }, 500),
148
149 _dovisibilitychange: function (event) {
150 if (event.target.hidden)
151 this._stopRender();
152 else
153 this._startRender();
154 },
155
156 _dopageshow: function (event) {
157 if (!history.state)
158 history.pushState("yuu director", "");
159 this._stopRender();
160 if (!document.hidden)
161 this._startRender();
162 },
163
164 _dopopstate: function (event) {
165 var cmds = [];
166 if (this._dispatchSceneInput("back", [])
167 || (cmds = this.input.change("back"))) {
168 history.pushState("yuu director", "");
169 yf.each.call(this, this.execute, cmds);
170 yuu.stopPropagation(event, true);
171 } else {
172 history.back();
173 }
174 },
175
176 __dogesture: function (event) {
177 this._updateCaps(event.gesture.srcEvent.type.toLowerCase(), true);
178 var type = event.type.toLowerCase();
179 var p0 = yuu.deviceFromCanvas(event.gesture.startEvent.center);
180 var p1 = yuu.deviceFromCanvas(event.gesture.center);
181 if (this._dispatchSceneInput(type, [p0, p1]))
182 yuu.stopPropagation(event, true);
183 },
184
185 // TODO: This munges events, but also, InputState's mousemove
186 // etc. munge events, in a slightly different but still
187 // related and fragile way.
188 //
189 // Additionally, things run in a Scene handler won't
190 // affect the InputState's internal state - good for
191 // avoiding bind execution, bad for consistency. Even if
192 // a scene handles e.g. "keydown a", input.pressed.a
193 // should be true.
194 //
195 // This is compounded by the lack of actual use cases for any
196 // of the non-gesture events other than "back" and
197 // "mousemove".
198
199 _ARGS_FOR: {
200 keydown: function (event) {
201 return [yuu.keyEventName(event), {}];
202 },
203 keyup: function (event) {
204 return [yuu.keyEventName(event), {}];
205 },
206 mousemove: function (event) {
207 return [yuu.deviceFromCanvas(event)];
208 },
209 mouseup: function (event) {
210 return [event.button, yuu.deviceFromCanvas(event)];
211 },
212 mousedown: function (event) {
213 return [event.button, yuu.deviceFromCanvas(event)];
214 },
215 gamepadbuttondown: function (event) {
216 return [event.detail.gamepad,
217 event.detail.button];
218 },
219 gamepadbuttonup: function (event) {
220 return [event.detail.gamepad, event.detail.button];
221 },
222 },
223
224 _updateCaps: function (type, definite) {
225 if (type.startsWith("mouse")) {
226 if (this._devices.mouse === undefined || definite)
227 this._devices.mouse = Date.now();
228 this._devices.touch = this._devices.touch || false;
229 } else if (type.startsWith("touch")) {
230 this._devices.mouse = this._devices.mouse || false;
231 this._devices.touch = Date.now();
232 this._devices.keyboard = this._devices.keyboard || false;
233 } else if (type.startsWith("key")) {
234 this._devices.keyboard = Date.now();
235 } else if (type.startsWith("gamepad")) {
236 this._devices.gamepad = Date.now();
237 this._devices.touch = this._devices.touch || false;
238 }
239 },
240
241 preferredDevice: function (options) {
242 options = options || ["keyboard", "touch", "mouse", "gamepad"];
243 var devices = this._devices;
244 var best = yf.foldl(function (best, option) {
245 var dbest = devices[best];
246 var doption = devices[option];
247 return dbest === undefined && doption ? option
248 : doption > dbest ? option : best;
249 }, options);
250 for (var i = 0; devices[best] === false && i < options.length; ++i)
251 if (devices[options[i]] !== false)
252 best = options[i];
253 return best;
254 },
255
256 _doevent: function (event) {
257 var type = event.type.toLowerCase();
258 if (type.startsWith("yuu"))
259 type = type.slice(3);
260 var args = this._ARGS_FOR[type](event);
261 var cmds;
262 this._updateCaps(type, false);
263 if (this._dispatchSceneInput(type, args))
264 yuu.stopPropagation(event, true);
265 else if ((cmds = this.input[type].apply(this.input, args))) {
266 var ctx = yf.last(args);
267 yf.each.call(this, this.execute, cmds, yf.repeat(ctx, cmds.length));
268 yuu.stopPropagation(event, true);
269 }
270 },
271
272 _addListener: function (target, name, handler) {
273 handler = (handler || this["_do" + name] || this._doevent).bind(this);
274 this._events[name] = { target: target, handler: handler };
275 target.addEventListener(name, handler);
276 },
277
278 _removeListener: function (name) {
279 this._events[name].target.removeEventListener(
280 name, this._events[name].handler);
281 delete this._events[name];
282 },
283
284 tickHz: {
285 get: function () { return this._tickHz; },
286 set: function (hz) {
287 this._tickHz = hz;
288 this._tickCount = 0;
289 this._timerStart = 0;
290 }
291 },
292
293 currentTime: { get: function () {
294 return this._timerStart + 1000 * this._tickCount / this._tickHz;
295 } },
296
297 currentAudioTime: { get: function () {
298 /** Audio time of the current tick.
299 */
300 return (this.currentTime + this._audioOffset) / 1000;
301 } },
302
303 _startRender: function () {
304 if (this._rafId !== null)
305 return;
306 this._tickCount = 0;
307 this._timerStart = 0;
308 // GNU/Linux with node-webkit sizes things incorrectly on
309 // startup, so force a recalculating as soon as the render
310 // loop runs.
311 this._resized = true;
312 var director = this;
313 this._rafId = window.requestAnimationFrame(function _ (t) {
314 if (!director._timerStart)
315 director._timerStart = t;
316 director._rafId = window.requestAnimationFrame(_);
317 director.render(t);
318 });
319 },
320
321 _stopRender: function () {
322 if (this._rafId !== null)
323 window.cancelAnimationFrame(this._rafId);
324 this._rafId = null;
325 },
326
327 start: function () {
328 /** Begin ticking and rendering scenes */
329 yf.each(this._addListener.bind(this, window),
330 this.WINDOW_EVENTS);
331 yf.each(this._addListener.bind(this, document),
332 this.DOCUMENT_EVENTS);
333 yf.each(this._addListener.bind(this, yuu.canvas),
334 this.CANVAS_EVENTS);
335
336 this._gesture = typeof Hammer !== "undefined"
337 ? new Hammer(yuu.canvas, { "tap_always": false,
338 "hold_timeout": 300 })
339 : { on: function () {}, off: function () {} };
340 this._gesture.on(this.GESTURES.join(" "), this._dogesture);
341
342 // Treat the back button as another kind of input event. Keep
343 // a token state on the stack to catch the event, and if no
344 // scene handles it, just go back one more.
345 //
346 // Because of browser session restore, state might already be
347 // on the stack. Throw it out if so.
348 if (!history.state)
349 history.pushState("yuu director", "");
350 else
351 history.replaceState("yuu director", "");
352 this._startRender();
353 },
354
355 stop: function () {
356 /** Stop ticking and rendering, clear all scenes */
357 this._stopRender();
358 yf.eachr(function (scene) { scene.done(); }, this._scenes);
359 this._scenes = [];
360 yf.each.call(this, this._removeListener, Object.keys(this._events));
361 this._gesture.off(this.GESTURES.join(" "), this._dogesture);
362 this._gesture = null;
363 },
364
365 message: function () {
366 /** Send a message to all entities/scenes, bottom to top */
367 this.entity0.message.apply(this.entity0, arguments);
368 var scenes = this._scenes;
369 for (var i = 0; i < scenes.length; ++i)
370 scenes[i].message.apply(scenes[i], arguments);
371 },
372
373 _takeScreenshot: function () {
374 var date = (new Date()).toLocaleString();
375 try {
376 yuu.downloadURL(
377 yuu.canvas.toDataURL("image/png"),
378 document.title + " (" + date + ").png");
379 this.toast("&#x1f4f7;", 0.5, "screenshot");
380 } catch (exc) {
381 var dialog = yuu.showError(exc);
382 if (dialog)
383 this.showOverlay(dialog.id);
384 }
385 },
386
387 render: function (t) {
388 /** Tick and render all scenes, bottom to top */
389 var i;
390
391 this._audioOffset = yuu.audio
392 ? yuu.audio.currentTime * 1000 - t
393 : 0;
394
395 if (this._resized) {
396 this._dispatchSceneInput("resize", [yuu.canvas]);
397 this._resized = false;
398 }
399
400 t = t - this._timerStart;
401 var oneTick = 1000.0 / this._tickHz;
402 while (oneTick * this._tickCount < t)
403 this.message("tick", oneTick * this._tickCount++, oneTick);
404 this.message("tock", (t % oneTick) / oneTick);
405
406 yuu.gl.clear(yuu.gl.COLOR_BUFFER_BIT);
407 var scenes = this._scenes;
408 var cursor = "default";
409 for (i = 0; i < scenes.length; ++i) {
410 scenes[i].render();
411 cursor = scenes[i].cursor || cursor;
412 }
413
414 if (cursor !== yuu.canvas.style.cursor)
415 yuu.canvas.style.cursor = cursor;
416
417 for (i = 0; i < this._afterRender.length; ++i)
418 this._afterRender[i]();
419 this._afterRender.length = 0;
420 },
421
422 toast: yuu.cmd(function (markup, duration, id) {
423 var toasts = this._toasts;
424 id = "yuu-toast-" + id;
425 var toast = id ? document.querySelector("#" + id) : null;
426 duration = duration || 4;
427
428 if (!toast) {
429 toast = document.createElement("div");
430 toast.id = id;
431 toast.className = "yuu-toast yuu-fade";
432 document.body.appendChild(toast);
433 }
434 if (toasts[id]) {
435 clearTimeout(toasts[id]);
436 delete toasts[id];
437 }
438 toast.innerHTML = markup;
439 yuu.afterAnimationFrame(function () {
440 toast.className = "yuu-toast";
441 });
442
443 var to = setTimeout(function () {
444 toast.className = "yuu-toast yuu-fade";
445 toast.addEventListener("transitionend", function fade () {
446 toast.removeEventListener("transitionend", fade);
447 // Stop if the toast was revived between the
448 // timeout event and transition end, i.e. while it
449 // was fading out.
450 if (id && toasts[id] !== to)
451 return;
452 toast.className += " yuu-squish";
453 toast.addEventListener("transitionend", function squish () {
454 toast.removeEventListener("transitionend", squish);
455 if (id && toasts[id] === to) {
456 delete toasts[id];
457 toast.parentNode.removeChild(toast);
458 }
459 });
460 });
461 }, duration * 1000);
462 if (id)
463 toasts[id] = to;
464 }, "<markup> <duration?>", "show a toast message"),
465
466 showOverlay: yuu.cmd(function (id, animation, dismissKeys) {
467 var overlay = new yuu.Overlay(
468 document.getElementById(id), animation, dismissKeys);
469 this.pushScene(overlay);
470 }, "<overlay ID> <animation?> <dismissKeys?>", "show an HTML overlay"),
471
472 screenshot: yuu.cmd(function () {
473 this._afterRender.push(this._takeScreenshot.bind(this));
474 }, "take a screenshot"),
475
476 fullscreen: yuu.cmd(function (v) {
477 if (arguments.length > 0) {
478 yuu.fullscreen = !!v;
479 // Most browser/OS combinations will drop key events
480 // during the "transition to fullscreen" animation.
481 // This means the key to enter fullscreen is recorded
482 // as "stuck down" inside the input code, and pressing
483 // it again won't trigger exiting fullscreen, just
484 // clear the stuck bit - you would have to press it
485 // *again* to actually transition out of fullscreen.
486 //
487 // Obviously this is not good, and the chance of the
488 // player actually trying to do something meaningful
489 // during fullscreen transition is unlikely, so just
490 // blow away the internal state and act like
491 // everything the player does is new.
492 this.input.reset();
493 }
494 return yuu.fullscreen;
495 }, "<enabled?>", "enable/disable fullscreen"),
496
497 execute: { proxy: "_commandStack.execute" },
498 });
499
500 yuu.Scene = yT({
501 constructor: function () {
502 /** A collection of entities, a layer, keybinds, and commands
503
504 The single argument is as function that will be scalled
505 during construction with `this` as the newly-created
506 scene.
507
508 */
509 this.entity0 = new yuu.E();
510 this.layer0 = new yuu.Layer();
511 this.keybinds = new yuu.KeyBindSet(this.KEYBINDS);
512 this.commands = yuu.extractCommands(this);
513 },
514
515 addEntity: { proxy: "entity0.addChild" },
516 removeEntity: { proxy: "entity0.removeChild" },
517 addEntities: { proxy: "entity0.addChildren" },
518 removeEntities: { proxy: "entity0.removeChildren" },
519 message: { proxy: "entity0.message" },
520
521 init: function (director) {
522 /** Called when the director starts this scene */
523 },
524
525 done: function () {
526 /** Called when the director stops this scene */
527 },
528
529 render: function () {
530 /** Queue renderables from the entities and render each layer */
531 this.message("queueRenderables", this.layer0.rdros);
532 this.layer0.render();
533 this.layer0.clear();
534 },
535
536 inputs: {},
537 KEYBINDS: {}
538 });
539
540 yuu.Overlay = yT(yuu.Scene, {
541 constructor: function (element, animation, dismissKeys) {
542 yuu.Scene.call(this);
543 this.dismissKeys = dismissKeys
544 || (element.getAttribute("data-yuu-dismiss-key") || "").split(" ");
545 this.animation = animation
546 || element.getAttribute("data-yuu-animation")
547 || "yuu-from-top";
548 this.element = element;
549 this.className = element.className;
550 this._keydown = function (event) {
551 var name = yuu.keyEventName(event);
552 if (this.inputs.keydown.call(this, name))
553 yuu.stopPropagation(event);
554 }.bind(this);
555 },
556
557 inputs: {
558 back: function () { this.dismiss(); return true; },
559 keydown: function (key) {
560 if (yf.contains(this.dismissKeys, key))
561 this.dismiss();
562 return true;
563 },
564 touch: function () { this.dismiss(); return true; },
565 mousedown: function () { this.dismiss(); return true; },
566 },
567
568 init: function (director) {
569 var element = this.element;
570 var className = this.className;
571 var elements = element.querySelectorAll("[data-yuu-command]");
572
573 yf.each(function (element) {
574 var command = getCommand(element);
575 switch (element.tagName.toLowerCase()) {
576 case "input":
577 switch (element.type.toLowerCase()) {
578 case "range":
579 element.value = director.execute(command);
580 break;
581 case "checkbox":
582 var res = !!director.execute(command);
583 element.checked = res;
584 break;
585 }
586 break;
587 }
588 }, elements);
589
590 this._director = director;
591
592 element.className = className + " " + this.animation;
593 element.style.display = "block";
594 element.tabIndex = 0;
595 element.focus();
596 element.addEventListener("keydown", this._keydown);
597
598 yuu.afterAnimationFrame(function () {
599 element.className = className;
600 });
601 },
602
603 dismiss: yuu.cmd(function () {
604 var element = this.element;
605 var className = this.className;
606 var director = this._director;
607 var scene = this;
608 element.className = className + " " + this.animation;
609 element.addEventListener("transitionend", function _ () {
610 element.removeEventListener("transitionend", _);
611 director.removeScene(scene);
612 });
613 }, "", "dismiss this overlay"),
614
615 done: function () {
616 this.element.style.display = "none";
617 this.element.tabIndex = -1;
618 this.element.className = this.className;
619 this.element.removeEventListener("keydown", this._keydown);
620 this._director = null;
621 yuu.canvas.focus();
622 },
623
624 KEYBINDS: {
625 "escape": "dismiss"
626 }
627 });
628
629 yuu.registerInitHook(function () {
630 var elements = document.querySelectorAll("[data-yuu-command]");
631
632 function handleElement (event) {
633 /*jshint validthis:true */
634 /* `this` comes from being a DOM element event handler. */
635 var command = getCommand(this);
636 switch (this.tagName.toLowerCase()) {
637 case "input":
638 switch (this.type.toLowerCase()) {
639 case "range":
640 command += " " + this.value;
641 break;
642 case "checkbox":
643 command += " " + (this.checked ? "1" : "0");
644 break;
645 }
646 break;
647 }
648 yuu.director.execute(command);
649 yuu.stopPropagation(event);
650 }
651
652 yf.each(function (element) {
653 switch (element.tagName.toLowerCase()) {
654 case "input":
655 switch (element.type.toLowerCase()) {
656 case "range":
657 element.oninput = handleElement;
658 element.onchange = handleElement;
659 break;
660 case "checkbox":
661 element.onchange = handleElement;
662 break;
663 }
664 break;
665 case "div":
666 case "span":
667 element.onclick = handleElement;
668 element.onkeydown = function (event) {
669 var name = yuu.keyEventName(event);
670 if (name === "space" || name === "return")
671 handleElement.call(this, event);
672 };
673 break;
674 default:
675 element.onclick = handleElement;
676 break;
677 }
678 }, elements);
679
680 yuu.defaultKeybinds.bind("control+`", "showDevTools");
681 yuu.defaultKeybinds.bind("f11", "++fullscreen");
682 yuu.defaultKeybinds.bind("f12", "screenshot");
683 yuu.defaultKeybinds.bind(
684 "control+s", "++mute && toast &#x1f508; 1 mute || toast &#x1f50a; 1 mute");
685
686 var director = yuu.director = new yuu.Director();
687 /** The standard director */
688
689 yuu.registerInitHook(function () {
690 return yuu.ready(director._scenes);
691 });
692 });
693
694 }).call(typeof exports === "undefined" ? this : exports,
695 typeof exports === "undefined"
696 ? this.yuu : (module.exports = require('./core')));