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