javascriptで作るゲーム【ジャンケンマンフィーバー】完結編（音声出力）

前回まではこちらを参照してください。
https://qiita.com/tri-comma/items/e9790b420d9786f91149
https://qiita.com/tri-comma/items/be169c9163d637d82af3

最後に音声再生を実装しました。
（ついでにコインも描画しました）

# 前回ソースからの差分

```diff_javascript: [差分1] JankenFever.onstop()に引数追加、JFViewクラスの各イベントハンドラに音声再生処理を追加
  if (this.status == this.STS.SPINING) {
   const medal = this.ROULETTE[this.ridx];
   this.medal -= medal;
   this.player.medal += medal;
   this.status = this.STS.STANDBY;
-   this.onstop ? this.onstop() : null;
+   this.onstop ? this.onstop(this.ridx, this.ROULETTE) : null;
   return { result: true, medal: this.medal, pmedal: this.player.medal };
  } else { return { result: false, status: this.status }; }
 }
 insertMedal(player) {
  if (player.medal > 0 && this.medal > Math.max.apply(null,this.ROULETTE)) {
   player.medal--;
   this.medal++;
   return true;
  }
  return false;
 }
 judge(myHand, yourHand) { return this.JUDGE[myHand+yourHand]; }
 rnd(m) { return Math.floor(Math.random()*m); }
}
class JFView {
 constructor(cid, iid) {
  this.game = new JankenFever(100);
-  this.game.onstart = ()=>this.drawComThinking();
+  this.game.onstart = ()=>{
+   this.pon = 'pon';
+   this.drawComThinking();
+   this.sounds.play('janken');
+   this.drawCoins(this.game.player.medal);
+  };
  this.game.onwin = ()=>{
+   this.sounds.playSeq([this.pon,'fever']);
   clearInterval(this.plyItvID);
   this.canInsertMedal = true;
   this.blinkResult('win');
  };
  this.game.onlose = ()=>{
+   this.sounds.playSeq([this.pon,'zuko']);
   clearInterval(this.plyItvID);
   this.canInsertMedal = true;
   this.blinkResult('lose');
   this.plyItvID = setTimeout(()=>this.drawDemo(), 4000);
  };
  this.game.ondraw = ()=>{
+   this.sounds.playSeq([this.pon,'aikode']);
   clearInterval(this.plyItvID);
   this.blinkResult('draw');
   this.plyItvID = setTimeout(()=>this.drawComThinking(false), 1500);
+   this.pon = 'sho';
  };
-  this.game.onspin = (ri, rm) => this.drawRoulette(ri, rm);
+  this.game.onspin = (ri, rm) => {
+   this.drawRoulette(ri, rm);
+   this.sounds.play('beep');
+  };
-  this.game.onstop = () => this.plyItvID = setTimeout(()=>this.drawDemo(), 3000);
+  this.game.onstop = (ri, rm) => {
+   this.drawRoulette(ri, rm);  // bugfix
+   this.plyItvID = setTimeout(()=>this.drawDemo(), 3000);
+   this.drawCoins(this.game.player.medal);
+   this.sounds.play('yappy');
+  };
  this.canvas = document.getElementById(cid);
  this.ctx = this.canvas.getContext('2d');
```

```diff_javascript: [差分2] JFViewクラスのコンストラクタに音声再生関連の初期化処理を追加
   if (cvsx>355 && cvsx<441 && cvsy>586 && cvsy<632) this.onpress('R');
   if (cvsx>487 && cvsx<573 && cvsy>586 && cvsy<632) this.onpress('S');
   if (cvsx>621 && cvsx<707 && cvsy>586 && cvsy<632) this.onpress('P');
  });
+  this.pon = 'pon';
+  this.sounds = {
+   janken: new Audio('janken.wav'),
+   pon:    new Audio('pon.wav'),
+   aikode: new Audio('aikode.wav'),
+   sho:    new Audio('sho.wav'),
+   zuko:   new Audio('zuko.wav'),
+   fever:  new Audio('fever.wav'),
+   yappy:  new Audio('yappy.wav'),
+   beep:   new Audio('beep.wav'),
+   current: null,
+   play: (name) => {
+    if (this.sounds.current) {
+     this.sounds.current.pause();
+     this.sounds.current.currentTime = 0;     
+    }
+    this.sounds.current = this.sounds[name];
+    this.sounds.current.play();
+   },
+   playSeq: (names) => {
+    if (names.length === 0) return;
+    let name = names.shift();
+    this.sounds[name].addEventListener('ended', ()=>{
+     this.sounds[name].addEventListener('ended', null);
+     this.sounds.playSeq(names);
+    });
+    this.sounds[name].play();
+   },
+  };
 }
 drawInit() {
  const { ctx, image, wmgn } = this;
  ctx.drawImage(image, 0, 0, 1, 1024, 0, 0, wmgn + 1, 1024);
  ctx.drawImage(image, 1022, 0, 1, 1024, wmgn + 1023, 0, wmgn + 1, 1024);
  ctx.drawImage(image, 0, 0, 1024, 1024, wmgn, 0, 1024, 1024);
+  this.drawCoins(this.game.player.medal);
  this.drawDemo();
 }
```

```diff_javascript: [差分3] コインの描画処理を追加
   if (result === 'win') {
    d = p[i % 2]['winr'];
    ctx.drawImage(image, d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], d[8]);
   }
   if (i === 7) clearInterval(self.resItvID);
  };
 }
+ drawCoins(count) {
+  const { wmgn, ctx, image } = this;
+  ctx.drawImage(image, 425, 700, 200, 250, wmgn + 425, 700, 200, 250);
+  for (let i = 0; i < Math.min(count, 51); i++) {
+   ctx.drawImage(image, 1110, 615, 30, 22, wmgn + 452, 898 - i*3, 30, 22);
+  }
+ }
}
const view = new JFView('canvas', 'base');
```

# 解説

## 音声再生処理

JFViewクラスのconstructor()の中で、soundsというメンバ変数としてオブジェクト定義しました。[差分2]
ソースとしてはあまり綺麗な書き方じゃないですけど、個人制作の規模だし、まあいいかなと。
htmlと同じ階層にwavファイルを置いて、それを `new Audio("ファイル名")` で読み込んでいます。
- play(): 現在再生中の音があればそれを停止してから任意の音を再生する。
- playSeq(): 音を立て続けに再生する。
    - ポン！の後にフィーバー！とかズコーとか続くパターンで使用。

音の再生処理はonwinやonloseなど、ゲームのイベント処理内で呼び出します。[差分1]
じゃんけん「ポン」とあいこで「ショ」の切り替えは割とアナログです笑

## コイン描画処理

[差分3]に追加した関数でコインを描画してます。
一度描画領域（200px * 250px）をリセットして、枚数分コインを積み重ねるという処理です。
描画領域に限りがあるのでコインは50枚までしか描画しません。
呼び出し元は、初期化時・コイン投入時・ルーレット当選時の３つです。

## バグfix

前回のソースでは、ルールレットが止まった数字が実際に当選した数字よりも１つ手前になってしまってました。
最後の "spin" を描画してないためです。
なので、onstop()でもonspin()と同じようにルーレット描画処理を呼び出すように修正しました。

# すべてのソース

## html


```html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ジャンケンマンフィーバー2024</title>
    <style>
body {
	margin: 0;
	overflow: hidden;
}
#canvas {
	margin: auto;
	width: 100vh;
	height: 100vh;
}
#base {
	display: none;
}
    </style>
</head>
<body>
	<canvas id="canvas"></canvas>
	<img id="base" src="rps.png" />
	<script>
class JankenFever {
	#status;
	constructor(ms = 100) {
		this.STS = { STANDBY: 0, PLAYING: 1, SPINING: 2 };
		this.RSP = [ 'R', 'S', 'P' ];
		this.ROULETTE = [1, 2, 4, 7, 2, 4, 1, 2, 7, 4, 2, 20];
		this.RESULT = {
			WIN:  { val:'WIN',  next: this.STS.SPINING, callback: this.onwin  },
			LOSE: { val:'LOSE', next: this.STS.STANDBY, callback: this.onlose },
			DRAW: { val:'DRAW', next: this.STS.PLAYING, callback: this.ondraw },
		};
		this.JUDGE = {
			'RR': this.RESULT.DRAW,	'SS': this.RESULT.DRAW, 'PP': this.RESULT.DRAW,
			'RS': this.RESULT.WIN, 	'SP': this.RESULT.WIN, 	'PR': this.RESULT.WIN,
			'RP': this.RESULT.LOSE,	'SR': this.RESULT.LOSE, 'PS': this.RESULT.LOSE,		
		};
		this.status = this.STS.STANDBY;
		this.medal = 1000;
		this.player = { medal: 10 };
		this.onstart = null;
		this.onspin = null;
		this.onstop = null;
		this.interval = ms;
		this.ridx = 0;
	}
	set status(sts) {
		this.#status = sts;
		if (sts === this.STS.SPINING) this.spin();
	}
	get status() { return this.#status;	}
	set onwin(fn)  { this.RESULT.WIN.callback = fn; }
	set onlose(fn) { this.RESULT.LOSE.callback = fn; }
	set ondraw(fn) { this.RESULT.DRAW.callback = fn; }
	start() {
		if (this.status === this.STS.STANDBY && this.insertMedal(this.player)) {
			this.status = this.STS.PLAYING;
			this.onstart ? this.onstart() : null;
			return { result: true, medal: this.medal, pmedal: this.player.medal };
		} else { return { reslut: false, status: this.status, medal: this.medal, pmedal: this.player.medal }; }
	}
	play(yourHand) {
		if (this.status == this.STS.PLAYING && this.RSP.indexOf(yourHand) > -1) {
			const myHand = this.RSP[this.rnd(this.rnd(this.RSP.length))];
			const result = this.judge(myHand, yourHand);
			this.status = result.next;
			result.callback ? result.callback() : null;
			return { result: result.val, medal: this.medal, pmedal: this.player.medal };
		} else { return { result: false, status: this.status, yourHand }; }
	}
	spin() {
		if (this.status == this.STS.SPINING) {
			this.ridx = this.rnd(this.ROULETTE.length);
			this.scnt = 0;
			this.riid = setInterval(()=>{
				this.onspin ? this.onspin(this.ridx, this.ROULETTE) : null;
				this.ridx = (this.ridx + 1) % this.ROULETTE.length;
				if (this.scnt++ > 31) {
					clearInterval(this.riid);
					this.stop();
				}
			}, this.interval);
			return { result: true, medal: this.medal, pmedal: this.player.medal };
		} else { return { result: false, status: this.status }; }
	}
	stop() {
		if (this.status == this.STS.SPINING) {
			const medal = this.ROULETTE[this.ridx];
			this.medal -= medal;
			this.player.medal += medal;
			this.status = this.STS.STANDBY;
			this.onstop ? this.onstop(this.ridx, this.ROULETTE) : null;
			return { result: true, medal: this.medal, pmedal: this.player.medal };
		} else { return { result: false, status: this.status }; }
	}
	insertMedal(player) {
		if (player.medal > 0 && this.medal > Math.max.apply(null,this.ROULETTE)) {
			player.medal--;
			this.medal++;
			return true;
		}
		return false;
	}
	judge(myHand, yourHand) { return this.JUDGE[myHand+yourHand]; }
	rnd(m) { return Math.floor(Math.random()*m); }
}

class JFView {
	constructor(cid, iid) {
		this.game = new JankenFever(100);
		this.game.onstart = ()=>{
			this.pon = 'pon';
			this.drawComThinking();
			this.sounds.play('janken');
			this.drawCoins(this.game.player.medal);
		};
		this.game.onwin = ()=>{
			this.sounds.playSeq([this.pon,'fever']);
			clearInterval(this.plyItvID);
			this.canInsertMedal = true;
			this.blinkResult('win');
		};
		this.game.onlose = ()=>{
			this.sounds.playSeq([this.pon,'zuko']);
			clearInterval(this.plyItvID);
			this.canInsertMedal = true;
			this.blinkResult('lose');
			this.plyItvID = setTimeout(()=>this.drawDemo(), 4000);
		};
		this.game.ondraw = ()=>{
			this.sounds.playSeq([this.pon,'aikode']);
			clearInterval(this.plyItvID);
			this.blinkResult('draw');
			this.plyItvID = setTimeout(()=>this.drawComThinking(false), 1500);
			this.pon = 'sho';
		};
		this.game.onspin = (ri, rm) => {
			this.drawRoulette(ri, rm);
			this.sounds.play('beep');
		};
		this.game.onstop = (ri, rm) => {
			this.drawRoulette(ri, rm);
			this.plyItvID = setTimeout(()=>this.drawDemo(), 3000);
			this.drawCoins(this.game.player.medal);
			this.sounds.play('yappy');
		};
		this.canvas = document.getElementById(cid);
		this.ctx = this.canvas.getContext('2d');
		this.image = document.getElementById(iid);
		this.canvas.width = 1024 * (window.innerWidth / window.innerHeight);
		this.canvas.height = 1024;
		this.canvas.style.width = window.innerWidth + 'px';
		this.canvas.style.height = window.innerHeight + 'px';
		this.wmgn = Math.floor((this.canvas.width - 1024) / 2);
		this.image.addEventListener('load', e=>this.drawInit());
		this.canInsertMedal = true;
		this.plyItvID;
		this.resItvID;
		this.myHand = '';
		this.cx = (canvas.width - 1024) / 2 + 536;
		this.cy = 411;
		this.r = 130;
		this.degs = [-76,-47,-15,17,49,77,102,131,163,-165,-133,-101];
		this.fs = 28;
		this.ctx.font = `bold ${this.fs}px Impact`;
		this.ctx.lineWidth = 2.5;
		this.canvas.addEventListener('click', (e) => {
			const cvsx = Math.floor(1024 * (e.x / window.innerHeight) - this.wmgn);
			const cvsy = Math.floor(1024 * (e.y / window.innerHeight));
			if (cvsx>440 && cvsx<615 && cvsy>705 && cvsy<925 && this.canInsertMedal) {
				clearInterval(this.plyItvID);
				this.game.start();
			}
			if (cvsx>355 && cvsx<441 && cvsy>586 && cvsy<632) this.onpress('R');
			if (cvsx>487 && cvsx<573 && cvsy>586 && cvsy<632) this.onpress('S');
			if (cvsx>621 && cvsx<707 && cvsy>586 && cvsy<632) this.onpress('P');
		});
		this.pon = 'pon';
		this.sounds = {
			janken: new Audio('janken.wav'),
			pon:    new Audio('pon.wav'),
			aikode: new Audio('aikode.wav'),
			sho:    new Audio('sho.wav'),
			zuko:   new Audio('zuko.wav'),
			fever:  new Audio('fever.wav'),
			yappy:  new Audio('yappy.wav'),
			beep:   new Audio('beep.wav'),
			current: null,
			play: (name) => {
				if (this.sounds.current) {
					this.sounds.current.pause();
					this.sounds.current.currentTime = 0;					
				}
				this.sounds.current = this.sounds[name];
				this.sounds.current.play();
			},
			playSeq: (names) => {
				if (names.length === 0) return;
				let name = names.shift();
				this.sounds[name].addEventListener('ended', ()=>{
					this.sounds[name].addEventListener('ended', null);
					this.sounds.playSeq(names);
				});
				this.sounds[name].play();
			},
		};
	}
	drawInit() {
		const { ctx, image, wmgn } = this;
		ctx.drawImage(image, 0, 0, 1, 1024, 0, 0, wmgn + 1, 1024);
		ctx.drawImage(image, 1022, 0, 1, 1024, wmgn + 1023, 0, wmgn + 1, 1024);
		ctx.drawImage(image, 0, 0, 1024, 1024, wmgn, 0, 1024, 1024);
		this.drawCoins(this.game.player.medal);
		this.drawDemo();
	}
	drawDemo() {
		this.drawComHand(0);
		this.plyItvID = setInterval((()=>{
			let rsp = 0;
			return () => this.drawComHand(rsp = (rsp + 1) % 3);
		})(), 1000);	
	}
	drawComHand(rsp) {
		const { ctx, image, wmgn, game } = this;
		const rsp_xy = [ {x:1024,y:0}, {x:1024,y:170}, {x:1024,y:340} ][rsp];
		ctx.drawImage(image, rsp_xy.x, rsp_xy.y, 150, 170, wmgn + 448, 317, 150, 170);
		this.drawRoulette(-1, game.ROULETTE);
	}
	drawRoulette(rt = -1, rtmst) {
		const { degs, ctx, cx, cy, r, fs } = this;
		let i = 0;
		rtmst.forEach((num)=>{
			const [numStr, rad] = [num + '', Math.PI / 180 * degs[i]];
			ctx.strokeStyle = rt===i ? '#FF0' : ['#C22','#C22'][i%2];
			ctx.fillStyle = rt===i ? '#F00' : ['#FFF','#FFC'][i%2];
			const dx = Math.floor(cx + r * Math.cos(rad) - (fs * numStr.length) / 2);
			const dy = Math.floor(cy + r * Math.sin(rad) * 185/210);
			ctx.strokeText(numStr, dx, dy);
			ctx.fillText(numStr, dx, dy);
			i++;
		});
	}
	onpress(rsp) {
		this.drawButton(rsp);
		if (!this.canInsertMedal) this.game.play(this.myHand = rsp);
	}
	drawButton(rsp) {
		const { ctx, image, wmgn } = this;
		const xy = {
			R: { px:1024, py:614, bx:355, by:586 },
			S: { px:1024, py:660, bx:487, by:586 },
			P: { px:1024, py:706, bx:621, by:586 }
		}[rsp];
		ctx.drawImage(image, xy.px, xy.py, 86, 46, wmgn + xy.bx, xy.by, 86, 46);
		setTimeout( () => ctx.drawImage(image, xy.bx, xy.by, 86, 46, wmgn + xy.bx, xy.by, 86, 46), 150);
	}
	drawComThinking(flush = true) {
		this.canInsertMedal = false;
		flush ? this.blinkOff() : null;
		let rsp = 0;
		this.plyItvID = setInterval(() => {
			rsp = ([0,1,2].filter(i=>i!==rsp))[Math.round(Math.random())];
			this.drawComHand(rsp);
		}, 100);
	}
	blinkOff() {
		const { resItvID, ctx, image, wmgn } = this;
		clearInterval(resItvID);
		ctx.drawImage(image, 343, 261, 68, 52, wmgn + 343, 261, 68, 52);
		ctx.drawImage(image, 646, 260, 68, 52, wmgn + 646, 260, 68, 52);
		ctx.drawImage(image, 343, 503, 68, 52, wmgn + 343, 503, 68, 52);
		ctx.drawImage(image, 646, 503, 68, 52, wmgn + 646, 503, 68, 52);
	}
	blinkResult(result) {
		const handSels = { win:{'R':1,'S':2,'P':0}, lose:{'R':2,'S':0,'P':1}, draw:{'R':0,'S':1,'P':2} };
		this.drawComHand(handSels[result][this.myHand]);
		this.blinkOff();
		const drawBlink = this.getDrawBlink(result);
		drawBlink();
		this.resItvID = setInterval(drawBlink, 500);
	}
	getDrawBlink(result) {
		const { wmgn, ctx, image } = this;
		const self = this;
		const p = [{
				winl:[1024, 510, 68, 52, wmgn + 343, 261, 68, 52],
				winr:[1092, 510, 68, 52, wmgn + 646, 260, 68, 52],
				lose:[1024, 562, 68, 52, wmgn + 343, 503, 68, 52],
				draw:[1092, 562, 68, 52, wmgn + 646, 503, 68, 52],
			},
			{
				winl:[343, 261, 68, 52, wmgn + 343, 261, 68, 52],
				winr:[646, 260, 68, 52, wmgn + 646, 260, 68, 52],
				lose:[343, 503, 68, 52, wmgn + 343, 503, 68, 52],
				draw:[646, 503, 68, 52, wmgn + 646, 503, 68, 52],
			}];
		let i = 0;
		return function() {
			i++;
			let d = p[i % 2][result === 'win' ? 'winl' : result];
			ctx.drawImage(image, d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], d[8]);
			if (result === 'win') {
				d = p[i % 2]['winr'];
				ctx.drawImage(image, d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], d[8]);
			}
			if (i === 7) clearInterval(self.resItvID);
		};
	}
	drawCoins(count) {
		const { wmgn, ctx, image } = this;
		ctx.drawImage(image, 425, 700, 200, 250, wmgn + 425, 700, 200, 250);
		for (let i = 0; i < Math.min(count, 51); i++) {
			ctx.drawImage(image, 1110, 615, 30, 22, wmgn + 452, 898 - i*3, 30, 22);
		}
	}
}

const view = new JFView('canvas', 'base');

</script>
</body>

</html>
```

## 画像

![rps.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/127776/d2296c14-8783-6437-59a6-818c521f6509.png)

## 音声ファイル

AIで作成したので著作権的に問題ないですが、Qiitaに添付できるか不明なので各自お好きな音声ファイルをご用意ください。

# 課題

- どこからコインを入れるか、とか、コイン枚数がオーバーしている時の表現方法とか、UI上は課題を残す結果に。でも今回は気にしない。
- スマホではルーレットの音がほぼならない。再生開始前に次の音を再生開始しようとしているためだと思われる。チューニングは容易な部分なので今回は気にしない。
- 相変わらずゲームバランスが悪い。めちゃメダル増える。チューニングすればゲームバランスは無限大の可能性なので今回は気にしない。
- やはりゲーム用のライブラリで実装した方が楽なんじゃないか？説。Phaser3で実装してみたいが、今回は気にしない。