💽

Vue 3 でマルチプレイブラウザゲームを作る (4) データ編

2025/01/08に公開

つづき

ラウンジ(ホスト)編では、サーバーとクライアントの通信手段を定めたうえで、クライアント側のラウンジ(ホスト)画面を作成しました。

今回は、サーバー側の処理を作り始めていきます。繰り返しになりますが、サーバー側では Vue は使いません。

データ構造

参加者のグループ分けや、じゃんけんでどんな手を出したかなどをサーバーでは管理する必要があり、今回は SQLite3 でデータを管理します。

管理するデータは 2 種類です。

グループテーブル

同時に対戦する人々をまとめるグループを管理するためのテーブルです。

ホストがアクセスしてくる度に(つまり、引数なしの game.html へのアクセスがある度に)1 グループ作成され、1 レコード増えます。

「グループ ID」カラムがいわゆる ID で主キーとなっており、連番で付与されます。サーバー内部のみで使用し、外部には出しません。ROWID を使えば良いのかもしれませんが、取得に一手間かかりそうだったのでカラムを設けました。

「グループ UUID」カラムは UUID (v4) で付与されます。外部に(ブラウザ側に)グループ識別子を出したい時に使います。この仕様により、グループ専用 URL が "http://localhost:3000/public/game.html?group=599e15af-ff3d-40fc-92de-85791a0a5aa6" のような形になります。連番を外部に出すと意図しない参加者がグループに乱入できてしまうので、それを防ぐ仕様です。

「作成日時」カラムは現時点で未使用です。元々は、古いデータを削除する時に使えるかと思って用意したものです。

「ステータス」カラムでグループのゲーム進捗を管理します。参加者募集中なら hiring 、プレイが始まったら playing になります。

メンバーテーブル

参加者を管理するためのテーブルです。

ホストまたはゲストがアクセスしてくる度に(つまり、引数があろうとなかろうと game.html へのアクセスがある度に)1 メンバー作成され、1 レコード増えます。

「メンバー ID」カラムがいわゆる ID で主キーとなっており、連番で付与されます。

「所属グループ ID」カラムはそのメンバーがどのグループに所属しているかで、グループテーブルの「グループ ID」(連番のほう)です。

「参加順」カラムはグループ内でのアクセス順です。ホストが 0 で、1 人目のゲストが 1、2 人目のゲストが 2……のようになります。グループが異なればまた 0 から始まります。

「名前」カラムは参加者の名前です。現時点ではサーバーが勝手に決めているので、ホストが "Host"、ゲストは "Guest n"(n は参加順)になります。

「ステータス」カラムは参加者の参加状態です。プレイ中なら playing、ブラウザを閉じると withdrew になります。

「ソケット ID」カラムはそのままソケット ID です。

「出した手」カラムはじゃんけんの手です。まだ出していないなら thinking、出すと gu/choki/pa になります。

「勝利点」カラムは何回じゃんけんで勝ったかです。勝った時に +1 され、負けやあいこでは変動しません。

データベース作成

サーバー初回起動時にデータベースを作成します。作成しているのは create_database.js です(Web じゃんけんのソースコードは GitHub に上げてあります)。

create_database.js(抜粋)
const db = new sqlite3.Database(dbc.path);
db.serialize(() => {
    let sentence = "create table " + dbc.group.t + "("
        + dbc.group.cId + " " + dbc.tInt + " " + dbc.pPrimaryKey + " " + dbc.pAutoIncrement + ", "
        + dbc.group.cUuid + " " + dbc.tText + " " + dbc.pNotNull + " " + dbc.pUnique + ", "
        + dbc.group.cCreated + " " + dbc.tReal + " " + dbc.pNotNull + ", "
        + dbc.group.cStatus + " " + dbc.tInt + " " + dbc.pNotNull + ")";
    run(db, sentence);
    sentence = "create index index_" + dbc.group.cUuid + " on " + dbc.group.t + "(" + dbc.group.cUuid + ")";
    run(db, sentence);
});

function run(db, sentence) {
    console.log(sentence);
    db.run(sentence);
}

カラム名などを即値ではなく定数にしていますが、実際の SQL 文は

create table t_group(group_id integer primary key autoincrement, group_uuid text not null unique, group_created real not null, group_status integer not null)
create index index_group_uuid on t_group(group_uuid)

となります。

Node.js サーバーでの SQLite3 操作は非同期であることに注意が必要です。ソースコード上は create tablecreate index の順番になっていても、実際には create table が完了しないうちに create index されてしまうことがあり得ます。テーブルがないのにインデックスは張れないので正常に動作しません。

そこで登場するのが db.serialize() です。この中に記述したデータベース操作は同期的に(つまり順番に、1 つの操作が終わってから次の操作が)実行されます。

これにより、create tablecreate index の順番で実行されることが確実になります。

サーバー全体像

ここでサーバーの全体像について説明します。

サーバーの起点となるのは server.js で、以下のようになっています。

server.js(抜粋)
// データベース作成
const createDatabase = require("./create_database");
createDatabase.createDatabase();

// ルーティング
const routesIndex = require("./routes/index");
const expressApp = express();
routesIndex.setRoutesIndex(express, expressApp);

// ソケット通信
const setSocket = require("./lib/socket");
const httpServer = http.Server(expressApp);
setSocket.setSocket(httpServer);

// 待ち受け
const port = process.env.PORT || 3000;
httpServer.listen(port, function () {
  console.log("稼働開始。ポート番号:" + httpServer.address().port);
});

データベース作成は先ほど説明した通りです。

ルーティングについては routes フォルダーの index.js を呼びだしていますが、やっていることは分担編のサーバーと同じで、public フォルダーに配置したファイルを何の加工もせずにそのままブラウザに送りつけます。

ソケット通信について次章で詳しく説明します。

ソケット

ソケット通信処理を lib フォルダーの socket.js で行っています。

ラウンジ(ホスト)編でクライアント側のホストのラウンジ画面を作りましたので、そこに対応する部分を説明します。

socket.js(抜粋)
function setSocket(httpServer) {
    const io = require("socket.io")(httpServer);

    // socket.io 接続イベント
    io.on("connection", (socket) => {
        // 接続以外のクライアントからのイベント受信は io.on() ではなく socket.on() であることに注意
        // 新規グループ作成イベント
        socket.on(csc.socketEvents.newGroup, async () => {
            try {
                await onNewGroupRequestedAsync(socket);
            } catch (e) {
                notifyException(socket, e);
            }
        });
    });
}

io.on("connection") でクライアントからソケット接続された時の処理を定義します。クライアント側は接続時のイベント名が "connect" だったのにサーバー側は "connection" で微妙に違うので注意が必要です。

socket.on(csc.socketEvents.newGroup, async () => {
    try {
        await onNewGroupRequestedAsync(socket);
    } catch (e) {
        notifyException(socket, e);
    }
});

クライアントから csc.socketEvents.newGroup イベント(新規グループ作成依頼)を受信した際に、onNewGroupRequestedAsync() を実行します。io.on("connection") 内で記述していますが、onNewGroupRequestedAsync() の実行タイミングは接続時ではなく csc.socketEvents.newGroup イベント受信時になります。

onNewGroupRequestedAsync() の内容は以下のようになっています。

async function onNewGroupRequestedAsync(socket) {
    const uuid = crypto.randomUUID();
    const db = new sqlite3.Database(dbc.path);

    // グループテーブルに新規グループを登録:①
    const created = julianDay.dateTimeToModifiedJulianDate(new Date());
    await new Promise((resolve, reject) => {
        const sentence = "insert into " + dbc.group.t
            + "(" + dbc.group.cUuid + ", " + dbc.group.cCreated + ", " + dbc.group.cStatus + ") "
            + "values(?, ?, ?)";
        db.run(sentence, uuid, created, dbc.group.status.hiring, (err) => {
            if (err) {
                reject(new Error("グループ UUID が重複しました:" + uuid + ", " + err));
            } else {
                resolve();
            }
        });
    });

    // 登録したグループの ID などを取得:②
    const groupRecord = await selectGroupByUuidAsync(db, uuid);

    // グループメンバーテーブルにホストユーザーを登録:③
    await insertMemberAsync(db, groupRecord[dbc.group.cId], socket.id);

    // ソケット上のグループを登録
    socket.join(uuid);

    // クライアント(ホスト)にグループ UUID を通知
    socket.emit(csc.socketEvents.groupUuid, uuid);
}

①~③はデータベース処理ですが、複数のデータベース処理を順番に行うためのやり方がデータベース作成時とは異なっています。

データベース作成時に使用した db.serialize() は手軽に同期処理できて便利なのですが、途中でエラーが発生した場合に例外を発生させるとキャッチできません(もしかしたらうまいやり方があるのかもしれず、ご存じの方はご教示いただけると幸いです)。

仕方が無いので、地道に 1 行ずつ await で待機することで、データベース処理を順番に行っています。①のデータベース処理は

await new Promise((resolve, reject) => {
    const sentence = "insert into " + dbc.group.t
        + "(" + dbc.group.cUuid + ", " + dbc.group.cCreated + ", " + dbc.group.cStatus + ") "
        + "values(?, ?, ?)";
    db.run(sentence, uuid, created, dbc.group.status.hiring, (err) => {
        if (err) {
            reject(new Error("グループ UUID が重複しました:" + uuid + ", " + err));
        } else {
            resolve();
        }
    });
});

のようになっていますが、db.run() が完了した時の処理を次行から書いています。何かエラーがあれば reject() で例外を発生させ、エラーがなければ resolve() で正常終了です。JavaScript の Promise~resolve~reject パターンを使うことで、データベース処理の完了を待つことができています。

②③も関数内で Promise~resolve~reject パターンを使用しています。

db.serialize() よりも記述量が増えますが、着実に 1 行 1 行進められるので、これはこれでいいのかなという気もしています。

onNewGroupRequestedAsync() でデータベースに登録していることは、

  • グループテーブルに新規グループを登録
  • メンバーテーブルにホストを登録

です。

その後、socket.emit() でホストにグループ UUID を通知しています。

ラウンジ(ホスト)編のクライアント側の処理と合わせてソケット接続後の処理をまとめると

のようになります(ソケットで送信している部分を緑色で表示しています)。

データ編まとめ

今回はサーバー側のデータ構造を定め、ラウンジ画面に対応するサーバー処理を作成しました。

次回

  • (作成中)

Discussion