Posted at

React.jsとCSS3で長女のためにWEB絵本を作った話

More than 3 years have passed since last update.

まずはこちらの動画をご覧ください。

こちらは娘が2歳半の頃に児童館で撮影したものです。

かわいい・・・

生後1000日近くともなると、子どもは自分で絵本を読むようになります。

並行してひらがな覚え始めるので、親としてはガンガン絵本を読んでほしいのですが、実際には挿絵に合わせて適当な話を創をして遊んだり、まじめにひらがなを音読し始めたかと思えば数ページも読まないうちに次の絵本を引っ張り出してきては読んで、飽きてまた違う絵本に手を出すだけで、一向にちゃんと読んでくれません。

ちゃんと読まない原因として、もしかして絵本に動きがないから飽きてしまうのでは、と思った自分は

もしもパパが作った動く絵本があったら、娘は大喜びして本を読むようになり、どんどん集中力が身について、あいうえおもマスターしてパパ大満足!なんてことになるんじゃないだろうか。

なんてことを考え、実際にWEB絵本を作成して娘に見せるまでの全ての経緯がこちら。


あらすじ

少しでも集中力が続くように、これまで子どもに読んで聞かせたお話は避け、完全オリジナルストーリーで行くことにします。

あらすじはずっと昔に奥さんと二人で考えた話をベースに、推敲しないで一気に作りました。

ざっとまとめるとこんな感じ


あらすじ


  • 羊の兄弟、それと母親の物語

  • 羊の兄弟のうち、一番小さい弟羊には毛が生えてなかった

  • それをネタに兄弟にいじめられるので、自分は羊の子じゃないんだと思い込んで家出する

  • 本当の親はヤギなんじゃないかと教えられる

  • 道中いろんな目に遭いながらどうにかこうにかヤギと対面

  • でもヤギはもちろん羊の親ではなく、それを聞いて弟羊は後悔

  • 駆けつけた母親羊に謝罪し、帰路に着く

  • 家に帰ると、兄羊の毛が人間に刈り取られていて、毛がなくなるとみんな一緒だよね

  • 兄羊、弟羊に謝罪

  • めでたしめでたし

先に言っておきますが、ストーリーはちゃんと考えたほうが良いです。


技術選定

あらすじが決まったら、具体的な実装方法を決めます。

娘は主にiPadを使っているため、iPadに対応した技術が必要不可欠なので、iPadで絵本が作れそうなものをざっと洗い出してみます。


候補に挙げた技術


  • iBooks Author

  • Cocoa2d-x

  • Adobe Edge Animation

  • HTML+CSS+Javascript

子持ちエンジニア、特に小さい子供を持つ親にはほとんど自由時間がありません。土日は子供につきっきりだし、夜は昼間の疲れでライフはほとんどゼロです。

平日は平日で残業だったり、早く帰れる日は就寝前の子供に会える可能性に賭けてマッハで帰宅、一通り遊んだあと寝かしつけしてその後ご飯を食べたり風呂に入ったりで、まとまった開発時間を取ることは困難です。

なので開発はおのずと会社の昼休みや就業前、定時後などの時間でやりくりするしかありません。

そうなると家と会社のPCで隙あらばコーディング、修正したらすぐgithubにプッシュというやり方が望ましく、となるとベンダーロックインや開発環境の固定化(自宅はMac/会社のPCはWindows)による開発環境の固定化はできるだけ避ける必要があります。

また絵本の必須条件として当然挿絵を沢山用意しないといけないのですが、デザイナーでもない1エンジニアが、大量に絵を描いてアニメーションを設定してストライプなど用意するのは至難の業です。

ところで実際の絵本を見てみると、キャラクターは直線と曲線のシンプルな構成なものが多いです。

つまり最低限直線と曲線の組み合わせで絵が描けて、それらにアニメーションを付与でき、かつ開発環境が固定化されないものとなるとHTMLとCSSが一番良さそうです。

というわけで、


  • HTML+CSS+Javascript

で作り始めることに決めました。

他にも理由があるのですが、長くなる上に本編と関係ないのでポエムとして後半にまとめました。

興味のない人は読み飛ばしてください。


CCSでキャラクターを作る

codepenで検索すると、CSSだけで羊などの動物を描いている方がいます。



http://codepen.io/davidetesta/pen/aCstL

こちらを参考に、自分で作った羊がこちら。



いけそうな気がしてきたので、本格的にHTMLとCSSでWEB絵本の実装に取り掛かります。


絵本をめくるエフェクト

HTMLとCSSで作るからといって、Aタグでページ遷移するようなWEB絵本は味気ないし、そもそもそれでは絵本とは言えません。ページめくりは必須です。

ページめくり効果はブラウザやウェブについて知っておきたい 20 のことの解説がHTML5Rocksに記載されています。

こちらがとてもわかりやすいので、このエフェクトを丸々使わせていただきました。


CSSアニメーション

キャラクターができたら次はアニメーションです。

今回は単純な動きだけなので、CSS3のアニメーション機能だけしか使ってません。

また視聴環境を娘のiPadに絞ったため、ベンダープレフィックスは-webkitのみです。


弟羊をいじめる兄羊のサンプルCSS

@-webkit-keyframes attacked1 {

50% {
-webkit-transform: rotate(55deg);
}
100% {
-webkit-transform: rotate(35deg);
}
}


ページ作成

頁の枚数だけ<section>を用意し、各ページのコンテンツをその中に記述します。

初回読み込み時にJavascriptが<section>pages変数に格納します。


pageflip.js

    // List of all the page elements in the DOM

var pages = book.getElementsByTagName( "section" );

<section>属性のz-indexを調整し、ページを重ね合わせます。


pageflip.js

    // Organize the depth of our pages and create the flip definitions

for( var i = 0, len = pages.length; i < len; i++ ) {
pages[i].style.zIndex = len - i;

flips.push( {
// Current progress of the flip (left -1 to right +1)
progress: 1,
// The target value towards which progress is always moving
target: 1,
// The page DOM element related to this flip
page: pages[i],
// True while the page is being dragged
dragging: false
} );
}


頁をタップすると、<canvas>要素が持ち上がり、マウスの位置に合わせて<canvas>を変形させ、本をめくるような効果を実現しています(上のGIFを参照)

mouseDownHandlerでマウスドラッグ(タップ)イベントを監視し、めくる方向を確認します。


pageflip.js

    function mouseDownHandler( event ) {

// Make sure the mouse pointer is inside of the book
if (Math.abs(mouse.x) < PAGE_WIDTH) {
if (mouse.x < 0 && page - 1 >= 0) {
// We are on the left side, drag the previous page
flips[page - 1].dragging = true;
}
else if (mouse.x > 0 && page + 1 < flips.length) {
// We are on the right side, drag the current page
flips[page].dragging = true;
}
}

// Prevents the text selection
event.preventDefault();
}


mouseUpHandlerで指がマウス(画面)から離れた状態を検知し、ページめくりが必要と判断したら後(前)のページに差し替えます。


pageflip.js

    function mouseUpHandler( event ) {

for( var i = 0; i < flips.length; i++ ) {
// If this flip was being dragged, animate to its destination
if( flips[i].dragging ) {
// Figure out which page we should navigate to
if( mouse.x < 0 ) {
flips[i].target = -1;
page = Math.min( page + 1, flips.length );
}
else {
flips[i].target = 1;
page = Math.max( page - 1, 0 );
}
}

flips[i].dragging = false;
}
}


ページのレンダリングはrenderを60fps間隔で呼び出して行います。


pageflip.js

    function render() {

// Reset all pixels in the canvas
context.clearRect( 0, 0, canvas.width, canvas.height );

for( var i = 0, len = flips.length; i < len; i++ ) {
var flip = flips[i];

if( flip.dragging ) {
flip.target = Math.max( Math.min( mouse.x / PAGE_WIDTH, 1 ), -1 );
}

// Ease progress towards the target value
flip.progress += ( flip.target - flip.progress ) * 0.2;

// If the flip is being dragged or is somewhere in the middle of the book, render it
if( flip.dragging || Math.abs( flip.progress ) < 0.997 ) {
drawFlip( flip );
}

}

}



pageflip.js

    // Render the page flip 60 times a second

setInterval( render, 1000 / 60 );

ただしページめくりの描画処理自体はrender内のdrawFlip(flip)で行っています。


動的にコンテンツを書き換える

20things_pageflipのロジックをそのまま使うと、最初に全ページのコンテンツ(今回の場合はセリフと挿絵)を<section>内に記述しておかないといけません。

このやり方だと、今回のようにCSSアニメーションを設定してる場合、初期表示時に全てのページのCSSアニメーションが動いてしまうため正しいタイミングでアニメーションが実行できないという問題があります。

なので20things_pageflipのJSの一部を改修し、ページ切り替えを検知して動的にコンテンツの描画・再描画を行う修正が必要になります。

JavascriptのinnerHtmlcreateElementを使ってゴリゴリと書き進めるのもつらいので、ここは優れたJSテンプレートエンジンであるReact.jsを使うことにしました。

とはいっても、使ってるのはほぼReact.renderだけで、FluxやReduxなどといった複雑なフローは使ってません。


React.jsでコンテンツの描画を管理する

1ページを1テンプレートとして、各ページのテンプレートReact.createClassを通じて登録し、全てのページをpageElement配列に追加します。

以下は1ページ目の挿絵とセリフをReact.createClassにセットしているところです。


セリフ1ページ目

selifElement[1] = React.createClass({

render: function(){
return (
<div>
<div className="caption cap2-1">
あるところに
</div>
<div className="caption cap2-2">
ひつじのかぞくがすんでいました
</div>
</div>
);
}
});


挿絵1ページ目

pageElement[1]= React.createClass({

render: function(){
return (
<div>
<div className="sky">
<div className="house">
<div className="window">
</div>
<div className="chimney"></div>
<div className="smokecontainer"><span className="smoke"></span>
</div>
</div>
</div>
<div className="hill"></div>
</div>
);
}
});

こんな感じでセリフと挿絵を全ページ分、ひたすら作成します。

すべてのページのpageElementselifElementを作成したら、ページ切り替えのタイミングでReact.createElementを呼び出して絵本の台詞と挿絵をレンダリングするための関数、renderCurrentPageを作成します。

renderCurrentPageは引数にpageを取ります。pageをインデックスにselifElementpageElementから表示すべき台詞と挿絵を選択しReact.renderに渡すことでコンテンツの描画を行います。


pageflip.js

// Render each page elements

function renderCurrentPage(page){
React.render(React.createElement(selifElement[page]), document.getElementById("selif"+page));
React.render(React.createElement(pageElement[page]), document.getElementById("page"+page));
}

また表示ページ以外の台詞と挿絵コンテンツを初期化するための関数unMountOtherPagesも用意します。


pageflip.js

// Unmount hidden page elements

function unMountOtherPages(page){
for( var i = 0; i < len; i++ ) {
if (i != page){
React.unmountComponentAtNode(document.getElementById('page'+i));
React.unmountComponentAtNode(document.getElementById('selif'+i));
}
}
}

あとはrenderCurrentPagemouseUpHandeler のページ切り替え判定に追加し、ページがめくられたタイミングでコンテンツが描画・再描画されるように変更します。

function mouseUpHandler( event ) {

for( var i = 0; i < flips.length; i++ ) {
// If this flip was being dragged, animate to its destination
if( flips[i].dragging) {
// Figure out which page we should navigate to
if( mouse.x < 0 && draggedMousePos >= 0) {
flips[i].target = -1;
page = Math.min( page + 1, flips.length );
renderCurrentPage(page); // <- ここに追加
}
else {
if(draggedMousePos <= 0){
flips[i].target = 1;
page = Math.max( page - 1, 0 );
renderCurrentPage(page); // <- ここに追加
}
}
}

flips[i].dragging = false;
}
}

さらに現在表示されているページ以外のセリフと挿絵を初期化するため、React.createClasscomponentDidUpdatecomponentDidMountunMountOtherPagesを登録し、ページの描画完了と同時に他のページの台詞と挿絵を初期化します。


pageflip.js

pageElement[1]= React.createClass({

componentDidUpdate: function(){ // ここから
unMountOtherPages(1);
},
componentDidMount: function(){
unMountOtherPages(1);
}, // ここまで追加
render: function(){
return (
<div>
<div className="sky">
<div className="house">
<div className="window">
</div>
<div className="chimney"></div>
<div className="smokecontainer"><span className="smoke"></span>
</div>
</div>
</div>
<div className="hill"></div>
</div>
);
}
});

この処理により、ユーザー(娘)が以前のページを行き来しても、コンテンツが毎回初期化されるので正しいタイミングでアニメーションが実行されるようになります。

すべてのページの作成が完了したら、pageflip.jsに絵本の総ページ数を伝えるために<section>エレメントの生成を行います。

for(i=0;i<=len;i++){

var section = document.createElement("section");
section.id = "page"+i;
pagesElement.appendChild(section);
var selifSection = document.createElement("div");
selifSection.id = "selif"+i;
selifsElement.appendChild(selifSection);
}

ここについては、Reactコンポーネントを使って要素を生成していないので、行儀の良い方法ではないですが、いい方法が思いつかなかったのでやむなしとしました・・・


完成品

こうしてできた完成品がこちらになります。

http://masahikoofjoytoy.github.io/picturebook/

iPadまたはiOSで閲覧ください。

Chromeの場合はデベロッパーツールでデバイスモードをiPadにしないとちゃんと動きません。


娘の感想

当初の目的の一つに、娘にひらがな・カタカナを覚えてもらうというのがあったのですが、一言目から

娘「パパが読んで〜」

パパ「自分で読みなよ」

娘「ヤダー、パパが読んで〜」

悲しいかな父親というのは娘に頼まれると嫌とは言えない生き物なので、仕方なく娘を膝に乗せて数ページ読み進めたところで、話が面白くなかったのか彼女はページめくりに夢中になり、本編そっちのけで同じページを行ったり来たりして話に興味を持ってくれない(泣)

それでも何とか最後まで読み続け、終わった時の感想

「ほかのおはなしないの〜?」

そういうと娘はソファのうえでぴょんぴょん跳ねながら魔法使いプリキュアごっこを始め(なぜか敵はばいきんまん)、もはやWEB絵本のことは忘却の彼方へ。

ばいきんまんと化した父親は無事キュアップ・ラパパと倒されてしまいましたとさ。

ただその日の晩、娘が寝る直前に「パパのきょうのえほん、おもしろかったよ」と言ってくれて正直泣きそうになった。


ポエム

HTMLとCSSをベースにした一番の理由は、環境構築が不要で学習コストを極力抑えたアニメーションが作れるので、プログラムがわからない子供でも簡単にチャレンジできるんじゃないかと考えたからです。

単純なHTMLだと動きがないのですぐに飽きてしまいますが、CSS3ならアニメーションを作ることができます。

しかもモダンブラウザはほとんど対応しているので、学校で放置されているWindows XPでもブラウザとエディタさえあれば描くことができます。

もちろん複雑なアニメーションはできないし、ゲームなどでは当たり前なインタラクティブなイベントを発行することもデータをセーブすることもできません。

WEBで複雑なことをやるためには、どうしてもJavascriptの知識が必要になります。

でもCSSやHTMLを起点にJavascriptに入れば、やりたいことが明確な分だけ挫折する可能性がずっと少なくなると思います。

JavascriptができればUnityでアプリケーションが作れますし、CordovaやTitaniumならJavascriptでスマートフォン用アプリも作れる、node.jsならサーバサイドもJavascriptで動かせる、Electronをならデスクトップアプリもできる。さらに複雑なことをやりたくなったとき、一つの言語を知っていれば応用が効くので学習コストを下げることができる。

何よりブラウザというインターネットに必要不可欠なツール、それもすでにどのPCやスマホにもインストール済みで、生活に必要不可欠なツールを開発環境として使えるというのは、とても大きなアドバンテージだと思います。

この絵本からJavascriptを外し、各ページをHTMLにしてハイパーリンクを張れば、CSSとHTMLだけでアニメーションができます。

まず一番簡単なところから入って、だれでも簡単にアニメーションが書けるし、githubのアカウントさえあれば世界に公開できることを知ってもらえたらいいなと思います。

そのうえでさらに凝ったことをしたければJavascriptを、Javascriptであればネットには参考文献が山ほどあるので、継続して学習できる可能性が高いでしょう。

そういう思いから、HTML+CSS+JSでWEB絵本を作って公開することにしました。

もちろん娘に喜んでもらいたいというのが一番の理由でしたが(泣)


反省点


  • 開発に時間をかけすぎてしまった(開発期間13か月)。その間に娘はひらがな、カタカナを全部読めるようになってしまった。放っておいても子は育つ。

  • マークアップがとにかく大変だった。枚数を用意するのに相当量のHTMLとCSSコーディングが必要。最適化もサボったので似たようなclassやidを量産してしまう結果に。

  • ストーリーをオリジナルにする必要はまったくなかった。知らない話のほうが食いつくかと思ったが、それはコンテンツ力が高い場合のみで、素人の考えたオリジナルストーリーでは子供は食いつかない。かにむかしとか、ヘンゼルとグレーテルのような、子どもが気に入ってる話を選ぶべき。

  • もっとインタラクティブ要素を入れて、アクションや回答によってページが変わるような工夫があれば最後まで飽きずに読んでくれたかもしれない。

  • 絵が動いても思ったほど食いつかなかった。そもそも動的か静的かなどは彼女にとっては些細な問題なのかもしれないが、話がつまらなかった可能性のほうが遥かに高い。


感想

開発開始からなんだかんだで1年かかってしまい、2歳半だった娘は3歳半となり、この春から幼稚園に入園します。徐々に親離れしていくのかと思うと寂しいような嬉しいような複雑な気持ちですが、この時期に子供のために何かやってあげることができたことは父親としては満足です。


今後の予定

次女が生後5ヶ月なので、彼女が2歳になる頃にまた新しい絵本でリベンジしたいと思います。。。


ソースコード

ソースコードはgithubに置いてあります。

https://github.com/masahikoofjoytoy/masahikoofjoytoy.github.io

少しでもWEB絵本に興味を持った方は是非試してみてください。