爆転振動!バイブレード

https://github.com/NazonoKansatugata/Vibrade

TypeScript

React

CSS

Node.js

TailwindCSS

スマホを振ってシュート! 衝突を振動で感じるベイバトル!

Kuroko

なす

推しアイデア

ベイ同士がぶつかり合うと、プレイヤーのスマホが振動! 激しくぶつかりあえば、脳汁振動演出だ!!!

作った背景

ウィンド→竜巻→回転→ベイブレード ウェーブ→振動→バイブレーション スマホでシュート!激熱振動ベイバトル! 3!2!1!へいらっしゃい!

推し技術

phaser3を用いた滑らかなゲーム画面 バイブレーションだけではない、音声を用いたクロスプラットフォームなハプティクス発火

プロジェクト詳細

1. プロダクト概要

爆転振動!バイブレードは、PCをスタジアム表示、スマートフォンをモーションコントローラーとして使うクロスデバイス対戦ゲームです。
「スマホを振って発射」「傾けて操作」「衝突を振動で感じる」という一連の体験を、ブラウザだけで成立させることを狙っています。

1-1. 各画面説明

デモ動画 https://youtu.be/rKRmavGqSMs

タイトル画面 image マッチング画面 image ゲーム画面 image リザルト画面 image 遊び方 image image image

2. 技術の無駄遣いポイント

2-1. センサー入力

  • DeviceOrientation / DeviceMotionの生データはノイズが多いため、指数移動平均(EMA)で平滑化。
  • さらにデッドゾーンを設けて微小揺れを無視し、角度を -1〜1 に正規化。
  • 体感を優先して、傾き用フィルタと加速度用フィルタで係数を分けています。

2-2. 発射ジェスチャーを状態機械で実装

  • IDLE -> PULLING -> LAUNCH -> COOLDOWN の状態遷移で誤発射を抑制。
  • しきい値を2段階に分け、振りかぶりと発射を分離。
  • クールダウンを入れて多重発火を防ぎ、UI上も状態を可視化。

2-3. 通信側でも多重発射をブロック

  • モバイル側のsendInputはエッジトリガー化し、shakePower > 0を毎フレーム送っても発射は1回だけ有効化。
  • 実運用で起きやすい「クールダウン中の連続送信」を設計で潰しています。

2-4. Phaser側のバトルロジックを“ゲームらしさ”優先で自作

  • 反発係数・摩擦・エネルギー減衰・ノックバックを個別定義。
  • 場外判定は単純な境界判定ではなく、衝突で蓄積するringoutRiskと外向き速度の合成で決定。
  • 「速いと飛びやすい」「粘るタイプは残りやすい」など、感覚的に納得できる挙動を狙っています。

2-5. ベイタイプごとの多軸チューニング

  • バランス / パワー / ディフェンス / スタミナごとに、
    • 発射倍率
    • 操作アシスト
    • ダメージ倍率(与/被)
    • ノックバック(攻/耐)
    • 場外リスクの増減 を個別パラメータ化。
  • 見た目だけでなく、操作感と勝ち筋が変わる設計。

2-6. ルーム人数に応じてスタジアム半径を可変

  • 参加人数からアリーナ半径を動的計算し、1v1でも多人数でも密度を調整。
  • 同じルールでも、人数で戦術が変わるようにしています。

2-7. ハプティクス

  • 衝突・発射・強イベントで振動パターンを切り替え、端末の手触りだけで「今なにが起きたか」を判別できる設計。
  • 衝突振動にはクールダウンを入れ、連続接触時でも過剰に震えないよう制御。
  • PCホストからのイベントはルーム内メンバーだけに配信し、対象指定振動と全体振動を使い分け可能。
  • 振動非対応端末でも体験が破綻しないよう、画面エフェクト側のフィードバックと併用。

3. こだわった実装(技術的に語れるポイント)

3-1. イベント順序の揺らぎ対策

  • GAME_STARTGAME_STATEより先に届くケースを考慮し、モバイル側は受信順が前後しても開始状態へ遷移可能。
  • ネットワーク揺らぎでゲームが始まらない、を防ぐための防御的実装です。

3-2. 端末向き差の吸収

  • 発射判定で端末の向き依存(特定軸の正負)を避け、合成加速度中心に判定。
  • iOS/Androidの持ち方差・姿勢差で体験が壊れにくい設計にしています。

3-3. クロスデバイス接続を最短導線に固定

  • PCがルームを持ち、スマホはQRで同一ルームに参加するだけ。
  • アプリインストール不要、URLベースで即プレイ可能。
  • デモ現場でのセットアップ時間を最小化できます。

3-4. ルームマッチングと再接続耐性

  • モバイル側でplayerIdをローカル保存し、再接続時は同一IDでjoinRoomする設計。
  • サーバー側はexistingPlayerIdが一致するプレイヤーを再利用してsocketIdを差し替え、プレイヤー状態を引き継ぎ。
  • 切断直後に即退室させず、一定猶予(タイムアウト)を持たせて短時間のリロードや通信断を吸収。
  • ホスト側はルーム作り直し時に古いルームを掃除し、参加者の取り残しや重複ルームを防止。

3-5. 一番こだわった点: 振動をゲーム情報として同期

  • 衝突時はゲーム側の当たり判定イベントを起点に、モバイルへ即時に振動通知を送信。
  • 振動はimpact / launch / specialの意図ごとに扱いを分離し、パターン長から演出強度も切り替え。
  • ホスト権限のみが他端末振動を発火できるようにして、悪意ある連打や誤操作による体験破壊を防止。
  • 体感品質のため、振動の強さだけでなく「鳴らしすぎない制御(クールダウン)」まで含めて設計対象にした。

実装例コード

  1. PC側: 衝突イベントのハプティクス発火(クールダウンあり)
private emitCollisionHaptic(playerIds: string[], kind: CollisionEventPayload['kind']) { if (!this.onCollision) { return } const now = this.game.loop.time || Date.now() const filtered = playerIds.filter((playerId) => { const lastAt = this.lastCollisionHapticAt.get(playerId) ?? 0 if (now - lastAt < HAPTIC_COLLISION_COOLDOWN_MS) { return false } this.lastCollisionHapticAt.set(playerId, now) return true }) if (filtered.length > 0) { this.onCollision({ playerIds: filtered, kind }) } }
  1. Server側: ホスト権限チェック + 対象指定/全体配信
socket.on(ClientEvents.TRIGGER_VIBRATE, (data: { roomId: string; targetSocketIds?: string[]; pattern?: number[] }) => { const room = roomManager.getRoom(data.roomId) if (!room) { socket.emit(ServerEvents.ERROR, { code: 'NOT_FOUND', message: 'Room not found' }) return } // ホストのみ振動トリガー可能 if (room.hostSocketId !== socket.id) { socket.emit(ServerEvents.ERROR, { code: 'UNAUTHORIZED', message: 'Only host can trigger vibration' }) return } const pattern = data.pattern && data.pattern.length > 0 ? data.pattern : [200, 100, 200] const requestedTargets = Array.isArray(data.targetSocketIds) ? data.targetSocketIds : [] const allowedTargets = new Set(room.players.map((player) => player.socketId)) const targetSocketIds = Array.from(new Set(requestedTargets.filter((targetId) => allowedTargets.has(targetId)))) if (requestedTargets.length > 0) { if (targetSocketIds.length === 0) { socket.emit(ServerEvents.ERROR, { code: 'INVALID_TARGETS', message: 'No valid target sockets in room' }) return } targetSocketIds.forEach((targetId) => { io.to(targetId).emit(ServerEvents.VIBRATE, { pattern, timestamp: Date.now() }) }) return } io.to(data.roomId).emit(ServerEvents.VIBRATE, { pattern, timestamp: Date.now() }) })
  1. Mobile側: 受信パターンから意図を判定して端末振動へ反映
const onVibrate = (data: { pattern?: number[] }) => { const pattern = data.pattern || [200, 100, 200] const totalDuration = pattern.reduce((acc, ms) => acc + ms, 0) const intent: FeedbackFxIntent = totalDuration >= 1000 ? 'special' : 'launch' setLastFxIntent(intent) setFxPulse((prev) => prev + 1) triggerFeedback(pattern, intent === 'special' ? 'special' : 'launch') } const onCollision = () => { setLastFxIntent('impact') setFxPulse((prev) => prev + 1) triggerFeedback([100, 50, 100], 'impact') }

この3段で、ゲームイベント(衝突/必殺)を「通信で届けて」「手で感じる」まで一気通貫で実装しています。

4. 技術スタック

  • PC Client: React, TypeScript, Vite, Phaser 3
  • Mobile Client: React, TypeScript, Vite, Tailwind CSS
  • Server: Node.js, Express, Socket.io, TypeScript
  • Device APIs: DeviceOrientation, DeviceMotion, Vibration API

5. 開発体制と実装プロセス

  • 本開発は2人で進め、うち1人は開始時点で実装経験が浅い状態から参加。
  • 役割を固定しすぎず、サーバー/フロントの両方に触るタスク設計にして、仕様理解を縦に分断しない進行を採用。
  • 具体的には、Socketイベント設計・ルーム管理・UI実装を往復しながら担当し、片側だけでは見えない不具合を早期に発見。
  • 学習コストは高い一方で、実装と設計の往復回数が増え、結果的に仕様の言語化と再現性が高まりました。

6. 今後の拡張余地

  • パーツ単位の詳細カスタマイズ(軸先・ディスク・レイヤー)
  • ジェスチャー派生による必殺技システム
  • ルーム戦績の永続化とランキング
  • リプレイ保存と衝突ログ可視化

Kuroko

@Kuroko