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");
21 constructor: function (commandStack
, input
, tickHz
) {
22 /** Manage and update a set of Scenes
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.
32 this.entity0
= new yuu
.E();
33 this._commandStack
= commandStack
|| yuu
.commandStack
;
34 this.input
= input
|| new yuu
.InputState([yuu
.defaultKeybinds
]);
38 this._audioOffset
= 0;
40 this._tickHz
= tickHz
|| 60;
41 this._afterRender
= [];
43 this.commands
= yuu
.extractCommands(this);
44 this._commandStack
.push(this.commands
);
45 this._dogesture
= this.__dogesture
.bind(this);
47 this._resized
= false;
52 pushScene: function (scene
) {
53 /** Add a Scene onto the director's stack */
54 this.insertScene(scene
, this._scenes
.length
);
57 popScene: function () {
58 /** Remove the top scene from the director's stack */
59 this.removeScene(yf
.last(this._scenes
));
62 pushPopScene: function (scene
) {
63 /** Replace the top scene on the stack */
65 this.pushScene(scene
);
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(
74 this._scenes
[idx
+ 1] && this._scenes
[idx
+ 1].commands
);
75 this.input
.insertBefore(
77 this._scenes
[idx
+ 1] && this._scenes
[idx
+ 1].keybinds
);
79 if (scene
.inputs
.resize
)
80 scene
.inputs
.resize
.call(scene
, yuu
.canvas
);
83 insertUnderScene: function (scene
, over
) {
84 return this.insertScene(scene
, this._scenes
.indexOf(over
));
87 removeScene: function (scene
) {
88 /** Remove a Scene onto the director's stack */
89 this._scenes
= yf
.without(this._scenes
, scene
);
91 this.input
.remove(scene
.keybinds
);
92 this._commandStack
.remove(scene
.commands
);
95 DOCUMENT_EVENTS
: [ "keydown", "keyup", "visibilitychange" ],
97 CANVAS_EVENTS
: [ "mousemove", "mousedown", "mouseup" ],
99 WINDOW_EVENTS
: [ "popstate", "resize", "pageshow",
100 "yuugamepadbuttondown", "yuugamepadbuttonup" ],
103 "touch", "release", "hold", "tap", "doubletap",
104 "dragstart", "drag", "dragend", "dragleft", "dragright",
105 "dragup", "dragdown", "swipe", "swipeleft", "swiperight",
106 "swipeup", "swipedown", "pinch", "pinchin", "pinchout"
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
))
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?
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.
127 else if (scene
.inputs
.consume
128 && yf
.contains(scene
.inputs
.consume
, name
))
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;
142 _dovisibilitychange: function (event
) {
143 if (event
.target
.hidden
)
149 _dopageshow: function (event
) {
151 history
.pushState("yuu director", "");
153 if (!document
.hidden
)
157 _dopopstate: function (event
) {
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);
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);
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.
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
188 // This is compounded by the lack of actual use cases for any
189 // of the non-gesture events other than "back" and
193 keydown: function (event
) {
194 return [yuu
.keyEventName(event
), {}];
196 keyup: function (event
) {
197 return [yuu
.keyEventName(event
), {}];
199 mousemove: function (event
) {
200 return [yuu
.deviceFromCanvas(event
)];
202 mouseup: function (event
) {
203 return [event
.button
, yuu
.deviceFromCanvas(event
)];
205 mousedown: function (event
) {
206 return [event
.button
, yuu
.deviceFromCanvas(event
)];
208 gamepadbuttondown: function (event
) {
209 return [event
.detail
.gamepad
,
210 event
.detail
.button
];
212 gamepadbuttonup: function (event
) {
213 return [event
.detail
.gamepad
, event
.detail
.button
];
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;
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
;
243 for (var i
= 0; devices
[best
] === false && i
< options
.length
; ++i
)
244 if (devices
[options
[i
]] !== false)
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
);
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);
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
);
271 _removeListener: function (name
) {
272 this._events
[name
].target
.removeEventListener(
273 name
, this._events
[name
].handler
);
274 delete this._events
[name
];
278 get: function () { return this._tickHz
; },
282 this._timerStart
= 0;
286 currentTime
: { get: function () {
287 return this._timerStart
+ 1000 * this._tickCount
/ this._tickHz
;
290 currentAudioTime
: { get: function () {
291 /** Audio time of the current tick.
293 return (this.currentTime
+ this._audioOffset
) / 1000;
296 _startRender: function () {
297 if (this._rafId
!== null)
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
304 this._resized
= true;
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
313 director
._rafId
= window
.requestAnimationFrame(_
);
318 _stopRender: function () {
319 if (this._rafId
!== null)
320 window
.cancelAnimationFrame(this._rafId
);
325 /** Begin ticking and rendering scenes */
326 yf
.each(this._addListener
.bind(this, window
),
328 yf
.each(this._addListener
.bind(this, document
),
329 this.DOCUMENT_EVENTS
);
330 yf
.each(this._addListener
.bind(this, yuu
.canvas
),
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
);
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.
343 // Because of browser session restore, state might already be
344 // on the stack. Throw it out if so.
346 history
.pushState("yuu director", "");
348 history
.replaceState("yuu director", "");
353 /** Stop ticking and rendering, clear all scenes */
355 yf
.eachr(function (scene
) { scene
.done(); }, 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;
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);
370 _takeScreenshot: function () {
371 var date = (new Date()).toLocaleString();
374 yuu.canvas.toDataURL("image/png"),
375 document.title + " (" + date + ").png");
376 this.toast("\uf030", 0.5, "screenshot");
378 var dialog = yuu.showError(exc);
380 this.showOverlay(dialog.id);
384 render: function (t) {
385 /** Tick and render all scenes, bottom to top */
389 this._dispatchSceneInput("resize", [yuu
.canvas
]);
390 this._resized
= false;
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
);
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
) {
404 cursor
= scenes
[i
].cursor
|| cursor
;
407 if (cursor
!== yuu
.canvas
.style
.cursor
)
408 yuu
.canvas
.style
.cursor
= cursor
;
410 for (i
= 0; i
< this._afterRender
.length
; ++i
)
411 this._afterRender
[i
]();
412 this._afterRender
.length
= 0;
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;
422 toast
= document
.createElement("div");
424 toast
.className
= "yuu-toast yuu-fade";
425 document
.body
.appendChild(toast
);
428 clearTimeout(toasts
[id
]);
431 toast
.innerHTML
= markup
;
432 yuu
.afterAnimationFrame(function () {
433 toast
.className
= "yuu-toast";
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
443 if (id
&& toasts
[id
] !== to
)
445 toast
.className
+= " yuu-squish";
446 toast
.addEventListener("transitionend", function squish () {
447 toast
.removeEventListener("transitionend", squish
);
448 if (id
&& toasts
[id
] === to
) {
450 toast
.parentNode
.removeChild(toast
);
457 }, "<markup> <duration?>", "show a toast message"),
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"),
465 screenshot
: yuu
.cmd(function () {
466 this._afterRender
.push(this._takeScreenshot
.bind(this));
467 }, "take a screenshot"),
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.
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.
487 return yuu
.fullscreen
;
488 }, "<enabled?>", "enable/disable fullscreen"),
490 execute
: { proxy
: "_commandStack.execute" },
494 constructor: function () {
495 /** A collection of entities, a layer, keybinds, and commands
497 The single argument is as function that will be scalled
498 during construction with `this` as the newly-created
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);
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" },
514 init: function (director
) {
515 /** Called when the director starts this scene */
519 /** Called when the director stops this scene */
522 render: function () {
523 /** Queue renderables from the entities and render each layer */
524 this.message("queueRenderables", this.layer0
.rdros
);
525 this.layer0
.render();
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")
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
);
551 back: function () { this.dismiss(); return true; },
552 keydown: function (key
) {
553 if (yf
.contains(this.dismissKeys
, key
))
557 touch: function () { this.dismiss(); return true; },
558 mousedown: function () { this.dismiss(); return true; },
561 init: function (director
) {
562 var element
= this.element
;
563 var className
= this.className
;
564 var elements
= element
.querySelectorAll("[data-yuu-command]");
566 yf
.each(function (element
) {
567 var command
= element
.getAttribute("data-yuu-command");
568 switch (element
.tagName
.toLowerCase()) {
570 switch (element
.type
.toLowerCase()) {
572 element
.value
= director
.execute(command
);
575 var res
= !!director
.execute(command
);
576 element
.checked
= res
;
583 this._director
= director
;
585 element
.className
= className
+ " " + this.animation
;
586 element
.style
.display
= "block";
587 element
.tabIndex
= 0;
589 element
.addEventListener("keydown", this._keydown
);
591 yuu
.afterAnimationFrame(function () {
592 element
.className
= className
;
596 dismiss
: yuu
.cmd(function () {
597 var element
= this.element
;
598 var className
= this.className
;
599 var director
= this._director
;
601 element
.className
= className
+ " " + this.animation
;
602 element
.addEventListener("transitionend", function _ () {
603 element
.removeEventListener("transitionend", _
);
604 director
.removeScene(scene
);
606 }, "", "dismiss this overlay"),
609 this.element
.style
.display
= "none";
610 this.element
.tabIndex
= -1;
611 this.element
.className
= this.className
;
612 this.element
.removeEventListener("keydown", this._keydown
);
613 this._director
= null;
622 yuu
.registerInitHook(function () {
623 var elements
= document
.querySelectorAll("[data-yuu-command]");
625 function handleElement (event
) {
626 /*jshint validthis:true */
627 /* `this` comes from being a DOM element event handler. */
628 var command
= this.getAttribute("data-yuu-command");
629 switch (this.tagName
.toLowerCase()) {
631 switch (this.type
.toLowerCase()) {
633 command
+= " " + this.value
;
636 command
+= " " + (this.checked
? "1" : "0");
641 yuu
.director
.execute(command
);
642 yuu
.stopPropagation(event
);
645 yf
.each(function (element
) {
646 switch (element
.tagName
.toLowerCase()) {
648 switch (element
.type
.toLowerCase()) {
650 element
.oninput
= handleElement
;
651 element
.onchange
= handleElement
;
654 element
.onchange
= handleElement
;
660 element
.onclick
= handleElement
;
661 element
.onkeydown = function (event
) {
662 var name
= yuu
.keyEventName(event
);
663 if (name
=== "space" || name
=== "return")
664 handleElement
.call(this, event
);
668 element
.onclick
= handleElement
;
673 yuu
.defaultKeybinds
.bind("control+`", "showDevTools");
674 yuu
.defaultKeybinds
.bind("f11", "++fullscreen");
675 yuu
.defaultKeybinds
.bind("f12", "screenshot");
676 yuu
.defaultKeybinds
.bind(
677 "control+s", "++mute && toast \uf026 1 mute || toast \uf028 1 mute");
679 var director
= yuu
.director
= new yuu
.Director();
680 /** The standard director */
682 yuu
.registerInitHook(function () {
683 return yuu
.ready(director
._scenes
);
687 }).call(typeof exports
=== "undefined" ? this : exports
,
688 typeof exports
=== "undefined"
689 ? this.yuu
: (module
.exports
= require('./core')));