キーボードイベントやProxyを使ったレトロゲーム機もどき

## はじめに
執筆時点では、Switch2が話題になっています。
それに触発されて、子供時代に遊んだゲーム機のことを思い浮かべました。
当時を彷彿とさせる題材を使って、あまり使う機会のなかったProxyオブジェクトを使ってみようと思い、以下のようなものを作ってみました。

## 作ってみたもの
CodePenをご確認ください。
HTMLとCSSで見た目を作成し、JavaScriptでボタンアクションを実装しています
- `Escape`キーを入力すると中央画面が切り替わります（または左下の横長ボタン）
- 表示内容は、キー（ボタン）入力に対応するボタン文字を表示するだけです

<p class="codepen" data-height="500" data-slug-hash="LEEzEoM" data-pen-title="sample" data-preview="true" data-user="eaqqryou-the-sans" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/eaqqryou-the-sans/pen/LEEzEoM">
  sample</a> by j (<a href="https://codepen.io/eaqqryou-the-sans">@eaqqryou-the-sans</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://public.codepenassets.com/embed/index.js"></script>


| キーボード入力 | 対応するボタン |
|:-:|:-:|
|  Esc | 電源ON・OFF  |
|  Enter / Space / Z |  A |
|  Shift / X |  B |
|  W / ↑ | 上 |
|  D / → |  右 |
|  S / ↓ |  下 |
|  A / ← |  左 |


## Proxyオブジェクトをざっくり補足
[Proxy](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy)オブジェクトは、通常のオブジェクトを拡張したものです。
`new Proxy()`で宣言し、引数として`get()`と`set()`というハンドラー関数（トラップ）を用いて実装します。

```js
// 普通のオブジェクトを定義
const object = {
    prop: true
}

// Proxyオブジェクト
// 第1引数：Proxyを設定するオブジェクト
// 第2引数: ハンドラーオブジェクト
const proxyObject = new Proxy(object, {
    // プロパティが代入されると実行
    set(target, property, value) {
        // target   => object
        // property => objectのプロパティ名
        // value    => objectのプロパティへの代入値
        target[property] = value;

        // 代入されたプロパティ名と値をコンソールログで出力する
        console.log(property, value);
        return true;
    },
    // プロパティにアクセスされると実行
    get(target, property) {
        // アクセスされたプロパティ名をコンソールログで出力する
        console.log(property);
        return target[property];
    }
})
```

Proxyオブジェクトのプロパティに代入があると、`set()`の処理が実行される。
```js
proxyObject.property = true
proxyObject[property] = true
```

Proxyオブジェクトのプロパティにアクセスがあると、`get()`の処理が実行される。
```js
proxyObject.property
proxyObject[property]
```

---

以降では、CodePenに貼ったソースコードの一部を補足します。

## 操作状態をProxyオブジェクトで管理する
変数`inputState`をProxyで定義し、以下の目的で使用しています。
- キーボードの入力キーをプロパティの値として更新する
- 更新の度に、入力キーに対応するボタンがあればボタンアクションを実行する

**inputState.current（文字列）**
選択されたボタンの種類を保存しています。

**inputState.eventType（文字列）**
発生したイベントの種類を保存しています。
実際にはキーボードイベントだけではないので、`press`と`release`という単語で管理しています。

```js
// 特定のキーボード入力に対応するボタンアクションの準備, 対応表
const inputMap = {
    ...power, // 電源ボタンの設定
    ...dPad, // 十字ボタンの設定
    ...actionButtons // ABボタンの設定
};

// キー入力状態を管理
const inputState = new Proxy({
    // 現在入力されているキー
    current: "",
    // currentが更新されたときのイベントタイプ（"press", "release"が入ります）
    eventType: ""
}, {
    set: (target, prop, value) => {
        // currentプロパティが更新されたとき
        if (isCurrentProperty(prop)) {
            // 入力されたキーに対応するボタンの種類を取得(a,b,up,right,down,left)
            const selectdKey = findSelectedKey(value, inputMap);
            // eventTypeプリパティの値で、ボタンアクションを振り分ける(押された時、離された時のアクション)
            executeButtonActionByEventType(target, selectdKey, inputMap);
        }
        return Reflect.set(target, prop, value);
    },
    get: (target, prop) => Reflect.get(target, prop)
});
```


## キー入力に応じたボタンアクションを準備する
下記は十字ボタンの設定です。各ボタンに3つの設定項目をつけています。

**triggerKeys（配列）**
方向ボタンに対応させたい[KeyboardEvent.keyの値](https://developer.mozilla.org/ja/docs/Web/API/UI_Events/Keyboard_event_key_values)を入れています。

**onPress（関数）**
キーが押された時に実行したい処理です。

**onRelease（関数）**
キーが離された時に実行したい処理です。

```js
const dPad = {
    up: {
        // 「上ボタン」と判定するキー
        triggerKeys: [ "arrowup", "w" ],
        // 押した時の処理
        onPress: (target, settings) => {
            game.press?.Up(target, settings);
            setFocusButton(dPad, "up");
        }, 
        // 離したの処理
        onRelease: (target, settings) => {
            game.release?.Up(target, settings);
            setBlurButton(dPad, "up");
        }
    },
    right: {
        // 「右ボタン」と判定するキー
        triggerKeys: [ "arrowright", "d" ],
        // 押した時の処理
        onPress: (target, settings) => {
            game.press?.Right(target, settings);
            setFocusButton(dPad, "right");
        },
        // 離したの処理
        onRelease: (target, settings) => {
            game.release?.Right(target, settings);
            setBlurButton(dPad, "right");
        }
    },
    down: {
        // 「下ボタン」と判定するキー
        triggerKeys: [ "arrowdown", "s" ],
        // 押した時の処理
        onPress: (target, settings) => {
            game.press?.Down(target, settings);
            setFocusButton(dPad, "down");
        },
        // 離したの処理
        onRelease: (target, settings) => {
            game.release?.Down(target, settings);
            setBlurButton(dPad, "down");
        }
    },
    left: {
        // 「左ボタン」と判定するキー
        triggerKeys: [ "arrowleft", "a" ],
        // 押した時の処理
        onPress: (target, settings) => {
            game.press?.Left(target, settings);
            setFocusButton(dPad, "left");
        },
        // 離したの処理
        onRelease: (target, settings) => {
            game.release?.Left(target, settings);
            setBlurButton(dPad, "left");
        }
    }
};
```


## イベント登録

### キーボードイベント

`keydown`でボタンを押した時、`keyup`でボタンを離した時の処理を登録しています。
Proxyオブジェクトである`inputState`のプロパティに値を代入することで、setトラップに記述した処理が自動的に実行されます。

`keydown`では、[KeyboardEvent.repeat](https://developer.mozilla.org/ja/docs/Web/API/KeyboardEvent/repeat)プロパティを利用することで1回目のキー入力のみ処理を実行しています。

```js
// 特定のキー（ボタン）がリピートされたか判定する
function isPreventedKeyRepeat(e, keys = [ "escape", " ", "enter", "z", "shift", "x" ]) {
    return e.repeat && keys.includes(e.key.toLowerCase());
}

// keydownとkeyupイベントを登録
function addKeyEventListeners(target = window, handler) {
    target.addEventListener("keydown", (e => {
        if (isPreventedKeyRepeat(e)) return;
        // イベントタイプを"press"として実行
        handler("press", e.key.toLowerCase(), e);
    }));
    target.addEventListener("keyup", (e => {
        // イベントタイプを"release"として実行
        handler("release", e.key.toLowerCase(), e);
    }));
}
```

### ポインターイベント
基本的には、キーボードイベントとほとんど同じです。

```js
// pointerdownとpointerupイベントを登録
function addButtonEventListeners(buttons, handler) {
    buttons.forEach((button => {
        button.addEventListener("pointerdown", (e => {
            // イベントタイプを"press"として実行
            handler("press", button.dataset.key, e);
        }));
        button.addEventListener("pointerup", (e => {
            // イベントタイプを"release"として実行
            handler("release", button.dataset.key, e);
        }));
    }));
}
```

上記イベントでは、ターゲットとなるボタンの`data-key`を利用してボタン判定を行っています。こちらには、[KeyboardEvent.key値](https://developer.mozilla.org/ja/docs/Web/API/UI_Events/Keyboard_event_key_values)を指定しており、キー入力を模している状態です。

```html:index.html
<!-- data-keyをevent.keyの代替案として使う -->
<button data-key="shift" type="button">B</button>
<button data-key="enter" type="button">A</button>
```

## ボタンアクション
下記の変数には、実際に行われるボタンアクションの内容を定義しています。

**press（オブジェクト）**
各ボタンが押された時のアクションです。

```js
const press = {
    // Aボタンが押されたら、
    A: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            // "A"という文字列を表示する。
            keyDisplayArea.textContent = "A";
            // コンソールログに文字列を出力する。
            console.log("Aを押しました");
        }
    },
    B: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            keyDisplayArea.textContent = "B";
            console.log("Bを押しました");
        }
    },
    Up: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            keyDisplayArea.textContent = "△";
            console.log("上を押しました");
        }
    },
    Down: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            keyDisplayArea.textContent = "▽";
            console.log("下を押しました");
        }
    },
    Left: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            keyDisplayArea.textContent = "◁";
            console.log("左を押しました");
        }
    },
    Right: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            keyDisplayArea.textContent = "▷";
            console.log("右を押しました");
        }
    }
};
```


**release（オブジェクト）**
各ボタンが離された時のアクションです。

```js

const release = {
    // Aボタンが離されたら、
    A: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            // コンソールログに文字列を出力する。
            console.log("Aを離しました");
        }
    },
    B: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            console.log("Bを離しました");
        }
    },
    Up: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            console.log("上を離しました");
        }
    },
    Down: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            console.log("下を離しました");
        }
    },
    Left: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            console.log("左を離しました");
        }
    },
    Right: (target, settings) => {
        if (settings.switch.isSwitchOn) {
            console.log("右を離しました");
        }
    }
};
```


## 実行

最後に`game.init()`関数で初期化を行っています。

```js
game.init(sample, {
    press: press,
    release: release,
    timeUntilLow: 60000,
    timeUntilOff: 180000
});
```

**sample（関数）**
電源起動後に表示するゲーム部分のHTMLを追加しています。

```js
function sample(romSlot) {
    console.log("default");
    const html = `\n    <div id="default" class="game-default" data-rom="default">\n      <h1>PRESS</h1>\n      <div id="js-key">ここに押したボタンを表示する</div>\n    </div>`;
    romSlot.insertAdjacentHTML("afterbegin", html);
}
```

**press, release（オブジェクト）**
ボタンアクションとして変数宣言した`press`と`release`が指定されています。

```js
press: press,
release: release,
```

**timeUntilLow（数値 or null）**
電源ONにしてから1分経過すると、右上の電源ランプが赤色に変わります。
```js
timeUntilLow: 60000,
```

**timeUntilOff（数値 or null）**
電源ONにしてから3分経過すると、問答無用で電源OFFになります。
```js
timeUntilOff: 180000
```

## 最後に
こちらが暇つぶしや何かの足しになれば幸いです。
ご一読ありがとうございました。


## 参考文献
**Proxy**
[Proxy - JavaScript | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy)

**イベント**
[ポインターイベント](https://developer.mozilla.org/ja/docs/Web/API/Pointer_events)
[KeyboardEvent](https://developer.mozilla.org/ja/docs/Web/API/KeyboardEvent)
[キーボードイベントの key の値](https://developer.mozilla.org/ja/docs/Web/API/UI_Events/Keyboard_event_key_values)