シンプルなスマホ対応オンラインゲームの作り方

 コンピュータ  61

 この記事では、HTML、JavaScript、Pythonを使ったシンプルなオンライン2D横スクロール風ゲームのサンプルを紹介します。

1. ゲーム概要

このゲームは、以下のような特徴を持っています。

  • 2D横スクロール風ゲーム:画面上にキャラクターが描画され、左右の移動やジャンプが可能です。
  • オンライン対応:Socket.IO を利用して複数のプレイヤーが同時に参加し、互いの動きをリアルタイムで共有します。
  • シンプルなマップ生成:サーバ側でランダムな凸凹のある地面データ(マップ)を生成し、クライアント側と共有します。

2. クライアント側コードの解説

クライアント側は HTML と JavaScript を利用して、ゲームの描画やプレイヤー操作、サーバとの通信を行います。

HTML と CSS

まずは、HTML と CSS によってゲーム画面のレイアウトやスタイルを定義します。ログインオーバーレイ、ゲームキャンバス、チャットウィンドウ、ジョイスティックなどの UI 要素を配置しています。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>オンライン2D横スクロール風ゲーム</title>
  <style>
    body { margin: 0; overflow: hidden; font-family: sans-serif; }
    canvas { display: block; background: #87ceeb; }
    #chat {
      position: absolute;
      top: 10px;
      left: 10px;
      width: 300px;
      height: 200px;
      background: rgba(255,255,255,0.8);
      overflow-y: auto;
      padding: 5px;
      font-size: 14px;
      display: none;
    }
    #chatInput {
      position: absolute;
      top: 220px;
      left: 10px;
      width: 300px;
      box-sizing: border-box;
      display: none;
    }
    #joystick {
      position: absolute;
      bottom: 20px;
      left: 50%;
      transform: translateX(-50%);
      width: 100px;
      height: 100px;
      background: rgba(0,0,0,0.2);
      border-radius: 50%;
      touch-action: none;
      display: none;
    }
    #joystickInner {
      position: absolute;
      left: 50%;
      top: 50%;
      width: 40px;
      height: 40px;
      background: rgba(0,0,0,0.5);
      border-radius: 50%;
      transform: translate(-50%, -50%);
    }
    /* ログインオーバーレイ */
    #loginOverlay {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0, 0, 0, 0.7);
      color: white;
      display: flex;
      align-items: center;
      justify-content: center;
      flex-direction: column;
      z-index: 10;
    }
    #loginOverlay input {
      padding: 10px;
      font-size: 16px;
      margin-bottom: 10px;
    }
    #loginOverlay button {
      padding: 10px 20px;
      font-size: 16px;
    }
  </style>
</head>
<body>
  <!-- ログインオーバーレイ -->
  <div id="loginOverlay">
    <h2>ニックネームを入力してください</h2>
    <input type="text" id="nicknameInput" placeholder="例:Player1">
    <button id="loginButton">スタート</button>
  </div>

  <canvas id="gameCanvas"></canvas>
  <div id="chat"></div>
  <input type="text" id="chatInput" placeholder="チャットメッセージを入力">
  <div id="joystick">
    <div id="joystickInner"></div>
  </div>

  <!-- Socket.IO クライアントライブラリ -->
  <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
  <script>
    // ここからJavaScriptでゲームロジックを実装します
    // DOM要素の取得
    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    const chat = document.getElementById('chat');
    const chatInput = document.getElementById('chatInput');
    const joystick = document.getElementById('joystick');
    const joystickInner = document.getElementById('joystickInner');
    const loginOverlay = document.getElementById('loginOverlay');
    const nicknameInput = document.getElementById('nicknameInput');
    const loginButton = document.getElementById('loginButton');

    // キャンバスサイズの設定
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;

    // キャラクター画像の読み込み
    const characterImg = new Image();
    characterImg.src = "/wp-content/uploads/2025/03/ふくろくじゅさん.png";
    const charWidth = 50, charHeight = 50;

    // 自キャラと他プレイヤーの状態を管理するための変数
    let myPlayer = { id: null, nickname: "", x: 50, y: canvas.height - 150, vx: 0, vy: 0, direction: "left", onGround: false };
    let players = {};

    // サーバーから送られてくる共有マップデータ
    let mapData = [];

    // ジョイスティック用の設定変数
    let joystickStart = null;
    let joystickDelta = {x: 0, y: 0};

    // Socket.IO を利用してサーバーに接続
    const socket = io();

    // ログイン処理
    loginButton.addEventListener('click', function() {
      const nickname = nicknameInput.value.trim();
      if (nickname === "") return;
      // サーバーにログインイベントを送信
      socket.emit('login', { nickname: nickname });
      myPlayer.nickname = nickname;
      // ログイン画面を隠し、ゲームのUI(チャットやジョイスティック)を表示
      loginOverlay.style.display = 'none';
      chat.style.display = 'block';
      chatInput.style.display = 'block';
      joystick.style.display = 'block';
    });

    // Socket.IO の各種イベントハンドラ
    socket.on('connect', () => {
      console.log('Socket.IO 接続成功:', socket.id);
    });

    socket.on('init', (data) => {
      myPlayer.id = data.id;
      console.log('初期化情報:', data);
    });

    socket.on('state', (data) => {
      // 全プレイヤーの状態を更新
      players = data.players;
    });

    socket.on('map', (data) => {
      // サーバーから受信した共有マップの設定
      mapData = data.map;
    });

    socket.on('chat', (data) => {
      // チャットメッセージを画面に表示
      const msgDiv = document.createElement('div');
      msgDiv.textContent = `${data.nickname}: ${data.message}`;
      chat.appendChild(msgDiv);
      chat.scrollTop = chat.scrollHeight;
    });

    // チャット入力の送信処理
    chatInput.addEventListener('keydown', function(e) {
      if (e.key === 'Enter') {
        const message = chatInput.value.trim();
        if (message !== "") {
          socket.emit('chat', { message: message });
          chatInput.value = '';
        }
      }
    });

    // ジョイスティックのタッチ操作
    joystick.addEventListener('touchstart', function(e) {
      e.preventDefault();
      const touch = e.touches[0];
      joystickStart = { x: touch.clientX, y: touch.clientY };
    });
    joystick.addEventListener('touchmove', function(e) {
      e.preventDefault();
      if (!joystickStart) return;
      const touch = e.touches[0];
      joystickDelta.x = touch.clientX - joystickStart.x;
      joystickDelta.y = touch.clientY - joystickStart.y;
      const maxDist = 30;
      const dist = Math.sqrt(joystickDelta.x ** 2 + joystickDelta.y ** 2);
      if (dist > maxDist) {
        joystickDelta.x = (joystickDelta.x / dist) * maxDist;
        joystickDelta.y = (joystickDelta.y / dist) * maxDist;
      }
      joystickInner.style.transform = `translate(${joystickDelta.x}px, ${joystickDelta.y}px)`;
    });
    joystick.addEventListener('touchend', function(e) {
      e.preventDefault();
      // 下方向のスワイプでジャンプ(地面にいる場合)
      if (joystickDelta.y > 20 && myPlayer.onGround) {
        myPlayer.vy = -15;
        myPlayer.onGround = false;
      }
      // 左右移動:オフセットに応じて速度を設定
      if (Math.abs(joystickDelta.x) > 5) {
        myPlayer.vx = joystickDelta.x * 0.5;
        myPlayer.direction = joystickDelta.x > 0 ? "right" : "left";
      } else {
        myPlayer.vx = 0;
      }
      joystickStart = null;
      joystickDelta = {x: 0, y: 0};
      joystickInner.style.transform = `translate(0px, 0px)`;
    });

    // ゲームループ処理:物理演算、状態の送信、描画処理を行う
    function gameLoop() {
      // 物理演算:重力や移動の計算
      myPlayer.vy += 0.8;
      myPlayer.x += myPlayer.vx;
      myPlayer.y += myPlayer.vy;

      // 地面との衝突判定(共有マップデータを利用)
      let groundY = baseGroundY;
      if (mapData.length > 0) {
        for (let i = 0; i < mapData.length - 1; i++) {
          const p1 = mapData[i];
          const p2 = mapData[i+1];
          if (myPlayer.x + charWidth/2 >= p1.x && myPlayer.x + charWidth/2 <= p2.x) {
            const t = (myPlayer.x + charWidth/2 - p1.x) / (p2.x - p1.x);
            groundY = p1.y * (1 - t) + p2.y * t;
            break;
          }
        }
      }
      if (myPlayer.y + charHeight > groundY) {
        myPlayer.y = groundY - charHeight;
        myPlayer.vy = 0;
        myPlayer.onGround = true;
      }

      // サーバーへ自分の状態を送信
      if (socket.connected) {
        socket.emit('update', {
          id: myPlayer.id,
          x: myPlayer.x,
          y: myPlayer.y,
          vx: myPlayer.vx,
          vy: myPlayer.vy,
          direction: myPlayer.direction
        });
      }

      // 描画処理
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      // 背景(空)は canvas の背景色をそのまま使用

      // 地面の描画(共有マップ)
      if (mapData.length > 0) {
        ctx.fillStyle = "#808080";
        ctx.beginPath();
        ctx.moveTo(0, canvas.height);
        ctx.lineTo(mapData[0].x, mapData[0].y);
        for (let i = 1; i < mapData.length; i++) {
          ctx.lineTo(mapData[i].x, mapData[i].y);
        }
        ctx.lineTo(canvas.width, canvas.height);
        ctx.closePath();
        ctx.fill();
      }

      // 各プレイヤーの描画
      for (let id in players) {
        const p = players[id];
        ctx.save();
        if (p.direction === "right") {
          ctx.translate(p.x + charWidth/2, 0);
          ctx.scale(-1, 1);
          ctx.drawImage(characterImg, -charWidth/2, p.y, charWidth, charHeight);
        } else {
          ctx.drawImage(characterImg, p.x, p.y, charWidth, charHeight);
        }
        ctx.restore();
      }

      requestAnimationFrame(gameLoop);
    }
    gameLoop();

    // 画面リサイズに対応
    window.addEventListener('resize', function() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    });
  </script>
</body>
</html>

クライアント側コードのポイント

  • ログイン処理:プレイヤーがゲームに参加する際、ニックネームを入力することでログイン画面が消え、チャットやジョイスティックが有効になります。
  • Socket.IO による通信:サーバーとの接続確立後、loginupdatechat などのイベントを通じてリアルタイム通信を行います。
  • ゲームループrequestAnimationFrame を利用して、物理演算(重力、移動、衝突判定)と描画処理を毎フレーム実行しています。
  • キャラクター画像: 「DOTOWN」様の素材をお借りしています。
ふくろくじゅさん

3. サーバー側コードの解説

サーバー側は Python、Flask、Flask-SocketIO を利用して実装しています。ここでは、プレイヤーの管理や共有マップの生成、クライアントからの状態更新・チャットの中継を行っています。

import random
from flask import Flask, send_from_directory, request
from flask_socketio import SocketIO, emit

app = Flask(__name__, static_url_path='', static_folder='.')
app.config['SECRET_KEY'] = 'secret!'
socketio = SocketIO(app, cors_allowed_origins="*")  # CORS を許可

# グローバルなプレイヤー情報(各キーは接続時の request.sid)
players = {}

# サーバー側で生成するマップデータ(地面の凸凹ポイント群)
map_data = []
point_interval = 100
# ここでは一例としてキャンバス幅 1200px を想定
canvas_width = 1200
base_ground_y = 500  # 地面の基本の高さ
for x in range(0, canvas_width + point_interval, point_interval):
    # -10〜+10 のランダムなオフセットを適用
    offset = random.randint(-10, 10)
    map_data.append({'x': x, 'y': base_ground_y + offset})

@app.route('/')
def index():
    return send_from_directory('.', 'index.html')

@socketio.on('connect')
def handle_connect():
    print(f'Client connected: {request.sid}')
    # クライアントへ共有マップデータと現在のプレイヤー状態を送信
    emit('map', {'map': map_data})
    emit('state', {'players': players})

@socketio.on('login')
def handle_login(data):
    """
    クライアントから送られてくるデータ例:
    { "nickname": "Player1" }
    """
    nickname = data.get("nickname", "NoName")
    # 新規ログイン時に初期位置とニックネームを設定
    players[request.sid] = {
        "nickname": nickname,
        "x": 50, 
        "y": 300,
        "vx": 0,
        "vy": 0,
        "direction": "left"
    }
    print(f'Client {request.sid} logged in as {nickname}')
    # 接続したクライアントへ初期情報を送信
    emit('init', {'id': request.sid, 'nickname': nickname})
    # 最新のプレイヤー状態を全クライアントにブロードキャスト
    socketio.emit('state', {'players': players})

@socketio.on('update')
def handle_update(data):
    # クライアントからの状態更新(例: { "x": 100, "y": 300, "vx": 5, "vy": 0, "direction": "right" })
    if request.sid not in players:
        return
    players[request.sid].update({
        "x": data.get("x", players[request.sid]["x"]),
        "y": data.get("y", players[request.sid]["y"]),
        "vx": data.get("vx", 0),
        "vy": data.get("vy", 0),
        "direction": data.get("direction", "left")
    })
    # 更新された状態を全クライアントに送信
    socketio.emit('state', {'players': players})

@socketio.on('chat')
def handle_chat(data):
    """
    クライアントからのチャットメッセージ例:
    { "message": "Hello everyone!" }
    """
    message = data.get("message", "")
    nickname = players.get(request.sid, {}).get("nickname", request.sid)
    chat_data = {"nickname": nickname, "message": message}
    print(f"Chat from {nickname}: {message}")
    socketio.emit('chat', chat_data)

@socketio.on('disconnect')
def handle_disconnect():
    print(f'Client disconnected: {request.sid}')
    if request.sid in players:
        del players[request.sid]
    socketio.emit('state', {'players': players})

if __name__ == '__main__':
    # 外部からの接続を許可するためにホストを 0.0.0.0、ポート5000で起動
    socketio.run(app, host='0.0.0.0', port=5000)

サーバー側コードのポイント

  • Flask と Socket.IO の連携:Flask アプリケーションに Socket.IO を組み込み、リアルタイム通信を実現しています。
  • 共有マップの生成map_data にランダムなオフセットを持つ地面のポイント群を生成し、クライアントと共有します。
  • プレイヤー管理:各クライアントごとに request.sid をキーとしてプレイヤー情報を保持し、loginupdatedisconnect イベントで更新・削除を行います。
  • チャット機能:クライアントからのメッセージを受け取り、全員にブロードキャストします。

4. ゲームの動作確認と導入方法

必要な環境

  • Python 3.x
  • Flask および Flask-SocketIO のインストール
    例:
pip install flask flask-socketio

起動方法

  1. サーバー側のコード(Python ファイル)を保存します(例:server.py)。
  2. ターミナルで保存先ディレクトリに移動し、以下のコマンドを実行します。
python server.py
  1. ブラウザで クライアント側のHTMLを開くと、ログイン画面が表示されます。
  2. ニックネームを入力して「スタート」ボタンをクリックすると、ゲーム画面が表示され、ジョイスティック操作やチャットで他のプレイヤーと連携したオンラインゲームが開始されます。

5. まとめ

 今回紹介したサンプルは、シンプルながらもオンラインゲームの基本的な要素(リアルタイム通信、物理演算、マップ生成、チャット機能など)を含んでいます。初心者の方でもこのコードを通して、HTML/CSS/JavaScript によるフロントエンドの構築と、Python/Flask を使ったバックエンドの実装方法を学ぶことができます。ぜひ、実際にコードを動かしてみて、必要に応じてカスタマイズしながら自分だけのゲームを作ってみましょう!



関連記事