#XAMLとかCSSとかどちらかというとダメな方
我ながらXAMLとかCSSが苦手な方です。最近のこのテはどんどん高度になっていて、「ちょっとしたこと」とは言えないほどのことまでできてしまったりして、こうなると「今作ってるもの、CSSでできちゃうんじゃないか」という不安に常に付きまとわれてしまいます。
そんなプレッシャーもあってか、割と最初から開き直ることが多いです。僕は結構な数のストアアプリをリリースしていますが、いわゆるVisualStudioのテンプレの階層構造をそのまま使ったものは「エッシャーパターン デラックス」ぐらいしかありません。それ以外のものは全てC++&Direct2Dか、JavaScript+Canvasで作られていて、メニューっぽく見えるものはCanvas上に実装されたものです。
こうやってメニューエンジンを書くことにどんなメリットがあるかと聞かれると、
- JavaScriptとCanvasの文法以外の面倒なルールを覚えなくて済む。
- こんなことしたいんだけどどうするんだろう、というのをいちいち調べなくて済む。作り方は考えないといけないけど。
- CSSだと状況によってレイアウトが崩れたりしてイラッとすることがあるが、自分で書けばある程度固定的なものにできる。
- CSSの互換性とか、このプロパティはサポートされてないとか、そういうのに悩まされなくて済む。iOSやAndroidに移植しようとした時などに面倒なことにならない。
- そして何より、XamlやCSSで簡単にはできなさそうなことも作り込んでいける(できないこと、と言えないところが怖いことで、頑張ると結構できたりもする)。
こんな、前向きのような後ろ向きのような理屈で、審査を通った実績のあるメニューエンジンを「ペイントヘドロン」から抜粋してみたいと思います。
#地味な作業
とはいえ、やっていることは地道に一つ一つ実装を重ねていくだけなのです。
大体以下のような作業が必要になります。
- ボタンクラス的なものを作って、そこにタッチできるボタンの情報を埋め込めるようにする。
- ビュークラス的なものを作って、メインメニュー、サブメニュー、ゲーム画面の間を行ったり来たりできるようにする。
- メインメニューとサブメニューにボタンを張って、クリックできるようにする。
- ボタンの数が画面に収まらない場合に横スクロールできるようにする。
- 「戻る」ボタンを作ってクリックできるようにする。
- タイマーイベントをハンドリングして、カーソルがホバーした時にボタンのアニメが走るようにする。
これに加えて、本当ならキーボードによるボタンの選択と押下ができないといけないのですが、ペイントヘドロンはどのみちキーボードだけではどうにもならないので割愛しました。こういうケースならキーボードが使えなくとも審査に落ちることはなさそうです。ちなみにDirect2Dで作った「サンダーパズル」は、キーボードでの操作もサポートしています。この頃はまだマメだったのです。ところでペイントヘドロンはJavaScriptで実装されています。タイトルにXamlが入っているのでC#の記事を期待されていた人すみません。
#ボタンの作成
function TopMenuButton(key, x, y) {
this.x = x;
this.y = y;
this.w = BUTTONWIDTH;
this.h = BUTTONHEIGHT;
this.key = key;
this.pressing = false;
this.animFrame = 0;
this.buttonImg = buttonImg[this.key];
this.title = polyhedrons[key].name;
}
まず、トップメニューのボタンを実装します。ペイントヘドロンの場合、トップメニューには多面体ごとにボタンがあるのでキーは多面体に振られたコードになります。オブジェクトのコンストラクタにはこのキーと座標が渡される必要があります。buttonImgはイメージの配列の配列ですので、this.buttonImgにはイメージの配列が設定されます。
同様にサブメニューのボタンは以下のような感じで作ります。
function SubMenuButton(dt, x, y) {
this.data = dt
this.x = x;
this.y = y;
this.w = BUTTONWIDTH;
this.h = BUTTONHEIGHT;
this.polyhedron = new Painthedron();
this.polyhedron.initialize_xy(x + IN_BUTTON_MARGIN,
y + IN_BUTTON_MARGIN,
BUTTONWIDTH - IN_BUTTON_MARGIN * 2,
BUTTONHEIGHT - IN_BUTTON_MARGIN * 2, dt);
this.pressing = false;
}
PaintHedronは多面体の座標とポリゴン情報を持ち、回転などの3D処理を行うクラスです。サブメニューではどのようなゲームかわからないとしょうがないので、実際にボタン上に多面体をリアルタイム描画しています。そのため、ボタンごとに多面体とその面のON/OFF情報を持つdataを持たせています。
#ビューを作成する
画面全体を描画するクラスをビュークラスとします。これは描画機能のほか、マウスやタイマーに反応する機能を持たなければなりません。基本的な機能を基底クラスに持たせて、画面の種類ごとに実際の描画機能を実装します。
var DrawView = WinJS.Class.define(
function () {
},
{
clearScreen: function () {
this.context.fillStyle = "rgb(9, 0, 31)";
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
},
{}
);
これを基底クラスにして、トップメニューを実装します。
var TopMenu = WinJS.Class.derive(DrawView,
function () {
this.mode = MODE_TOPMENU;
this.posX = 0;
this.posY = 0;
this.appTitle = WinJS.Resources.getString("DisplayName").value;
},
{
initialize: function (canvasname) {
this.canvas = document.getElementById(canvasname);
this.context = this.canvas.getContext('2d');
this.sizeX = this.canvas.width;
this.sizeY = this.canvas.height;
this.buttons = new Array(groups.length);
for (var i = 0; i < groups.length; i++) {
var x = Math.floor(i / 2);
var y = i % 2;
var key = groups[i]
this.buttons[i] = new TopMenuButton(key, x * 260 + 120, y * 260 + 180);
}
},
draw: function () {
this.context.fillStyle = "white";
this.context.textAlign = "left";
this.context.globalAlpha = 1;
this.context.font = "60px 'Segoe UI'";
this.context.fillText(this.appTitle, 120, 100);
},
onMouseDown: function (x, y) {
},
onMouseMove: function (x, y) {
},
onMouseUp: function (x, y) {
},
onMouseWheel: function (delta) {
},
onTimer: function () {
}
},
{}
);
こんな感じで、サブメニューとゲーム画面のひな形を作ってしまいます。
メニューやゲーム画面間の移動は、onMouseUpの戻り値をdefault.jsで判断してシーンオブジェクトを切り替えて行います。
var clickResult = currentScene.onMouseUp(e.offsetX, e.offsetY);
if (clickResult == "") { // トップメニュー
currentScene = topMenu;
subMenu = null;
} else if (isIn(clickResult, groups)) { // サブメニュー
appbarCtrl.disabled = true;
currentScene = topMenu;
if (subMenu == null) { // トップ画面から来た
subMenu = new SubMenu();
currentScene = subMenu;
currentScene.initialize(CANVAS_NAME, clickResult);
} else { // ゲーム画面から戻った
currentScene = subMenu;
}
} else if (clickResult != null) { // ゲーム画面
currentScene = new GameScene();
currentScene.initialize(CANVAS_NAME, clickResult);
}
ちょっとバッドノウハウの匂いもしますが(これ以上画面が増えたら困る)、こんな感じでやっております。
#メニューにボタンを張ってクリックできるようにする
トップメニューのinitializeにこんなコードを追加します。
this.buttons = new Array(groups.length);
for (var i = 0; i < groups.length; i++) {
var x = Math.floor(i / BUTTONS_IN_LINE);
var y = i % BUTTONS_IN_LINE;
var key = groups[i]
this.buttons[i] = new TopMenuButton(key,
x * (BUTTONWIDTH + OUT_BUTTON_MARGIN) + MARGIN_LEFT,
y * (BUTTONWIDTH + OUT_BUTTON_MARGIN) + MARGIN_TOP);
}
座標を決めてボタンを張り込みます。
次は描画です。drawメソッドにコードを追加します。
this.context.save();
for (var i = 0; i < this.buttons.length; i++) {
var b = this.buttons[i];
var bx = b.x;
var by = b.y;
// ボタンの外形を描画
this.context.globalAlpha = 1;
this.context.fillStyle = "rgb(0, 0, 0)";
this.context.fillRect(bx, by, b.w, b.h);
// ボタンのイメージを描画
var img = b.buttonImg[b.animFrame];
this.context.drawImage(img, 0, 0, img.width, img.height, bx, by, b.w, b.h);
// 文字をのせる影を描画
this.context.fillStyle = "rgb(0, 0, 0)";
this.context.globalAlpha = 0.7;
this.context.fillRect(bx, by + b.h / 3 * 2, b.w, b.h / 3);
// 文字を描画
this.context.fillStyle = "white";
this.context.textAlign = "left";
this.context.globalAlpha = 1;
this.context.font = "20px 'Segoe UI'";
this.context.fillText(polyhedrons[b.key].name, bx + 10, by + b.h / 3 * 2 + 20 + 10, b.w - 20);
}
this.context.restore();
さらに、クリックに反応する部分を作成します。
onMouseDown: function (x, y) {
this.posX = this.startX = x;
this.posY = this.startY = y;
this.clicking = true;
for (var i = 0; i < this.buttons.length; i++) {
var b = this.buttons[i];
var bx = b.x;
if (isIn(x, y, b)x > bx && x < bx + b.w && y > b.y && y < b.y + b.h) {
b.pressing = true;
break;
}
}
},
onMouseMove: function (x, y) {
this.posX = x;
this.posY = y;
},
onMouseUp: function (x, y) {
this.clicking = false;
for (var i = 0; i < this.buttons.length; i++) {
var b = this.buttons[i];
var bx = b.x;
if (x > bx && x < bx + b.w && y > b.y && y < b.y + b.h && b.pressing) {
b.pressing = false;
return b.key;
}
b.pressing = false;
}
return null;
},
マウス操作は基本的にこの三点セットで行います。実際には触り心地向上のためもう少し色々やっています。
マウスのX座標をいちいちbxに取っているのは、この後横スクロールの作業をするためです。
b.pressingというフラグを設定するようにしたので、描画時にボタンが少し動くよう、drawメソッドのボタン座標部分を以下のように変更します。
var bx = b.x;
bx = b.pressing ? bx + 3 : bx;
var by = b.y;
by = b.pressing ? by + 3 : by;
これでボタンが動くようになりました。
#画面を横スクロールさせる
もうこの辺りになってくるとひたすらに面倒な感じがしてくるのですが、ボタン数が多いとどうしても横スクロールに手を付ける必要があります。
onMouseMoveに細工をします。
onMouseMove: function (x, y) {
var lastbutton = this.buttons[this.buttons.length - 1];
var newScrollPos = this.scrollPos + (this.posX - x);
if (this.clicking) {
if (0 < newScrollPos &&
newScrollPos < (lastbutton.x + lastbutton.w + MARGIN_LEFT - this.sizeX)) {
this.scrollPos = newScrollPos;
this.draw();
}
}
this.posX = x;
this.posY = y;
},
this.scrollPos に左端の論理座標を持たせて、からdrawの描画やクリックのチェックをこれ基準にします。
this.context.save();
for (var i = 0; i < this.buttons.length; i++) {
var b = this.buttons[i];
var bx = b.x - this.scrollPos;
bx = b.pressing ? bx + 3 : bx;
マウスハンドラの関数群にも手を加えて、マウスの位置検出にスクロール位置を加味します。
var bx = b.x - this.scrollPos;
最後に、マウスホイールでもスクロールしてほしいので、イベントハンドラを書きます。
onMouseWheel: function (delta) {
var lastbutton = this.buttons[this.buttons.length - 1];
var newScrollPos = this.scrollPos - delta;
if (0 < newScrollPos &&
newScrollPos < (lastbutton.x + lastbutton.w + MARGIN_LEFT - this.sizeX)) {
this.scrollPos = newScrollPos;
this.draw();
}
},
ちなみに、マウスホイールのイベントは以下のような方法で取得します。ボタンのクリックと少し違います。
function mouseWheelEvent(event) {
var delta = 0;
if (!event) event = window.event;
currentScene.onMouseWheel(event.wheelDelta);
}
currentScene.canvas.onmousewheel = mouseWheelEvent;
これで横方向のスクロールができるようになります。地味ですね・・・
#「戻る」ボタンの実装
実は、自前でメニューを作るときにこのボタンを左上に設置する作業が最も不毛な気がします。うまい方法もあるのかもしれませんが、ペイントヘドロンでは、ON/OFFのビットマップを張り込んでいます。
var backButtonOnimg = Image();
backButtonOnimg.src = 'images/backButton_on.png';
var backButtonOffimg = Image();
backButtonOffimg.src = 'images/backButton_off.png';
基底クラスのDrawViewにボタンを描画するメソッドと、カーソルがボタンの上にあるかどうかを返すメソッドを追加します。
drawButton: function () {
this.context.drawImage(this.buttonImg, 0, 0, this.buttonImg.width, this.buttonImg.height);
},
isInButton: function (x, y) {
var bx = BACKBUTTONPOS;
var by = BACKBUTTONPOS;
var bw = BACKBUTTONSIZE;
var bh = BACKBUTTONSIZE;
if (x < bx) return false;
if (x > bx + bw) return false;
if (y < by) return false;
if (y > by + bh) return false;
return true;
},
drawButtonメソッドは、トップメニュー以外のシーンクラスのdrawの最後で呼び出すようにします。
「戻る」ボタンのあるシーンのメニューハンドラには、
onMouseDown: function (x, y) {
~ 中略 ~
if (this.isInButton(x, y)) {
this.buttonImg = backButtonOnimg;
this.draw();
}
},
onMouseMove: function (x, y) {
~ 中略 ~
if (this.isInButton(x, y)) {
this.buttonImg = backButtonOnimg;
} else {
this.buttonImg = backButtonOffimg;
}
},
onMouseUp: function (x, y) {
~ 中略 ~
if (this.isInButton(x, y)) {
return "";
}
this.buttonImg = backButtonOffimg;
return null;
},
というようなコードを入れ込んでマウスの状態によって描画されるボタンのイメージが切り替わるようにします。
#ボタンのアニメーション
最後はボタンのアニメーションを作ります。とはいってもここまでくればもうタイマーイベントを追加し、drawで描画されるものを切り替えるだけです。
onTimer: function () {
for (var i = 0; i < this.buttons.length; i++) {
var b = this.buttons[i];
var bx = b.x - this.scrollPos;
if (this.posX > bx && this.posX < bx + b.w && this.posY > b.y && this.posY < b.y + b.h) {
b.animFrame = (b.animFrame + 1) % PICTURES_PER_ROUND;
this.draw();
}
}
}
この場合、カーソルが乗っているボタンしかアニメーションしないようになっていますが、やりたければ全部やっても構いません。ペイントヘドロンの場合は、全部動かすと遅くなってしまうししつこい感じがしたので多少面倒ですがこうしました。
#まとめ
こんな調子で一度メニュー画面を実装してしまえば、後は背景に雪を降らすことも遷移に勝手にエフェクトをかけることも自在です(やっていませんが)。「サンダーパズル」では、メニュー画面の背景にパズルのピースを降らせてそれを広告にしています。皆様も色々と自由な発想で人目を引くような斬新なメニューを作りましょう。
今回のプログラムは、トップメニュー-サブメニュー-ゲーム画面を行き来できるサンプルソースとして公開しています。オマケで正八面体を回すこともできます。誰が作っても同じようなプログラムになるとは思いますが(否、もっとマメな人が作ればきれいなサンプルになると思いますが)、これまでメニュー画面で冒険をしたかったけれどもめんどくさいのでやめていた人も、これをベースにすれば割と楽にできると思いますのでスタート地点として是非ご利用ください。
それでは。
(文責:片山 功士)