※記事のタイトルをクリックして、個別でページを開かないと、正しく動作しません。
前バージョン
organize.hatenablog.jp
変更点
- 掛け声に合わせて、タイミングよくボタンを押さないと、負けるようにしました。
感想
- BattleAreaクラスが大きくなりすぎなので、もうちょっと整理して小さくしたいところ。
- とにかく表示する文字をいい感じにするのが大変でした。
- 出した手を、今は文字で表示していますが、画像にした方が制御しやすい気がします。
- 要素のidに紐づけて、cssを定義しているところがありますが、idは要素の特定にだけ使って、見た目はclassだけで管理した方がいいですよとAIに言われたので、変更すると思います。
- 結局types.tsはそのままです。どうするかまだ考えてません。
- あいこの時は勝負を続けるようにしたいですね。
- 勝負のログとかも見れるといいなと思いました。
次のバージョン
organize.hatenablog.jp
クラス図

index.html
<html lang="ja">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/janken.svg" />
<link rel="stylesheet" href="./src/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>janken</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/JankenGame.ts"></script>
</body>
</html>
h1{
text-align: center;
}
.explanation{
padding: 2rem;
text-align: center;
}
#battle-area {
width: 100%;
border: 3px solid #333;
background-color: #f0f0f0;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
#timingbar {
width: 100%;
padding: 10px;
background-color: #222;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
}
.lamp {
flex: 1;
height: 30px;
}
.lamp.light-off {
background-color: #ffc10777;
border: 1px solid #e0a800;
}
.lamp.light-on {
background-color: hsl(59, 100%, 66%);
border: 1px solid #e0a800;
}
#center-lamp.light-off {
background-color: #dc354697;
border-color: #b02a37;
}
#center-lamp.light-on {
background-color: #ff4242;
border-color: #b02a37;
}
.put-light{
background-color: #00ff08;
}
#hand-area{
position: relative;
height: 15rem;
display: flex;
flex-direction: column;
justify-content: space-between;
}
#compute-hand{
height:5rem;
line-height: 5rem;
font-size: clamp(8px, 5cqh, 300px);
text-align: center;
}
#my-hand{
height:5rem;
line-height: 5rem;
font-size: clamp(8px, 5cqh, 300px);
text-align: center;
}
#verbal-cue{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
height: 8rem;
width: 100%;
line-height: 8rem;
text-align: center;
white-space: nowrap;
font-size: clamp(1rem, 2rem, 3rem);
font-weight: bold;
color: #005f13b1;
}
.hand-buttons{
display: flex;
align-items: center;
justify-content: center;
margin-top: 1rem;
margin-bottom: 1rem;
}
.hand-button{
width: 5em;
height: 5em;
margin-left: 1em;
margin-right: 1em;
}
.start-button{
display: flex;
align-items: center;
justify-content: center;
margin-top: 1rem;
margin-left: auto;
margin-right: auto;
}
#result.win{
color: #ff0000;
}
#result.lose{
color: #0000ff;
}
#result.draw{
color: #000000;
}
.hidden{
display: none;
}
JankenGame.ts
import { BattleArea } from "./BattleArea";
import { HandButtonArea } from "./HandButtonArea";
import { Header } from "./Header";
class JankenGame {
private header: Header;
private battleArea: BattleArea;
private startButton: HTMLButtonElement;
private handButtonArea: HandButtonArea;
@param
constructor(parent: HTMLElement){
this.header = new Header();
parent.appendChild(this.header.getElement());
this.battleArea = new BattleArea();
this.battleArea.setEndHandler(()=>{
this.setLady();
})
parent.appendChild(this.battleArea.getElement());
this.startButton = document.createElement("button");
this.startButton.classList.add('start-button', 'hand-button')
this.startButton.textContent = "勝負!";
this.startButton.addEventListener('click', ()=>{
this.startButton.classList.add('hidden');
this.handButtonArea.getElement().classList.remove('hidden');
this.battleArea.startShoot(this.rand(30, 50));
})
parent.appendChild(this.startButton);
this.handButtonArea = new HandButtonArea();
for(const handButton of this.handButtonArea.getButtons()){
handButton.getElement().addEventListener('click', () => {
if(!this.battleArea.canPutHand()){
return;
}
const canPutNext = this.battleArea.setMyHand(handButton.getKind());
if(!canPutNext){
this.handButtonArea.getElement().classList.add('hidden');
}
});
}
parent.appendChild(this.handButtonArea.getElement());
this.setLady();
}
private setLady(){
this.handButtonArea.getElement().classList.add('hidden');
this.startButton.classList.remove('hidden');
}
@param
@param
@returns
private rand(min: number, max: number): number {
return (Math.floor(Math.random() * (max - min + 1)) + min);
}
}
const appElement = document.querySelector('#app') as HTMLElement;
new JankenGame(appElement);
export class Header{
private element:HTMLElement;
private title:HTMLElement;
private explanation:HTMLElement;
constructor(){
this.element = document.createElement("div");
this.title = document.createElement('h1');
this.title.textContent = 'ジャンケンゲーム';
this.element.appendChild(this.title);
this.explanation = document.createElement('div');
this.explanation.className ='explanation';
this.explanation.innerHTML = `
<div>勝負ボタンを押すと「さいしょはグー、じゃんけんぽん」の掛け声が始まります。</div>
<div>「ぽん」のタイミングで出したい手のボタンを押してください。</div>
<div>上側が相手、下側があなたが出した手になります。</div>
<div>タイミングが早いと、超反応速度であなたの手を見られて、必ず負けてしまいます。</div>
<div>タイミングが遅いと、後出しだといちゃもんをつけられて、負けてしまいます。</div>
<div>タイミングよくボタンを押しましょう。</div>`;
this.element.appendChild(this.explanation);
}
@returns
getElement():HTMLElement{
return this.element;
}
}
BattleArea.ts
import { GameResult, HandButtonsDefine, HandKind, ResultNames, type PutTiming } from "./types";
/**
* バトルエリア
*/
export class BattleArea{
/** ルート要素 */
private element:HTMLElement;
/** 中央ランプ */
private centerLamp!:HTMLElement;
/** 左側ランプ */
private leftLamps:HTMLElement[] = [];
private rightLamps:HTMLElement[] = [];
private lightOnIndex:number = 0;
private phase:number = 0;
private verbalCueArea:HTMLElement;
private verbalCue:HTMLElement;
private result:HTMLElement;
private intervalId:number = 0;
private myHand:HTMLElement;
private computeHand:HTMLElement;
private isPutHand:boolean = false;
private putTiming:PutTiming;
private endHandler:Function | null = null;
constructor(){
this.element = document.createElement('div');
this.element.id = 'battle-area';
const timingBar = document.createElement('div');
timingBar.id = 'timingbar';
this.element.appendChild(timingBar);
for(let index = 0; index < 25; index++){
const lamp = document.createElement('span');
lamp.classList.add('lamp', 'light-off');
if(index === 12){
lamp.id = 'center-lamp';
this.centerLamp = lamp;
}else if(index < 12){
lamp.id = 'left-lamp' + (index + 1);
this.leftLamps.push(lamp);
}else{
lamp.id = 'rignt-lamp' + (25- index);
this.rightLamps.unshift(lamp);
}
timingBar.appendChild(lamp)
}
const handArea = document.createElement('div');
handArea.id = 'hand-area';
this.element.appendChild(handArea);
this.computeHand = document.createElement('div');
this.computeHand.id = 'compute-hand';
handArea.appendChild(this.computeHand);
this.myHand = document.createElement('div');
this.myHand.id = 'my-hand';
handArea.appendChild(this.myHand);
this.verbalCueArea = document.createElement('div');
this.verbalCueArea.id = 'verbal-cue';
handArea.appendChild(this.verbalCueArea);
this.verbalCue = document.createElement('span');
this.verbalCueArea.appendChild(this.verbalCue);
this.result = document.createElement('span');
this.result.id = 'result';
this.verbalCueArea.appendChild(this.result);
this.putTiming = {phase:0, lightIndex:-1}
}
@returns
getElement(): HTMLElement{
return this.element;
}
@param
lightOn(lamp:HTMLElement){
if(lamp.classList.contains("put-light")){
return;
}
lamp.classList.remove('light-off');
lamp.classList.add('light-on');
}
@param
lightOff(lamp:HTMLElement){
if(lamp.classList.contains("put-light")){
return;
}
lamp.classList.remove('light-on');
lamp.classList.add('light-off');
}
@param
lightOnByIndex(index:number){
this.lightOn(this.leftLamps[index]);
this.lightOn(this.rightLamps[index]);
}
@param
lightOffByIndex(index:number){
this.lightOff(this.leftLamps[index]);
this.lightOff(this.rightLamps[index]);
}
lightOffAll(){
for(let index = 0; index < this.leftLamps.length; index++){
this.leftLamps[index].classList.remove('put-light');
this.rightLamps[index].classList.remove('put-light');
this.lightOffByIndex(index);
}
this.centerLamp.classList.remove('put-light');
this.lightOff(this.centerLamp);
}
@param
startShoot(interval:number){
if(this.intervalId !== 0){
return;
}
this.lightOffAll();
this.myHand.textContent = "";
this.computeHand.textContent = "";
this.verbalCue.textContent = "";
this.result.textContent = "";
this.lightOnIndex = 0;
this.phase = 1;
this.isPutHand = false;
this.putTiming.phase = 0;
this.putTiming.lightIndex = -1;
this.intervalId = setInterval(() => {
if(this.lightOnIndex >= 1){
this.lightOffByIndex(this.lightOnIndex-1);
}else{
this.lightOff(this.centerLamp);
}
if(this.lightOnIndex >= this.leftLamps.length){
this.lightOn(this.centerLamp);
}else{
this.lightOnByIndex(this.lightOnIndex);
}
this.lightOnIndex++;
if(this.lightOnIndex > this.leftLamps.length){
this.lightOnIndex = 0;
if(this.phase === 1){
this.verbalCue.textContent = "さい"
}else if(this.phase === 2){
this.verbalCue.textContent = "しょは"
}else if(this.phase === 3){
this.verbalCue.textContent = "グー"
this.computeHand.textContent = HandButtonsDefine[HandKind.Rock].name;
}else if(this.phase === 4){
this.verbalCue.textContent = ""
}else if(this.phase === 5){
this.verbalCue.textContent = "じゃん"
this.myHand.textContent = "";
this.computeHand.textContent = "";
}else if(this.phase === 6){
this.verbalCue.textContent = "けん"
}else if(this.phase === 7){
this.verbalCue.textContent = "ぽん"
this.putComputeHand();
}else if(this.phase === 8){
this.stopShoot();
this.setResult();
}
this.phase++;
}
}, interval);
}
stopShoot(){
clearInterval(this.intervalId);
this.intervalId = 0;
}
@param
@returns
setMyHand(hand:HandKind):boolean{
this.myHand.textContent = HandButtonsDefine[hand].name;
this.centerLamp.classList.remove('put-light');
if(this.lightOnIndex - 1 >= this.leftLamps.length){
this.centerLamp.classList.add('light-on');
}else{
this.centerLamp.classList.add('light-off');
}
for(let index = 0; index < this.leftLamps.length; index++){
this.leftLamps[index].classList.remove('put-light');
this.rightLamps[index].classList.remove('put-light');
if(this.lightOnIndex - 1 === index){
this.leftLamps[index].classList.add('light-on');
this.rightLamps[index].classList.add('light-on');
}else{
this.leftLamps[index].classList.add('light-off');
this.rightLamps[index].classList.add('light-off');
}
}
if(this.lightOnIndex - 1 >= 0 && this.lightOnIndex - 1 < this.leftLamps.length){
this.leftLamps[this.lightOnIndex-1].classList.add('put-light');
this.leftLamps[this.lightOnIndex-1].classList.remove('light-on', 'light-off');
this.rightLamps[this.lightOnIndex-1].classList.add('put-light');
this.rightLamps[this.lightOnIndex-1].classList.remove('light-on', 'light-off');
}else{
this.centerLamp.classList.add('put-light');
this.centerLamp.classList.remove('light-on', 'light-off');
}
if(this.phase >= 7 && this.phase <= 8){
this.isPutHand = true;
this.putTiming.phase = this.phase;
this.putTiming.lightIndex = this.lightOnIndex - 1;
return false;
}
return true;
}
@returns
isFighting():boolean{
if(this.phase >= 1){
return true;
}
return false;
}
@returns
canPutHand():boolean{
if(this.phase <= 0 || this.phase > 8){
return false;
}
if(this.isPutHand){
return false;
}
return true;
}
putComputeHand(){
let computeHand = this.getRandomHand();
if(this.myHand.textContent.length !== 0){
const myHand = this.getHandKindByName(this.myHand.textContent)
if(myHand){
computeHand = this.getWindHand(myHand);
}
}
this.computeHand.textContent = HandButtonsDefine[computeHand].name;
}
@param
@returns
getHandKindByName(name:string):HandKind | undefined{
const keys = Object.keys(HandButtonsDefine) as HandKind[];
return keys.find(key => HandButtonsDefine[key].name === name);
}
setResult(){
this.verbalCue.textContent = ""
const myHand = this.getHandKindByName(this.myHand.textContent);
const computeHand = this.getHandKindByName(this.computeHand.textContent);
if(!computeHand){
alert("コンピュータがまだ手を出していないのに、結果を表示しようとしています。")
console.error("コンピュータがまだ手を出していません。")
return;
}
if(!myHand){
this.verbalCue.textContent = ResultNames[GameResult.Lose];
}else{
const result = this.getResult(myHand, computeHand);
this.result.textContent = ResultNames[result];
this.setResultColor(result);
if(this.putTiming.phase >= 8 && this.putTiming.lightIndex >= 0){
this.verbalCue.textContent = '後出しなので';
this.setResultColor(GameResult.Lose);
this.result.textContent = ResultNames[GameResult.Lose];
}else if(this.putTiming.phase <= 7){
this.result.textContent = ResultNames[result];
this.setResultColor(GameResult.Lose);
this.verbalCue.textContent = "(出すのが速い)"
}
}
if(this.endHandler){
this.endHandler();
}
}
@param
setResultColor(result:GameResult){
for(const gameResult of Object.values(GameResult)){
if(result === gameResult){
this.result.classList.add(result);
}else{
this.result.classList.remove(gameResult);
}
}
}
@returns
private getRandomHand(): HandKind {
const hands = Object.values(HandKind);
const randomIndex = Math.floor(Math.random() * hands.length);
return hands[randomIndex];
}
@param
@returns
private getWindHand(hand: HandKind): HandKind{
if(hand == HandKind.Rock){
return HandKind.Paper;
}else if(hand == HandKind.Scissors){
return HandKind.Rock;
}else{
return HandKind.Scissors;
}
}
@param
@param
@returns
private getResult(playerHand: HandKind, computerHand: HandKind): GameResult {
if (playerHand === computerHand) {
return GameResult.Draw;
} else if (
(playerHand === HandKind.Rock && computerHand === HandKind.Scissors) ||
(playerHand === HandKind.Scissors && computerHand === HandKind.Paper) ||
(playerHand === HandKind.Paper && computerHand === HandKind.Rock)
) {
return GameResult.Win;
} else {
return GameResult.Lose;
}
}
@param
setEndHandler(handler:Function){
this.endHandler = handler;
}
}
import { HandButton } from "./HandButton";
import { HandButtonsDefine } from "./types";
export class HandButtonArea{
private element:HTMLElement;
private handButtons:HandButton[] = [];
constructor(){
this.element = document.createElement('div');
this.element.className = 'hand-buttons';
Object.values(HandButtonsDefine).forEach(value => {
const handButton = new HandButton(value);
this.element.appendChild(handButton.getElement());
this.handButtons.push(handButton);
});
}
@returns
getElement():HTMLElement{
return this.element;
}
@returns
getButtons():HandButton[]{
return this.handButtons;
}
}
import type { HandButtonOption, HandKind } from "./types";
export class HandButton{
private kind: HandKind;
private element: HTMLElement;
@param
constructor(buttonDefine: HandButtonOption){
this.element = document.createElement('button');
this.element.id = buttonDefine.id;
this.element.className = "hand-button";
this.element.textContent = buttonDefine.name;
this.kind = buttonDefine.id;
}
@returns
getKind(): HandKind {
return this.kind;
}
@returns
getElement(): HTMLElement {
return this.element;
}
}
types.ts
export interface HandButtonOption {
id: HandKind;
name: string;
}
export const HandKind = {
Rock: "rock",
Scissors: "scissors",
Paper: "paper"
} as const
export type HandKind = typeof HandKind[keyof typeof HandKind];
export const HandButtonsDefine: Record<HandKind, HandButtonOption> = {
[HandKind.Rock]: {id:HandKind.Rock, name:"グー"},
[HandKind.Scissors]: {id:HandKind.Scissors, name:"チョキ"},
[HandKind.Paper]: {id:HandKind.Paper, name:"パー"},
} as const
export const GameResult = {
Win: "win",
Lose: "lose",
Draw: "draw"
} as const
export type GameResult = typeof GameResult[keyof typeof GameResult];
export const ResultNames: Record<GameResult, string> = {
[GameResult.Win]: "勝ち",
[GameResult.Lose]: "負け",
[GameResult.Draw]: "あいこ",
} as const
export interface PutTiming{
phase:number;
lightIndex:number;
}
