0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

簡易的なゲーミングキーボードを作ってみた

0
Posted at

初めに

初めまして!エンジニアを目指して奮闘している者です!
簡易的ではありますがゲーミングキーボードを作ってみました!
もしよければご覧ください!

作成工程

今、毎日アニメーション作成を継続してるのでHTML/CSS/JavaScript での作成となります!
構想というかキーボードを見てこれ作るか!という完全なノリです(笑)
ただキーボード作成と光らせる方法は

  • 簡易的なキーボードをgridと配列を用いて作成する
  • スネーク上に光らせていく
  • 光らせ終わったら波みたいに虹を無限ループさせる

という手順で行いました!
HTMLはクラス一つなので割愛させていただきます。


先ずはCSSでボードを作成していきます

.board {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  grid-template-rows: repeat(4, 1fr);
  gap: var(--key-gap);

  width: 100%;
  max-width: var(--board-width);
  aspect-ratio: 3 / 1;

  transform: perspective(500px) rotateX(var(--board-tilt));

  background-color: var(--bg-board);
  padding: 15px;
  border-radius: var(--board-radius);
  box-shadow:
    0 20px 50px rgba(0, 0, 0, 0.5),
    0 1px 0 #555 inset;
}

取りあえず12×4で右上の端をpowerボタンという感じで組みます

repeat(12, 1fr) の意味。

キーボードのように規則正しく並ぶものは、FlexboxよりもGrid Layoutが最適です。 repeat(12, 1fr) とすることで、横幅を均等に12分割し、レスポンシブで画面が縮んでも崩れない盤面を作っています。

aspect ratio

次に、画面サイズが変わっても「長方形」を保てるようにaspect-ratio: 3 / 1で固定
3 / 1(横3:縦1)と指定すれば、幅が 100% や max-width で可変しても、常に長方形の比率を維持してくれます

perspective と rotateX の組み合わせ。

ただ rotateX(X軸回転)させるだけでは、紙が潰れたようにしか見えません。 重要なのは perspective(500px) です。これは 『どのくらいの距離から見ているか』という視点の深さを作るプロパティです 。これを入れることで、手前は大きく、奥は小さく見える 『パース(遠近感)』 が生まれ、リアルな奥行き表現が可能になります。

ただこれだけでは画面に表示されないのでJavaScriptで作っていきますが全部貼ると大変なことになるので
スネーク上に光らせたコードを貼ります!

const getSnakeOrder = () => {
  const result = [];

  for (let i = 0; i < COUNT; i++) {
    // 下から数えた行番号を計算
    const rowFromBottom = Math.floor(i / COLS);

    // 実際の行番号(上から0,1,2...)に変換
    // 配列操作は上からが基準のため
    const currentRow = ROWS - 1 - rowFromBottom;

    // 基本の列番号
    const colIndex = i % COLS;

    // 進行方向の決定
    // 偶数段は右から左、奇数段は左から右へ
    const targetCol =
      rowFromBottom % 2 === 0
        ? COLS - 1 - colIndex // 反転(最大値 - 現在地)
        : colIndex; // そのまま

    // 最終的な配列インデックスに変換して格納
    result.push(currentRow * COLS + targetCol);
  }
  return result;
};

/**
 * メインのアニメーションループ
 * @param {number} timestamp - ブラウザから渡される現在時刻
 */
const loop = (timestamp) => {
  if (!lastTime) lastTime = timestamp;

  // 前回の描画から何ミリ秒経過したか計算
  const elapsed = timestamp - lastTime;

  // 設定した間隔(30ms)以上経過していたら処理を実行
  // これによりPCの性能や画面リフレッシュレートに依存せず速度が一定になる
  if (elapsed > FRAME_INTERVAL_MS) {
    // まだ光らせるキーが残っている場合
    if (snakeIndex < snakeOrder.length) {
      const targetIndex = snakeOrder[snakeIndex];
      // キャッシュから高速に要素を取得
      const key = cachedBlocks[targetIndex];

      if (key) {
        key.classList.add("mode-snake");
      }

      snakeIndex++;
      lastTime = timestamp; // 時間を更新
    } else {
      // 全てのキーを光らせ終わった場合
      // CSSの光るアニメーション(transition)が終わるのを待ってから
      // レインボーモード(Wave)へ切り替える
      waveTimeoutId = setTimeout(() => {
        switchToWaveMode();
      }, TRANSITION_WAIT_MS);

      return; // ループをここで終了させる
    }
  }
    // 次のフレーム描画を予約
  animationId = requestAnimationFrame(loop);
};

こだわりポイント

画面に貼っていませんが盤面生成でconst fragment = document.createDocumentFragment();
cachedBlocks = document.querySelectorAll(".block");を使用しています
フラグメントでブラウザの計算を一回で済ませ、生成直後にDOM要素をキャッシュしておくことでアニメーション中に毎回DOM要素の検索をしなくていいようにしています。

スネークの格納方法

何とかしてfor二重を回避する方法ないかなーと思いめっちゃ計算式を考えてました。
具体的な方法はコードとコメントに書いてありますが二次元配列ではなく一次元配列に変換してスネーク上に光らせる処理を作りました。

Delta Timerequestanimationframe

デルタタイムはAIに教えてもらいました(笑)
何とPCの性能やリフレッシュレートに依存せず速度が一定になるらしいすごい

  • もし requestAnimationFrame だけで書くと:
    60Hzのモニター(普通のPC): 1秒間に60マス進む(ちょうどいい)。
    144Hzや240Hzのモニター(ゲーミングPC): 1秒間に144マス進む速すぎ!

  • Delta Time方式だと:
    「前回から30ms経った?」というチェックを入れているため、どんな高性能なPCでも、どんな古いスマホでも、必ず「30msに1回」 しか進みません。

  • 「コマ落ち」しても時間がズレない
    setTimeout(func, 30) は「処理が終わってから30ms待つ」という挙動になりがちで、重い処理が入るとどんどん時間が後ろにズレていきます(ドリフト現象)。

  • 一方、この timestamp - lastTime(引き算)を使う方式は、 「現実の時間」 を基準にしています。 もしPCが重くて一瞬止まっても、次のフレームで 「あ、時間がだいぶ経ってるな」と判定できるため、リズムを崩さずにアニメーションを継続できます。

処理の流れ(図解イメージ)

requestAnimationFrame は全力で回っていますが、ゲート(if文) が開くのは時間が経った時だけ、というイメージです。
Frame 1 (0ms): ループ回る → elapsed は0ms → 処理しない
Frame 2 (16ms): ループ回る → elapsed は16ms → 処理しない (30ms未満だから)
Frame 3 (32ms): ループ回る → elapsed は32ms → 処理実行! (30ms超えた!) & lastTime更新
この時はすごいこんなのがあるんやーと一部だけ自分のタイピングゲームに使えるなと思い実装させてもらいました(笑)ありがとうAI!

// 起動・停止を切り替えるだけの純粋な関数
const togglePower = () => {
  isPowerOn ? shutdownAnimation() : startGamingAnimation();
};

/* =========================================
   マウス操作 (Click Event)
   ========================================= */
window.addEventListener("click", (e) => {
  // クリックされた要素が .block か確認
  const keyElement = e.target.closest(".block");
  if (!keyElement) return;

  // 画面上の電源ボタンが押された場合
  if (keyElement.textContent === CHAR_POWER) {
    togglePower();
  }
});

/* =========================================
    キーボード操作 (Keydown Event)
   ========================================= */
window.addEventListener("keydown", (e) => {
  if (e.key === "Backspace" || e.key === "Enter") {
    // BSキーはブラウザの「戻る」が発動する場合があるので防ぐ
    e.preventDefault();

    togglePower();
  }
});

今回はクリックとキーボードで反応するようにしてみました。

最後に実行してみる
ゲーミングキーボード
キーボードとクリックで同時に行けました
今回キーボードのデザイン等は簡易的ではありますがCSSとロジックは大変でしたね...
だいぶ端折りましたが作成できて満足です!
AIにヒントをもらいながら組むことが出来ましたがこれを自力で組めたらかっこいいなと思いました。
これからもガンガンコード書いたりブラウザの挙動を理解したりの勉強をやらなきゃですね!これからも頑張ります!
ここまで読んでいただきありがとうございました!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?