[Javascript + CSS] ゲームで使えるワイプ機能(animateでmask-sizeが使えない時の対処法)

先日、とあるブラウザゲーム開発で、mask-imageを使って、ワイプ処理を作ることになったのだが、色々と出来る事と出来ない事がわかったので、メモを残しておきます。

事前準備

今回作るワイプ機能では、丸いオブジェクトが拡大や縮小をすることで、ワイプ-インやアウトを実現する仕様です。 PNGなどで簡易に丸い画像をピクセルで作っても良いのですが、画面いっぱいに拡大する事を考えると、サイズに依存しないsvgを採用した方がいいと思い、svgでの丸素材を作っておきます。

svgの丸画像

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0,0,100,100"> <circle cx="50" cy="50" r="50" /> </svg> svg画像を、更にcssに埋め込むために、base64文字列に変換します。

svgをbase64化

url('data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%2C0%2C100%2C100%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20%2F%3E%3C%2Fsvg%3E')

cssでワイプ機能をコーディング

wipe.html

<link rel='stylesheet' href='wipe.css'/> <div class='wipe'> <img src='sample.jpg'> </div>

wipe.css

.wipe img{ width:100%; height:100%; object-fit:cover; } .wipe{ width:100%; height:300px; -webkit-mask-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%2C0%2C100%2C100%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20%2F%3E%3C%2Fsvg%3E'); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; animation: 2s ease-in 1s infinite alternate wipe; } @keyframes wipe{ from{ -webkit-mask-size: 0; } to{ -webkit-mask-size: 100%; } }

Demo

※わかりやすいように背景に市松模様をセットしています。

解説

1. mask-image

どうやら、-webkit-というプレフィックスを付けないとChromeやSafariでは動作しないようなので、-webkit-mask-imageで記述してます。 念の為、mask-image:***;も書いておいたほうがいいかもしれません。(将来的にプレフィックス無し仕様に変更される可能性があるため) 上記で作成した、丸画像(svg)のbase64文字列を入れています。

2. mask-repeat

繰り返しパターン表示をしないので、-webkit-mask-repeat: no-repeat;をセットしてます。

3. mask-position

画面のどの位置に表示するかの指定ができます。 ピクセルしていでも、%指定でも書けますが、画面中央の場合は、centerで問題ないです。 デフォルトでは、左上の0,0になります。

4. animation機能

-webkit-mask-sizeを 0~100%の範囲で行き来させています。

5. ワイプ適用範囲 

mask-imageをセットしたタグの内包する要素全てに対して、ワイプが適用されます。

Javascript(失敗パターン)

次に上記のcss表示をjavascriptでコントロールできるようにセットしてみます。

wipe_js.html

<link rel='stylesheet' href='wipe_js.css'/> <script src='wipe.js'></script> <div class='frame'> <div class='wipe-js'> <img src='gdpr-3518253_1280.jpg'> </div> </div> <button class='wipe-button'>wipe</button>

wipe.css

.frame{ border:1px solid black; background: linear-gradient(45deg, #3331 25%, transparent 25%, transparent 75%, #3331 75%), linear-gradient(45deg, #3331 25%, transparent 25%, transparent 75%, #3331 75%); background-size: 40px 40px; background-position: 0 0, 20px 20px; white-space:normal; font-size:0; } .frame *{ white-space:normal; font-size:0; } button.wipe-button{ width:100px; padding:10px; border:1px solid #ccc; background-color:#eee; color:black; border-radius:4px; cursor:pointer; margin:10px; } button.wipe-button:hover{ opacity:0.5; } button.wipe-button:active{ background-color:#F003; } img{ width:100%; height:100%; object-fit:cover; } .wipe-js{ width:100%; height:300px; -webkit-mask-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%2C0%2C100%2C100%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20%2F%3E%3C%2Fsvg%3E'); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: 0; }

wipe.js

(function(){ function Wipe(){ this.button = this.elm_button() this.wipe = this.elm_wipe() if(this.button){ this.button.addEventListener('click' , this.click_wipe_button.bind(this)) } } Wipe.prototype.elm_button = function(){ return document.querySelector('button.wipe-button') } Wipe.prototype.elm_wipe = function(){ return document.querySelector('.wipe-js') } Wipe.prototype.click_wipe_button = function(e){ if(this.wipe.hasAttribute('data-animating')){return} // this.wipe.style.setProperty('-webkit-mask-size' , `100%` , '') const button = e.currentTarget this.wipe.setAttribute('data-animating' , 1) if(button.getAttribute('data-status') !== 'in'){ button.setAttribute('data-status' , 'in') this.wipe_in() } else{ button.setAttribute('data-status' , 'out') this.wipe_out() } } Wipe.prototype.wipe_in = function(){ const time = 1000 this.wipe.style.setProperty('transition-duration' , `${time}ms` , '') this. wipe_anim(time , '0%','120%') } Wipe.prototype.wipe_out = function(){ const time = 1000 this.wipe.style.setProperty('transition-duration' , `${time}ms` , '') this. wipe_anim(time , '120%' , '0%') } Wipe.prototype.wipe_anim = function(time , size_from , size_to){ this.wipe.animate([ { '-webkit-mask-size' : size_from }, { '-webkit-mask-size' : size_to }, ], { duration : time, }) Promise.all(this.wipe.getAnimations().map(e => e.finished)).then(e=>{ console.log('finish') this.wipe.removeAttribute('data-animating') }) } switch(document.readyState){ case 'complete': case 'interactive': new Wipe() break default: window.addEventListener('load' , (()=> new Wipe())) break } })()

解説

上記Javascriptコードでは、動きません。 動かない原因は、animate機能です。 animate自体は動いていて、Promiseの非同期後にちゃんとアニメーション完了のコールバックは実行されています。 keyframeの内容の、"-webkig-mask-size"がどうやら機能していないようです。 以下の設定も試してみました。
-webkig-mask-size : ** WebkigMaskSize : ** mask-size : ** maskSize : **
どうも出来ないことを追求しても仕方がないので、大幅に設計を変えて、animate機能を使わないバージョンを作ることにしました。

Javascript(成功パターン)

少し手直ししたバージョンです。

wipe.js

(function(){ function Wipe(){ this.button = this.elm_button() this.wipe = this.elm_wipe() if(this.button){ this.button.addEventListener('click' , this.click_wipe_button.bind(this)) } } Wipe.prototype.elm_button =unction(){ return document.querySelector('button.wipe-button') } Wipe.prototype.elm_wipe = function(){ return document.querySelector('.wipe-js') } Wipe.prototype.click_wipe_button = function(e){ if(this.wipe.hasAttribute('data-animating')){return} const button = e.currentTarget this.wipe.setAttribute('data-animating' , 1) if(button.getAttribute('data-status') !== 'in'){ button.setAttribute('data-status' , 'in') this.wipe_in() } else{ button.setAttribute('data-status' , 'out') this.wipe_out() } } Wipe.prototype.wipe_in = function(){ const time = 1000 this.wipe.style.setProperty('-webkit-mask-size' , `0%` , '') this.wipe.style.setProperty('transition-duration' , `${time}ms` , '') setTimeout(this.wipe_anim.bind(this , time , '120%') , 0) } Wipe.prototype.wipe_out = function(){ const time = 1000 this.wipe.style.setProperty('-webkit-mask-size' , `120%` , '') this.wipe.style.setProperty('transition-duration' , `${time}ms` , '') setTimeout(this.wipe_anim.bind(this , time , '0%') , 0) } Wipe.prototype.wipe_anim = function(time , size){ this.wipe.style.setProperty('-webkit-mask-size' , size , '') setTimeout(()=>{ this.wipe.removeAttribute('data-animating') } , time) } switch(document.readyState){ case 'complete': case 'interactive': new Wipe() break default: window.addEventListener('load' , (()=> new Wipe())) break } })()

Demo

問題の回避策の解説

animate機能ではなく、setTimeoutでduration時間後にcallbackする仕様に変更しました。 あと、ちょっとした技ですが、wipe_animという関数を書いているのは、setPropertyで、transitionを同じ関数内で記述しても、正常にtransitionが上書きされてしまうだけなので、 ソコもsetTimeoutを0秒で実行するようにしています。 今後ESでも対応してくれるように願いましょう。