AndroidにWebSocketサーバを立ててPCブラウザゲームのコントローラにする

投稿日: 2025-08-09

Androidスマホ上にWebSocketのサーバを立てて、PCブラウザと通信させることでスマホをコントローラとして使えるか試しました。
サーバの実装にはKtorを使いました。
https://ktor.io/docs/server-websockets.html

Ktorを使うと、Android上にも簡単にWebサーバを立てることができます。


感想

簡単なサンプルだったからかもしれませんが、想像よりも入力遅延がありませんでした。
細かいチューニング等は一切せずに無限ループで加速度センサー、ボタン入力の情報を繰り返し送信させただけで十分に操作できるレベルになりました。
無限ループには10msのdelayを入れています(なくてもいいかもしれません)。
また今回はデータをJSONにして通信しましたが、シリアライズ、デシリアライズが速度的に問題になるのであればもっと単純な形式でやり取りすることも可能だと思います。

今回は加速度センサーとボタン入力のみを利用しましたが、Androidには他にもいろいろなセンサーが存在します。
また、ブラウザ側から指示を出して音を鳴らしたり、バイブレーションを使ったりもできると思います。


ソースコード

Android側

MainActivity.kt

class MainActivity : AppCompatActivity(), SensorEventListener {

  private val websocketServer = WebsocketServer()
  private lateinit var sensorManager: SensorManager
  private var accelerationSensor: Sensor? = null
  private var rotationSensor: Sensor? = null

  private lateinit var ipAddressLabel: TextView
  private lateinit var serverSwitch: ToggleButton
  private lateinit var shootButton: Button

  @SuppressLint("SetTextI18n")
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
    setContentView(R.layout.activity_main)
    setupWindowInsets()

    initViews()
    initSensors()

    ipAddressLabel.text = "IPアドレス: ${getDeviceIpAddress() ?: "取得失敗"}"

    setupEventListeners()
  }

  override fun onResume() {
    super.onResume()
    registerSensorListeners()
  }

  override fun onPause() {
    super.onPause()
    sensorManager.unregisterListener(this)
  }

  override fun onSensorChanged(event: SensorEvent) {
      when (event.sensor.type) {
        Sensor.TYPE_LINEAR_ACCELERATION -> {
          SensorDataHolder.accelerationX = event.values[0]
          SensorDataHolder.accelerationY = event.values[1]
          SensorDataHolder.accelerationZ = event.values[2]
        }
        Sensor.TYPE_ROTATION_VECTOR -> {
          updateRotationData(event.values)
        }
      }
  }

  override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
  }

  private fun initSensors() {
    sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
    accelerationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION)
    rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
  }

  private fun registerSensorListeners() {
    accelerationSensor?.let {
      sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
    }
    rotationSensor?.let {
      sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME)
    }
  }

  private fun updateRotationData(rotationVector: FloatArray) {
    val rotationMatrix = FloatArray(9)
    SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVector)

    val orientationAngles = FloatArray(3)
    SensorManager.getOrientation(rotationMatrix, orientationAngles)

    SensorDataHolder.rotationX = Math.toDegrees(orientationAngles[1].toDouble()).toFloat() // Pitch
    SensorDataHolder.rotationY = Math.toDegrees(orientationAngles[2].toDouble()).toFloat() // Roll
    SensorDataHolder.rotationZ = Math.toDegrees(orientationAngles[0].toDouble()).toFloat() // Azimuth
  }

  private fun initViews() {
    ipAddressLabel = findViewById(R.id.textView)
    serverSwitch = findViewById(R.id.server_switch)
    shootButton = findViewById(R.id.button)
  }

  private fun setupEventListeners() {
    serverSwitch.setOnCheckedChangeListener { _, isChecked ->
      if (isChecked) {
        websocketServer.start()
      } else {
        websocketServer.stop()
      }
    }

    shootButton.setOnClickListener {
      SensorDataHolder.btnPressCount += 1
    }
  }

  private fun setupWindowInsets() {
    ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
      val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
      v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
      insets
    }
  }

  private fun getDeviceIpAddress(): String? {
    val cm = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
    return cm.getLinkProperties(cm.activeNetwork)
        ?.linkAddresses
        ?.firstOrNull { it.address.address.size == 4 }
        ?.address
        ?.hostAddress
  }
}

WebsocketServer.kt

class WebsocketServer {
  private var serverJob: Job? = null
  private val nettyServer = embeddedServer(Netty, port = 8000) {
    install(WebSockets) {
      pingPeriod = 5.seconds
      timeout = 15.seconds
      maxFrameSize = Long.MAX_VALUE
      masking = false
    }

    routing {
      webSocket("/") {
        val session = this

        try {
          while (isActive) {
            val sensorData = SensorDataHolder.toJsonString
            outgoing.send(Frame.Text(sensorData))
            delay(10)
          }
        } catch (e: ClosedReceiveChannelException) {
          println("Client disconnected due to normal closure: ${closeReason.await()}")
        } catch (e: Exception) {
          println("Client disconnected due to an error: ${e.message}")
        }
      }
    }
  }
  fun start() {
    if (serverJob?.isActive == true) {
      println("Server is already running.")
      return
    }

    println("WebsocketServer starting on port 8000")
    serverJob = CoroutineScope(Dispatchers.IO).launch {
      nettyServer.start(wait = true)
    }
  }
  fun stop() {
    if (serverJob?.isActive == false) {
      println("Server is not running.")
      return
    }

    println("WebsocketServer stopping")
    nettyServer.stop(1000, 1000)
    serverJob?.cancel()
  }
}

SensorDataHolder.kt

@Serializable
data class MyData(
  val accelerationX: Float,
  val accelerationY: Float,
  val accelerationZ: Float,
  val rotationX: Float,
  val rotationY: Float,
  val rotationZ: Float,
  val btnPressed: Boolean
)

object SensorDataHolder {
  var accelerationX: Float = 0.0f
  var accelerationY: Float = 0.0f
  var accelerationZ: Float = 0.0f

  var rotationX: Float = 0.0f
  var rotationY: Float = 0.0f
  var rotationZ: Float = 0.0f

  var btnPressCount: Int = 0


  val toJsonString: String
    get() {
      val myData = MyData(
        accelerationX,
        accelerationY,
        accelerationZ,
        rotationX,
        rotationY,
        rotationZ,
        btnPressCount > 0
      )
      if (btnPressCount > 0)
        btnPressCount -= 1

      return Json.encodeToString(MyData.serializer(), myData)
    }

}

ブラウザ側

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>WebSocket Client</title>

    <style>
      body {
        display: flex;
        flex-direction: column;
        align-items: center;
        font-family: sans-serif;
      }
      canvas {
        border: 2px solid black;
        background-color: #000000;
      }
      button {
        margin-top: 10px;
        padding: 10px 20px;
        font-size: 16px;
      }
  </style>
</head>
<body>
    <h1>WebSocket Client</h1>

    <div id="status">ステータス: 接続されていません</div>
    <div id="messages"></div>

    <canvas id="gameCanvas" width="800" height="400"></canvas>

    <button id="connectButton">接続</button>
    <button id="disconnectButton">切断</button>

    <script src="script.js"></script>
</body>
</html>

script.js

const statusElement = document.getElementById('status');
const messagesElement = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const connectButton = document.getElementById('connectButton');
const disconnectButton = document.getElementById('disconnectButton');

const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');

let animationFrameId = null;
let gameOver = false;
let score = 0;

const character = {
    x: 50,
    y: 350,
    width: 30,
    height: 30,
    color: 'blue',
    vy: 0,
    isJumping: false,
    initialY: 350
};

let obstacles = [];
const obstacleSpeed = 5;
const obstacleMinWidth = 20;
const obstacleMaxWidth = 60;
const obstacleMinHeight = 30;
const obstacleMaxHeight = 160;

let stars = [];
const starSpeed = 7;
const starSize = 5;

let lastObstacleTime = 0;
const minObstacleInterval = 700;
const maxObstacleInterval = 1000;
let currentObstacleInterval = 1500;

const gravity = 0.5;
const baseGravity = 0.5;
const gravityMultiplier = 0.03; 
const jumpBaseForce = -10;
const jumpMultiplier = -1.2;
const jumpThreshold = 5;

function init() {
    gameOver = false;
    score = 0;
    lastObstacleTime = 0;
    obstacles = [];
    stars = [];
    character.vy = 0;
    character.isJumping = false;

    if (animationFrameId !== null) {
        cancelAnimationFrame(animationFrameId);
    }
}

function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    ctx.fillStyle = character.color;
    ctx.fillRect(character.x, character.y, character.width, character.height);

    obstacles.forEach(obs => {
        const stemWidth = 20;
        const stemHeight = obs.height;
        const stemX = obs.x + (obs.width - stemWidth) / 2;
        const stemY = obs.y + obs.height - stemHeight;

        ctx.fillStyle = '#228B22';
        ctx.fillRect(stemX, stemY, stemWidth, stemHeight);

        const armWidth = 10;
        const armHeight = stemHeight / 3;
        ctx.fillRect(stemX - armWidth, stemY + stemHeight / 4, armWidth, armHeight);
        ctx.fillRect(stemX + stemWidth, stemY + stemHeight / 4, armWidth, armHeight);
    });

    ctx.fillStyle = 'yellow';
    stars.forEach(star => {
        ctx.beginPath();
        ctx.arc(star.x, star.y, starSize, 0, Math.PI * 2);
        ctx.fill();
    });

    ctx.font = '24px sans-serif';
    ctx.fillStyle = 'white';
    ctx.textAlign = 'right';
    ctx.fillText(`スコア: ${score}`, canvas.width - 20, 40);

    if (gameOver) {
        ctx.font = '48px sans-serif';
        ctx.fillStyle = 'white';
        ctx.textAlign = 'center';
        ctx.fillText('ゲームオーバー', canvas.width / 2, canvas.height / 2);
    }
}

function update(deltaTime) {
    if (gameOver) return;

    character.y += character.vy;
    character.vy += gravity;

    if (character.isJumping) {
        if (character.vy < 0) {
            character.y += character.vy;
            character.vy += gravity;
        } else {
            const adjustedGravity = baseGravity + (Math.abs(character.initialJumpVy) * gravityMultiplier);
            character.y += character.vy;
            character.vy += adjustedGravity;
        }
    } else {
        character.y = character.initialY;
    }

    if (character.y >= character.initialY) {
        character.y = character.initialY;
        character.vy = 0;
        character.isJumping = false;
        character.initialJumpVy = 0;
    }

    if (Date.now() - lastObstacleTime > currentObstacleInterval) {
        const randomWidth = Math.floor(Math.random() * (obstacleMaxWidth - obstacleMinWidth + 1)) + obstacleMinWidth;
        const randomHeight = Math.floor(Math.random() * (obstacleMaxHeight - obstacleMinHeight + 1)) + obstacleMinHeight;

        obstacles.push({
            x: canvas.width,
            y: character.initialY + character.height - randomHeight,
            width: randomWidth,
            height: randomHeight,
            passed: false
        });
        lastObstacleTime = Date.now();
        currentObstacleInterval = Math.floor(Math.random() * (maxObstacleInterval - minObstacleInterval + 1)) + minObstacleInterval;
    }

    for (let i = 0; i < obstacles.length; i++) {
        obstacles[i].x -= obstacleSpeed;
        if (obstacles[i].x + obstacles[i].width < character.x && !obstacles[i].passed) {
            score++;
            obstacles[i].passed = true;
        }
    }
    obstacles = obstacles.filter(obs => obs.x + obs.width > 0);    

    stars.forEach(star => {
        star.x += starSpeed;
    });
    stars = stars.filter(star => star.x < canvas.width);

    for (let i = obstacles.length - 1; i >= 0; i--) {
        for (let j = stars.length - 1; j >= 0; j--) {
            const obs = obstacles[i];
            const star = stars[j];

            if (
                star.x + starSize > obs.x &&
                star.x - starSize < obs.x + obs.width &&
                star.y + starSize > obs.y &&
                star.y - starSize < obs.y + obs.height
            ) {
                obstacles.splice(i, 1);
                stars.splice(j, 1);
                score++;
                break;
            }
        }
    }

    obstacles.forEach(obs => {
        if (
            character.x < obs.x + obs.width &&
            character.x + character.width > obs.x &&
            character.y < obs.y + obs.height &&
            character.y + character.height > obs.y
        ) {
            gameOver = true;
        }
    });
}

function gameLoop(currentTime) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    update();
    draw();
    animationFrameId = requestAnimationFrame(gameLoop);
}

function handleSensorData(rawData) {
    try {
        const parsedData = JSON.parse(rawData);
        console.log(parsedData);
        if (Math.abs(parsedData.accelerationY) > jumpThreshold && !character.isJumping) {
            const jumpForce = jumpBaseForce + (Math.abs(parsedData.accelerationY) * jumpMultiplier);
            character.vy = jumpForce;
            character.isJumping = true;
            character.initialJumpVy = jumpForce;
        }

        if (parsedData.btnPressed) {
            stars.push({
                x: character.x + character.width / 2,
                y: character.y + character.height / 2
            });
        }
    } catch (e) {
        console.error("Failed to parse JSON:", e);
    }
}

let ws;
connectButton.addEventListener('click', () => {
    if (ws) {
        ws.close();
    }

    // WebSocketサーバーのアドレスを指定
    ws = new WebSocket("ws://xxx.xxx.xxx.xxx:8000/");

    ws.onopen = () => {
        statusElement.textContent = "ステータス: 接続済み";
        console.log("WebSocket接続が開かれました");
        init();
        gameLoop(0);
    };

    ws.onmessage = (event) => {
        handleSensorData(event.data);
    };

    ws.onerror = (error) => {
        statusElement.textContent = "ステータス: エラー";
        console.error("WebSocketエラー:", error);
    };

    ws.onclose = () => {
        statusElement.textContent = "ステータス: 接続が切断されました";
        console.log("WebSocket接続が閉じられました");
        ws = null;
    };
});

disconnectButton.addEventListener('click', () => {
    if (ws) {
        ws.close();
    }
});