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/
10 var yT
= this.yT
|| require("./yT");
11 var yf
= this.yf
|| require("./yf");
13 // It's vaguely plausible to want a director without any scenes
14 // (only entity0 and the canvas), which means the renderer is not
16 if (!yuu
.E
) require("./ce");
17 if (!yuu
.InputState
) require("./input");
18 if (!yuu
.Material
) require("./gfx");
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
);
28 constructor: function (commandStack
, input
, tickHz
) {
29 /** Manage and update a set of Scenes
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.
39 this.entity0
= new yuu
.E();
40 this._commandStack
= commandStack
|| yuu
.commandStack
;
41 this.input
= input
|| new yuu
.InputState([yuu
.defaultKeybinds
]);
45 this._audioOffset
= 0;
47 this._tickHz
= tickHz
|| 60;
48 this._afterRender
= [];
50 this.commands
= yuu
.extractCommands(this);
51 this._commandStack
.push(this.commands
);
52 this._dogesture
= this.__dogesture
.bind(this);
54 this._resized
= false;
59 pushScene: function (scene
) {
60 /** Add a Scene onto the director's stack */
61 this.insertScene(scene
, this._scenes
.length
);
64 popScene: function () {
65 /** Remove the top scene from the director's stack */
66 this.removeScene(yf
.last(this._scenes
));
69 pushPopScene: function (scene
) {
70 /** Replace the top scene on the stack */
72 this.pushScene(scene
);
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(
81 this._scenes
[idx
+ 1] && this._scenes
[idx
+ 1].commands
);
82 this.input
.insertBefore(
84 this._scenes
[idx
+ 1] && this._scenes
[idx
+ 1].keybinds
);
86 if (scene
.inputs
.resize
)
87 scene
.inputs
.resize
.call(scene
, yuu
.canvas
);
90 insertUnderScene: function (scene
, over
) {
91 return this.insertScene(scene
, this._scenes
.indexOf(over
));
94 removeScene: function (scene
) {
95 /** Remove a Scene onto the director's stack */
96 this._scenes
= yf
.without(this._scenes
, scene
);
98 this.input
.remove(scene
.keybinds
);
99 this._commandStack
.remove(scene
.commands
);
102 DOCUMENT_EVENTS
: [ "keydown", "keyup", "visibilitychange" ],
104 CANVAS_EVENTS
: [ "mousemove", "mousedown", "mouseup" ],
106 WINDOW_EVENTS
: [ "popstate", "resize", "pageshow",
107 "yuugamepadbuttondown", "yuugamepadbuttonup" ],
110 "touch", "release", "hold", "tap", "doubletap",
111 "dragstart", "drag", "dragend", "dragleft", "dragright",
112 "dragup", "dragdown", "swipe", "swipeleft", "swiperight",
113 "swipeup", "swipedown", "pinch", "pinchin", "pinchout"
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
))
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?
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.
134 else if (scene
.inputs
.consume
135 && yf
.contains(scene
.inputs
.consume
, name
))
141 // Aside from the performance considerations, deferring
142 // resizing by multiple frames fixes mis-sizing during startup
143 // and fullscreen transition in NW.js on Windows. (And
144 // probably similar bugs in other configurations.)
145 _doresize
: yf
.debounce(function () {
146 this._resized
= true;
149 _dovisibilitychange: function (event
) {
150 if (event
.target
.hidden
)
156 _dopageshow: function (event
) {
158 history
.pushState("yuu director", "");
160 if (!document
.hidden
)
164 _dopopstate: function (event
) {
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);
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);
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.
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
195 // This is compounded by the lack of actual use cases for any
196 // of the non-gesture events other than "back" and
200 keydown: function (event
) {
201 return [yuu
.keyEventName(event
), {}];
203 keyup: function (event
) {
204 return [yuu
.keyEventName(event
), {}];
206 mousemove: function (event
) {
207 return [yuu
.deviceFromCanvas(event
)];
209 mouseup: function (event
) {
210 return [event
.button
, yuu
.deviceFromCanvas(event
)];
212 mousedown: function (event
) {
213 return [event
.button
, yuu
.deviceFromCanvas(event
)];
215 gamepadbuttondown: function (event
) {
216 return [event
.detail
.gamepad
,
217 event
.detail
.button
];
219 gamepadbuttonup: function (event
) {
220 return [event
.detail
.gamepad
, event
.detail
.button
];
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;
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
;
250 for (var i
= 0; devices
[best
] === false && i
< options
.length
; ++i
)
251 if (devices
[options
[i
]] !== false)
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
);
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);
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
);
278 _removeListener: function (name
) {
279 this._events
[name
].target
.removeEventListener(
280 name
, this._events
[name
].handler
);
281 delete this._events
[name
];
285 get: function () { return this._tickHz
; },
289 this._timerStart
= 0;
293 currentTime
: { get: function () {
294 return this._timerStart
+ 1000 * this._tickCount
/ this._tickHz
;
297 currentAudioTime
: { get: function () {
298 /** Audio time of the current tick.
300 return (this.currentTime
+ this._audioOffset
) / 1000;
303 _startRender: function () {
304 if (this._rafId
!== null)
307 this._timerStart
= 0;
308 // GNU/Linux with NW.js sizes things incorrectly on
309 // startup, so force a recalculating as soon as the render
311 this._resized
= true;
313 this._rafId
= window
.requestAnimationFrame(function _ (t
) {
314 if (!director
._timerStart
)
315 director
._timerStart
= t
;
316 director
._rafId
= window
.requestAnimationFrame(_
);
321 _stopRender: function () {
322 if (this._rafId
!== null)
323 window
.cancelAnimationFrame(this._rafId
);
328 /** Begin ticking and rendering scenes */
329 yf
.each(this._addListener
.bind(this, window
),
331 yf
.each(this._addListener
.bind(this, document
),
332 this.DOCUMENT_EVENTS
);
333 yf
.each(this._addListener
.bind(this, yuu
.canvas
),
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
);
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.
346 // Because of browser session restore, state might already be
347 // on the stack. Throw it out if so.
349 history
.pushState("yuu director", "");
351 history
.replaceState("yuu director", "");
356 /** Stop ticking and rendering, clear all scenes */
358 yf
.eachr(function (scene
) { scene
.done(); }, 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;
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);
373 _takeScreenshot: function () {
374 var date = (new Date()).toLocaleString();
377 yuu.canvas.toDataURL("image/png"),
378 document.title + " (" + date + ").png");
379 this.toast("📷", 0.5, "screenshot");
381 var dialog = yuu.showError(exc);
383 this.showOverlay(dialog.id);
387 render: function (t) {
388 /** Tick and render all scenes, bottom to top */
391 this._audioOffset
= yuu
.audio
392 ? yuu
.audio
.currentTime
* 1000 - t
396 this._dispatchSceneInput("resize", [yuu
.canvas
]);
397 this._resized
= false;
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
);
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
) {
411 cursor
= scenes
[i
].cursor
|| cursor
;
414 if (cursor
!== yuu
.canvas
.style
.cursor
)
415 yuu
.canvas
.style
.cursor
= cursor
;
417 for (i
= 0; i
< this._afterRender
.length
; ++i
)
418 this._afterRender
[i
]();
419 this._afterRender
.length
= 0;
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;
429 toast
= document
.createElement("div");
431 toast
.className
= "yuu-toast yuu-fade";
432 document
.body
.appendChild(toast
);
435 clearTimeout(toasts
[id
]);
438 toast
.innerHTML
= markup
;
439 yuu
.afterAnimationFrame(function () {
440 toast
.className
= "yuu-toast";
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
450 if (id
&& toasts
[id
] !== to
)
452 toast
.className
+= " yuu-squish";
453 toast
.addEventListener("transitionend", function squish () {
454 toast
.removeEventListener("transitionend", squish
);
455 if (id
&& toasts
[id
] === to
) {
457 toast
.parentNode
.removeChild(toast
);
464 }, "<markup> <duration?>", "show a toast message"),
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"),
472 screenshot
: yuu
.cmd(function () {
473 this._afterRender
.push(this._takeScreenshot
.bind(this));
474 }, "take a screenshot"),
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.
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.
494 return yuu
.fullscreen
;
495 }, "<enabled?>", "enable/disable fullscreen"),
497 execute
: { proxy
: "_commandStack.execute" },
501 constructor: function () {
502 /** A collection of entities, a layer, keybinds, and commands
504 The single argument is as function that will be scalled
505 during construction with `this` as the newly-created
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);
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" },
521 init: function (director
) {
522 /** Called when the director starts this scene */
526 /** Called when the director stops this scene */
529 render: function () {
530 /** Queue renderables from the entities and render each layer */
531 this.message("queueRenderables", this.layer0
.rdros
);
532 this.layer0
.render();
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")
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
);
558 back: function () { this.dismiss(); return true; },
559 keydown: function (key
) {
560 if (yf
.contains(this.dismissKeys
, key
))
564 touch: function () { this.dismiss(); return true; },
565 mousedown: function () { this.dismiss(); return true; },
568 init: function (director
) {
569 var element
= this.element
;
570 var className
= this.className
;
571 var elements
= element
.querySelectorAll("[data-yuu-command]");
573 yf
.each(function (element
) {
574 var command
= getCommand(element
);
575 switch (element
.tagName
.toLowerCase()) {
577 switch (element
.type
.toLowerCase()) {
579 element
.value
= director
.execute(command
);
582 var res
= !!director
.execute(command
);
583 element
.checked
= res
;
590 this._director
= director
;
592 element
.className
= className
+ " " + this.animation
;
593 element
.style
.display
= "block";
594 element
.tabIndex
= 0;
596 element
.addEventListener("keydown", this._keydown
);
598 yuu
.afterAnimationFrame(function () {
599 element
.className
= className
;
603 dismiss
: yuu
.cmd(function () {
604 var element
= this.element
;
605 var className
= this.className
;
606 var director
= this._director
;
608 element
.className
= className
+ " " + this.animation
;
609 element
.addEventListener("transitionend", function _ () {
610 element
.removeEventListener("transitionend", _
);
611 director
.removeScene(scene
);
613 }, "", "dismiss this overlay"),
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;
629 yuu
.registerInitHook(function () {
630 var elements
= document
.querySelectorAll("[data-yuu-command]");
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()) {
638 switch (this.type
.toLowerCase()) {
640 command
+= " " + this.value
;
643 command
+= " " + (this.checked
? "1" : "0");
648 yuu
.director
.execute(command
);
649 yuu
.stopPropagation(event
);
652 yf
.each(function (element
) {
653 switch (element
.tagName
.toLowerCase()) {
655 switch (element
.type
.toLowerCase()) {
657 element
.oninput
= handleElement
;
658 element
.onchange
= handleElement
;
661 element
.onchange
= handleElement
;
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
);
675 element
.onclick
= handleElement
;
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 🔈 1 mute || toast 🔊 1 mute");
686 var director
= yuu
.director
= new yuu
.Director();
687 /** The standard director */
689 yuu
.registerInitHook(function () {
690 return yuu
.ready(director
._scenes
);
694 }).call(typeof exports
=== "undefined" ? this : exports
,
695 typeof exports
=== "undefined"
696 ? this.yuu
: (module
.exports
= require('./core')));