ゲームを作って暇つぶし

暇つぶしにやったこと

じゃんけんゲーム Ver.2.5

※記事のタイトルをクリックして、個別でページを開かないと、正しく動作しません。

前バージョン

organize.hatenablog.jp

変更点

  • タイミングライトのインデックス管理をGameControllerでさせていましたが、TimingBarクラスに次のライトを点灯させるメソッドを追加したので、GameControllerは初期位置だけ決めればよくなりました。
  • また、phaseCounterが11になるタイミングで次のフェーズに移行していましたが、0になるタイミングで次のフェーズに移行するように変更しました。
  • タイミングスピードごとに、初期値をいろいろといじって、成立させていましたが、上記二つの変更により、だいぶすっきりさせることが出来ました。
  • startShootメソッドが大きくなったので、何個かのメソッドに分割しました。

感想

  • 最初、AIに相談したら、Strategyパターンを使いましょうと言われました。ですが、もっと根本的になにかが間違っていると思ったので、もっと単純化して考え直しました。
  • 1フェーズ12カウントの設定でしたが、わかりやすくするために1フェーズ4カウントにして考えてみたところ、1カウント目でフェーズを進めた方が、タイミングスピード普通と遅いの時に都合が良いことが分かりました。

クラス図

変更点なし

index.html

<!doctype 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>

JankenGame.ts

import { GameController } from "./GameController";
import { GameScreen } from "./GameScreen";

/**
 * ジャンケンゲームのメインクラス
 */
class JankenGame {
    /**
     * コンストラクタ
     * @param parent ジャンケンゲームの親要素
     */
    constructor(parent: HTMLElement){
        // ゲーム画面
        new GameScreen(parent);

        // ゲームコントローラー
        new GameController();
    }    
}

// app要素を取得
const appElement = document.querySelector('#app') as HTMLElement;
// ジャンケンゲーム作成
new JankenGame(appElement);

EventBus.ts

import type { EventMap } from "./types";

type Callback<T> = (payload: T) => void;

/**
 * Pub/Subパターンの中核となるイベントバス
 */
export class EventBus {
  /** イベント購読者マップ */
  private listeners: { [K in keyof EventMap]?: Callback<EventMap[K]>[] } = {};

  /**
   * イベントを購読(Subscribe)する
   * @param eventName イベント名
   * @param callback イベント発生時に実行されるコールバック関数
   */
  subscribe<K extends keyof EventMap>(
    eventName: K,
    callback: Callback<EventMap[K]>
  ): void {
    if (!this.listeners[eventName]) {
      this.listeners[eventName] = [];
    }
    this.listeners[eventName]!.push(callback);
  }

  /**
   * イベントを発行(Publish)する
   * @param eventName イベント名
   * @param payload イベントで渡すデータ
   */
  publish<K extends keyof EventMap>(
    eventName: K,
    payload: EventMap[K]
  ): void {
    if (!this.listeners[eventName]) {
      console.log(`[EventBus] No subscribers for event: "${eventName}"`);
      return;
    }
    
    this.listeners[eventName]!.forEach((callback) => {
      try {
        callback(payload);
      } catch (error) {
        console.error(`[EventBus] Error in subscriber for event "${eventName}":`, error);
      }
    });
  }
}

// アプリケーション全体で一つのインスタンスを共有する
export const appEventBus = new EventBus();

GameController.ts

import { appEventBus } from "./EventBus";
import { JankenRule } from "./JankenRule";
import { TimingBar } from "./TimingBar";
import { BattlePhase, GameResult, HandKind, TimingSpeed, type PutTiming, type SetResultEvent } from "./types";
import { randRange } from "./utils";

/**
 * ゲームコントローラー
 */
export class GameController{
    /** イベントバス */
    private eventBus = appEventBus;
    /** 自分の手 */
    private myHand:HandKind = HandKind.None;
    /** 相手の手 */
    private computeHand:HandKind = HandKind.None;
    /** タイマーID */
    private intervalId:number = 0;
    /** フェーズ */
    private phase:number = BattlePhase.INIT;
    /** フェーズカウンター */
    private phaseCounter:number = 0;
    /** 点灯パターン */
    private lightingPattern:TimingSpeed = TimingSpeed.First;
    /** 点灯更新頻度 */
    private lightingRefreshRate: number = 1;
    /** 手を出したか */
    private isPutHand:boolean = false;
    /** 手を出したタイミング */
    private putTiming:PutTiming;

    /**
     * コンストラクタ
     */
    constructor(){
        // 手を出したタイミング初期化
        this.putTiming = {lightIndex:-1, phase:BattlePhase.INIT};

        // 点灯パターン変更イベントハンドラー設定
        this.eventBus.subscribe('change-lighting-pattern', (pattern) => {
            this.lightingPattern = pattern;
        });

        this.eventBus.subscribe('set-put-light-index', (index) => {
            this.putTiming.lightIndex = index;
        });

        // ゲームスタートイベントハンドラー設定
        this.eventBus.subscribe('battle-started', () => {
            this.startShoot(randRange(30, 50))
        });

        // ハンドボタンクリックイベントハンドラー設定
        this.eventBus.subscribe('hand-Button-Clicked', (handKind) => {
            // 出した手を設定
            this.clickHandButton(handKind);
        });
    }

    /**
     * ジャンケン開始
     * @param interval ランプ点灯間隔
     */
    private startShoot(interval:number){
        // すでに開始していたら何もしない
        if(this.intervalId !== 0){
            return;
        }

        // 初期化
        this.initializeShoot();
        
        // タイマー開始
        this.intervalId = setInterval(() => {
            this.shootLoopTick();
        }, interval);
        
    }

    /**
     * ジャンケン初期化
     */
    private initializeShoot(){
        // 初期化
        this.myHand = HandKind.None;
        this.computeHand = HandKind.None;
        this.isPutHand = false;
        this.putTiming.phase = BattlePhase.INIT;
        this.phase = BattlePhase.INIT;
        this.phaseCounter = 1;
        
        // タイミングパターンによって変わる値
        this.lightingRefreshRate = 1;
        let initLightIndex = -1;

        if(this.lightingPattern === TimingSpeed.Normal){
            this.lightingRefreshRate = 2;
            initLightIndex = 5;
        }else if(this.lightingPattern === TimingSpeed.Slow){
            this.lightingRefreshRate = 4;
            initLightIndex = 2;
        }

        this.eventBus.publish('timing-light-on', initLightIndex);
    }


    /**
     * 1フェーズカウントごとの処理
     */
    private shootLoopTick(){
        // ライトを点灯
        if(this.phaseCounter % this.lightingRefreshRate === 0){
            this.eventBus.publish('timing-light-next', undefined);
        }
        
        // フェーズカウンターが最初に戻ったら
        if(this.phaseCounter <= 0){
            // フェーズを進める
            this.advancePhase();
        }

        // フェーズカウンターを進める
        this.phaseCounter++;
        if(this.phaseCounter >= TimingBar.PHASE_COUNT){
            this.phaseCounter = 0;
        }
    }

    /**
     * フェーズを進める
     */
    private advancePhase() {
        // フェーズを進める
        this.phase++;
        
        // フェーズ変更イベント発行
        this.eventBus.publish('phase-changed', this.phase);

        // フェーズに応じた処理
        this.handlePhaseProcess();        
    }

    /**
     * 現在のフェーズに応じた処理を実行
     */
    private handlePhaseProcess() {
        switch (this.phase) {
            case BattlePhase.JAN:
                // 自分の手をクリア:「じゃん」
                this.myHand = HandKind.None;
                this.putTiming.phase = BattlePhase.INIT;
                this.putTiming.lightIndex = -1;
                break;

            case BattlePhase.PON:
            case BattlePhase.SYO:
                // 相手の手を出す:「ぽん」または「しょ」
                this.putComputeHand();
                break;

            case BattlePhase.EMPTY2:
            case BattlePhase.EMPTY3:
                // 勝敗判定フェーズ
                this.checkGameResult();
                break;
        }
    }

    /**
     * 勝敗を判定し、終了か継続(あいこ)か判定
     */
    private checkGameResult() {
        const resultEvent = this.getResult();

        if (resultEvent.result !== GameResult.Draw) {
            // 勝敗がついた場合:終了
            this.stopShoot();
            this.setResult(resultEvent);
        } else {
            // あいこの場合:継続
            // 判定フェーズが EMPTY3 (あいこでしょの後) なら、EMPTY2 (ポン直後と同じ状態) に戻してループさせる
            if (this.phase === BattlePhase.EMPTY3) {
                this.phase = BattlePhase.EMPTY2;
            }
            this.resetHandButton();
        }
    }

    /**
     * ジャンケン終了
     */
    private stopShoot(){
        clearInterval(this.intervalId);
        this.intervalId = 0;
        // バトル終了イベント発行
        this.eventBus.publish('battle-ended', undefined);
    }

    /**
     * ハンドボタンクリックハンドラー
     * @param handKind 手の種類
     */
    private clickHandButton(handKind:HandKind){
        if(this.isPutHand){
            return;
        }

        // 自分の手を保持
        this.myHand = handKind;

        // 手を出したフェーズを保持
        this.putTiming.phase = this.phase;

        // 自分の手を表示
        this.eventBus.publish('set-my-hand', handKind);
    
        // 点灯しているランプの色を変更
        this.eventBus.publish('put-light-on', undefined);

        // 「けん」また「こで」以降に手を出したら、もう次の手は出せない
        if((this.phase >= BattlePhase.KEN && this.phase <= BattlePhase.PON) ||
           (this.phase >= BattlePhase.KODE && this.phase <= BattlePhase.SYO)){
            this.isPutHand = true;
            // ハンドボタンを非表示
            this.eventBus.publish('hide-hand-buttons', undefined);
        }
    }

    /**
     * あいこの時に手を出せるようにする
     */
    private resetHandButton(){
        this.myHand = HandKind.None;
        this.computeHand = HandKind.None;

        this.isPutHand = false;
        this.putTiming.phase = BattlePhase.INIT;
        this.putTiming.lightIndex = -1;
        
        this.eventBus.publish('show-hand-buttons', undefined);
    }

    /**
     * 相手の手を出す
     */
    private putComputeHand(){
        // ランダムで手を取得
        this.computeHand = JankenRule.getRandomHand();

        // 「ぽん」のタイミングより早く、自分が手を出していたら
        if(this.myHand !== HandKind.None){
            // 勝てる手を取得
            this.computeHand = JankenRule.getWindHand(this.myHand);
        }

        // 相手の手を表示
        this.eventBus.publish('set-compute-hand', this.computeHand);
    }

    private getResult(){
        const retResult:SetResultEvent = {reason:"", result:GameResult.Draw};

        console.log(this.putTiming)

        // ハンドボタンが押されていない
        if(this.myHand === HandKind.None){
            // 負けを表示
            retResult.reason = "手を出してないので";
            retResult.result = GameResult.Lose;
        }else{
            // 勝敗を取得
            const result = JankenRule.getResult(this.myHand, this.computeHand);
            retResult.result = result;

            // 出すタイミングが遅かった場合
            if((this.putTiming.phase == BattlePhase.PON && this.putTiming.lightIndex < 11) ||
               (this.putTiming.phase == BattlePhase.SYO && this.putTiming.lightIndex < 11)){
                retResult.reason = '後出しなので';
                retResult.result = GameResult.Lose;
            }else if((this.putTiming.phase >= BattlePhase.JAN && this.putTiming.phase <= BattlePhase.KEN) ||
                     (this.putTiming.phase >= BattlePhase.AI && this.putTiming.phase <= BattlePhase.KODE)){
                // 出すタイミングが早かった場合
                retResult.reason = '出すのが速すぎて';
                retResult.result = GameResult.Lose;
            }
        }

        return retResult;
    }

    /**
     * 結果表示
     */
    private setResult(result:SetResultEvent){
        // 結果表示イベント発行
        this.eventBus.publish('set-result', result);
    }
}

GameScreen.ts

import { BattleArea } from "./BattleArea";
import { appEventBus } from "./EventBus";
import { HandButtonArea } from "./HandButtonArea";
import { Header } from "./Header";

/**
 * ゲーム画面
 */
export class GameScreen{
    /** イベントバス */
    private eventBus = appEventBus;
    /** ヘッダー */
    private header: Header;
    /** バトルエリア */
    private battleArea: BattleArea;
    /** スタートボタン */
    private startButton: HTMLButtonElement;
    /** グーチョキパーボタン */
    private handButtonArea: HandButtonArea;

    constructor(parent: HTMLElement){
        // ヘッダー
        this.header = new Header();
        parent.appendChild(this.header.getElement());

        // バトルエリア
        this.battleArea = new BattleArea();
        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.textClear();
            // すべてのランプを消灯
            this.battleArea.lightOffAll();
            // 開始イベント発行
            this.eventBus.publish('battle-started', undefined);
        })
        parent.appendChild(this.startButton);

        // グー・チョキ・パーボタン
        this.handButtonArea = new HandButtonArea();
        parent.appendChild(this.handButtonArea.getElement());

        // 準備状態にする
        this.setLady();

        // ジャンケン終了イベントハンドラー設定
        this.eventBus.subscribe('battle-ended', () => {
            this.setLady();
        });
    }

    /**
     * バトル準備
     */
    private setLady(){
        // ハンドボタンエリアを非表示
        this.handButtonArea.getElement().classList.add('hidden');
        // スタートボタンを表示
        this.startButton.classList.remove('hidden');
    }
}

Header.ts

import { appEventBus } from "./EventBus";
import { TimingSpeed, TimingSpeedNames } from "./types";

/**
 * タイトル・説明を表示
 */
export class Header{
    /** ルート要素 */
    private element:HTMLElement;
    /** タイトル要素 */
    private title:HTMLElement;
    /** 説明要素 */
    private explanation:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    
    private debugArea: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);

        // 点灯パターン
        const fieldset = document.createElement('fieldset');
        this.element.appendChild(fieldset);
        const legend = document.createElement('legend');
        legend.textContent = 'タイミングスピード';
        fieldset.appendChild(legend);
        const div = document.createElement('div');
        fieldset.appendChild(div);

        let radioCounter = 1;
        Object.values(TimingSpeed).forEach(value => {
            const radio1 = document.createElement('input');
            radio1.type = 'radio';
            radio1.name = 'pattern';
            radio1.value = value;
            radio1.id = 'pattern-' + value;
            if(radioCounter === 1)
                radio1.checked = true;
            div.appendChild(radio1);

            const label1 = document.createElement('label');
            label1.htmlFor = 'pattern-' + value;
            label1.textContent = TimingSpeedNames[value];
            div.appendChild(label1);

            radio1.addEventListener('change', (e) => {
                const target = e.target as HTMLInputElement;
                this.eventBus.publish('change-lighting-pattern', target.value as TimingSpeed);
            });

            radioCounter++;
        });


        // デバッグエリア
        this.debugArea = document.createElement('div');
        this.debugArea.id = 'debug-area';
        this.element.appendChild(this.debugArea); 

        this.eventBus.subscribe('debug-log', (msg) => {
            const p = document.createElement('p');
            p.textContent = msg;
            this.debugArea.innerHTML = '';
            this.debugArea.appendChild(p);
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement():HTMLElement{
        return this.element;
    }
}

BattleArea.ts

import { appEventBus } from "./EventBus";
import { TimingBar } from "./TimingBar";
import { BattlePhase, GameResult, HandButtonsDefine, HandKind, ResultNames } from "./types";

/**
 * バトルエリア
 */
export class BattleArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** タイミングバー */
    private timingbar:TimingBar;
    /** 掛け声エリア */
    private verbalCueArea:HTMLElement;
    /** 掛け声 */
    private verbalCue:HTMLElement;
    /** 勝敗結果 */
    private result:HTMLElement;
    /** 自分の手 */
    private myHand:HTMLElement;
    /** 相手の手 */
    private computeHand:HTMLElement;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'battle-area';

        // タイミングバー
        this.timingbar = new TimingBar();
        this.element.appendChild(this.timingbar.getElement());

        // ハンドエリア
        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.eventBus.subscribe('set-compute-hand', (handKind) => {
            this.setComputeHand(handKind);
        });

        // 自分の手を表示
        this.eventBus.subscribe('set-my-hand', (handKind) => {
            this.setMyHand(handKind);
        });

        // 結果表示
        this.eventBus.subscribe('set-result', (data) => {
            this.setResult(data.reason, data.result);
        });

        // フェーズ変更イベントハンドラー設定
        this.eventBus.subscribe('phase-changed', (phase) => {
            // フェーズに合わせて掛け声を表示
            if(phase === BattlePhase.SAI){
                this.setVerbalCue("さい");
            }else if(phase === BattlePhase.SYOHA){
                this.setVerbalCue("しょは");
            }else if(phase === BattlePhase.GUU){
                this.setVerbalCue("グー");
                this.setComputeHand(HandKind.Rock);
            }else if(phase === BattlePhase.EMPTY1){
                this.setVerbalCue("")
                this.timingbar.putLightOff();
            }else if(phase === BattlePhase.JAN){
                this.textClear();
                this.setVerbalCue("じゃん")
            }else if(phase === BattlePhase.KEN){
                this.setVerbalCue("けん")
            }else if(phase === BattlePhase.PON){
                this.setVerbalCue("ぽん")
            }else if(phase === BattlePhase.EMPTY2){
                // 何もしない
            }else if(phase === BattlePhase.AI){
                this.textClear();
                this.setVerbalCue('あい');
            }else if(phase === BattlePhase.KODE){
                this.setVerbalCue('こで');
            }else if(phase === BattlePhase.SYO){
                this.setVerbalCue('しょ');
            }
        });
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement(): HTMLElement{
        return this.element;
    }

    /**
     * 自分の手を表示
     * @param hand 自分の手
     */
    private setMyHand(hand:HandKind){
        this.myHand.textContent = HandButtonsDefine[hand].name;
    }

    /**
     * 結果を表示
     * @param reson 理由 
     * @param result 勝敗
     */
    private setResult(reson:string, result:GameResult){
        this.verbalCue.textContent = reson;
        this.result.textContent = ResultNames[result];
        this.setResultColor(result);
    }

    /**
     * 勝敗に合わせた文字色を設定
     * @param result 勝敗
     */
    private setResultColor(result:GameResult){
        for(const gameResult of Object.values(GameResult)){
            if(result === gameResult){
                this.result.classList.add(result);
            }else{
                this.result.classList.remove(gameResult);
            }
        }
    }

    /**
     * タイミングバーをすべて消灯
     */
    lightOffAll(){
        this.timingbar.lightOffAll();
    }

    /**
     * 表示テキストをクリア
     */
    textClear(){
        this.myHand.textContent = "";
        this.computeHand.textContent = "";
        this.verbalCue.textContent = "";
        this.result.textContent = "";
    }

    /**
     * 掛け声表示
     * @param text 掛け声 
     */
    private setVerbalCue(text:string){
        this.verbalCue.textContent = text;
    }

    /**
     * 相手の手を表示
     * @param hand 相手の手
     */
    private setComputeHand(hand:HandKind){
        this.computeHand.textContent = HandButtonsDefine[hand].name;
    }
}

TimingBar.ts

import { appEventBus } from "./EventBus";

/**
 * タイミングバー
 */
export class TimingBar{
    /** 左右ランプのカウント数 */
    private static readonly LAMP_COUNT = 11;
    /** 1フェーズのカウント数 */
    public static readonly PHASE_COUNT = TimingBar.LAMP_COUNT + 1;
    /** ランプの総数 */
    private static readonly TOTAL_LAMP_COUNT = TimingBar.LAMP_COUNT * 2 + 1;

    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** 中央ランプ */
    private centerLamp!:HTMLElement;
    /** 左側ランプ */
    private leftLamps:HTMLElement[] = [];
    /** 右側ランプ */
    private rightLamps:HTMLElement[] = [];
    /** 点灯ランプ */
    private lightOnIndex:number = -1;
    /** 手を出したタイミング */
    private putIndex:number = -1;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'timingbar';

        // 左に12個、右に12個、中央1個
        for(let index = 0; index < TimingBar.TOTAL_LAMP_COUNT; index++){
            const lamp = document.createElement('span');
            lamp.classList.add('lamp');
            if(index === TimingBar.LAMP_COUNT){
                // 中央
                lamp.classList.remove('lamp')
                lamp.classList.add('center-lamp')
                lamp.id = 'center-lamp';
                this.centerLamp = lamp;
            }else if(index < TimingBar.LAMP_COUNT){
                // 左側
                lamp.id = 'left-lamp' + (index + 1);
                this.leftLamps.push(lamp);
            }else{
                // 右側
                lamp.id = 'rignt-lamp' + (TimingBar.TOTAL_LAMP_COUNT- index);
                this.rightLamps.unshift(lamp);
            }

            this.element.appendChild(lamp)
        }

        // タイミングライト点灯イベントハンドラー設定
        this.eventBus.subscribe('timing-light-on', (index) => {
            this.timingLightOn(index);
        });  

        // 次のタイミングライトを点灯するイベントハンドラー設定
        this.eventBus.subscribe('timing-light-next', () => {
            let nextIndex = this.lightOnIndex + 1; 
            if(nextIndex >= this.getLength()){
                nextIndex = 0;
            }  
            this.timingLightOn(nextIndex);
        });
        
        // 手を出したときのライト色変更イベントハンドラー設定
        this.eventBus.subscribe('put-light-on', () => {
            this.putLightOn();
            this.eventBus.publish('set-put-light-index', this.lightOnIndex);
        });
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement():HTMLElement{
        return this.element;
    }

    /**
     * ライト点灯
     * @param lamps ライト 
     * @param tokens クラス名
     */
    private lightOn(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.add(...tokens);
        }
    }
    
    /**
     * ライト消灯
     * @param lamps ライト
     * @param tokens クラス名
     */
    private lightOff(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.remove(...tokens);
        }
    }


    /**
     * インデックスを指定してライトを消灯
     * @param index インデックス
     */
    private lightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on', 'put-light');
    }

    /**
     *  すべてのライトを消灯
     */
    lightOffAll(){
        for(let index = 0; index < this.getLength(); index++){
            this.lightOffByIndex(index);
        }

        // インデックスを初期化
        this.lightOnIndex = -1;
        this.putIndex = -1;
    }

    /**
     * ライトの数
     * @returns ライトの数
     */
    private getLength():number{
        return this.leftLamps.length + 1;
    }

    /**
     * インデックスを指定してタイミングライト消灯
     * @param index インデックス
     */
    private timingLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on');
    }

    /**
     * インデックスを指定してタイミングライト点灯
     * @param index インデックス
     */
    private timingLightOn(index:number){
        // 前回のライトを消灯
        this.timingLightOffByIndex(this.lightOnIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'light-on');

        // インデックスを保持
        this.lightOnIndex = index;
    }

    /**
     * インデックスを指定して、手を出した時の色のライトを消灯
     * @param index インデックス
     */
    private putLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'put-light');
    }

    /**
     * 現在点灯しているライトの色を、手を出したときの色にする
     */
    private putLightOn(){
        // 現在点灯しているランプの色を変える
        this.putLightOnByIndex(this.lightOnIndex);
    }

    putLightOff(){
        // 手を出したときのライトを消灯
        this.putLightOffByIndex(this.putIndex);
    }

    /**
     * インデックスを指定して、手を出したときの色にする
     * @param index インデックス
     */
    private putLightOnByIndex(index:number){
        // 前回のライト消灯
        this.putLightOffByIndex(this.putIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'put-light');

        // インデックスを保持
        this.putIndex = index;
    }

    /**
     * インデックスで指定したランプを取得
     * @param index インデックス
     * @returns ランプ配列
     */
    private getLamps(index:number): HTMLElement[]{
        if(index < 0 || index >= this.getLength()){
            return [];
        }

        const lamps:HTMLElement[] = [];

        if(index === this.getLength() - 1){
            lamps.push(this.centerLamp);
        }else{
            lamps.push(this.leftLamps[index]);
            lamps.push(this.rightLamps[index]);
        }

        return lamps;
    }
}

HandButtonArea.ts

import { appEventBus } from "./EventBus";
import { HandButton } from "./HandButton";
import { HandButtonsDefine, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン表示エリア
 */
export class HandButtonArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** グー・チョキ・パーボタン */
    private handButtons:HandButton[] = [];

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.className = 'hand-buttons';
        
        // グーチョキパーボタンを作成
        Object.values(HandButtonsDefine).forEach(value => {
            if(value.id !== HandKind.None){
                // ボタン作成
                const handButton = new HandButton(value);

                // 格納場所に追加
                this.element.appendChild(handButton.getElement());

                // ボタンを保持
                this.handButtons.push(handButton);
            }
        });

        // ハンドボタン表示イベントハンドラー設定
        this.eventBus.subscribe('show-hand-buttons', () => {
            this.element.classList.remove('hidden');
        });

        // ハンドボタン非表示イベントハンドラー設定
        this.eventBus.subscribe('hide-hand-buttons', () => {
            this.element.classList.add('hidden');
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement():HTMLElement{
        return this.element;
    }
}

HandButton.ts

import { appEventBus } from "./EventBus";
import type { HandButtonOption, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン
 */
export class HandButton{
    /** 種別 */
    private kind: HandKind;
    /** ルート要素 */
    private element: HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;


    /**
     * コンストラクタ
     * @param buttonDefine ボタン定義
     */
    constructor(buttonDefine: HandButtonOption){
        // ボタン要素を作成
        this.element = document.createElement('button');
        // idを設定
        this.element.id = buttonDefine.id;
        // classを設定
        this.element.className = "hand-button";
        // 表示名を設定
        this.element.textContent = buttonDefine.name;
        // 種別を設定
        this.kind = buttonDefine.id;
        // クリックイベント設定
        this.element.addEventListener('click', () => {
            this.eventBus.publish('hand-Button-Clicked', this.kind);
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement(): HTMLElement {
        return this.element;
    }

}

JankenRule.ts

import { GameResult, HandButtonsDefine, HandKind } from "./types";
import { randRange } from "./utils";

/**
 * ジャンケンのルール
 */
export class JankenRule{

    /**
     * 手をランダムに取得
     * @returns グー or チョキ or パー
     */
    static getRandomHand(): HandKind {
        const hands = Object.values(HandKind);
        const randomIndex = randRange(Object.keys(HandButtonsDefine).indexOf(HandKind.Rock), Object.keys(HandButtonsDefine).indexOf(HandKind.Paper));
        return hands[randomIndex];
    }

    /**
     * 勝てる手を取得
     * @param hand HandKind
     * @returns HandKind
     */
    static getWindHand(hand: HandKind): HandKind{
        if(hand == HandKind.Rock){
            return HandKind.Paper;
        }else if(hand == HandKind.Scissors){
            return HandKind.Rock;
        }else{
            return HandKind.Scissors;
        }
    }

    /**
     * 自分と相手の手から勝敗を判定
     * @param playerHand 自分の手 
     * @param computerHand 相手の手
     * @returns 勝敗
     */
    static 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;
        }
    }
}

types.ts

/** グーチョキパーボタンのオプション */
export interface HandButtonOption {
    id: HandKind;
    name: string;
}

/** グーチョキーパー種別 */
export const HandKind = {
    None: "none",
    Rock: "rock",
    Scissors: "scissors",
    Paper: "paper"
} as const

export type HandKind = typeof HandKind[keyof typeof HandKind];

/** グーチョキパーボタン定義 */
export const HandButtonsDefine: Record<HandKind, HandButtonOption> = {
    [HandKind.None]: {id:HandKind.None, name:""},
    [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 const TimingSpeed = {
    First: 'first',
    Normal: 'normal',
    Slow: 'slow'
} as const;

export type TimingSpeed = typeof TimingSpeed[keyof typeof TimingSpeed];

/** タイミングスピード名 */
export const TimingSpeedNames: Record<TimingSpeed, string> = {
    [TimingSpeed.First]: '速い',
    [TimingSpeed.Normal]: '普通',
    [TimingSpeed.Slow]: '遅い',
} as const;

/** フェーズ定義 */
export const BattlePhase ={
    INIT: -1,
    SAI: 0,
    SYOHA: 1,
    GUU: 2,
    EMPTY1: 3,
    JAN: 4,
    KEN: 5,
    PON: 6,
    EMPTY2: 7,
    AI: 8,
    KODE: 9,
    SYO: 10,
    EMPTY3: 11,
} as const

export type BattlePhase = typeof BattlePhase[keyof typeof BattlePhase];

/** 手を出したタイミング */
export interface PutTiming{
    /** フェーズ */
    phase:number;
    /** 点灯ライト */
    lightIndex:number;
}

/** 結果表示イベントの引数 */
export interface SetResultEvent{
    reason:string;
    result:GameResult;
}

/** イベント一覧 */
export interface EventMap {
    'set-my-hand': HandKind;
    'set-compute-hand': HandKind;
    'set-result': SetResultEvent;
    'battle-started': undefined;
    'battle-ended': undefined;
    'phase-changed': number;
    'hand-Button-Clicked': HandKind;
    'show-hand-buttons': undefined;
    'hide-hand-buttons': undefined;
    'change-lighting-pattern': TimingSpeed;
    'timing-light-on': number;
    'timing-light-next': undefined;
    'put-light-on': undefined;
    'set-put-light-index': number;

    'debug-log': string;
}

utils.ts

/**
 * 乱数取得
 * @param min 最低値 
 * @param max 最大値
 * @returns min以上max以下の乱数
 */
export const randRange = (min: number, max: number): number => {
    return (Math.floor(Math.random() * (max - min + 1)) + min);
}

じゃんけんゲーム Ver.2.4

※記事のタイトルをクリックして、個別でページを開かないと、正しく動作しません。

前バージョン

organize.hatenablog.jp

変更点

  • タイミングを取るライトのパターンを増やしました。
    • 遅いタイミング程、中央のランプが光っている猶予が多いので、簡単になります。

感想

  • もうちょっと簡単に実装できると思ったけど、意外と手間取りました。
  • GameControllerクラスのstartShootメソッドがだいぶ肥大化してしまいました。
  • 次バージョンでは整理してすっきりさせたいですね。

次のバージョン

organize.hatenablog.jp

クラス図

変更はありません。

index.html

<!doctype 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>

JankenGame.ts

import { GameController } from "./GameController";
import { GameScreen } from "./GameScreen";

/**
 * ジャンケンゲームのメインクラス
 */
class JankenGame {
    /**
     * コンストラクタ
     * @param parent ジャンケンゲームの親要素
     */
    constructor(parent: HTMLElement){
        // ゲーム画面
        new GameScreen(parent);

        // ゲームコントローラー
        new GameController();
    }    
}

// app要素を取得
const appElement = document.querySelector('#app') as HTMLElement;
// ジャンケンゲーム作成
new JankenGame(appElement);

GameController.ts

import { appEventBus } from "./EventBus";
import { JankenRule } from "./JankenRule";
import { TimingBar } from "./TimingBar";
import { BattlePhase, GameResult, HandKind, TimingSpeed, type PutTiming, type SetResultEvent } from "./types";
import { randRange } from "./utils";

/**
 * ゲームコントローラー
 */
export class GameController{
    /** イベントバス */
    private eventBus = appEventBus;
    /** 自分の手 */
    private myHand:HandKind = HandKind.None;
    /** 相手の手 */
    private computeHand:HandKind = HandKind.None;
    /** タイマーID */
    private intervalId:number = 0;
    /** フェーズ */
    private phase:number = BattlePhase.INIT;
    /** フェーズカウンター */
    private phaseCounter:number = 0;
    /** 点灯パターン */
    private lightingPattern:TimingSpeed = TimingSpeed.First;
    /** 点灯ランプインデックス */
    private lightIndex:number = 0;
    /** 手を出したか */
    private isPutHand:boolean = false;
    /** 手を出したタイミング */
    private putTiming:PutTiming;

    /**
     * コンストラクタ
     */
    constructor(){
        // 手を出したタイミング初期化
        this.putTiming = {lightIndex:-1, phase:BattlePhase.INIT};

        // 点灯パターン変更イベントハンドラー設定
        this.eventBus.subscribe('change-lighting-pattern', (pattern) => {
            this.lightingPattern = pattern;
        });

        this.eventBus.subscribe('put-light-index', (index) => {
            this.putTiming.lightIndex = index;
        });

        // ゲームスタートイベントハンドラー設定
        this.eventBus.subscribe('battle-started', () => {
            this.startShoot(randRange(30, 50))
        });

        // ハンドボタンクリックイベントハンドラー設定
        this.eventBus.subscribe('hand-Button-Clicked', (handKind) => {
            // 出した手を設定
            this.clickHandButton(handKind);
        });
    }

    /**
     * ジャンケン開始
     * @param interval ランプ点灯間隔
     */
    private startShoot(interval:number){
        // すでに開始していたら何もしない
        if(this.intervalId !== 0){
            return;
        }

        // 初期化
        this.myHand = HandKind.None;
        this.computeHand = HandKind.None;
        this.phaseCounter = 0;
        this.isPutHand = false;
        this.putTiming.phase = BattlePhase.INIT;
        
        // タイミングパターンによって変わる値
        this.phase = BattlePhase.INIT;
        this.lightIndex = 0;
        let lightIndexInit = 0;
        let lightIncrement = 1;
        let lightingIncrementSurplus = 0;
        let phaseSurplus = 0;

        if(this.lightingPattern === TimingSpeed.First){

        }else if(this.lightingPattern === TimingSpeed.Normal){
            lightIndexInit = -1;
            lightIncrement = 2;
            lightingIncrementSurplus = 1;
            this.lightIndex = lightIndexInit;
            this.phase = BattlePhase.INIT -1;
        }else if(this.lightingPattern === TimingSpeed.Slow){
            lightIndexInit = -1;
            lightIncrement = 4;
            lightingIncrementSurplus = 3;
            phaseSurplus = 2;
            this.lightIndex = lightIndexInit;
            this.phase = BattlePhase.INIT-1;
        }

        // タイマー開始
        this.intervalId = setInterval(() => {
            // ライトを点灯
            if(this.lightingPattern === TimingSpeed.First){
                this.eventBus.publish('timing-light-on', this.lightIndex);
            }else{
                if(this.phase > BattlePhase.INIT && this.lightIndex == -1){
                    this.eventBus.publish('timing-light-on', TimingBar.PHASE_COUNT-1);
                }else{
                    this.eventBus.publish('timing-light-on', this.lightIndex);
                }
            }

            // フェーズカウンターを進める
            this.phaseCounter++;

            // 点灯ライトインデックスを進める
            if(this.phaseCounter % lightIncrement === lightingIncrementSurplus){
                this.lightIndex++;
            }

            // フェーズカウンターが規定値に達したら
            if(this.phaseCounter >= TimingBar.PHASE_COUNT){
                // フェーズを進める
                this.phase++;
                
                // 点灯ライトのインデックスを初期化
                if(this.phase % lightIncrement === phaseSurplus){
                    this.lightIndex = lightIndexInit;
                }

                // フェーズカウンターを初期化
                this.phaseCounter = 0;

                // フェーズ変更イベント発行
                this.eventBus.publish('phase-changed', this.phase);

                if(this.phase === BattlePhase.JAN){
                    // 自分の手をクリア
                    this.myHand = HandKind.None;
                    this.putTiming.phase = BattlePhase.INIT;
                    this.putTiming.lightIndex = -1;
                }else if(this.phase === BattlePhase.PON){
                    // 相手の手を出す
                    this.putComputeHand();
                }else if(this.phase === BattlePhase.EMPTY2){
                    const result = this.getResult();
                    if(result.result !== GameResult.Draw){
                        // ジャンケン終了
                        this.stopShoot();
                        // 結果表示
                        this.setResult(result);
                    }else{
                        // あいこなので手を出せるようにする
                        this.resetHandButton();
                    }
                }else if(this.phase === BattlePhase.SYO){
                    // 相手の手を出す
                    this.putComputeHand();
                }else if(this.phase === BattlePhase.EMPTY3){
                    const result = this.getResult();
                    if(result.result !== GameResult.Draw){
                        // ジャンケン終了
                        this.stopShoot();
                        // 結果表示
                        this.setResult(result);
                    }else{
                        // あいこなのでフェーズを戻す
                        this.phase = BattlePhase.EMPTY2; 
                        // 手を出せるようにする
                        this.resetHandButton();
                    }
                }
            }

        }, interval);
        
    }

    /**
     * ジャンケン終了
     */
    private stopShoot(){
        clearInterval(this.intervalId);
        this.intervalId = 0;
        // バトル終了イベント発行
        this.eventBus.publish('battle-ended', undefined);
    }

    /**
     * ハンドボタンクリックハンドラー
     * @param handKind 手の種類
     */
    private clickHandButton(handKind:HandKind){
        if(this.isPutHand){
            return;
        }

        // 自分の手を保持
        this.myHand = handKind;

        // 手を出したフェーズを保持
        this.putTiming.phase = this.phase;
        //this.putTiming.lightIndex = this.lightIndex;
        // if(this.lightingPattern === LightingPattern.First){
        //     this.putTiming.lightIndex -= 1;
        // }

        // 自分の手を表示
        this.eventBus.publish('set-my-hand', handKind);
    
        // 点灯しているランプの色を変更
        this.eventBus.publish('put-light-on', undefined);

        // 「けん」また「こで」以降に手を出したら、もう次の手は出せない
        if((this.phase >= BattlePhase.KEN && this.phase <= BattlePhase.PON) ||
           (this.phase >= BattlePhase.KODE && this.phase <= BattlePhase.SYO)){
            this.isPutHand = true;
            // ハンドボタンを非表示
            this.eventBus.publish('hide-hand-buttons', undefined);
        }
    }

    /**
     * あいこの時に手を出せるようにする
     */
    private resetHandButton(){
        this.myHand = HandKind.None;
        this.computeHand = HandKind.None;

        this.isPutHand = false;
        this.putTiming.phase = BattlePhase.INIT;
        this.putTiming.lightIndex = -1;
        
        this.eventBus.publish('show-hand-buttons', undefined);
    }

    /**
     * 相手の手を出す
     */
    private putComputeHand(){
        // ランダムで手を取得
        this.computeHand = JankenRule.getRandomHand();

        // 「ぽん」のタイミングより早く、自分が手を出していたら
        if(this.myHand !== HandKind.None){
            // 勝てる手を取得
            this.computeHand = JankenRule.getWindHand(this.myHand);
        }

        // 相手の手を表示
        this.eventBus.publish('set-compute-hand', this.computeHand);
    }

    private getResult(){
        const retResult:SetResultEvent = {reason:"", result:GameResult.Draw};

        console.log(this.putTiming)

        // ハンドボタンが押されていない
        if(this.myHand === HandKind.None){
            // 負けを表示
            retResult.reason = "手を出してないので";
            retResult.result = GameResult.Lose;
        }else{
            // 勝敗を取得
            const result = JankenRule.getResult(this.myHand, this.computeHand);
            retResult.result = result;

            // 出すタイミングが遅かった場合
            if((this.putTiming.phase == BattlePhase.PON && this.putTiming.lightIndex < 11) ||
               (this.putTiming.phase == BattlePhase.SYO && this.putTiming.lightIndex < 11)){
                retResult.reason = '後出しなので';
                retResult.result = GameResult.Lose;
            }else if((this.putTiming.phase >= BattlePhase.JAN && this.putTiming.phase <= BattlePhase.KEN) ||
                     (this.putTiming.phase >= BattlePhase.AI && this.putTiming.phase <= BattlePhase.KODE)){
                // 出すタイミングが早かった場合
                retResult.reason = '出すのが速すぎて';
                retResult.result = GameResult.Lose;
            }
        }

        return retResult;
    }

    /**
     * 結果表示
     */
    private setResult(result:SetResultEvent){
        // 結果表示イベント発行
        this.eventBus.publish('set-result', result);
    }
}

EventBus.ts

import type { EventMap } from "./types";

type Callback<T> = (payload: T) => void;

/**
 * Pub/Subパターンの中核となるイベントバス
 */
export class EventBus {
  /** イベント購読者マップ */
  private listeners: { [K in keyof EventMap]?: Callback<EventMap[K]>[] } = {};

  /**
   * イベントを購読(Subscribe)する
   * @param eventName イベント名
   * @param callback イベント発生時に実行されるコールバック関数
   */
  subscribe<K extends keyof EventMap>(
    eventName: K,
    callback: Callback<EventMap[K]>
  ): void {
    if (!this.listeners[eventName]) {
      this.listeners[eventName] = [];
    }
    this.listeners[eventName]!.push(callback);
  }

  /**
   * イベントを発行(Publish)する
   * @param eventName イベント名
   * @param payload イベントで渡すデータ
   */
  publish<K extends keyof EventMap>(
    eventName: K,
    payload: EventMap[K]
  ): void {
    if (!this.listeners[eventName]) {
      console.log(`[EventBus] No subscribers for event: "${eventName}"`);
      return;
    }
    
    this.listeners[eventName]!.forEach((callback) => {
      try {
        callback(payload);
      } catch (error) {
        console.error(`[EventBus] Error in subscriber for event "${eventName}":`, error);
      }
    });
  }
}

// アプリケーション全体で一つのインスタンスを共有する
export const appEventBus = new EventBus();

GameScreen.ts

import { BattleArea } from "./BattleArea";
import { appEventBus } from "./EventBus";
import { HandButtonArea } from "./HandButtonArea";
import { Header } from "./Header";

/**
 * ゲーム画面
 */
export class GameScreen{
    /** イベントバス */
    private eventBus = appEventBus;
    /** ヘッダー */
    private header: Header;
    /** バトルエリア */
    private battleArea: BattleArea;
    /** スタートボタン */
    private startButton: HTMLButtonElement;
    /** グーチョキパーボタン */
    private handButtonArea: HandButtonArea;

    constructor(parent: HTMLElement){
        // ヘッダー
        this.header = new Header();
        parent.appendChild(this.header.getElement());

        // バトルエリア
        this.battleArea = new BattleArea();
        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.textClear();
            // すべてのランプを消灯
            this.battleArea.lightOffAll();
            // 開始イベント発行
            this.eventBus.publish('battle-started', undefined);
        })
        parent.appendChild(this.startButton);

        // グー・チョキ・パーボタン
        this.handButtonArea = new HandButtonArea();
        parent.appendChild(this.handButtonArea.getElement());

        // 準備状態にする
        this.setLady();

        // ジャンケン終了イベントハンドラー設定
        this.eventBus.subscribe('battle-ended', () => {
            this.setLady();
        });
    }

    /**
     * バトル準備
     */
    private setLady(){
        // ハンドボタンエリアを非表示
        this.handButtonArea.getElement().classList.add('hidden');
        // スタートボタンを表示
        this.startButton.classList.remove('hidden');
    }
}

Header.ts

import { appEventBus } from "./EventBus";
import { TimingSpeed, TimingSpeedNames } from "./types";

/**
 * タイトル・説明を表示
 */
export class Header{
    /** ルート要素 */
    private element:HTMLElement;
    /** タイトル要素 */
    private title:HTMLElement;
    /** 説明要素 */
    private explanation:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    
    private debugArea: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);

        // 点灯パターン
        const fieldset = document.createElement('fieldset');
        this.element.appendChild(fieldset);
        const legend = document.createElement('legend');
        legend.textContent = 'タイミングスピード';
        fieldset.appendChild(legend);
        const div = document.createElement('div');
        fieldset.appendChild(div);

        let radioCounter = 1;
        Object.values(TimingSpeed).forEach(value => {
            const radio1 = document.createElement('input');
            radio1.type = 'radio';
            radio1.name = 'pattern';
            radio1.value = value;
            radio1.id = 'pattern-' + value;
            if(radioCounter === 1)
                radio1.checked = true;
            div.appendChild(radio1);

            const label1 = document.createElement('label');
            label1.htmlFor = 'pattern-' + value;
            label1.textContent = TimingSpeedNames[value];
            div.appendChild(label1);

            radio1.addEventListener('change', (e) => {
                const target = e.target as HTMLInputElement;
                this.eventBus.publish('change-lighting-pattern', target.value as TimingSpeed);
            });

            radioCounter++;
        });


        // デバッグエリア
        this.debugArea = document.createElement('div');
        this.debugArea.id = 'debug-area';
        this.element.appendChild(this.debugArea); 

        this.eventBus.subscribe('debug-log', (msg) => {
            const p = document.createElement('p');
            p.textContent = msg;
            this.debugArea.innerHTML = '';
            this.debugArea.appendChild(p);
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement():HTMLElement{
        return this.element;
    }
}

BattleArea.ts

import { appEventBus } from "./EventBus";
import { TimingBar } from "./TimingBar";
import { BattlePhase, GameResult, HandButtonsDefine, HandKind, ResultNames } from "./types";

/**
 * バトルエリア
 */
export class BattleArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** タイミングバー */
    private timingbar:TimingBar;
    /** 掛け声エリア */
    private verbalCueArea:HTMLElement;
    /** 掛け声 */
    private verbalCue:HTMLElement;
    /** 勝敗結果 */
    private result:HTMLElement;
    /** 自分の手 */
    private myHand:HTMLElement;
    /** 相手の手 */
    private computeHand:HTMLElement;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'battle-area';

        // タイミングバー
        this.timingbar = new TimingBar();
        this.element.appendChild(this.timingbar.getElement());

        // ハンドエリア
        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.eventBus.subscribe('set-compute-hand', (handKind) => {
            this.setComputeHand(handKind);
        });

        // 自分の手を表示
        this.eventBus.subscribe('set-my-hand', (handKind) => {
            this.setMyHand(handKind);
        });

        // 結果表示
        this.eventBus.subscribe('set-result', (data) => {
            this.setResult(data.reason, data.result);
        });

        // フェーズ変更イベントハンドラー設定
        this.eventBus.subscribe('phase-changed', (phase) => {
            // フェーズに合わせて掛け声を表示
            if(phase === BattlePhase.SAI){
                this.setVerbalCue("さい");
            }else if(phase === BattlePhase.SYOHA){
                this.setVerbalCue("しょは");
            }else if(phase === BattlePhase.GUU){
                this.setVerbalCue("グー");
                this.setComputeHand(HandKind.Rock);
            }else if(phase === BattlePhase.EMPTY1){
                this.setVerbalCue("")
                this.timingbar.putLightOff();
            }else if(phase === BattlePhase.JAN){
                this.textClear();
                this.setVerbalCue("じゃん")
            }else if(phase === BattlePhase.KEN){
                this.setVerbalCue("けん")
            }else if(phase === BattlePhase.PON){
                this.setVerbalCue("ぽん")
            }else if(phase === BattlePhase.EMPTY2){
                // 何もしない
            }else if(phase === BattlePhase.AI){
                this.textClear();
                this.setVerbalCue('あい');
            }else if(phase === BattlePhase.KODE){
                this.setVerbalCue('こで');
            }else if(phase === BattlePhase.SYO){
                this.setVerbalCue('しょ');
            }
        });
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement(): HTMLElement{
        return this.element;
    }

    /**
     * 自分の手を表示
     * @param hand 自分の手
     */
    private setMyHand(hand:HandKind){
        this.myHand.textContent = HandButtonsDefine[hand].name;
    }

    /**
     * 結果を表示
     * @param reson 理由 
     * @param result 勝敗
     */
    private setResult(reson:string, result:GameResult){
        this.verbalCue.textContent = reson;
        this.result.textContent = ResultNames[result];
        this.setResultColor(result);
    }

    /**
     * 勝敗に合わせた文字色を設定
     * @param result 勝敗
     */
    private setResultColor(result:GameResult){
        for(const gameResult of Object.values(GameResult)){
            if(result === gameResult){
                this.result.classList.add(result);
            }else{
                this.result.classList.remove(gameResult);
            }
        }
    }

    /**
     * タイミングバーをすべて消灯
     */
    lightOffAll(){
        this.timingbar.lightOffAll();
    }

    /**
     * 表示テキストをクリア
     */
    textClear(){
        this.myHand.textContent = "";
        this.computeHand.textContent = "";
        this.verbalCue.textContent = "";
        this.result.textContent = "";
    }

    /**
     * 掛け声表示
     * @param text 掛け声 
     */
    private setVerbalCue(text:string){
        this.verbalCue.textContent = text;
    }

    /**
     * 相手の手を表示
     * @param hand 相手の手
     */
    private setComputeHand(hand:HandKind){
        this.computeHand.textContent = HandButtonsDefine[hand].name;
    }
}

TimingBar.ts

import { appEventBus } from "./EventBus";

/**
 * タイミングバー
 */
export class TimingBar{
    /** 左右ランプのカウント数 */
    private static readonly LAMP_COUNT = 11;
    /** 1フェーズのカウント数 */
    public static readonly PHASE_COUNT = TimingBar.LAMP_COUNT + 1;
    /** ランプの総数 */
    private static readonly TOTAL_LAMP_COUNT = TimingBar.LAMP_COUNT * 2 + 1;

    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** 中央ランプ */
    private centerLamp!:HTMLElement;
    /** 左側ランプ */
    private leftLamps:HTMLElement[] = [];
    /** 右側ランプ */
    private rightLamps:HTMLElement[] = [];
    /** 点灯ランプ */
    private lightOnIndex:number = -1;
    /** 手を出したタイミング */
    private putIndex:number = -1;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'timingbar';

        // 左に12個、右に12個、中央1個
        for(let index = 0; index < TimingBar.TOTAL_LAMP_COUNT; index++){
            const lamp = document.createElement('span');
            lamp.classList.add('lamp');
            if(index === TimingBar.LAMP_COUNT){
                // 中央
                lamp.classList.remove('lamp')
                lamp.classList.add('center-lamp')
                lamp.id = 'center-lamp';
                this.centerLamp = lamp;
            }else if(index < TimingBar.LAMP_COUNT){
                // 左側
                lamp.id = 'left-lamp' + (index + 1);
                this.leftLamps.push(lamp);
            }else{
                // 右側
                lamp.id = 'rignt-lamp' + (TimingBar.TOTAL_LAMP_COUNT- index);
                this.rightLamps.unshift(lamp);
            }

            this.element.appendChild(lamp)
        }

        // タイミングライト点灯イベントハンドラー設定
        this.eventBus.subscribe('timing-light-on', (index) => {
            this.timingLightOn(index);
        });  
        
        // 手を出したときのライト色変更イベントハンドラー設定
        this.eventBus.subscribe('put-light-on', () => {
            this.putLightOn();
            this.eventBus.publish('put-light-index', this.lightOnIndex);
        });
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement():HTMLElement{
        return this.element;
    }

    /**
     * ライト点灯
     * @param lamps ライト 
     * @param tokens クラス名
     */
    private lightOn(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.add(...tokens);
        }
    }
    
    /**
     * ライト消灯
     * @param lamps ライト
     * @param tokens クラス名
     */
    private lightOff(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.remove(...tokens);
        }
    }


    /**
     * インデックスを指定してライトを消灯
     * @param index インデックス
     */
    private lightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on', 'put-light');
    }

    /**
     *  すべてのライトを消灯
     */
    lightOffAll(){
        for(let index = 0; index < this.getLength(); index++){
            this.lightOffByIndex(index);
        }

        // インデックスを初期化
        this.lightOnIndex = -1;
        this.putIndex = -1;
    }

    /**
     * ライトの数
     * @returns ライトの数
     */
    private getLength():number{
        return this.leftLamps.length + 1;
    }

    /**
     * インデックスを指定してタイミングライト消灯
     * @param index インデックス
     */
    private timingLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on');
    }

    /**
     * インデックスを指定してタイミングライト点灯
     * @param index インデックス
     */
    private timingLightOn(index:number){
        // 前回のライトを消灯
        this.timingLightOffByIndex(this.lightOnIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'light-on');

        // インデックスを保持
        this.lightOnIndex = index;
    }

    /**
     * インデックスを指定して、手を出した時の色のライトを消灯
     * @param index インデックス
     */
    private putLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'put-light');
    }

    /**
     * 現在点灯しているライトの色を、手を出したときの色にする
     */
    private putLightOn(){
        // 現在点灯しているランプの色を変える
        this.putLightOnByIndex(this.lightOnIndex);
    }

    putLightOff(){
        // 手を出したときのライトを消灯
        this.putLightOffByIndex(this.putIndex);
    }

    /**
     * インデックスを指定して、手を出したときの色にする
     * @param index インデックス
     */
    private putLightOnByIndex(index:number){
        // 前回のライト消灯
        this.putLightOffByIndex(this.putIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'put-light');

        // インデックスを保持
        this.putIndex = index;
    }

    /**
     * インデックスで指定したランプを取得
     * @param index インデックス
     * @returns ランプ配列
     */
    private getLamps(index:number): HTMLElement[]{
        if(index < 0 || index >= this.getLength()){
            return [];
        }

        const lamps:HTMLElement[] = [];

        if(index === this.getLength() - 1){
            lamps.push(this.centerLamp);
        }else{
            lamps.push(this.leftLamps[index]);
            lamps.push(this.rightLamps[index]);
        }

        return lamps;
    }
}

HandButtonArea.ts

import { appEventBus } from "./EventBus";
import { HandButton } from "./HandButton";
import { HandButtonsDefine, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン表示エリア
 */
export class HandButtonArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** グー・チョキ・パーボタン */
    private handButtons:HandButton[] = [];

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.className = 'hand-buttons';
        
        // グーチョキパーボタンを作成
        Object.values(HandButtonsDefine).forEach(value => {
            if(value.id !== HandKind.None){
                // ボタン作成
                const handButton = new HandButton(value);

                // 格納場所に追加
                this.element.appendChild(handButton.getElement());

                // ボタンを保持
                this.handButtons.push(handButton);
            }
        });

        // ハンドボタン表示イベントハンドラー設定
        this.eventBus.subscribe('show-hand-buttons', () => {
            this.element.classList.remove('hidden');
        });

        // ハンドボタン非表示イベントハンドラー設定
        this.eventBus.subscribe('hide-hand-buttons', () => {
            this.element.classList.add('hidden');
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement():HTMLElement{
        return this.element;
    }
}

HandButton.ts

import { appEventBus } from "./EventBus";
import type { HandButtonOption, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン
 */
export class HandButton{
    /** 種別 */
    private kind: HandKind;
    /** ルート要素 */
    private element: HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;


    /**
     * コンストラクタ
     * @param buttonDefine ボタン定義
     */
    constructor(buttonDefine: HandButtonOption){
        // ボタン要素を作成
        this.element = document.createElement('button');
        // idを設定
        this.element.id = buttonDefine.id;
        // classを設定
        this.element.className = "hand-button";
        // 表示名を設定
        this.element.textContent = buttonDefine.name;
        // 種別を設定
        this.kind = buttonDefine.id;
        // クリックイベント設定
        this.element.addEventListener('click', () => {
            this.eventBus.publish('hand-Button-Clicked', this.kind);
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement(): HTMLElement {
        return this.element;
    }

}

JankenRule.ts

import { GameResult, HandButtonsDefine, HandKind } from "./types";
import { randRange } from "./utils";

/**
 * ジャンケンのルール
 */
export class JankenRule{

    /**
     * 手をランダムに取得
     * @returns グー or チョキ or パー
     */
    static getRandomHand(): HandKind {
        const hands = Object.values(HandKind);
        const randomIndex = randRange(Object.keys(HandButtonsDefine).indexOf(HandKind.Rock), Object.keys(HandButtonsDefine).indexOf(HandKind.Paper));
        return hands[randomIndex];
    }

    /**
     * 勝てる手を取得
     * @param hand HandKind
     * @returns HandKind
     */
    static getWindHand(hand: HandKind): HandKind{
        if(hand == HandKind.Rock){
            return HandKind.Paper;
        }else if(hand == HandKind.Scissors){
            return HandKind.Rock;
        }else{
            return HandKind.Scissors;
        }
    }

    /**
     * 自分と相手の手から勝敗を判定
     * @param playerHand 自分の手 
     * @param computerHand 相手の手
     * @returns 勝敗
     */
    static 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;
        }
    }
}

types.ts

/** グーチョキパーボタンのオプション */
export interface HandButtonOption {
    id: HandKind;
    name: string;
}

/** グーチョキーパー種別 */
export const HandKind = {
    None: "none",
    Rock: "rock",
    Scissors: "scissors",
    Paper: "paper"
} as const

export type HandKind = typeof HandKind[keyof typeof HandKind];

/** グーチョキパーボタン定義 */
export const HandButtonsDefine: Record<HandKind, HandButtonOption> = {
    [HandKind.None]: {id:HandKind.None, name:""},
    [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 const TimingSpeed = {
    First: 'first',
    Normal: 'normal',
    Slow: 'slow'
} as const;

export type TimingSpeed = typeof TimingSpeed[keyof typeof TimingSpeed];

/** タイミングスピード名 */
export const TimingSpeedNames: Record<TimingSpeed, string> = {
    [TimingSpeed.First]: '速い',
    [TimingSpeed.Normal]: '普通',
    [TimingSpeed.Slow]: '遅い',
} as const;

/** フェーズ定義 */
export const BattlePhase ={
    INIT: -1,
    SAI: 0,
    SYOHA: 1,
    GUU: 2,
    EMPTY1: 3,
    JAN: 4,
    KEN: 5,
    PON: 6,
    EMPTY2: 7,
    AI: 8,
    KODE: 9,
    SYO: 10,
    EMPTY3: 11,
} as const

export type BattlePhase = typeof BattlePhase[keyof typeof BattlePhase];

/** 手を出したタイミング */
export interface PutTiming{
    /** フェーズ */
    phase:number;
    /** 点灯ライト */
    lightIndex:number;
}

/** 結果表示イベントの引数 */
export interface SetResultEvent{
    reason:string;
    result:GameResult;
}

/** イベント一覧 */
export interface EventMap {
    'set-my-hand': HandKind;
    'set-compute-hand': HandKind;
    'set-result': SetResultEvent;
    'battle-started': undefined;
    'battle-ended': undefined;
    'phase-changed': number;
    'hand-Button-Clicked': HandKind;
    'show-hand-buttons': undefined;
    'hide-hand-buttons': undefined;
    'change-lighting-pattern': TimingSpeed;
    'timing-light-on': number;
    'put-light-on': undefined;
    'put-light-index': number;

    'debug-log': string;
}

utils.ts

/**
 * 乱数取得
 * @param min 最低値 
 * @param max 最大値
 * @returns min以上max以下の乱数
 */
export const randRange = (min: number, max: number): number => {
    return (Math.floor(Math.random() * (max - min + 1)) + min);
}

style.css

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; /* borderとpaddingをwidth/heightに含める */

  /* 子要素を縦に並べるためのFlexbox設定 */
  display: flex;
  flex-direction: column;
}

#timingbar {
  width: 100%; /* 親要素(#battle-area)いっぱいに広げる */
  padding: 10px;
  background-color: #222; /* バーの背景色 */
  box-sizing: border-box;

  /* 子要素(ランプ)を横に並べるためのFlexbox設定 */
  display: flex;
  align-items: center; /* ランプを垂直方向中央に揃える */
  gap: 8px; /* ランプ間のすき間を8pxに設定 */
}

.lamp {
  /* flex-grow: 1;, flex-shrink: 1;, flex-basis: 0%; */
  flex: 1; 
  height: 30px; /* 高さ */  
  background-color: #ffc10777; /* ランプの色 */
  border: 1px solid #e0a800; /* ランプの枠線 */
}

.lamp.light-on {
  background-color: hsl(59, 100%, 66%); /* ランプの色 */
  border: 1px solid #e0a800; /* ランプの枠線 */
}

.lamp.light-on.put-light {
  background-color: #00ff08;
}

.lamp.put-light {
  background-color: #00ff08;
}

/* --- 中央のランプだけ色を変える(例) --- */
.center-lamp {
  flex: 1; 
  height: 30px; /* 高さ */  
  background-color: #dc354697; /* 赤色 */
  border-color: #b02a37;
}

#center-lamp.light-on {
  background-color: #ff4242; /* 赤色 */
  border-color: #b02a37;
}

#center-lamp.light-on.put-light {
  background-color: #00ff08;
}

#center-lamp.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%;          /* 親要素の上から50%の位置に配置 */
  left: 50%;         /* 親要素の左から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;
}

じゃんけんゲーム Ver.2.3

※記事のタイトルをクリックして、個別でページを開かないと、正しく動作しません。

前バージョン

organize.hatenablog.jp

変更点

  • あいこの場合、勝負が続くようにしました。
  • 掛け声のフェーズは数字で管理してたけど、定数にして少しだけわかりやすくしました。

次のバージョン

organize.hatenablog.jp

クラス図

前バージョンから変更なし

index.html

<!doctype 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>

JankenGame.ts

import { GameController } from "./GameController";
import { GameScreen } from "./GameScreen";

/**
 * ジャンケンゲームのメインクラス
 */
class JankenGame {
    /**
     * コンストラクタ
     * @param parent ジャンケンゲームの親要素
     */
    constructor(parent: HTMLElement){
        // ゲーム画面
        new GameScreen(parent);

        // ゲームコントローラー
        new GameController();
    }    
}

// app要素を取得
const appElement = document.querySelector('#app') as HTMLElement;
// ジャンケンゲーム作成
new JankenGame(appElement);

GameController.ts

import { appEventBus } from "./EventBus";
import { JankenRule } from "./JankenRule";
import { TimingBar } from "./TimingBar";
import { BattlePhase, GameResult, HandKind, type PutTiming, type SetResultEvent } from "./types";
import { randRange } from "./utils";

/**
 * ゲームコントローラー
 */
export class GameController{
    /** フェーズ */
    private phase:number = BattlePhase.INIT;
    /** イベントバス */
    private eventBus = appEventBus;
    /** 自分の手 */
    private myHand:HandKind = HandKind.None;
    /** 相手の手 */
    private computeHand:HandKind = HandKind.None;
    /** タイマーID */
    private intervalId:number = 0;
    /** 点灯ランプインデックス */
    private lightOnIndex:number = 0;
    /** 手を出したか */
    private isPutHand:boolean = false;
    /** 手を出したタイミング */
    private putTiming:PutTiming;
    
    /**
     * コンストラクタ
     */
    constructor(){
        // 手を出したタイミング初期化
        this.putTiming = {lightIndex:-1, phase:BattlePhase.INIT};

        // ゲームスタートイベントハンドラー設定
        this.eventBus.subscribe('battle-started', () => {
            this.startShoot(randRange(30, 50))
        });

        // ハンドボタンクリックイベントハンドラー設定
        this.eventBus.subscribe('hand-Button-Clicked', (handKind) => {
            // 出した手を設定
            this.clickHandButton(handKind);
        });
    }

    /**
     * ジャンケン開始
     * @param interval ランプ点灯間隔
     */
    private startShoot(interval:number){
        // すでに開始していたら何もしない
        if(this.intervalId !== 0){
            return;
        }

        // 初期化
        this.myHand = HandKind.None;
        this.computeHand = HandKind.None;
        this.lightOnIndex = 0;
        this.phase = BattlePhase.INIT;
        this.isPutHand = false;
        this.putTiming.phase = BattlePhase.INIT;
        this.putTiming.lightIndex = -1;

        // タイマー開始
        this.intervalId = setInterval(() => {
            // ライトを点灯
            this.eventBus.publish('timing-light-on', this.lightOnIndex);

            // 点灯ライトのインデックスを加算
            this.lightOnIndex++;

            // 中央ランプが点灯した場合
            if(this.lightOnIndex >= TimingBar.PHASE_COUNT){
                // フェーズを進める
                this.phase++;
                
                // 点灯ライトのインデックスを初期化
                this.lightOnIndex = 0;

                // フェーズ変更イベント発行
                this.eventBus.publish('phase-changed', this.phase);

                if(this.phase === BattlePhase.JAN){
                    // 自分の手をクリア
                    this.myHand = HandKind.None;
                    this.putTiming.phase = BattlePhase.INIT;
                    this.putTiming.lightIndex = -1;
                }else if(this.phase === BattlePhase.PON){
                    // 相手の手を出す
                    this.putComputeHand();
                }else if(this.phase === BattlePhase.EMPTY2){
                    const result = this.getResult();
                    if(result.result !== GameResult.Draw){
                        // ジャンケン終了
                        this.stopShoot();
                        // 結果表示
                        this.setResult(result);
                    }else{
                        // あいこなので手を出せるようにする
                        this.resetHandButton();
                    }
                }else if(this.phase === BattlePhase.SYO){
                    // 相手の手を出す
                    this.putComputeHand();
                }else if(this.phase === BattlePhase.EMPTY3){
                    const result = this.getResult();
                    if(result.result !== GameResult.Draw){
                        // ジャンケン終了
                        this.stopShoot();
                        // 結果表示
                        this.setResult(result);
                    }else{
                        // あいこなのでフェーズを戻す
                        this.phase = BattlePhase.EMPTY2; 
                        // 手を出せるようにする
                        this.resetHandButton();
                    }
                }
            }

        }, interval);
    }

    /**
     * ジャンケン終了
     */
    private stopShoot(){
        clearInterval(this.intervalId);
        this.intervalId = 0;
        // バトル終了イベント発行
        this.eventBus.publish('battle-ended', undefined);
    }

    /**
     * ハンドボタンクリックハンドラー
     * @param handKind 手の種類
     */
    private clickHandButton(handKind:HandKind){
        if(this.isPutHand){
            return;
        }

        // 自分の手を保持
        this.myHand = handKind;

        // 手を出したタイミングを保持
        this.putTiming.phase = this.phase;
        this.putTiming.lightIndex = this.lightOnIndex - 1;

        // 自分の手を表示
        this.eventBus.publish('set-my-hand', handKind);
    
        // 点灯しているランプの色を変更
        this.eventBus.publish('put-light-on', undefined);

        // 「けん」また「こで」以降に手を出したら、もう次の手は出せない
        if((this.phase >= BattlePhase.KEN && this.phase <= BattlePhase.PON) ||
           (this.phase >= BattlePhase.KODE && this.phase <= BattlePhase.SYO)){
            this.isPutHand = true;
            // ハンドボタンを非表示
            this.eventBus.publish('hide-hand-buttons', undefined);
        }
    }

    /**
     * あいこの時に手を出せるようにする
     */
    private resetHandButton(){
        this.myHand = HandKind.None;
        this.computeHand = HandKind.None;

        this.isPutHand = false;
        this.putTiming.phase = BattlePhase.INIT;
        this.putTiming.lightIndex = -1;
        
        this.eventBus.publish('show-hand-buttons', undefined);
    }

    /**
     * 相手の手を出す
     */
    private putComputeHand(){
        // ランダムで手を取得
        this.computeHand = JankenRule.getRandomHand();

        // 「ぽん」のタイミングより早く、自分が手を出していたら
        if(this.myHand !== HandKind.None){
            // 勝てる手を取得
            this.computeHand = JankenRule.getWindHand(this.myHand);
        }

        // 相手の手を表示
        this.eventBus.publish('set-compute-hand', this.computeHand);
    }

    private getResult(){
        const retResult:SetResultEvent = {reason:"", result:GameResult.Draw};

        console.log(this.putTiming)

        // ハンドボタンが押されていない
        if(this.myHand === HandKind.None){
            // 負けを表示
            retResult.reason = "手を出してないので";
            retResult.result = GameResult.Lose;
        }else{
            // 勝敗を取得
            const result = JankenRule.getResult(this.myHand, this.computeHand);
            retResult.result = result;

            // 出すタイミングが遅かった場合
            if((this.putTiming.phase == BattlePhase.PON && this.putTiming.lightIndex >= 0) ||
               (this.putTiming.phase == BattlePhase.SYO && this.putTiming.lightIndex >= 0)){
                retResult.reason = '後出しなので';
                retResult.result = GameResult.Lose;
            }else if((this.putTiming.phase >= BattlePhase.JAN && this.putTiming.phase <= BattlePhase.KEN) ||
                     (this.putTiming.phase >= BattlePhase.AI && this.putTiming.phase <= BattlePhase.KODE)){
                // 出すタイミングが早かった場合
                retResult.reason = '出すのが速すぎて';
                retResult.result = GameResult.Lose;
            }
        }

        return retResult;
    }

    /**
     * 結果表示
     */
    private setResult(result:SetResultEvent){
        // 結果表示イベント発行
        this.eventBus.publish('set-result', result);
    }
}

GameScreen.ts

import { BattleArea } from "./BattleArea";
import { appEventBus } from "./EventBus";
import { HandButtonArea } from "./HandButtonArea";
import { Header } from "./Header";

/**
 * ゲーム画面
 */
export class GameScreen{
    /** イベントバス */
    private eventBus = appEventBus;
    /** ヘッダー */
    private header: Header;
    /** バトルエリア */
    private battleArea: BattleArea;
    /** スタートボタン */
    private startButton: HTMLButtonElement;
    /** グーチョキパーボタン */
    private handButtonArea: HandButtonArea;

    constructor(parent: HTMLElement){
        // ヘッダー
        this.header = new Header();
        parent.appendChild(this.header.getElement());

        // バトルエリア
        this.battleArea = new BattleArea();
        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.textClear();
            // すべてのランプを消灯
            this.battleArea.lightOffAll();
            // 開始イベント発行
            this.eventBus.publish('battle-started', undefined);
        })
        parent.appendChild(this.startButton);

        // グー・チョキ・パーボタン
        this.handButtonArea = new HandButtonArea();
        parent.appendChild(this.handButtonArea.getElement());

        // 準備状態にする
        this.setLady();

        // ジャンケン終了イベントハンドラー設定
        this.eventBus.subscribe('battle-ended', () => {
            this.setLady();
        });
    }

    /**
     * バトル準備
     */
    private setLady(){
        // ハンドボタンエリアを非表示
        this.handButtonArea.getElement().classList.add('hidden');
        // スタートボタンを表示
        this.startButton.classList.remove('hidden');
    }
}

Header.ts

/**
 * タイトル・説明を表示
 */
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 { appEventBus } from "./EventBus";
import { TimingBar } from "./TimingBar";
import { BattlePhase, GameResult, HandButtonsDefine, HandKind, ResultNames } from "./types";

/**
 * バトルエリア
 */
export class BattleArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** タイミングバー */
    private timingbar:TimingBar;
    /** 掛け声エリア */
    private verbalCueArea:HTMLElement;
    /** 掛け声 */
    private verbalCue:HTMLElement;
    /** 勝敗結果 */
    private result:HTMLElement;
    /** 自分の手 */
    private myHand:HTMLElement;
    /** 相手の手 */
    private computeHand:HTMLElement;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'battle-area';

        // タイミングバー
        this.timingbar = new TimingBar();
        this.element.appendChild(this.timingbar.getElement());

        // ハンドエリア
        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.eventBus.subscribe('set-compute-hand', (handKind) => {
            this.setComputeHand(handKind);
        });

        // 自分の手を表示
        this.eventBus.subscribe('set-my-hand', (handKind) => {
            this.setMyHand(handKind);
        });

        // 結果表示
        this.eventBus.subscribe('set-result', (data) => {
            this.setResult(data.reason, data.result);
        });

        // フェーズ変更イベントハンドラー設定
        this.eventBus.subscribe('phase-changed', (phase) => {
            // フェーズに合わせて掛け声を表示
            if(phase === BattlePhase.SAI){
                this.setVerbalCue("さい");
            }else if(phase === BattlePhase.SYOHA){
                this.setVerbalCue("しょは");
            }else if(phase === BattlePhase.GUU){
                this.setVerbalCue("グー");
                this.setComputeHand(HandKind.Rock);
            }else if(phase === BattlePhase.EMPTY1){
                this.setVerbalCue("")
            }else if(phase === BattlePhase.JAN){
                this.textClear();
                this.setVerbalCue("じゃん")
            }else if(phase === BattlePhase.KEN){
                this.setVerbalCue("けん")
            }else if(phase === BattlePhase.PON){
                this.setVerbalCue("ぽん")
            }else if(phase === BattlePhase.EMPTY2){
                // 何もしない
            }else if(phase === BattlePhase.AI){
                this.textClear();
                this.setVerbalCue('あい');
            }else if(phase === BattlePhase.KODE){
                this.setVerbalCue('こで');
            }else if(phase === BattlePhase.SYO){
                this.setVerbalCue('しょ');
            }
        });
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement(): HTMLElement{
        return this.element;
    }

    /**
     * 自分の手を表示
     * @param hand 自分の手
     */
    private setMyHand(hand:HandKind){
        this.myHand.textContent = HandButtonsDefine[hand].name;
    }

    /**
     * 結果を表示
     * @param reson 理由 
     * @param result 勝敗
     */
    private setResult(reson:string, result:GameResult){
        this.verbalCue.textContent = reson;
        this.result.textContent = ResultNames[result];
        this.setResultColor(result);
    }

    /**
     * 勝敗に合わせた文字色を設定
     * @param result 勝敗
     */
    private setResultColor(result:GameResult){
        for(const gameResult of Object.values(GameResult)){
            if(result === gameResult){
                this.result.classList.add(result);
            }else{
                this.result.classList.remove(gameResult);
            }
        }
    }

    /**
     * タイミングバーをすべて消灯
     */
    lightOffAll(){
        this.timingbar.lightOffAll();
    }

    /**
     * 表示テキストをクリア
     */
    textClear(){
        this.myHand.textContent = "";
        this.computeHand.textContent = "";
        this.verbalCue.textContent = "";
        this.result.textContent = "";
    }

    /**
     * 掛け声表示
     * @param text 掛け声 
     */
    private setVerbalCue(text:string){
        this.verbalCue.textContent = text;
    }

    /**
     * 相手の手を表示
     * @param hand 相手の手
     */
    private setComputeHand(hand:HandKind){
        this.computeHand.textContent = HandButtonsDefine[hand].name;
    }
}

TimingBar.ts

import { appEventBus } from "./EventBus";

/**
 * タイミングバー
 */
export class TimingBar{
    /** 左右ランプのカウント数 */
    private static readonly LAMP_COUNT = 12;
    /** 1フェーズのカウント数 */
    public static readonly PHASE_COUNT = TimingBar.LAMP_COUNT + 1;
    /** ランプの総数 */
    private static readonly TOTAL_LAMP_COUNT = TimingBar.LAMP_COUNT * 2 + 1;

    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** 中央ランプ */
    private centerLamp!:HTMLElement;
    /** 左側ランプ */
    private leftLamps:HTMLElement[] = [];
    /** 右側ランプ */
    private rightLamps:HTMLElement[] = [];
    /** 点灯ランプ */
    private lightOnIndex:number = -1;
    /** 手を出したタイミング */
    private putIndex:number = -1;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'timingbar';

        // 左に12個、右に12個、中央1個
        for(let index = 0; index < TimingBar.TOTAL_LAMP_COUNT; index++){
            const lamp = document.createElement('span');
            lamp.classList.add('lamp');
            if(index === TimingBar.LAMP_COUNT){
                // 中央
                lamp.classList.remove('lamp')
                lamp.classList.add('center-lamp')
                lamp.id = 'center-lamp';
                this.centerLamp = lamp;
            }else if(index < TimingBar.LAMP_COUNT){
                // 左側
                lamp.id = 'left-lamp' + (index + 1);
                this.leftLamps.push(lamp);
            }else{
                // 右側
                lamp.id = 'rignt-lamp' + (TimingBar.TOTAL_LAMP_COUNT- index);
                this.rightLamps.unshift(lamp);
            }

            this.element.appendChild(lamp)
        }

        // タイミングライト点灯イベントハンドラー設定
        this.eventBus.subscribe('timing-light-on', (index) => {
            this.timingLightOn(index);
        });  
        
        // 手を出したときのライト色変更イベントハンドラー設定
        this.eventBus.subscribe('put-light-on', () => {
            this.putLightOn();
        });
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement():HTMLElement{
        return this.element;
    }

    /**
     * ライト点灯
     * @param lamps ライト 
     * @param tokens クラス名
     */
    private lightOn(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.add(...tokens);
        }
    }
    
    /**
     * ライト消灯
     * @param lamps ライト
     * @param tokens クラス名
     */
    private lightOff(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.remove(...tokens);
        }
    }


    /**
     * インデックスを指定してライトを消灯
     * @param index インデックス
     */
    private lightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on', 'put-light');
    }

    /**
     *  すべてのライトを消灯
     */
    lightOffAll(){
        for(let index = 0; index < this.getLength(); index++){
            this.lightOffByIndex(index);
        }

        // インデックスを初期化
        this.lightOnIndex = -1;
        this.putIndex = -1;
    }

    /**
     * ライトの数
     * @returns ライトの数
     */
    private getLength():number{
        return this.leftLamps.length + 1;
    }

    /**
     * インデックスを指定してタイミングライト消灯
     * @param index インデックス
     */
    private timingLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on');
    }

    /**
     * インデックスを指定してタイミングライト点灯
     * @param index インデックス
     */
    private timingLightOn(index:number){
        // 前回のライトを消灯
        this.timingLightOffByIndex(this.lightOnIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'light-on');

        // インデックスを保持
        this.lightOnIndex = index;
    }

    /**
     * インデックスを指定して、手を出した時の色のライトを消灯
     * @param index インデックス
     */
    private putLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'put-light');
    }

    /**
     * 現在点灯しているライトの色を、手を出したときの色にする
     */
    private putLightOn(){
        // 現在点灯しているランプの色を変える
        this.putLightOnByIndex(this.lightOnIndex);
    }

    /**
     * インデックスを指定して、手を出したときの色にする
     * @param index インデックス
     */
    private putLightOnByIndex(index:number){
        // 前回のライト消灯
        this.putLightOffByIndex(this.putIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'put-light');

        // インデックスを保持
        this.putIndex = index;
    }

    /**
     * インデックスで指定したランプを取得
     * @param index インデックス
     * @returns ランプ配列
     */
    private getLamps(index:number): HTMLElement[]{
        if(index < 0 || index >= this.getLength()){
            return [];
        }

        const lamps:HTMLElement[] = [];

        if(index === this.getLength() - 1){
            lamps.push(this.centerLamp);
        }else{
            lamps.push(this.leftLamps[index]);
            lamps.push(this.rightLamps[index]);
        }

        return lamps;
    }
}

HandButtonArea.ts

import { appEventBus } from "./EventBus";
import { HandButton } from "./HandButton";
import { HandButtonsDefine, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン表示エリア
 */
export class HandButtonArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** グー・チョキ・パーボタン */
    private handButtons:HandButton[] = [];

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.className = 'hand-buttons';
        
        // グーチョキパーボタンを作成
        Object.values(HandButtonsDefine).forEach(value => {
            if(value.id !== HandKind.None){
                // ボタン作成
                const handButton = new HandButton(value);

                // 格納場所に追加
                this.element.appendChild(handButton.getElement());

                // ボタンを保持
                this.handButtons.push(handButton);
            }
        });

        // ハンドボタン表示イベントハンドラー設定
        this.eventBus.subscribe('show-hand-buttons', () => {
            this.element.classList.remove('hidden');
        });

        // ハンドボタン非表示イベントハンドラー設定
        this.eventBus.subscribe('hide-hand-buttons', () => {
            this.element.classList.add('hidden');
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement():HTMLElement{
        return this.element;
    }
}

HandButton.ts

import { appEventBus } from "./EventBus";
import type { HandButtonOption, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン
 */
export class HandButton{
    /** 種別 */
    private kind: HandKind;
    /** ルート要素 */
    private element: HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;


    /**
     * コンストラクタ
     * @param buttonDefine ボタン定義
     */
    constructor(buttonDefine: HandButtonOption){
        // ボタン要素を作成
        this.element = document.createElement('button');
        // idを設定
        this.element.id = buttonDefine.id;
        // classを設定
        this.element.className = "hand-button";
        // 表示名を設定
        this.element.textContent = buttonDefine.name;
        // 種別を設定
        this.kind = buttonDefine.id;
        // クリックイベント設定
        this.element.addEventListener('click', () => {
            this.eventBus.publish('hand-Button-Clicked', this.kind);
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement(): HTMLElement {
        return this.element;
    }

}

EventBus.ts

import type { EventMap } from "./types";

type Callback<T> = (payload: T) => void;

/**
 * Pub/Subパターンの中核となるイベントバス
 */
export class EventBus {
  /** イベント購読者マップ */
  private listeners: { [K in keyof EventMap]?: Callback<EventMap[K]>[] } = {};

  /**
   * イベントを購読(Subscribe)する
   * @param eventName イベント名
   * @param callback イベント発生時に実行されるコールバック関数
   */
  subscribe<K extends keyof EventMap>(
    eventName: K,
    callback: Callback<EventMap[K]>
  ): void {
    if (!this.listeners[eventName]) {
      this.listeners[eventName] = [];
    }
    this.listeners[eventName]!.push(callback);
  }

  /**
   * イベントを発行(Publish)する
   * @param eventName イベント名
   * @param payload イベントで渡すデータ
   */
  publish<K extends keyof EventMap>(
    eventName: K,
    payload: EventMap[K]
  ): void {
    if (!this.listeners[eventName]) {
      console.log(`[EventBus] No subscribers for event: "${eventName}"`);
      return;
    }
    
    this.listeners[eventName]!.forEach((callback) => {
      try {
        callback(payload);
      } catch (error) {
        console.error(`[EventBus] Error in subscriber for event "${eventName}":`, error);
      }
    });
  }
}

// アプリケーション全体で一つのインスタンスを共有する
export const appEventBus = new EventBus();

JankenRule.ts

import { GameResult, HandButtonsDefine, HandKind } from "./types";
import { randRange } from "./utils";

/**
 * ジャンケンのルール
 */
export class JankenRule{

    /**
     * 手をランダムに取得
     * @returns グー or チョキ or パー
     */
    static getRandomHand(): HandKind {
        const hands = Object.values(HandKind);
        const randomIndex = randRange(Object.keys(HandButtonsDefine).indexOf(HandKind.Rock), Object.keys(HandButtonsDefine).indexOf(HandKind.Paper));
        return hands[randomIndex];
    }

    /**
     * 勝てる手を取得
     * @param hand HandKind
     * @returns HandKind
     */
    static getWindHand(hand: HandKind): HandKind{
        if(hand == HandKind.Rock){
            return HandKind.Paper;
        }else if(hand == HandKind.Scissors){
            return HandKind.Rock;
        }else{
            return HandKind.Scissors;
        }
    }

    /**
     * 自分と相手の手から勝敗を判定
     * @param playerHand 自分の手 
     * @param computerHand 相手の手
     * @returns 勝敗
     */
    static 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;
        }
    }
}

utils.ts

/**
 * 乱数取得
 * @param min 最低値 
 * @param max 最大値
 * @returns min以上max以下の乱数
 */
export const randRange = (min: number, max: number): number => {
    return (Math.floor(Math.random() * (max - min + 1)) + min);
}

types.ts

/** グーチョキパーボタンのオプション */
export interface HandButtonOption {
    id: HandKind;
    name: string;
}

/** グーチョキーパー種別 */
export const HandKind = {
    None: "none",
    Rock: "rock",
    Scissors: "scissors",
    Paper: "paper"
} as const

export type HandKind = typeof HandKind[keyof typeof HandKind];

/** グーチョキパーボタン定義 */
export const HandButtonsDefine: Record<HandKind, HandButtonOption> = {
    [HandKind.None]: {id:HandKind.None, name:""},
    [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 const BattlePhase ={
    INIT: -1,
    SAI: 0,
    SYOHA: 1,
    GUU: 2,
    EMPTY1: 3,
    JAN: 4,
    KEN: 5,
    PON: 6,
    EMPTY2: 7,
    AI: 8,
    KODE: 9,
    SYO: 10,
    EMPTY3: 11,
} as const

export type BattlePhase = typeof BattlePhase[keyof typeof BattlePhase];

/** 手を出したタイミング */
export interface PutTiming{
    /** フェーズ */
    phase:number;
    /** 点灯ライト */
    lightIndex:number;
}

/** 結果表示イベントの引数 */
export interface SetResultEvent{
    reason:string;
    result:GameResult;
}

/** イベント一覧 */
export interface EventMap {
    'set-my-hand': HandKind;
    'set-compute-hand': HandKind;
    'set-result': SetResultEvent;
    'battle-started': undefined;
    'battle-ended': undefined;
    'phase-changed': number;
    'hand-Button-Clicked': HandKind;
    'show-hand-buttons': undefined;
    'hide-hand-buttons': undefined;
    'timing-light-on': number;
    'put-light-on': undefined;
}

style.css

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; /* borderとpaddingをwidth/heightに含める */

  /* 子要素を縦に並べるためのFlexbox設定 */
  display: flex;
  flex-direction: column;
}

#timingbar {
  width: 100%; /* 親要素(#battle-area)いっぱいに広げる */
  padding: 10px;
  background-color: #222; /* バーの背景色 */
  box-sizing: border-box;

  /* 子要素(ランプ)を横に並べるためのFlexbox設定 */
  display: flex;
  align-items: center; /* ランプを垂直方向中央に揃える */
  gap: 8px; /* ランプ間のすき間を8pxに設定 */
}

.lamp {
  /* flex-grow: 1;, flex-shrink: 1;, flex-basis: 0%; */
  flex: 1; 
  height: 30px; /* 高さ */  
  background-color: #ffc10777; /* ランプの色 */
  border: 1px solid #e0a800; /* ランプの枠線 */
}

.lamp.light-on {
  background-color: hsl(59, 100%, 66%); /* ランプの色 */
  border: 1px solid #e0a800; /* ランプの枠線 */
}

.lamp.light-on.put-light {
  background-color: #00ff08;
}

.lamp.put-light {
  background-color: #00ff08;
}

/* --- 中央のランプだけ色を変える(例) --- */
.center-lamp {
  flex: 1; 
  height: 30px; /* 高さ */  
  background-color: #dc354697; /* 赤色 */
  border-color: #b02a37;
}

#center-lamp.light-on {
  background-color: #ff4242; /* 赤色 */
  border-color: #b02a37;
}

#center-lamp.light-on.put-light {
  background-color: #00ff08;
}

#center-lamp.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%;          /* 親要素の上から50%の位置に配置 */
  left: 50%;         /* 親要素の左から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;
}

じゃんけんゲーム Ver.2.2

※記事のタイトルをクリックして、個別でページを開かないと、正しく動作しません。

前バージョン

organize.hatenablog.jp

変更点

  • EventBusクラスを作成して、イベントの発行と受け取りで、処理をやり取りするようにした。

感想

  • AIさん曰く、イベント駆動にするとより疎結合になるとのこと。
  • イベントが増えると管理が大変になる気がする。
  • EventBusクラスでどういうイベントの一覧を定義しておくというのもアリなのかな?

次バージョン

organize.hatenablog.jp

クラス図

index.html

<!doctype 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>

JankenGame.ts

import { GameController } from "./GameController";
import { GameScreen } from "./GameScreen";

/**
 * ジャンケンゲームのメインクラス
 */
class JankenGame {
    /**
     * コンストラクタ
     * @param parent ジャンケンゲームの親要素
     */
    constructor(parent: HTMLElement){
        // ゲーム画面
        new GameScreen(parent);

        // ゲームコントローラー
        new GameController();
    }    
}

// app要素を取得
const appElement = document.querySelector('#app') as HTMLElement;
// ジャンケンゲーム作成
new JankenGame(appElement);

EventBus.ts

type Callback<T> = (payload: T) => void;

/**
 * Pub/Subパターンの中核となるイベントバス
 */
export class EventBus {
  private listeners: Map<string, Callback<any>[]> = new Map();

  /**
   * イベントを購読(Subscribe)する
   * @param eventName イベント名
   * @param callback イベント発生時に実行されるコールバック関数
   */
  subscribe<T>(eventName: string, callback: Callback<T>): void {
    if (!this.listeners.has(eventName)) {
      this.listeners.set(eventName, []);
    }
    this.listeners.get(eventName)!.push(callback);
    console.log(`[EventBus] SUBSCRIBED: "${eventName}"`);
  }

  /**
   * イベントを発行(Publish)する
   * @param eventName イベント名
   * @param payload イベントで渡すデータ
   */
  publish<T>(eventName: string, payload: T): void {
    if (!this.listeners.has(eventName)) {
      console.log(`[EventBus] No subscribers for event: "${eventName}"`);
      return;
    }
    
    console.log(`[EventBus] PUBLISHING: "${eventName}" with payload:`, payload);
    this.listeners.get(eventName)!.forEach((callback) => {
      try {
        callback(payload);
      } catch (error) {
        console.error(`[EventBus] Error in subscriber for event "${eventName}":`, error);
      }
    });
  }
}

// アプリケーション全体で一つのインスタンスを共有する
export const appEventBus = new EventBus();

GameController.ts

import { appEventBus } from "./EventBus";
import { JankenRule } from "./JankenRule";
import { TimingBar } from "./TimingBar";
import { GameResult, HandKind, type PutTiming, type SetResultEvent } from "./types";

/**
 * ゲームコントローラー
 */
export class GameController{
    /** フェーズ */
    private phase:number = -1;
    /** イベントバス */
    private eventBus = appEventBus;
    /** 自分の手 */
    private myHand:HandKind | null = null;
    /** 相手の手 */
    private computeHand:HandKind | null = null;
    /** タイマーID */
    private intervalId:number = 0;
    /** 点灯ランプインデックス */
    private lightOnIndex:number = 0;
    /** 手を出したか */
    private isPutHand:boolean = false;
    /** 手を出したタイミング */
    private putTiming:PutTiming;
    
    /**
     * コンストラクタ
     */
    constructor(){
        // 手を出したタイミング初期化
        this.putTiming = {lightIndex:-1, phase:-1};

        // ゲームスタートイベントハンドラー設定
        this.eventBus.subscribe<void>('game-started', () => {
            this.startShoot(this.rand(30, 50))
        });

        // ハンドボタンクリックイベントハンドラー設定
        this.eventBus.subscribe<HandKind>('hand-Button-Clicked', (handKind) => {
            // 手を出せるかどうか判定
            if(!this.canPutHand()){
                return;
            }

            // 出した手を設定
            this.clickHandButton(handKind);

            // 次の手を出せるか判定
            if(!this.canPutHand()){
                // 出せない場合はハンドボタンを非表示
                this.eventBus.publish<void>('hide-hand-buttons', undefined);
            }
        });
    }

    /**
     * ジャンケン開始
     * @param interval ランプ点灯間隔
     */
    private startShoot(interval:number){
        // すでに開始していたら何もしない
        if(this.intervalId !== 0){
            return;
        }

        // 初期化
        this.myHand = null;
        this.computeHand = null;
        this.lightOnIndex = 0;
        this.phase = 0;
        this.isPutHand = false;
        this.putTiming.phase = 0;
        this.putTiming.lightIndex = -1;

        // タイマー開始
        this.intervalId = setInterval(() => {
            // ライトを点灯
            this.eventBus.publish<number>('timing-light-on', this.lightOnIndex);

            // 点灯ライトのインデックスを加算
            this.lightOnIndex++;

            // 中央ランプが点灯した場合
            if(this.lightOnIndex >= TimingBar.PHASE_COUNT){
                // 点灯ライトのインデックスを初期化
                this.lightOnIndex = 0;

                // フェーズ変更イベント発行
                this.eventBus.publish<number>('phase-changed', this.phase);

                if(this.phase === 4){
                    // 自分の手をクリア
                    this.myHand = null;
                }
                else if(this.phase === 6){
                    // 相手の手を出す
                    this.putComputeHand();
                }else if(this.phase === 7){
                    // ジャンケン終了
                    this.stopShoot();
                    // 結果表示
                    this.setResult();
                }

                // フェーズを進める
                this.phase++;
            }

        }, interval);
    }

    /**
     * ジャンケン終了
     */
    private stopShoot(){
        clearInterval(this.intervalId);
        this.intervalId = 0;
    }

    /**
     * ハンドボタンクリックハンドラー
     * @param handKind 手の種類
     */
    private clickHandButton(handKind:HandKind){
        if(this.isPutHand){
            return;
        }

        // 自分の手を保持
        this.myHand = handKind;

        // 自分の手を表示
        this.eventBus.publish<HandKind>('set-my-hand', handKind);
    
        // 点灯しているランプの色を変更
        this.eventBus.publish<void>('put-light-on', undefined);

        // 「けん」以降に手を出したら、もう次の手は出せない
        if(this.phase >= 6 && this.phase <= 7){
            this.isPutHand = true;
            this.putTiming.phase = this.phase;
            this.putTiming.lightIndex = this.lightOnIndex - 1;
        }
    }

    /**
     * 相手の手を出す
     */
    private putComputeHand(){
        // ランダムで手を取得
        this.computeHand = JankenRule.getRandomHand();

        // 「ぽん」のタイミングより早く、自分が手を出していたら
        if(this.myHand !== null){
            // 勝てる手を取得
            this.computeHand = JankenRule.getWindHand(this.myHand);
        }

        // 相手の手を表示
        this.eventBus.publish<HandKind>('set-compute-hand', this.computeHand);
    }

    /**
     * 結果表示
     */
    private setResult(){
        if(!this.computeHand){
            throw new Error("相手が手を出さないと勝敗を判定できません");
        }

        console.log(this.putTiming)

        // ハンドボタンが押されていない
        if(!this.myHand){
            // 負けを表示
            this.eventBus.publish<SetResultEvent>('set-result', {reason:"手を出さなかったので", result:GameResult.Lose});
        }else{
            // 勝敗を取得
            const result = JankenRule.getResult(this.myHand, this.computeHand);
            // 勝敗を表示
            this.eventBus.publish<SetResultEvent>('set-result', {reason:"", result:result});

            // 出すタイミングが遅かった場合
            if(this.putTiming.phase >= 7 && this.putTiming.lightIndex >= 0){
                this.eventBus.publish<SetResultEvent>('set-result', {reason:'後出しなので', result:GameResult.Lose});
            }else if(this.putTiming.phase <= 6){
                // 出すタイミングが早かった場合
                this.eventBus.publish<SetResultEvent>('set-result', {reason:'出すのが速すぎて', result:GameResult.Lose});
            }
        }

        // バトル終了イベント発行
        this.eventBus.publish<void>('battle-ended', undefined);
    }

    /**
     * 手を出せるかどうか
     * @returns 手を出せるかどうか
     */
    private canPutHand():boolean{
        if(this.phase < 0 || this.phase > 7){
            return false;
        }

        if(this.isPutHand){
            return false;
        }
        return true;
    }

    /**
     * 乱数取得
     * @param min 最低値 
     * @param max 最大値
     * @returns 範囲内の乱数
     */
    private rand(min: number, max: number): number {
        return (Math.floor(Math.random() * (max - min + 1)) + min);
    }
}

GameScreen.ts

import { BattleArea } from "./BattleArea";
import { appEventBus } from "./EventBus";
import { HandButtonArea } from "./HandButtonArea";
import { Header } from "./Header";

/**
 * ゲーム画面
 */
export class GameScreen{
    /** イベントバス */
    private eventBus = appEventBus;
    /** ヘッダー */
    private header: Header;
    /** バトルエリア */
    private battleArea: BattleArea;
    /** スタートボタン */
    private startButton: HTMLButtonElement;
    /** グーチョキパーボタン */
    private handButtonArea: HandButtonArea;

    constructor(parent: HTMLElement){
        // ヘッダー
        this.header = new Header();
        parent.appendChild(this.header.getElement());

        // バトルエリア
        this.battleArea = new BattleArea();
        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.textClear();
            // すべてのランプを消灯
            this.battleArea.lightOffAll();
            // 開始イベント発行
            this.eventBus.publish<void>('game-started', undefined);
        })
        parent.appendChild(this.startButton);

        // グー・チョキ・パーボタン
        this.handButtonArea = new HandButtonArea();
        parent.appendChild(this.handButtonArea.getElement());

        // 準備状態にする
        this.setLady();

        // ジャンケン終了イベントハンドラー設定
        this.eventBus.subscribe<void>('battle-ended', () => {
            this.setLady();
        });
    }

    /**
     * バトル準備
     */
    private setLady(){
        // ハンドボタンエリアを非表示
        this.handButtonArea.getElement().classList.add('hidden');
        // スタートボタンを表示
        this.startButton.classList.remove('hidden');
    }
}

Header.ts

/**
 * タイトル・説明を表示
 */
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 { appEventBus } from "./EventBus";
import { TimingBar } from "./TimingBar";
import { GameResult, HandButtonsDefine, HandKind, ResultNames, type SetResultEvent } from "./types";

/**
 * バトルエリア
 */
export class BattleArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** タイミングバー */
    private timingbar:TimingBar;
    /** 掛け声エリア */
    private verbalCueArea:HTMLElement;
    /** 掛け声 */
    private verbalCue:HTMLElement;
    /** 勝敗結果 */
    private result:HTMLElement;
    /** 自分の手 */
    private myHand:HTMLElement;
    /** 相手の手 */
    private computeHand:HTMLElement;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'battle-area';

        // タイミングバー
        this.timingbar = new TimingBar();
        this.element.appendChild(this.timingbar.getElement());

        // ハンドエリア
        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.eventBus.subscribe<HandKind>('set-compute-hand', (handKind) => {
            this.setComputeHand(handKind);
        });

        // 自分の手を表示
        this.eventBus.subscribe<HandKind>('set-my-hand', (handKind) => {
            this.setMyHand(handKind);
        });

        // 結果表示
        this.eventBus.subscribe<SetResultEvent>('set-result', (data) => {
            this.setResult(data.reason, data.result);
        });

        // フェーズ変更イベントハンドラー設定
        this.eventBus.subscribe<number>('phase-changed', (phase) => {
            // フェーズに合わせて掛け声を表示
            if(phase === 0){
                this.setVerbalCue("さい");
            }else if(phase === 1){
                this.setVerbalCue("しょは");
            }else if(phase === 2){
                this.setVerbalCue("グー");
                this.setComputeHand(HandKind.Rock);
            }else if(phase === 3){
                this.setVerbalCue("")
            }else if(phase === 4){
                this.textClear();
                this.setVerbalCue("じゃん")
            }else if(phase === 5){
                this.setVerbalCue("けん")
            }else if(phase === 6){
                this.setVerbalCue("ぽん")
            }else if(phase === 7){
                // ジャンケン終了
            }
        });
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement(): HTMLElement{
        return this.element;
    }

    /**
     * 自分の手を表示
     * @param hand 自分の手
     */
    private setMyHand(hand:HandKind){
        this.myHand.textContent = HandButtonsDefine[hand].name;
    }

    /**
     * 結果を表示
     * @param reson 理由 
     * @param result 勝敗
     */
    private setResult(reson:string, result:GameResult){
        this.verbalCue.textContent = reson;
        this.result.textContent = ResultNames[result];
        this.setResultColor(result);
    }

    /**
     * 勝敗に合わせた文字色を設定
     * @param result 勝敗
     */
    private setResultColor(result:GameResult){
        for(const gameResult of Object.values(GameResult)){
            if(result === gameResult){
                this.result.classList.add(result);
            }else{
                this.result.classList.remove(gameResult);
            }
        }
    }

    /**
     * タイミングバーをすべて消灯
     */
    lightOffAll(){
        this.timingbar.lightOffAll();
    }

    /**
     * 表示テキストをクリア
     */
    textClear(){
        this.myHand.textContent = "";
        this.computeHand.textContent = "";
        this.verbalCue.textContent = "";
        this.result.textContent = "";
    }

    /**
     * 掛け声表示
     * @param text 掛け声 
     */
    private setVerbalCue(text:string){
        this.verbalCue.textContent = text;
    }

    /**
     * 相手の手を表示
     * @param hand 相手の手
     */
    private setComputeHand(hand:HandKind){
        this.computeHand.textContent = HandButtonsDefine[hand].name;
    }
}

TimingBar.ts

import { appEventBus } from "./EventBus";

/**
 * タイミングバー
 */
export class TimingBar{
    /** 左右ランプのカウント数 */
    private static readonly LAMP_COUNT = 12;
    /** 1フェーズのカウント数 */
    public static readonly PHASE_COUNT = TimingBar.LAMP_COUNT + 1;
    /** ランプの総数 */
    private static readonly TOTAL_LAMP_COUNT = TimingBar.LAMP_COUNT * 2 + 1;

    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** 中央ランプ */
    private centerLamp!:HTMLElement;
    /** 左側ランプ */
    private leftLamps:HTMLElement[] = [];
    /** 右側ランプ */
    private rightLamps:HTMLElement[] = [];
    /** 点灯ランプ */
    private lightOnIndex:number = -1;
    /** 手を出したタイミング */
    private putIndex:number = -1;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'timingbar';

        // 左に12個、右に12個、中央1個
        for(let index = 0; index < TimingBar.TOTAL_LAMP_COUNT; index++){
            const lamp = document.createElement('span');
            lamp.classList.add('lamp');
            if(index === TimingBar.LAMP_COUNT){
                // 中央
                lamp.classList.remove('lamp')
                lamp.classList.add('center-lamp')
                lamp.id = 'center-lamp';
                this.centerLamp = lamp;
            }else if(index < TimingBar.LAMP_COUNT){
                // 左側
                lamp.id = 'left-lamp' + (index + 1);
                this.leftLamps.push(lamp);
            }else{
                // 右側
                lamp.id = 'rignt-lamp' + (TimingBar.TOTAL_LAMP_COUNT- index);
                this.rightLamps.unshift(lamp);
            }

            this.element.appendChild(lamp)
        }

        // タイミングライト点灯イベントハンドラー設定
        this.eventBus.subscribe<number>('timing-light-on', (index) => {
            this.timingLightOn(index);
        });  
        
        // 手を出したときのライト色変更イベントハンドラー設定
        this.eventBus.subscribe<void>('put-light-on', () => {
            this.putLightOn();
        });
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement():HTMLElement{
        return this.element;
    }

    /**
     * ライト点灯
     * @param lamps ライト 
     * @param tokens クラス名
     */
    private lightOn(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.add(...tokens);
        }
    }
    
    /**
     * ライト消灯
     * @param lamps ライト
     * @param tokens クラス名
     */
    private lightOff(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.remove(...tokens);
        }
    }


    /**
     * インデックスを指定してライトを消灯
     * @param index インデックス
     */
    private lightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on', 'put-light');
    }

    /**
     *  すべてのライトを消灯
     */
    lightOffAll(){
        for(let index = 0; index < this.getLength(); index++){
            this.lightOffByIndex(index);
        }

        // インデックスを初期化
        this.lightOnIndex = -1;
        this.putIndex = -1;
    }

    /**
     * ライトの数
     * @returns ライトの数
     */
    private getLength():number{
        return this.leftLamps.length + 1;
    }

    /**
     * インデックスを指定してタイミングライト消灯
     * @param index インデックス
     */
    private timingLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on');
    }

    /**
     * インデックスを指定してタイミングライト点灯
     * @param index インデックス
     */
    private timingLightOn(index:number){
        // 前回のライトを消灯
        this.timingLightOffByIndex(this.lightOnIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'light-on');

        // インデックスを保持
        this.lightOnIndex = index;
    }

    /**
     * インデックスを指定して、手を出した時の色のライトを消灯
     * @param index インデックス
     */
    private putLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'put-light');
    }

    /**
     * 現在点灯しているライトの色を、手を出したときの色にする
     */
    private putLightOn(){
        // 現在点灯しているランプの色を変える
        this.putLightOnByIndex(this.lightOnIndex);
    }

    /**
     * インデックスを指定して、手を出したときの色にする
     * @param index インデックス
     */
    private putLightOnByIndex(index:number){
        // 前回のライト消灯
        this.putLightOffByIndex(this.putIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'put-light');

        // インデックスを保持
        this.putIndex = index;
    }

    /**
     * インデックスで指定したランプを取得
     * @param index インデックス
     * @returns ランプ配列
     */
    private getLamps(index:number): HTMLElement[]{
        if(index < 0 || index >= this.getLength()){
            return [];
        }

        const lamps:HTMLElement[] = [];

        if(index === this.getLength() - 1){
            lamps.push(this.centerLamp);
        }else{
            lamps.push(this.leftLamps[index]);
            lamps.push(this.rightLamps[index]);
        }

        return lamps;
    }
}

HandButtonArea.ts

import { appEventBus } from "./EventBus";
import { HandButton } from "./HandButton";
import { HandButtonsDefine } from "./types";

/**
 * グー・チョキ・パーボタン表示エリア
 */
export class HandButtonArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;
    /** グー・チョキ・パーボタン */
    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);
        });

        // ハンドボタン非表示イベントハンドラー設定
        this.eventBus.subscribe<void>('hide-hand-buttons', () => {
            this.element.classList.add('hidden');
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement():HTMLElement{
        return this.element;
    }
}

HandButton.ts

import { appEventBus } from "./EventBus";
import type { HandButtonOption, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン
 */
export class HandButton{
    /** 種別 */
    private kind: HandKind;
    /** ルート要素 */
    private element: HTMLElement;
    /** イベントバス */
    private eventBus = appEventBus;


    /**
     * コンストラクタ
     * @param buttonDefine ボタン定義
     */
    constructor(buttonDefine: HandButtonOption){
        // ボタン要素を作成
        this.element = document.createElement('button');
        // idを設定
        this.element.id = buttonDefine.id;
        // classを設定
        this.element.className = "hand-button";
        // 表示名を設定
        this.element.textContent = buttonDefine.name;
        // 種別を設定
        this.kind = buttonDefine.id;
        // クリックイベント設定
        this.element.addEventListener('click', () => {
            this.eventBus.publish<HandKind>('hand-Button-Clicked', this.kind);
        });
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement(): HTMLElement {
        return this.element;
    }

}

JankenRule.ts

import { GameResult, HandKind } from "./types";

/**
 * ジャンケンのルール
 */
export class JankenRule{

    /**
     * 手をランダムに取得
     * @returns グー or チョキ or パー
     */
    static getRandomHand(): HandKind {
        const hands = Object.values(HandKind);
        const randomIndex = Math.floor(Math.random() * hands.length);
        return hands[randomIndex];
    }

    /**
     * 勝てる手を取得
     * @param hand HandKind
     * @returns HandKind
     */
    static getWindHand(hand: HandKind): HandKind{
        if(hand == HandKind.Rock){
            return HandKind.Paper;
        }else if(hand == HandKind.Scissors){
            return HandKind.Rock;
        }else{
            return HandKind.Scissors;
        }
    }

    /**
     * 自分と相手の手から勝敗を判定
     * @param playerHand 自分の手 
     * @param computerHand 相手の手
     * @returns 勝敗
     */
    static 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;
        }
    }
}

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;
}

/** 結果表示イベントの引数 */
export interface SetResultEvent{
    reason:string;
    result:GameResult;
}

style.css

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; /* borderとpaddingをwidth/heightに含める */

  /* 子要素を縦に並べるためのFlexbox設定 */
  display: flex;
  flex-direction: column;
}

#timingbar {
  width: 100%; /* 親要素(#battle-area)いっぱいに広げる */
  padding: 10px;
  background-color: #222; /* バーの背景色 */
  box-sizing: border-box;

  /* 子要素(ランプ)を横に並べるためのFlexbox設定 */
  display: flex;
  align-items: center; /* ランプを垂直方向中央に揃える */
  gap: 8px; /* ランプ間のすき間を8pxに設定 */
}

.lamp {
  /* flex-grow: 1;, flex-shrink: 1;, flex-basis: 0%; */
  flex: 1; 
  height: 30px; /* 高さ */  
  background-color: #ffc10777; /* ランプの色 */
  border: 1px solid #e0a800; /* ランプの枠線 */
}

.lamp.light-on {
  background-color: hsl(59, 100%, 66%); /* ランプの色 */
  border: 1px solid #e0a800; /* ランプの枠線 */
}

.lamp.light-on.put-light {
  background-color: #00ff08;
}

.lamp.put-light {
  background-color: #00ff08;
}

/* --- 中央のランプだけ色を変える(例) --- */
.center-lamp {
  flex: 1; 
  height: 30px; /* 高さ */  
  background-color: #dc354697; /* 赤色 */
  border-color: #b02a37;
}

#center-lamp.light-on {
  background-color: #ff4242; /* 赤色 */
  border-color: #b02a37;
}

#center-lamp.light-on.put-light {
  background-color: #00ff08;
}

#center-lamp.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%;          /* 親要素の上から50%の位置に配置 */
  left: 50%;         /* 親要素の左から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;
}

じゃんけんゲーム Ver.2.1

※記事のタイトルをクリックして、個別でページを開かないと、正しく動作しません。

前バージョン

organize.hatenablog.jp

変更点

  • 肥大していたBattleAreaクラスからTimingBarを独立。
  • 画面とロジックを切り分けた方が良さそうだったので、GameControllerとGameScreenを作成。BattleAreaにあったロジック部分をGameControllerに持っていく。

感想

  • GameControllerがBattleAreaを参照しちゃっているので、本当はGameScreenだけ参照するようにしようと思ったが、そうなるとGameScreenに色々とBattleAreaを操作する処理を追加しないといけなくなるので、どうしたもんかなと悩んでいたところ、AIさんが「Pub/Subパターンで作るがよろし」と言ったので、次はそれで作ろうと思います。
  • ようはGameControllerとGameScreenの間に仲介するクラスを置くんだとか。まあ、よくわかってないけどとりあえず進めてみようと思います。

次のバージョン

organize.hatenablog.jp

クラス図

index.html

<!doctype 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>

JankenGame.ts

import { GameController } from "./GameController";
import { GameScreen } from "./GameScreen";

/**
 * ジャンケンゲームのメインクラス
 */
class JankenGame {
    /** ゲーム画面 */
    private gameScreen:GameScreen;

    /**
     * コンストラクタ
     * @param parent ジャンケンゲームの親要素
     */
    constructor(parent: HTMLElement){
        // ゲーム画面
        this.gameScreen = new GameScreen(parent);

        // ゲームコントローラー
        new GameController(this.gameScreen);
    }    
}

// app要素を取得
const appElement = document.querySelector('#app') as HTMLElement;
// ジャンケンゲーム作成
new JankenGame(appElement);

GameController.ts

import { BattleArea } from "./BattleArea";
import type { GameScreen } from "./GameScreen";
import { JankenRule } from "./JankenRule";
import { GameResult, HandKind, type PutTiming } from "./types";

export class GameController{
    /** フェーズ */
    private phase:number = -1;
    /** ゲーム画面 */
    private gameScreen:GameScreen;
    /** 戦闘エリア */
    private battleArea:BattleArea;
    /** 自分の手 */
    private myHand:HandKind | null = null;
    /** 相手の手 */
    private computeHand:HandKind | null = null;
    /** タイマーID */
    private intervalId:number = 0;
    /** 点灯ランプインデックス */
    private lightOnIndex:number = 0;
    /** 手を出したか */
    private isPutHand:boolean = false;
    /** 手を出したタイミング */
    private putTiming:PutTiming;
    
    constructor(gameScreen:GameScreen){
        this.gameScreen = gameScreen;
        this.battleArea = this.gameScreen.getBattleArea();
        this.putTiming = {lightIndex:-1, phase:-1};
        this.gameScreen.setStartButtonClickHandler(() => {
            this.startShoot(this.rand(30, 50))
        });
        this.gameScreen.setHandButtonClickHandler(this);
    }

    /**
     * ジャンケン開始
     * @param interval ランプ点灯間隔
     */
    startShoot(interval:number){
        // すでに開始していたら何もしない
        if(this.intervalId !== 0){
            return;
        }

        // すべてのランプを消灯
        this.battleArea.lightOffAll();

        // 表示テキスト初期化
        this.battleArea.textClear();

        this.myHand = null;
        this.computeHand = null;
        this.lightOnIndex = 0;
        this.phase = 0;
        this.isPutHand = false;
        this.putTiming.phase = 0;
        this.putTiming.lightIndex = -1;

        this.intervalId = setInterval(() => {
            // ライトを点灯
            this.battleArea.timingLightOn(this.lightOnIndex);

            // 点灯ライトのインデックスを加算
            this.lightOnIndex++;

            // 先頭ランプを点灯した場合
            if(this.lightOnIndex >= this.battleArea.getLampLength()){
                // 点灯ライトのインデックスを初期化
                this.lightOnIndex = 0;

                // フェーズに合わせて掛け声を表示
                if(this.phase === 0){
                    this.battleArea.setVerbalCue("さい");
                }else if(this.phase === 1){
                    this.battleArea.setVerbalCue("しょは");
                }else if(this.phase === 2){
                    this.battleArea.setVerbalCue("グー");
                    this.battleArea.setComputeHand(HandKind.Rock);
                }else if(this.phase === 3){
                    this.battleArea.setVerbalCue("")
                }else if(this.phase === 4){
                    this.myHand = null;
                    this.battleArea.textClear();
                    this.battleArea.setVerbalCue("じゃん")
                }else if(this.phase === 5){
                    this.battleArea.setVerbalCue("けん")
                }else if(this.phase === 6){
                    this.battleArea.setVerbalCue("ぽん")
                    // 相手の手を出す
                    this.putComputeHand();
                }else if(this.phase === 7){
                    // ジャンケン終了
                    this.stopShoot();
                    // 結果表示
                    this.setResult();
                }

                // フェーズを進める
                this.phase++;
            }

        }, interval);
    }

    /**
     * ジャンケン終了
     */
    stopShoot(){
        clearInterval(this.intervalId);
        this.intervalId = 0;
    }

    /**
     * ハンドボタンクリックハンドラー
     * @param handKind 手の種類
     */
    clickHandButton(handKind:HandKind){
        if(this.isPutHand){
            return;
        }

        this.myHand = handKind;

        // 自分の手を表示
        this.battleArea.setMyHand(handKind);
    
        // 点灯しているランプの色を変更
        this.battleArea.putLightOn();

        // 「けん」以降に手を出したら、もう次の手は出せない
        if(this.phase >= 6 && this.phase <= 7){
            this.isPutHand = true;
            this.putTiming.phase = this.phase;
            this.putTiming.lightIndex = this.lightOnIndex - 1;
        }
    }

    /**
     * 相手の手を出す
     */
    putComputeHand(){
        // ランダムで手を取得
        this.computeHand = JankenRule.getRandomHand();

        // 「ぽん」のタイミングより早く、自分が手を出していたら
        if(this.myHand !== null){
            // 勝てる手を取得
            this.computeHand = JankenRule.getWindHand(this.myHand);
        }

        // 相手の手を表示
        this.battleArea.setComputeHand(this.computeHand);
    }

    /**
     * 結果表示
     */
    setResult(){
        if(!this.computeHand){
            throw new Error("相手が手を出さないと勝敗を判定できません");
        }

        console.log(this.putTiming)

        // 掛け声を削除
        this.battleArea.setVerbalCue("");

        // ハンドボタンが押されていない
        if(!this.myHand){
            // 
            this.battleArea.setResult("", GameResult.Lose);
        }else{
            // 勝敗を取得
            const result = JankenRule.getResult(this.myHand, this.computeHand);
            // 勝敗を表示
            this.battleArea.setResult("", result);

            // 出すタイミングが遅かった場合
            if(this.putTiming.phase >= 7 && this.putTiming.lightIndex >= 0){
                this.battleArea.setResult('後出しなので', GameResult.Lose);
            }else if(this.putTiming.phase <= 6){
                // 出すタイミングが早かった場合
                this.battleArea.setResult('出すのが速すぎて', GameResult.Lose);
            }
        }

        // スタートボタンを表示
        this.gameScreen.setLady();
    }

    /**
     * 手を出せるかどうか
     * @returns 手を出せるかどうか
     */
    canPutHand():boolean{
        if(this.phase < 0 || this.phase > 7){
            return false;
        }

        if(this.isPutHand){
            return false;
        }
        return true;
    }

    /**
     * 乱数取得
     * @param min 最低値 
     * @param max 最大値
     * @returns 範囲内の乱数
     */
    private rand(min: number, max: number): number {
        return (Math.floor(Math.random() * (max - min + 1)) + min);
    }
}

GameScreen.ts

import { BattleArea } from "./BattleArea";
import type { GameController } from "./GameController";
import { HandButtonArea } from "./HandButtonArea";
import { Header } from "./Header";

export class GameScreen{
    /** ヘッダー */
    private header: Header;
    /** バトルエリア */
    private battleArea: BattleArea;
    /** スタートボタン */
    private startButton: HTMLButtonElement;
    /** グーチョキパーボタン */
    private handButtonArea: HandButtonArea;

    constructor(parent: HTMLElement){
        // ヘッダー
        this.header = new Header();
        parent.appendChild(this.header.getElement());

        // バトルエリア
        this.battleArea = new BattleArea();
        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');
        })
        parent.appendChild(this.startButton);

        // グー・チョキ・パーボタン
        this.handButtonArea = new HandButtonArea();
        parent.appendChild(this.handButtonArea.getElement());

        // 準備状態にする
        this.setLady();
    }

    /**
     * バトル準備
     */
    setLady(){
        // ハンドボタンエリアを非表示
        this.handButtonArea.getElement().classList.add('hidden');
        // スタートボタンを表示
        this.startButton.classList.remove('hidden');
    }

    getBattleArea():BattleArea{
        return this.battleArea;
    }

    setStartButtonClickHandler(func:EventListener){
        this.startButton.addEventListener('click', func);
    }

    hideHandbutton(){
        this.handButtonArea.getElement().classList.add('hidden');
    }

    setHandButtonClickHandler(gameController:GameController){
        for(const handButton of this.handButtonArea.getButtons()){
            // 押されたときの処理を設定
            handButton.getElement().addEventListener('click', () => {
                // 手を出せるかどうか判定
                if(!gameController.canPutHand()){
                    return;
                }

                // 出した手を設定
                gameController.clickHandButton(handButton.getKind());

                // 次の手を出せるか判定
                if(!gameController.canPutHand()){
                    // 出せない場合はハンドボタンを非表示
                    this.handButtonArea.getElement().classList.add('hidden');
                }
            });
        }
    }
}

Header.ts

/**
 * タイトル・説明を表示
 */
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 { TimingBar } from "./TimingBar";
import { GameResult, HandButtonsDefine, HandKind, ResultNames } from "./types";

/**
 * バトルエリア
 */
export class BattleArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** タイミングバー */
    private timingbar:TimingBar;
    /** 掛け声エリア */
    private verbalCueArea:HTMLElement;
    /** 掛け声 */
    private verbalCue:HTMLElement;
    /** 勝敗結果 */
    private result:HTMLElement;
    /** 自分の手 */
    private myHand:HTMLElement;
    /** 相手の手 */
    private computeHand:HTMLElement;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'battle-area';

        // タイミングバー
        this.timingbar = new TimingBar();
        this.element.appendChild(this.timingbar.getElement());

        // ハンドエリア
        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);
        
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement(): HTMLElement{
        return this.element;
    }

    /**
     * 自分の手を表示
     * @param hand 自分の手
     */
    setMyHand(hand:HandKind){
        this.myHand.textContent = HandButtonsDefine[hand].name;
    }

    /**
     * 結果を表示
     * @param reson 理由 
     * @param result 勝敗
     */
    setResult(reson:string, result:GameResult){
        this.verbalCue.textContent = reson;
        this.result.textContent = ResultNames[result];
        this.setResultColor(result);
    }

    /**
     * 勝敗に合わせた文字色を設定
     * @param result 勝敗
     */
    setResultColor(result:GameResult){
        for(const gameResult of Object.values(GameResult)){
            if(result === gameResult){
                this.result.classList.add(result);
            }else{
                this.result.classList.remove(gameResult);
            }
        }
    }

    /**
     * タイミングバーをすべて消灯
     */
    lightOffAll(){
        this.timingbar.lightOffAll();
    }

    /**
     * 表示テキストをクリア
     */
    textClear(){
        this.myHand.textContent = "";
        this.computeHand.textContent = "";
        this.verbalCue.textContent = "";
        this.result.textContent = "";
    }

    /**
     * ランプの数
     * @returns ランプの数
     */
    getLampLength():number{
        return this.timingbar.getLength();
    }

    /**
     * 掛け声表示
     * @param text 掛け声 
     */
    setVerbalCue(text:string){
        this.verbalCue.textContent = text;
    }

    /**
     * 相手の手を表示
     * @param hand 相手の手
     */
    setComputeHand(hand:HandKind){
        this.computeHand.textContent = HandButtonsDefine[hand].name;
    }

    /**
     * 手を出したときの色で点灯
     */
    putLightOn(){
        this.timingbar.putLightOn();
    }

    /**
     * タイミングライトを点灯
     * @param index 点灯させるライトのインデックス
     */
    timingLightOn(index:number){
        this.timingbar.timingLightOn(index);
    }
}

TimingBar.ts

export class TimingBar{
    /** ルート要素 */
    private element:HTMLElement;
    /** 中央ランプ */
    private centerLamp!:HTMLElement;
    /** 左側ランプ */
    private leftLamps:HTMLElement[] = [];
    /** 右側ランプ */
    private rightLamps:HTMLElement[] = [];
    /** 点灯ランプ */
    private lightOnIndex:number = -1;
    /** 手を出したタイミング */
    private putIndex:number = -1;

    /**
     * コンストラクタ
     */
    constructor(){
        // ルート要素
        this.element = document.createElement('div');
        this.element.id = 'timingbar';

        // 左に12個、右に12個、中央1個
        for(let index = 0; index < 25; index++){
            const lamp = document.createElement('span');
            lamp.classList.add('lamp');
            if(index === 12){
                // 中央
                lamp.classList.remove('lamp')
                lamp.classList.add('center-lamp')
                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);
            }

            this.element.appendChild(lamp)
        }
    }

    /**
     * 要素取得
     * @returns 要素 
     */
    getElement():HTMLElement{
        return this.element;
    }

    /**
     * ライト点灯
     * @param lamps ライト 
     * @param tokens クラス名
     */
    private lightOn(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.add(...tokens);
        }
    }
    
    /**
     * ライト消灯
     * @param lamps ライト
     * @param tokens クラス名
     */
    private lightOff(lamps:HTMLElement[], ...tokens: string[]){
        for(const lamp of lamps){
            lamp.classList.remove(...tokens);
        }
    }


    /**
     * インデックスを指定してライトを消灯
     * @param index インデックス
     */
    private lightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on', 'put-light');
    }

    /**
     *  すべてのライトを消灯
     */
    lightOffAll(){
        for(let index = 0; index < this.getLength(); index++){
            this.lightOffByIndex(index);
        }

        // インデックスを初期化
        this.lightOnIndex = -1;
        this.putIndex = -1;
    }

    /**
     * ライトの数
     * @returns ライトの数
     */
    getLength():number{
        return this.leftLamps.length + 1;
    }

    /**
     * インデックスを指定してタイミングライト消灯
     * @param index インデックス
     */
    private timingLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'light-on');
    }

    /**
     * インデックスを指定してタイミングライト点灯
     * @param index インデックス
     */
    timingLightOn(index:number){
        // 前回のライトを消灯
        this.timingLightOffByIndex(this.lightOnIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'light-on');

        // インデックスを保持
        this.lightOnIndex = index;
    }

    /**
     * インデックスを指定して、手を出した時の色のライトを消灯
     * @param index インデックス
     */
    private putLightOffByIndex(index:number){
        // ライト取得
        const lamps = this.getLamps(index);
        // 消灯
        this.lightOff(lamps, 'put-light');
    }

    /**
     * 現在点灯しているライトの色を、手を出したときの色にする
     */
    putLightOn(){
        // 現在点灯しているランプの色を変える
        this.putLightOnByIndex(this.lightOnIndex);
    }

    /**
     * インデックスを指定して、手を出したときの色にする
     * @param index インデックス
     */
    private putLightOnByIndex(index:number){
        // 前回のライト消灯
        this.putLightOffByIndex(this.putIndex);

        // ライト取得
        const lamps = this.getLamps(index);
        // 点灯
        this.lightOn(lamps, 'put-light');

        // インデックスを保持
        this.putIndex = index;
    }

    /**
     * インデックスで指定したランプを取得
     * @param index インデックス
     * @returns ランプ配列
     */
    private getLamps(index:number): HTMLElement[]{
        if(index < 0 || index >= this.getLength()){
            return [];
        }

        const lamps:HTMLElement[] = [];

        if(index === this.getLength() - 1){
            lamps.push(this.centerLamp);
        }else{
            lamps.push(this.leftLamps[index]);
            lamps.push(this.rightLamps[index]);
        }

        return lamps;
    }
}

HandButtonArea.ts

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;
    }
}

HandButton.ts

import type { HandButtonOption, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン
 */
export class HandButton{
    /** 種別 */
    private kind: HandKind;
    /** ルート要素 */
    private element: HTMLElement;

    /**
     * コンストラクタ
     * @param buttonDefine ボタン定義
     */
    constructor(buttonDefine: HandButtonOption){
        // ボタン要素を作成
        this.element = document.createElement('button');
        // idを設定
        this.element.id = buttonDefine.id;
        // classを設定
        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;
    }

}

JankenRule.ts

import { GameResult, HandKind } from "./types";

export class JankenRule{

    /**
     * 手をランダムに取得
     * @returns グー or チョキ or パー
     */
    static getRandomHand(): HandKind {
        const hands = Object.values(HandKind);
        const randomIndex = Math.floor(Math.random() * hands.length);
        return hands[randomIndex];
    }

    /**
     * 勝てる手を取得
     * @param hand HandKind
     * @returns HandKind
     */
    static getWindHand(hand: HandKind): HandKind{
        if(hand == HandKind.Rock){
            return HandKind.Paper;
        }else if(hand == HandKind.Scissors){
            return HandKind.Rock;
        }else{
            return HandKind.Scissors;
        }
    }

    /**
     * 自分と相手の手から勝敗を判定
     * @param playerHand 自分の手 
     * @param computerHand 相手の手
     * @returns 勝敗
     */
    static 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;
        }
    }
}

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;
}

style.css

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; /* borderとpaddingをwidth/heightに含める */

  /* 子要素を縦に並べるためのFlexbox設定 */
  display: flex;
  flex-direction: column;
}

#timingbar {
  width: 100%; /* 親要素(#battle-area)いっぱいに広げる */
  padding: 10px;
  background-color: #222; /* バーの背景色 */
  box-sizing: border-box;

  /* 子要素(ランプ)を横に並べるためのFlexbox設定 */
  display: flex;
  align-items: center; /* ランプを垂直方向中央に揃える */
  gap: 8px; /* ランプ間のすき間を8pxに設定 */
}

.lamp {
  /* flex-grow: 1;, flex-shrink: 1;, flex-basis: 0%; */
  flex: 1; 
  height: 30px; /* 高さ */  
  background-color: #ffc10777; /* ランプの色 */
  border: 1px solid #e0a800; /* ランプの枠線 */
}

.lamp.light-on {
  background-color: hsl(59, 100%, 66%); /* ランプの色 */
  border: 1px solid #e0a800; /* ランプの枠線 */
}

.lamp.light-on.put-light {
  background-color: #00ff08;
}

.lamp.put-light {
  background-color: #00ff08;
}

/* --- 中央のランプだけ色を変える(例) --- */
.center-lamp {
  flex: 1; 
  height: 30px; /* 高さ */  
  background-color: #dc354697; /* 赤色 */
  border-color: #b02a37;
}

#center-lamp.light-on {
  background-color: #ff4242; /* 赤色 */
  border-color: #b02a37;
}

#center-lamp.light-on.put-light {
  background-color: #00ff08;
}

#center-lamp.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%;          /* 親要素の上から50%の位置に配置 */
  left: 50%;         /* 親要素の左から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;
}

じゃんけんゲーム Ver2.0

※記事のタイトルをクリックして、個別でページを開かないと、正しく動作しません。

前バージョン

organize.hatenablog.jp

変更点

  • 掛け声に合わせて、タイミングよくボタンを押さないと、負けるようにしました。

感想

  • BattleAreaクラスが大きくなりすぎなので、もうちょっと整理して小さくしたいところ。
  • とにかく表示する文字をいい感じにするのが大変でした。
    • 出した手を、今は文字で表示していますが、画像にした方が制御しやすい気がします。
  • 要素のidに紐づけて、cssを定義しているところがありますが、idは要素の特定にだけ使って、見た目はclassだけで管理した方がいいですよとAIに言われたので、変更すると思います。
  • 結局types.tsはそのままです。どうするかまだ考えてません。
  • あいこの時は勝負を続けるようにしたいですね。
  • 勝負のログとかも見れるといいなと思いました。

次のバージョン

organize.hatenablog.jp

クラス図

index.html

<!doctype 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>

style.css

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; /* borderとpaddingをwidth/heightに含める */

  /* 子要素を縦に並べるためのFlexbox設定 */
  display: flex;
  flex-direction: column;
}

#timingbar {
  width: 100%; /* 親要素(#battle-area)いっぱいに広げる */
  padding: 10px;
  background-color: #222; /* バーの背景色 */
  box-sizing: border-box;

  /* 子要素(ランプ)を横に並べるためのFlexbox設定 */
  display: flex;
  align-items: center; /* ランプを垂直方向中央に揃える */
  gap: 8px; /* ランプ間のすき間を8pxに設定 */
}

.lamp {
  /* flex-grow: 1;, flex-shrink: 1;, flex-basis: 0%; */
  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%;          /* 親要素の上から50%の位置に配置 */
  left: 50%;         /* 親要素の左から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 parent ジャンケンゲームの親要素
     */
    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 min 最低値 
     * @param max 最大値
     * @returns 範囲内の乱数
     */
    private rand(min: number, max: number): number {
        return (Math.floor(Math.random() * (max - min + 1)) + min);
    }
}

// app要素を取得
const appElement = document.querySelector('#app') as HTMLElement;
// ジャンケンゲーム作成
new JankenGame(appElement);

Header.ts

/**
 * タイトル・説明を表示
 */
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;
    /** タイマーID */
    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);

        // 左に12個、右に12個、中央1個
        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 lamp ライト要素 
     */
    lightOn(lamp:HTMLElement){
        // 手を出した時のライトは消さない
        if(lamp.classList.contains("put-light")){
            return;
        }
        lamp.classList.remove('light-off');
        lamp.classList.add('light-on');
    }

    /**
     * ライト消灯
     * @param lamp ライト要素 
     */
    lightOff(lamp:HTMLElement){
        // 手を出した時のライトは消さない
        if(lamp.classList.contains("put-light")){
            return;
        }
        lamp.classList.remove('light-on');
        lamp.classList.add('light-off');
    }

    /**
     * インデックスを指定してライト点灯
     * @param index ライトのインデックス 
     */
    lightOnByIndex(index:number){
        this.lightOn(this.leftLamps[index]);
        this.lightOn(this.rightLamps[index]);
    }

    /**
     * インデックスを指定してライト消灯
     * @param index ライトのインデックス 
     */
    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 interval ランプ点灯間隔
     */
    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 hand 自分の手
     * @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;        
    }

    /**
     * 手の表示名からHandKindを取得
     * @param name 手の表示名
     * @returns HandKind
     */
    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 result 勝敗
     */
    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 グー or チョキ or パー
     */
    private getRandomHand(): HandKind {
        const hands = Object.values(HandKind);
        const randomIndex = Math.floor(Math.random() * hands.length);
        return hands[randomIndex];
    }

    /**
     * 勝てる手を取得
     * @param hand HandKind
     * @returns HandKind
     */
    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 playerHand 自分の手 
     * @param computerHand 相手の手
     * @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 handler ジャンケン終了時ハンドラ
     */
    setEndHandler(handler:Function){
        this.endHandler = handler;
    }
}

HandButtonArea.ts

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;
    }
}

HandButton.ts

import type { HandButtonOption, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン
 */
export class HandButton{
    /** 種別 */
    private kind: HandKind;
    /** ルート要素 */
    private element: HTMLElement;

    /**
     * コンストラクタ
     * @param buttonDefine ボタン定義
     */
    constructor(buttonDefine: HandButtonOption){
        // ボタン要素を作成
        this.element = document.createElement('button');
        // idを設定
        this.element.id = buttonDefine.id;
        // classを設定
        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;
}

ジャンケンゲーム Ver.1.1

※記事のタイトルをクリックして、個別でページを開かないと、正しく動作しません。

前のバージョン

organize.hatenablog.jp

変更点

  • メイン関数名を変更。Janken→JankenGame
  • 以下のクラスを新規作成。
    • タイトルや説明を表示するヘッダークラス。Header
    • グー・チョキ・パーボタンを管理するクラス。HandButtonArea
    • 結果を表示するクラス。ResultArea
  • innerHTMLに直接タグを記述していたが、createElementで要素を作成するように統一。
  • types.d.tsとしていたが、d.tsファイルはコンパイル時にオプション指定で作った方が良さそうな記事を見たので、types.tsに変更。*1(他にも色々d.tsファイルについて書いてあったけど、よくわからなかった)
  • enumはなるべく使わない方が良さそうなので、as constを使用したオブジェクト型に変更。*2

次バージョン

organize.hatenablog.jp

クラス図

index.html

<!doctype 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>

style.css

h1{
  text-align: center;
}

.explanation{
  padding: 2rem;
  text-align: center;
}

.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;
}

#result{
  text-align: center;
  margin-top: 2rem;
  margin-bottom: 2rem;
}

JankenGame.ts

import { HandButtonArea } from "./HandButtonArea";
import { Header } from "./Header";
import { ResultArea } from "./ResultArea";
import { HandKind, HandButtonsDefine, ResultNames, GameResult } from "./types";

/**
 * ジャンケンゲームのメインクラス
 */
class JankenGame {
    /** ヘッダー */
    private header: Header;
    /** グーチョキパーボタン */
    private handButtonArea: HandButtonArea;
    /** 結果表示エリア */
    private resultArea: ResultArea;

    /**
     * コンストラクタ
     * @param parent ジャンケンゲームの親要素
     */
    constructor(parent: HTMLElement){
        // ヘッダー
        this.header = new Header();
        parent.appendChild(this.header.getElement());

        // グー・チョキ・パーボタン
        this.handButtonArea = new HandButtonArea();
        for(const handButton of this.handButtonArea.getButtons()){
            // 押されたときの処理を設定
            handButton.getElement().addEventListener('click', () => {
                // 相手の手を取得
                const computerHand = this.getRandomHand();
                // 勝敗を取得
                const result = this.getResult(handButton.getKind(), computerHand);
                // 結果を表示
                this.resultArea.setResult(
                    HandButtonsDefine[handButton.getKind()].name,
                    HandButtonsDefine[computerHand].name,
                    ResultNames[result]
                );
            });
        }
        parent.appendChild(this.handButtonArea.getElement());

        // 結果
        this.resultArea = new ResultArea();
        parent.appendChild(this.resultArea.getElement());
    }

    /**
     * 手をランダムに取得
     * @returns グー or チョキ or パー
     */
    private getRandomHand(): HandKind {
        const hands = Object.values(HandKind);
        const randomIndex = Math.floor(Math.random() * hands.length);
        return hands[randomIndex];
    }

    /**
     * 自分と相手の手から勝敗を判定
     * @param playerHand 自分の手 
     * @param computerHand 相手の手
     * @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;
        }
    }
}

// app要素を取得
const appElement = document.querySelector('#app') as HTMLElement;
// ジャンケンゲーム作成
new JankenGame(appElement);

Header.ts

/**
 * タイトル・説明を表示
 */
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.textContent = '出したい手のボタンを押すと、じゃんけんの結果が表示されます。';
        this.element.appendChild(this.explanation);
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement():HTMLElement{
        return this.element;
    }
}

HandButtonArea.ts

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;
    }
}

HandButton.ts

import type { HandButtonOption, HandKind } from "./types";

/**
 * グー・チョキ・パーボタン
 */
export class HandButton{
    /** 種別 */
    private kind: HandKind;
    /** ルート要素 */
    private element: HTMLElement;

    /**
     * コンストラクタ
     * @param buttonDefine ボタン定義
     */
    constructor(buttonDefine: HandButtonOption){
        // ボタン要素を作成
        this.element = document.createElement('button');
        // idを設定
        this.element.id = buttonDefine.id;
        // classを設定
        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;
    }

}

ResultArea.ts

/**
 * 結果表示エリア
 */
export class ResultArea{
    /** ルート要素 */
    private element:HTMLElement;
    /** 自分の手要素 */
    private playerDiv:HTMLElement;
    /** 相手の手要素 */
    private computerDiv:HTMLElement;
    /** 勝敗要素 */
    private resultDiv:HTMLElement;

    constructor(){
        // 結果格納場所を作成
        this.element = document.createElement('div');
        // idを設定
        this.element.id = 'result';

        // 自分の手
        this.playerDiv = document.createElement('div');
        // 相手の手
        this.computerDiv = document.createElement('div');
        // 勝敗
        this.resultDiv = document.createElement('div');

        this.element.append(this.playerDiv, this.computerDiv, this.resultDiv);
    }

    /**
     * 要素取得
     * @returns 要素
     */
    getElement():HTMLElement{
        return this.element;
    }

    /**
     * 結果表示
     * @param myHand 自分の手 
     * @param computerHand 相手の手
     * @param result 勝敗
     */
    setResult(myHand:string, computerHand:string, result:string){
        this.playerDiv.textContent = `あなた: ${myHand}`;
        this.computerDiv.textContent = `相手: ${computerHand}`;
        this.resultDiv.textContent = `結果: ${result}`
    }

}

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

覚書

types.tsは別にいらないかも。

JankenGame.tsに書いとけばいいじゃね?と思ったりしています。

なんとなくtypescriptだとtypes.d.tsみたいなファイルが出てくるなと思ったから、定義関係を別ファイルにしたけど、別ファイルにする必要はないのかなと思っています。

また、新しく分けたクラスのコンストラクタに親要素を引数で渡すというのも考えたのですが、今はJankenGameの方でappendするようにしています。

コンストラクタの引数に何も渡さない場合と、親要素を渡せる2種類を用意するのが一番親切なのかなと、今は思い始めています。

まあ、ある程度コードの整理は出来たので、今度は色々と動作を変更していこうと思います。

次バージョン

organize.hatenablog.jp