JavaScript
HTML5
canvas
TypeScript
フロントエンド

TypeScript でタワシしか当たらないルーレットを作る

パジェロ!! パジェロ!! パジェロ!!

あぁ~残念タワシきちゃいましたねwwwwwwwwwwwwwwwwww

という、子供の頃某TV番組で見たアレに似たなにかを HTML5 Canvas + TypeScript で実装してみました。

roulette.gif

GitHubリポジトリ

DEMOサイト

タイトル詐欺ですが、設定次第ではタワシ以外もちゃんと当たります。


コード

今回はTypeScriptからJavaScriptへ変換するのにwebpackを使用しました。

開発環境の作成については割愛させて頂きますので、webpack.config.jspackage.jsonを見たい場合はGitHubリポジトリを参照してください。


index.html

適当にcanvas要素を含んだhtmlを用意します。

また、最低限ルーレットをスタートさせるボタンと何が当たったか表示する要素も加えておきます。


index.html


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>たわしルーレット DEMO</title>
<style>
.arrowWrapper{
margin-bottom: 15px;
width: 500px;
}
.arrow{
margin: 0 auto;
width: 0;
height: 0;
border-top: 25px solid #000;
border-bottom: 0;
border-left: 15px solid transparent;
border-right: 15px solid transparent;
}
.result{
font-size: 20px;
font-weight: 700;
}
</style>
</head>
<body>
<div class="arrowWrapper"><div class="arrow"></div></div>
<canvas id="canvas" width="500" height="500"></canvas>
<div><button id="button">回す</button></div>
<p id="result" class="result">&nbsp;</p>
<script src="dist/main.bundle.js"></script>
</body>
</html>



Rouletteクラス



長いので折り畳んでいます。


Roulette.ts

/**

* pieces 内のオブジェクト定義
* label: 表示名
* probability: 当たる確率
* ratio: 描写されるときの角度割合
* color: rgb(a) or # 記法の色指定
* _angle: setPieces() によって自動算出される弧度法の角度
* _label: setPieces() によって自動整形される縦書き用の label
*/

interface Piece {
label: string,
probability: number,
ratio: number,
color: string,
_angle: number,
_label: string[]
}

/**
* 上記 Piece のセッター用 interface
* _angle は自動算出されるため含まれない
*/

interface SetPiece {
label: string,
probability: number,
ratio: number,
color: string
}

export class Roulette {

/**
* このクラスが扱うカンバスのコンテキスト
*/

readonly ctx: CanvasRenderingContext2D;

/**
* このクラスが扱うカンバスの横幅(縦幅)
*/

readonly width: number;

/**
* この値が false だと描画が行われない
*/

private enable: boolean;

/**
* 描写する
*/

private pieces: Piece[];

/**
* start() を呼んだ時にセットされる当選したピース情報
*/

private piece: Piece | null;

/**
* このクラスが扱うカンバス
*/

private canvas: HTMLCanvasElement;

/**
* number が正の数であるか判定する
* allowZero を true にすると 0 の場合も true が返る
* @param number
* @param allowZero
* @private
*/

private _isNatural(number: number, allowZero: boolean = true): boolean {
return allowZero ? number >= 0 : number > 0;
}

/**
* 入力されたピース情報に不備がないか判定する
* 具体的には、
* probability or ratio に0未満の値が無いか
* それぞれの合算値が0より上か
* を判断基準に、問題が無ければ true, 問題があれば false を返す
* @param pieces
* @private
*/

private _isPeaces(pieces: SetPiece[]): boolean {
//割合の合算値
let totalProbability: number = 0;

//表示割合の合算値
let totalRatio: number = 0;

//各ピースごとに検査
for (let i = 0; i < pieces.length; i++){
//もし probability or ratio が0未満だったらエラー
if (! this._isNatural(pieces[i].probability) || ! this._isNatural(pieces[i].ratio)){
return false;
}

//合算値に加算
totalProbability += pieces[i].probability;
totalRatio += pieces[i].ratio;
}

//各合算値が0より大きい場合は true を返す
return this._isNatural(totalProbability, false) && this._isNatural(totalRatio, false);
}

/**
* this.pieces[index] が存在するか判定する
* SetPieces 型の pieces がセットされていたら this.pieces[index] ではなく pieces[index] を調べる
* @param index
* @param pieces
* @private
*/

private _isIndex(index: number, pieces: SetPiece[] | null = null): boolean {
return pieces === null ? this.pieces[index] !== undefined : pieces[index] !== undefined;
}

/**
* max ~ min 間のランダムな数を返す
* 少数を考慮する場合、小数点下 decimal 桁まで正しい精度で返す
* @param min
* @param max
* @param decimal
* @private
*/

private _getRand(min: number, max: number, decimal: number = 0): number {
//decimal を整数に直す
//decimal が 0 未満の場合は 0 に直す
decimal = decimal < 0 ? 0 : Math.floor(decimal);

//10 の digit 乗を算出
const digit = Math.pow(10, decimal);

//ランダム値を生成
const result = Math.round(Math.random() * (max * digit - min * digit - 1) + min * digit) / digit;

//小数点下の精度によっては min ~ max の範囲を超えることがあるので整形
if (result < min) {
return result + Math.pow(0.1, decimal);
}
if (result > max) {
return result - Math.pow(0.1, decimal);
}
return result;
}

/**
* this.pieces の 0番目 から index番目まで ratio を加算した数値を返す
* this.pieces[index] が存在しなかったら 0 を返す
* SetPieces 型の pieces がセットされていたら this.pieces[index] ではなく pieces[index] を調べる
* @param pieces
* @param index
* @private
*/

private _getAngleRatioByIndex(index: number, pieces: SetPiece[] | null = null): number {
//this.pieces[index] が存在するか判定
if (!this._isIndex(index, pieces)) {
return 0;
}

//このメソッドが返す変数
let angle = 0;

//実際に角度を加算
for (let i = 0; i <= index; i = (i + 1)|0) {
angle += pieces === null ? this.pieces[i].ratio : pieces[i].ratio;
}

//返す
return angle;
}

/**
* 2π を360度として、pieces の ratio から算出される角度を返す
* ratio に調べたいピースまでの ratio 合算値、total に全てのピース ratio 合算値を渡す
* total が0だったら0除算回避ため0を返す
* @param ratio
* @param total
* @private
*/

private _getRadian(ratio: number, total: number): number {
return total === 0 ? 0 : ratio / total * 2 * Math.PI;
}

/**
* 原点座標 x, y で半径が r の円の中心から外側に向かって角度 radian ラジアンの直線を引いた時、 円周と線の交点座標を返す
* @param x
* @param y
* @param r
* @param radian
* @private
*/

private _getCircleCoordinates(x: number, y: number, r: number, radian: number): {x: number, y: number} {
return {
x: x + r * Math.cos(radian),
y: y + r * Math.sin(radian)
};
}

/**
* 0~1の時間(t)に対してイージング加工された値を返す
* @param t
* @private
*/

private _ease(t: number) {
return t <.5 ? 8*t*t*t*t : 1-8*(--t)*t*t*t;
}

/**
* カンバスをクリアする
* @private
*/

private _clear() {
this.ctx.clearRect(0, 0, this.width, this.width);
}

/**
* this.pieces の情報を元に angle ラジアンぶん回転させたルーレットを描写する
* @param angle
* @private
*/

private _draw(angle: number) {
//半径の算出
const r = this.width / 2;

//ズレ無しで描画しようとすると90度の位置から描画しようとするが、0度の位置に data の0番がくるように初期ズレ値を算出する
const initAngle = 0.5 * Math.PI + angle;

//既に描画してあるものを全て削除する
this._clear();

//前のループで残っているシャドウ設定を見えなくする
this.ctx.shadowColor = 'rgba(0, 0, 0, 0)';

//テキスト描画情報退避変数
let labels: {_label: string[], angle: number, r: number}[] = [];

//ループして要素の数だけピースを描画する
for (let i = 0, max = this.pieces.length; i < max; i = (i + 1)|0)
{
//中心以外の頂点ラジアンを取得
const startRadian = this.pieces[i - 1] === undefined ? -initAngle : this.pieces[i - 1]._angle - initAngle;
const endRadian = this.pieces[i]._angle - initAngle;

//描画に必要な変数を先に算出(中央くり抜きの半径, 描画3点目の座標)
const clip = this.width * .15;
const thirdCoordinate = this._getCircleCoordinates(r, r, clip, endRadian);

//扇形を描画
this.ctx.beginPath();
this.ctx.arc(r, r, r, startRadian, endRadian, false);
this.ctx.lineTo(thirdCoordinate.x, thirdCoordinate.y);
this.ctx.arc(r, r, clip, endRadian, startRadian, true);
this.ctx.fillStyle = this.pieces[i].color;
this.ctx.fill();

//扇形の中心を取得し、テキストを描画するための情報を labels に追加
labels.push({_label: this.pieces[i]._label, angle: (endRadian - startRadian) / 2 + startRadian, r: (r - clip) / 2 + clip});
}

//テキストを図形と同タイミングで描写すると後から描写された図形の後ろに回ってしまうので後から描写
this.ctx.fillStyle = '#fff';
this.ctx.shadowColor = 'rgba(0, 0, 0, .8)';

//一文字当たりの高さ * 1.2 を取得
const labelHeight = this.ctx.measureText('W').width * 1.2;

for (let i = 0, max = labels.length; i < max; i = (i + 1)|0) {
//縦書き文章の中心を描画座標にしたいので文章の縦幅 / 2 を算出
const center = labelHeight * labels[i]._label.length / 2;

//一文字ずつ角度に沿った縦書きで描画
labels[i]._label.forEach((label, index) => {
//描画位置の座標を取得
const coordinate = this._getCircleCoordinates(r, r, labels[i].r - labelHeight * (index + 0.5) + center, labels[i].angle);

//描写
this.ctx.fillText(label, coordinate.x, coordinate.y);
});
}
}

/**
* このクラスが扱うコンテキストと幅(縦も同義)を注入する
* @param ctx
* @param width
*/

public constructor(canvas: HTMLCanvasElement, width: number) {
//カンバスが使用できるかチェック
if (!canvas.getContext) {
console.log('[Roulette.constructor] カンバスが使用できません');
this.enable = false;
return;
}

//カンバス・コンテキスト・大きさを注入する
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.width = width;

//enable を true にする
this.enable = true;

//pieces を [] にする
this.pieces = [];

//piece を null にする
this.piece = null;

//クラスを通して変わらないカンバス設定
this.ctx.font = "bold 15px '游ゴシック'";
this.ctx.textAlign = 'center';
this.ctx.shadowBlur = 2;
}

/**
* ピース情報をセットする
* SetPieces 型から Pieces 型への変換も行われる
* アニメーション中でも実行可能
* ピース情報が this._isPeaces() で false と判定される内容であれば描画がストップする
* @param pieces
*/

public setPieces(pieces: SetPiece[]): boolean {
//入力された情報が正しいか検査
if (! this._isPeaces(pieces)) {
//カンバスを消し、描画をストップする
this.enable = false;
this._clear();
console.log('[Roulette.setPieces] セットされた情報が正しくありません');

//失敗を返す
return false;
}

//全ピースの合算値を算出
const total = this._getAngleRatioByIndex(pieces.length - 1, pieces);

//_angle, _label を追加しつつ this.pieces をセット
this.pieces = pieces.map((current, index) => {
return {
label: current.label,
probability: current.probability,
ratio: current.ratio,
color: current.color,
_angle: this._getRadian(this._getAngleRatioByIndex(index, pieces), total),
_label: current.label.split('')
};
});

//描画ができるようにする
this.enable = true;

//初期描画
this._draw(0);

//成功を返す
return true;
}

/**
* this.pieces の probability による確率を基にランダムな index を返す
*/

public getRandIndex(): number {
//probability の合算値を取得
let sum = 0;
for (let i = 0, max = this.pieces.length; i < max; i = (i + 1)|0) {
sum = sum + this.pieces[i].probability;
}

//1 ~ sum 間でランダムな数を取得
const rand = this._getRand(1, sum);

//ランダムに選ばれた index を返す
for (let i = 0, max = this.pieces.length; i < max; i = (i + 1)|0) {
if ((sum -= this.pieces[i].probability) < rand)
{
return i;
}
}

//万が一なにも選ばれなかったら0を返す
return 0;
}

/**
* ルーレットをスタートさせる
* index は当てたいピースのインデックス
* duration はアニメーションさせたい秒数
* rotation は最終的なルーレットの回転数(index で指定されたピースで回転を止めるため、指定された分 + 0度 ~ 360度回転する)
* @param index
* @param duration
* @param rotation
*/

public start(index: number, duration: number, rotation: number) {
//描画可能状態か判定
if (!this.enable) {
console.log('[Roulette.start] 描画不可状態です');
return;
}

//this.pieces[index] が存在するか判定
if (!this._isIndex(index)) {
console.log('[Roulette.start] this.pieces が空です');
return;
}

//this.piece をセット
this.piece = this.pieces[index];

//duration をミリ秒に変換
const millDuration = duration * 1000;

//duration 秒間描画不可状態にする
this.enable = false;
setTimeout(() => {
this.enable = true;
}, millDuration);

//index 番目のピースが該当する範囲の角度をランダムに算出し、それに rotation * 2π を加算してアニメーション完了時にまでに回転する角度を求める
const angle = this._getRand(this.pieces[index - 1] === undefined ? 0 : this.pieces[index - 1]._angle, this.pieces[index]._angle, 1) + rotation * Math.PI * 2;

//アニメーション開始時刻の定義
const start = new Date().getTime();

//アニメーション関数
const loop = () => {
//この関数をループ
const animation = requestAnimationFrame(loop);

//経過時間から終了時間までの間を0 ~ 1割合で算出
const passage = (new Date().getTime() - start) / millDuration;

//easeInOutQuart のイージングで経過時間から現在の速さを取得し、回す
this._draw(this._ease(passage) * angle);

//passage が 1 以下だったらここで終了
if (passage < 1)
{
return;
}

//もし passage が1以上だった場合は最終回転角度でアニメーションを止める
this._draw(angle);
cancelAnimationFrame(animation);

//this.canvas へ endRoulette イベントを登録
if (typeof(Event) === 'function') {
//IE 以外
this.canvas.dispatchEvent(new Event('endRoulette'));
}
else {
//IE
const event = document.createEvent('Event');
event.initEvent('endRoulette', true, true);
this.canvas.dispatchEvent(event);
}
};

//アニメーション開始
loop();
}

/**
* 当たったピース情報を取得する
* @param index
*/

public getPiece(): Piece | null {
return this.piece;
}

}







index.ts

webpackでJavaScriptへ変換(上記index.html内ではmain.bundle.js)して読み込みます。


index.js

import {Roulette} from "./Roulette";

document.addEventListener('DOMContentLoaded', () => {
//カンバスをセット
const canvas = <HTMLCanvasElement>document.getElementById('canvas');
const roulette = new Roulette(canvas, 500);

//情報をセット
roulette.setPieces([
{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: 'ウニ',
probability: 0,
ratio: 20,
color: '#ff5722'
},
{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: 'パジェロ',
probability: 0,
ratio: 10,
color: '#d81b60'
},
{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: '高級ホッチキス',
probability: 0,
ratio: 10,
color: '#7cb342'
},
{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: 'Mac Book',
probability: 0,
ratio: 20,
color: '#039be5'
},
{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: 'お食事券',
probability: 0,
ratio: 20,
color: '#fdd835'
},
]);

//スタート
document.getElementById('button').addEventListener('click', () => {
document.getElementById('result').innerHTML = '&nbsp;';
roulette.start(roulette.getRandIndex(), 6, 30);
});

//カンバスのアニメーションが完了したタイミングで当たったピース情報を取得して表示する
canvas.addEventListener('endRoulette', () => {
//ピース情報の取得
const piece = roulette.getPiece();

//null でなければ描画
if (piece !== null) {
document.getElementById('result').innerText = '☆祝☆ '+piece.label+'を獲得しました!!! ☆祝☆';
}
});

});



実装の流れ


カンバス自体の情報を定義 constructor

なにはともあれ描画対象となる canvas の情報をクラスへ渡す必要があります。

canvas 要素自体以外にも幅・高さが必要ですが、今回のカンバス情報はwidthとheihgtが一緒という前提で作成するのでwidthだけで十分でしょう。

これら情報をconstructor()の引数へ渡します。

また、この時点でその他プロパティの初期値&不変のカンバス描画スタイル情報もセットします。


/**
* このクラスが扱うカンバスのコンテキスト
*/

readonly ctx: CanvasRenderingContext2D;

/**
* このクラスが扱うカンバスの横幅(縦幅)
*/

readonly width: number;

/**
* この値が false だと描画が行われない
*/

private enable: boolean;

/**
* 描写する
*/

private pieces: Piece[];

/**
* start() を呼んだ時にセットされる当選したピース情報
*/

private piece: Piece | null;

/**
* このクラスが扱うカンバス
*/

private canvas: HTMLCanvasElement;

/**
* このクラスが扱うコンテキストと幅(縦も同義)を注入する
* @param ctx
* @param width
*/

public constructor(canvas: HTMLCanvasElement, width: number) {
//カンバスが使用できるかチェック
if (!canvas.getContext) {
console.log('[Roulette.constructor] カンバスが使用できません');
this.enable = false;
return;
}

//カンバス・コンテキスト・大きさを注入する
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.width = width;

//enable を true にする
this.enable = true;

//pieces を [] にする
this.pieces = [];

//piece を null にする
this.piece = null;

//クラスを通して変わらないカンバス設定
this.ctx.font = "bold 15px '游ゴシック'";
this.ctx.textAlign = 'center';
this.ctx.shadowBlur = 2;
}


ピース情報の定義とセッター

上記index.tsでも出てきましたが、今回はこんな配列をRouletteクラスへ渡します。

DEMOサイト & 上記gif画像で用いている配列そのままです。

[

{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: 'ウニ',
probability: 0,
ratio: 20,
color: '#ff5722'
},
{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: 'パジェロ',
probability: 0,
ratio: 10,
color: '#d81b60'
},
{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: '高級ホッチキス',
probability: 0,
ratio: 10,
color: '#7cb342'
},
{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: 'Mac Book',
probability: 0,
ratio: 20,
color: '#039be5'
},
{
label: 'タワシ',
probability: 30,
ratio: 30,
color: '#cfd8dc'
},
{
label: 'お食事券',
probability: 0,
ratio: 20,
color: '#fdd835'
},
]

ルーレットを表示するために必要な情報について考えてみましょう。

扇型として表示するピース一つ一つについて以下の情報が必要です。


  • 表示文言

  • 表示割合(大きいほど角度が大きくなる)

  • 当たる確率

  • 背景色

折角TypeScriptで作成するので、これら情報をSetPiece型として定義します。

/**

* label: 表示名
* probability: 当たる確率
* ratio: 描写されるときの角度割合
* color: rgb(a) or # 記法の色指定
*/

interface SetPiece {
label: string,
probability: number,
ratio: number,
color: string
}

probabilityratioについては百分率としてもいいのですが、クラスを使用する側での設定が面倒になるので、上限は無しとしました。それぞれ、合算値に対して何割、といった値を、当たる確率・描画角度として用います。

タイトルのタワシしか当たらないとはつまり、タワシ以外のprobability0を設定しているということですね。描画される角度の大きさはratioで算出されるので見かけと確率が一致せず、まるで詐欺のようです。実運営する場合は良心が試されます。

さて、Canvas のアニメーションはいわゆるパラパラ漫画方式で、アニメーションフレームごとに描画処理を行う必要があります。

描画に必要な情報は色々あるのですが、事前にSetPiecesで定義した情報だけで算出可能な情報に、


  • 扇型を描写するために使用されるratioから算出される実際の角度情報

  • 角度に沿って文言を表示するためにlabelから文言を一文字筒区切った配列情報

があります。

これらをセッターで情報セットしたときに自動算出し、実際に格納する型としてPiece型を用意します。

/**

* pieces 内のオブジェクト定義
* label: 表示名
* probability: 当たる確率
* ratio: 描写されるときの角度割合
* color: rgb(a) or # 記法の色指定
* _angle: setPieces() によって自動算出される弧度法の角度
* _label: setPieces() によって自動整形される縦書き用の label
*/

interface Piece {
label: string,
probability: number,
ratio: number,
color: string,
_angle: number,
_label: string[]
}

以上の型を用いて、setPieces()メソッド(と内部で使用するプライベートメソッド)を作成しましょう。

(_draw()に関しては後述)

ちなみに計算量を減らすため、今回のクラス内で扱う角度は度数法ではなく直接弧度法を使用しています。

よく45度を表す際に45 * Math.PI / 180といった式を見ますが、Rouletteクラス内ではMath.PI / 4のように直接ラジアンで指定しています。

一見分かり辛いかもしれませんが、Math.PI3.1415926535...が丁度180度だと考えると理解しやすいでしょう。

  /**

* number が正の数であるか判定する
* allowZero を true にすると 0 の場合も true が返る
* @param number
* @param allowZero
* @private
*/

private _isNatural(number: number, allowZero: boolean = true): boolean {
return allowZero ? number >= 0 : number > 0;
}

/**
* カンバスをクリアする
* @private
*/

private _clear() {
this.ctx.clearRect(0, 0, this.width, this.width);
}

/**
* 入力されたピース情報に不備がないか判定する
* 具体的には、
* probability or ratio に0未満の値が無いか
* それぞれの合算値が0より上か
* を判断基準に、問題が無ければ true, 問題があれば false を返す
* @param pieces
* @private
*/

private _isPeaces(pieces: SetPiece[]): boolean {
//割合の合算値
let totalProbability: number = 0;

//表示割合の合算値
let totalRatio: number = 0;

//各ピースごとに検査
for (let i = 0; i < pieces.length; i++){
//もし probability or ratio が0未満だったらエラー
if (! this._isNatural(pieces[i].probability) || ! this._isNatural(pieces[i].ratio)){
return false;
}

//合算値に加算
totalProbability += pieces[i].probability;
totalRatio += pieces[i].ratio;
}

//各合算値が0より大きい場合は true を返す
return this._isNatural(totalProbability, false) && this._isNatural(totalRatio, false);
}

/**
* this.pieces の 0番目 から index番目まで ratio を加算した数値を返す
* this.pieces[index] が存在しなかったら 0 を返す
* SetPieces 型の pieces がセットされていたら this.pieces[index] ではなく pieces[index] を調べる
* @param pieces
* @param index
* @private
*/

private _getAngleRatioByIndex(index: number, pieces: SetPiece[] | null = null): number {
//this.pieces[index] が存在するか判定
if (!this._isIndex(index, pieces)) {
return 0;
}

//このメソッドが返す変数
let angle = 0;

//実際に角度を加算
for (let i = 0; i <= index; i = (i + 1)|0) {
angle += pieces === null ? this.pieces[i].ratio : pieces[i].ratio;
}

//返す
return angle;
}

/**
* 2π を360度として、pieces の ratio から算出される角度を返す
* ratio に調べたいピースまでの ratio 合算値、total に全てのピース ratio 合算値を渡す
* total が0だったら0除算回避ため0を返す
* @param ratio
* @param total
* @private
*/

private _getRadian(ratio: number, total: number): number {
return total === 0 ? 0 : ratio / total * 2 * Math.PI;
}

/**
* カンバスをクリアする
* @private
*/

private _clear() {
this.ctx.clearRect(0, 0, this.width, this.width);
}

/**
* ピース情報をセットする
* SetPieces 型から Pieces 型への変換も行われる
* アニメーション中でも実行可能
* ピース情報が this._isPeaces() で false と判定される内容であれば描画がストップする
* @param pieces
*/

public setPieces(pieces: SetPiece[]): boolean {
//入力された情報が正しいか検査
if (! this._isPeaces(pieces)) {
//カンバスを消し、描画をストップする
this.enable = false;
this._clear();
console.log('[Roulette.setPieces] セットされた情報が正しくありません');

//失敗を返す
return false;
}

//全ピースの合算値を算出
const total = this._getAngleRatioByIndex(pieces.length - 1, pieces);

//_angle, _label を追加しつつ this.pieces をセット
this.pieces = pieces.map((current, index) => {
return {
label: current.label,
probability: current.probability,
ratio: current.ratio,
color: current.color,
_angle: this._getRadian(this._getAngleRatioByIndex(index, pieces), total),
_label: current.label.split('')
};
});

//描画ができるようにする
this.enable = true;

//初期描画
this._draw(0);

//成功を返す
return true;
}


カンバスを描画する関数の用意

セッターによって必用な情報はほぼ揃いました。

現時点で描画するのに唯一足りていないのはルーレット自体の傾き情報のみですが、これはアニメーションによって変化するので描画関数を呼ぶ度に引数として渡してあげることにします。

  /**

* this.pieces の情報を元に angle ラジアンぶん回転させたルーレットを描写する
* @param angle
* @private
*/

private _draw(angle: number) {
//半径の算出
const r = this.width / 2;

//ズレ無しで描画しようとすると90度の位置から描画しようとするが、0度の位置に data の0番がくるように初期ズレ値を算出する
const initAngle = 0.5 * Math.PI + angle;

//~略~

扇形をの曲線部分を描写しようとする際にはCanvas 2D API の arc() メソッドを使用しますが、このメソッドはX軸の正方向、もっと雑に言うと右端から描画しだすので初期ズレ値としてnumber型のinitAngleを用意します。

この初期ズレ値に傾き引数であるangleを足すことで以降の描写をangleラジアンずらします。

r はこのルーレットの半径です。また、このルーレットは正方形カンバスの幅・高さ一杯に描写されるためrは半径だけでなく、ルーレットの中心座標におけるx, y としても使用します。

    //~略~

//既に描画してあるものを全て削除する
this._clear();

//前のループで残っているシャドウ設定を見えなくする
this.ctx.shadowColor = 'rgba(0, 0, 0, 0)';

//テキスト描画情報退避変数
let labels: {_label: string[], angle: number, r: number}[] = [];

//~略~

次に、パラパラ漫画のような切り替えを行うため、setPieces()の中でも呼んでいた_clear()でカンバスの中身を真っ白にします。

絶えず_draw()メソッドを呼ぶことでアニメーションになります。

加えて扇形描写ループ前にシャドウスタイルを初期化しておきます。後々テキストにシャドウを付けるのですが、それがそのまま残っていると扇型にもシャドウが付いてしまうので回避しましょう。

更に後々テキストを描写するための配列を用意しておきます。

Canvas にはレイヤーの概念が無く、扇型を描画した直後にテキストを描画すると次の扇型の下にテキストが隠れてしまうので一旦配列に逃がします。

_labelは文字を一文字づつ区切った配列、angleは文字を並べる角度、rは文字を並べたときの中央に位置する半径です。

  /**

* 原点座標 x, y で半径が r の円の中心から外側に向かって角度 radian ラジアンの直線を引いた時、 円周と線の交点座標を返す
* @param x
* @param y
* @param r
* @param radian
* @private
*/

private _getCircleCoordinates(x: number, y: number, r: number, radian: number): {x: number, y: number} {
return {
x: x + r * Math.cos(radian),
y: y + r * Math.sin(radian)
};
}

private _draw(angle: number) {

//~略~

//ループして要素の数だけピースを描画する
for (let i = 0, max = this.pieces.length; i < max; i = (i + 1)|0)
{
//中心以外の頂点ラジアンを取得
const startRadian = this.pieces[i - 1] === undefined ? -initAngle : this.pieces[i - 1]._angle - initAngle;
const endRadian = this.pieces[i]._angle - initAngle;

//描画に必要な変数を先に算出(中央くり抜きの半径, 描画3点目の座標)
const clip = this.width * .15;
const thirdCoordinate = this._getCircleCoordinates(r, r, clip, endRadian);

//扇形を描画
this.ctx.beginPath();
this.ctx.arc(r, r, r, startRadian, endRadian, false);
this.ctx.lineTo(thirdCoordinate.x, thirdCoordinate.y);
this.ctx.arc(r, r, clip, endRadian, startRadian, true);
this.ctx.fillStyle = this.pieces[i].color;
this.ctx.fill();

//扇形の中心を取得し、テキストを描画するための情報を labels に追加
labels.push({_label: this.pieces[i]._label, angle: (endRadian - startRadian) / 2 + startRadian, r: (r - clip) / 2 + clip});
}

//~略~

}

ループの中で各扇型を描画します。

時計回りに考えて、扇型の開始角度がstartRadian、終了角度がendRadianとなります。

endRadianの値はsetPieces()内で予め_angleとして算出済なのでそれを使用し、startRadianは一個前の_angle(0番目の場合は0扱い)を使用しましょう。

更に先ほどのinitAngleをそれぞれの値から引くことでルーレット全体の傾かせます。

ルーレット中央くり抜き領域の半径は全体幅 × 0.15 で固定としました(const clip)。

今回は扇型を描画するときに、下の図において1, 2, 3, 4の順で線を結んでいきます。

roulette1.png

さて、1から2、3から4はarcメソッド、4から1はfillメソッドで塗りつぶすときに自動で閉じてもらうので現状の情報だけで指定が可能です。

しかし、2から3へ直線を引く場合は3の座標情報が必要で、この情報は新たに算出する必要があります。

この座標情報は三角関数を用いて


  • 中心のx座標

  • 中心のy座標

  • 円の半径

  • 円周上、求めたい座標の角度

の四つがあれば求めることができます。

これが上記プライベートメソッド_getCircleCoordinates(x: number, y: number, r: number, radian: number)となります。

今回は下の図における青い点の座標が欲しいので、

roulette2.png


  • 中心のx座標 => 黄色い点のx座標(r)

  • 中心のy座標 => 黄色い点のy座標(r)

  • 円の半径 => 赤い線の長さ = 緑の円における半径(clip)

  • 円周上、求めたい座標の角度 => 赤い線の角度(endRadian)

_getCircleCoordinates()に渡して、const thirdCoordinateに座標情報を格納しました。

  private _draw(angle: number) {

//~略~

//テキストを図形と同タイミングで描写すると後から描写された図形の後ろに回ってしまうので後から描写
this.ctx.fillStyle = '#fff';
this.ctx.shadowColor = 'rgba(0, 0, 0, .8)';

//一文字当たりの高さ * 1.2 を取得
const labelHeight = this.ctx.measureText('W').width * 1.2;

for (let i = 0, max = labels.length; i < max; i = (i + 1)|0) {
//縦書き文章の中心を描画座標にしたいので文章の縦幅 / 2 を算出
const center = labelHeight * labels[i]._label.length / 2;

//一文字ずつ角度に沿った縦書きで描画
labels[i]._label.forEach((label, index) => {
//描画位置の座標を取得
const coordinate = this._getCircleCoordinates(r, r, labels[i].r - labelHeight * (index + 0.5) + center, labels[i].angle);

//描写
this.ctx.fillText(label, coordinate.x, coordinate.y);
});
}

}

最後にテキスト描画部分です。

扇型の描画時から色・影のスタイルを変更するため、カンバスのfillStyle, shadowColorプロパティを変更します。

このルーレットでは文字を扇型の中心かつ、扇形の角度に沿った並びで描画したいので、一文字づつ座標を指定して描画します。

手始めに文字一文字分の幅(高さ)を手に入れるため、measureText('W').widthを叩きます。

詳しくは調べていないのですが、大抵のフォントでが幅の広い文字の代表格かと思われるので、この文字幅の120%を一文字分の描画スペース(const labelHeight)として使用します。

ループ内では文字全体の中心を算出するために全文字数 * labelHeight / 2const centerに代入しています。

そして文字を一文字づつ描画するため、予め一文字づつ分解した文字列配列の_labelでループを行います。

先ほど登場した_getCircleCoordinates()で文字が置かれるべき座標を取得します。

計算がごちゃごちゃしていますが、第三引数の意味はルーレットの外周 - 文字幅 * (何文字目かのインデックス番号 + 0.5) + 文字全体の中心距離となります。

こうして取得できた座標をconst coordinateへ代入、実際に描画します。


確率に沿ってピースを一つ選ぶ関数を作る

確率のデータをセットしたので、その確率でランダムにピース情報を選ぶ関数を作成しましょう。

実案件ではサーバーサイドで行うことが多い処理ですが、今回はTypeScriptで書き直してみました。

PHP等では範囲を指定した乱数の取得が比較的楽ですが、JavaScriptでは一工夫が必要なので今回はプライベートメソッドとして_getRand()を用意しました。

  /**

* max ~ min 間のランダムな数を返す
* 少数を考慮する場合、小数点下 decimal 桁まで正しい精度で返す
* @param min
* @param max
* @param decimal
* @private
*/

private _getRand(min: number, max: number, decimal: number = 0): number {
//decimal を整数に直す
//decimal が 0 未満の場合は 0 に直す
decimal = decimal < 0 ? 0 : Math.floor(decimal);

//10 の digit 乗を算出
const digit = Math.pow(10, decimal);

//ランダム値を生成
const result = Math.round(Math.random() * (max * digit - min * digit - 1) + min * digit) / digit;

//小数点下の精度によっては min ~ max の範囲を超えることがあるので整形
if (result < min) {
return result + Math.pow(0.1, decimal);
}
if (result > max) {
return result - Math.pow(0.1, decimal);
}
return result;
}

/**
* this.pieces の probability による確率を基にランダムな index を返す
*/

public getRandIndex(): number {
//probability の合算値を取得
let sum = 0;
for (let i = 0, max = this.pieces.length; i < max; i = (i + 1)|0) {
sum = sum + this.pieces[i].probability;
}

//1 ~ sum 間でランダムな数を取得
const rand = this._getRand(1, sum);

//ランダムに選ばれた index を返す
for (let i = 0, max = this.pieces.length; i < max; i = (i + 1)|0) {
if ((sum -= this.pieces[i].probability) < rand)
{
return i;
}
}

//万が一なにも選ばれなかったら0を返す
return 0;
}


カンバスをアニメーションさせる

アニメーションを行うためには


  • 当たるピース情報

  • アニメーションにかかる時間

  • ルーレットの回転数

が必要なので、これらを引数とするstart()メソッドを用意します。

  /**

* this.pieces[index] が存在するか判定する
* SetPieces 型の pieces がセットされていたら this.pieces[index] ではなく pieces[index] を調べる
* @param index
* @param pieces
* @private
*/

private _isIndex(index: number, pieces: SetPiece[] | null = null): boolean {
return pieces === null ? this.pieces[index] !== undefined : pieces[index] !== undefined;
}

/**
* ルーレットをスタートさせる
* index は当てたいピースのインデックス
* duration はアニメーションさせたい秒数
* rotation は最終的なルーレットの回転数(index で指定されたピースで回転を止めるため、指定された分 + 0度 ~ 360度回転する)
* @param index
* @param duration
* @param rotation
*/

public start(index: number, duration: number, rotation: number) {
//描画可能状態か判定
if (!this.enable) {
console.log('[Roulette.start] 描画不可状態です');
return;
}

//this.pieces[index] が存在するか判定
if (!this._isIndex(index)) {
console.log('[Roulette.start] this.pieces が空です');
return;
}

//this.piece をセット
this.piece = this.pieces[index];

//duration をミリ秒に変換
const millDuration = duration * 1000;

//duration 秒間描画不可状態にする
this.enable = false;
setTimeout(() => {
this.enable = true;
}, millDuration);

//~略~
}

上記抜粋は、カンバスのアニメーション中はenablefalseにすることでクリック連打などを回避する部分です。

また、実際に当選したピース情報をプロパティに登録する部分も含まれます。

  public start(index: number, duration: number, rotation: number) {

//~略~

//index 番目のピースが該当する範囲の角度をランダムに算出し、それに rotation * 2π を加算してアニメーション完了時にまでに回転する角度を求める
const angle = this._getRand(this.pieces[index - 1] === undefined ? 0 : this.pieces[index - 1]._angle, this.pieces[index]._angle, 1) + rotation * Math.PI * 2;

//~略~
}

続いて、アニメーションの最後に到達すべき最終的な回転角度を算出します。

当たるピースは既にindexとして指定済ですが、当然ルーレットはそのピースを頂上に位置させた状態で止まらなくてはなりません。

なので、今回は回転数(Math.PI * 2 * rotation) + ピースが該当する角度の範囲内のランダムな角度を算出し、最終角度としてconst angleに代入します。

  /**

* 0~1の時間(t)に対してイージング加工された値を返す
* @param t
* @private
*/

private _ease(t: number) {
return t <.5 ? 8*t*t*t*t : 1-8*(--t)*t*t*t;
}

public start(index: number, duration: number, rotation: number) {
//~略~

//アニメーション開始時刻の定義
const start = new Date().getTime();

//アニメーション関数
const loop = () => {
//この関数をループ
const animation = requestAnimationFrame(loop);

//経過時間から終了時間までの間を0 ~ 1割合で算出
const passage = (new Date().getTime() - start) / millDuration;

//easeInOutQuart のイージングで経過時間から現在の速さを取得し、回す
this._draw(this._ease(passage) * angle);

//passage が 1 以下だったらここで終了
if (passage < 1)
{
return;
}

//もし passage が1以上だった場合は最終回転角度でアニメーションを止める
this._draw(angle);
cancelAnimationFrame(animation);

//this.canvas へ endRoulette イベントを登録
if (typeof(Event) === 'function') {
//IE 以外
this.canvas.dispatchEvent(new Event('endRoulette'));
}
else {
//IE
const event = document.createEvent('Event');
event.initEvent('endRoulette', true, true);
this.canvas.dispatchEvent(event);
}
};

//アニメーション開始
loop();
}

用意した_draw()の第一引数を変化させながら定期的に叩けばアニメーションになりますので、requestAnimationFrame()を用いてブラウザの都合に合わせたフレームレートごとにloop()を呼び続けます。ブラウザの調子がいいと1秒間あたり最大60回_draw()が呼び出される計算です。

最終的に到達する角度とアニメーションにかかる時間が分かれば、アニメーション開始時刻を変数に入れておき、アニメーション中は現在時刻と終了予定時刻を比較しながら現在どの角度ズレたルーレットを描写すべきか算出できます。

つまり、(現在時刻 - アニメーション開始時刻) / アニメーション時間を算出して得られた0 ~ 1間の少数をangleに掛けたラジアンを_draw()に渡すのです。

この式で算出された値をconst passageに代入しておきます。

ただし、ずっと一定速度だと自然なアニメーションに見えないのでイージング関数を噛ませます。

0~1の数を渡すとInOutQuadを通した数値に変換してくれる_easing()を用意し、これでpassageを変換した数値を_draw()に渡すことで、だんだん加速→だんだん減速したアニメーションが可能になります。

passageが1以上になったらアニメーション終了時刻・角度に到達したのでその時点でcancelAnimationFrame()でアニメーションを終了させます。

ついでにカンバスのアニメーションが終了したことをクラスを使う側のコードで判別できるようにendRouletteイベントを発火させておきます。

このイベントをaddEventListenerで捕捉してあげれば、下記

  /**

* 当たったピース情報を取得する
* @param index
*/

public getPiece(): Piece | null {
return this.piece;
}

で得た情報を元に、さもアニメーション終了時に情報が確定したかのような表示が可能です。


感想

普段はPHPばっかり書いている筆者ですが、TypeScriptのカッチリ感にハマると中々抜け出せなくなりそうです。

実案件で使う場合、この程度の小規模なコードだと素のJSの方が開発のスピードが早そうですが、メンテナンスは間違いなくしやすくなると思うので、これからもちょくちょく勉強していこうかなと思います。