はじめに
執筆時点では、Switch2が話題になっています。
それに触発されて、子供時代に遊んだゲーム機のことを思い浮かべました。
当時を彷彿とさせる題材を使って、あまり使う機会のなかったProxyオブジェクトを使ってみようと思い、以下のようなものを作ってみました。
作ってみたもの
CodePenをご確認ください。
HTMLとCSSで見た目を作成し、JavaScriptでボタンアクションを実装しています
-
Escape
キーを入力すると中央画面が切り替わります(または左下の横長ボタン) - 表示内容は、キー(ボタン)入力に対応するボタン文字を表示するだけです
See the Pen sample by j (@eaqqryou-the-sans) on CodePen.
キーボード入力 | 対応するボタン |
---|---|
Esc | 電源ON・OFF |
Enter / Space / Z | A |
Shift / X | B |
W / ↑ | 上 |
D / → | 右 |
S / ↓ | 下 |
A / ← | 左 |
Proxyオブジェクトをざっくり補足
Proxyオブジェクトは、通常のオブジェクトを拡張したものです。
new Proxy()
で宣言し、引数としてget()
とset()
というハンドラー関数(トラップ)を用いて実装します。
// 普通のオブジェクトを定義
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()
の処理が実行される。
proxyObject.property = true
proxyObject[property] = true
Proxyオブジェクトのプロパティにアクセスがあると、get()
の処理が実行される。
proxyObject.property
proxyObject[property]
以降では、CodePenに貼ったソースコードの一部を補足します。
操作状態をProxyオブジェクトで管理する
変数inputState
をProxyで定義し、以下の目的で使用しています。
- キーボードの入力キーをプロパティの値として更新する
- 更新の度に、入力キーに対応するボタンがあればボタンアクションを実行する
inputState.current(文字列)
選択されたボタンの種類を保存しています。
inputState.eventType(文字列)
発生したイベントの種類を保存しています。
実際にはキーボードイベントだけではないので、press
とrelease
という単語で管理しています。
// 特定のキーボード入力に対応するボタンアクションの準備, 対応表
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の値を入れています。
onPress(関数)
キーが押された時に実行したい処理です。
onRelease(関数)
キーが離された時に実行したい処理です。
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プロパティを利用することで1回目のキー入力のみ処理を実行しています。
// 特定のキー(ボタン)がリピートされたか判定する
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);
}));
}
ポインターイベント
基本的には、キーボードイベントとほとんど同じです。
// 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値を指定しており、キー入力を模している状態です。
<!-- data-keyをevent.keyの代替案として使う -->
<button data-key="shift" type="button">B</button>
<button data-key="enter" type="button">A</button>
ボタンアクション
下記の変数には、実際に行われるボタンアクションの内容を定義しています。
press(オブジェクト)
各ボタンが押された時のアクションです。
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(オブジェクト)
各ボタンが離された時のアクションです。
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()
関数で初期化を行っています。
game.init(sample, {
press: press,
release: release,
timeUntilLow: 60000,
timeUntilOff: 180000
});
sample(関数)
電源起動後に表示するゲーム部分のHTMLを追加しています。
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
が指定されています。
press: press,
release: release,
timeUntilLow(数値 or null)
電源ONにしてから1分経過すると、右上の電源ランプが赤色に変わります。
timeUntilLow: 60000,
timeUntilOff(数値 or null)
電源ONにしてから3分経過すると、問答無用で電源OFFになります。
timeUntilOff: 180000
最後に
こちらが暇つぶしや何かの足しになれば幸いです。
ご一読ありがとうございました。
参考文献
Proxy
Proxy - JavaScript | MDN