no-image

JavaScriptでグラブルみたいなゲームを作ろう!

みなさんグラブルやってますか。私は闇古戦場に向けて必死にヘルマニビス手に入れたのですが、結局1550万ダメージのポチ数減らなくてふて寝してます。フォールンソードに必殺ください。

グラブルは技術的になかなか面白い作りをしていて、もちろんひとりのプレイヤーとして普段は楽しんでいるのですが、技術者視点から見てワクワクする部分も結構あります。

そんなグラブルですが、一度自分であんなのを作ってみたいと思ったことはありませんか?作りたいですよね。よし、作りましょう!

グラブルを作るために

グラブルを作るためには、まずグラブルを知る必要があります。グラブルってどんな感じに動いているのでしょう。

グラブルは、というよりもブラウザゲームは基本的にほとんどをサーバサイドで処理します。ブラウザ側で処理していたら簡単に不正行為できちゃいますからね。

なので、基本的にクライアントサイドは表示だけ、計算などはサーバサイドでやります。

このあたりは詳しくは「グランブルーファンタジー(グラブル)はどんな技術で動いているのか」に書いているので、興味ある人は読んでみてください。

よって、どちらかというとサーバサイド偏重の作りになってしまいます。覚悟しておきましょう。

あと、グラブルをある程度やってシステムを大雑把に把握していることを前提にします。プログラミングをするときでもグラブルから逃げるな。

今回使うもの

  • Node.js
  • MongoDB

グラブルのサーバサイドはPHPで動いているらしいのですが、私がPHPさっぱりわからないのと、クライアントもサーバもJavaScriptで統一できた方が楽なので、Node.js使います。

データベースはMongoDBにします。本物のグラブルはこんなカジュアルなデータベース使ってないと思いますが、まあ今回はお遊びなので使いやすいのを選びました。

MongoDBはmacOSならbrewで入れられるので入れておいてください。WindowsやLinuxでのインストール方法は以下を参照してください:

https://docs.mongodb.com/manual/administration/install-community/

デフォルトのyumやaptのリポジトリだと石器時代みたいなバージョンのMongoDBが入ってくるので、必ず公式の手順に従って新しいのを入れてください。

今回目標とするもの

グラブルを作る、といっても全部作っていたら死にます。それかサイゲに就職できます。

私は死にたくないので、目標は軽めにしておきます。以下のような感じでどうでしょう:

  • クエスト選択画面がある
  • クエストを選択するとバトルが始まる
  • バトル中は攻撃やアビリティが使える

これだけできればもう実質グラブルでしょう。武器や召喚石は気が向いたら作ることにしましょう。属性相性は作りません。奥義チェインも作りません。硬直も作りません。めんどくさいんで。欲しかったら各自で作ってください。DIYの精神を大事にしましょう。

完成系はこんな感じです。

注意事項

今回作成するのは、あくまでお遊びの模倣品です。セキュリティなどについては一切考慮していません。他人の乗っ取りとか簡単にできます。

よってこのコードを絶対に仕事で使わないでください。何かあっても私は一切責任を取りません。あとサイゲに訴えられても知りません。

でも会社ぶっ壊したいならガンガン使ってください。私は反逆者(トリーズナー)を応援してます。

ソースコード

ソースコードは以下においてるんでメンドくさがりな人はそこから落としてください。

https://github.com/subterraneanflowerblog/guraburu-modoki

プロジェクトの準備

まず適当なディレクトリ(guraburu-modokiとかそんな名前でOK)を作ってnpm initしておきます。そうしたら必要そうなパッケージを入れておきます。

npm install --save express body-parser mongodb websocket
npm install --save passport passport-local express-session

expressはウェブサーバを簡単に作れるやつです。body-parserはexpressの追加モジュール。mongodbはその名の通りNode.jsからMongoDBを操作するためのやつです。websocketはリアルタイム通信で使います。

passportはログイン周りのあれこれです。passport-localはpassportの認証方式。express-sessionはセッション周り。

クライアントを作り始める

まずテスト用のクライアントを作りたいと思います。

その前にウェブサーバを立てる必要があります。「server.js」みたいなファイル名でスクリプトを用意してください。

以下のように書きます:

const express = require('express');
const app = express();

// 「web」フォルダを公開
app.use(express.static('web/private'));

// ポート3000で待ち受ける
app.listen(3000, () => console.log('Listening on port 3000'));

これだけでWebサーバが立って、「web」フォルダの中の「private」という名前のフォルダが公開されます。express便利ですね。

次にweb/privateフォルダを作って、その中に「index.html」を追加します。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>グなんとかブル</title>
<link rel="stylesheet" href="style.css"> <script src="home.js" defer></script>
</head>
<body>
</body>
</html>

ついでにhome.jsを作っておきます。中身は空っぽで大丈夫です。

これで一度server.jsを起動(node server.jsって打ったら起動できます)して、http://localhost:3000/にアクセスしてみてください。真っ白なページが表示されたはずです。

データベース作成

MongoDBを起動します。

sudo mkdir -p /tmp/mongo
mongod --dbpath=/tmp/mongo

必要なデータを登録します。以下のようなスクリプト書いて実行しましょう。init_database.jsみたいな名前で保存してnodeで叩いてください。

const MongoClient = require('mongodb').MongoClient;

MongoClient.connect('mongodb://localhost:27017/', async (err, client) => {
  if(err) {
    console.error('MongoDBに何か問題があるようです…');
    console.error(err);
    return;
  }

  // guraburuというデータベースに接続
  const db = client.db('guraburu');

  const abilityCollection = db.collection('ability');

  await abilityCollection.deleteMany({});

  const { insertedIds: abilityIds } = await abilityCollection.insertMany([
    {
      name: 'ウェポンバースト',
      type: 'buff',
      icon: '⚔️',
      recastTurn: 5,
      script: 'weapon_burst_1'
    },
    {
      name: 'レイジ',
      type: 'buff',
      icon: '💪',
      recastTurn: 5,
      script: 'rage_1'
    },
    {
      name: 'アーマーブレイク',
      type: 'attack',
      icon: '🛡',
      recastTurn: 5,
      script: 'armor_break_1'
    }
  ]);

  // ジョブ登録
  const jobCollection = db.collection('job');

  await jobCollection.deleteMany({});

  await jobCollection.insertOne({
    name: 'ファイター',
    graphic: '👩️‍',
    abilities: [
      abilityIds[0],
      abilityIds[1]
    ]
  });

  // キャラクター登録
  const characterCollection = db.collection('character');

  await characterCollection.deleteMany({});

  const { insertedIds: characterIds } = await characterCollection.insertMany([
    {
      name: 'シェロカルテ',
      graphic: '👧',
      type: '火',
      hp: 7000,
      attack: 8000,
      weapon: '剣',
      abilities: [
        abilityIds[2]
      ]
    }
  ]);

  // ユーザ登録
  const userCollection = db.collection('user');

  await userCollection.deleteMany({});

  await userCollection.insertOne({
    username: 'user',
    password: 'user', // 本当はパスワードを平文で保存しちゃダメだよ!ハッシュ化とかしてね
    name: 'ジータ',
    rank: 50,
    parties: [
      {
        name: '編成1',
        player: {
          job: 'ファイター',
          exAbilities: [abilityIds[2]]
        },
        members: [characterIds[0]]
      }
    ]
  });

  // エネミーグループ登録
  const enemyGroupCollection = db.collection('enemyGroup');

  await enemyGroupCollection.deleteMany({});

  const { insertedIds: enemyGroupIds } = await enemyGroupCollection.insertMany([
    {
      enemies: [
        {
          name: 'ゴリラ',
          graphic: '🦍',
          type: '土',
          hp: 1000000,
          attack: 80,
          ct: 3,
          overdriveDamage: 500000,
          breakDamage: 100000
        }
      ]
    },
    {
      enemies: [
        {
          name: 'カツウォヌス',
          type: '水',
          graphic: '🐟',
          hp: 2000000,
          attack: 50,
          ct: 2,
          overdriveDamage: 500000,
          breakDamage: 100000
        }
      ]
    }
  ]);

  // クエスト登録
  const questCollection = db.collection('quest');

  await questCollection.deleteMany({});

  await questCollection.insertMany([
    {
      title: 'ゴリラ Normal',
      enemyGroupId: enemyGroupIds[0]
    },
    {
      title: 'カツウォヌス Extreme',
      enemyGroupId: enemyGroupIds[1]
    }
  ]);

  client.close();
});

エラー出なけりゃオッケーです。

ログインの作成

データベースにユーザ情報を登録したのでログインの仕組みを作ります。このあたりはPassportというライブラリを使えば簡単に作れます。

まずwebフォルダ直下にlogin.htmlというファイルを作っておきます。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>ログイン</title>
</head>
<body>
<form action="/login" method="post">
  <label>username <input name="username"></label>
  <label>password <input name="password" type="password"></label>
  <input type="submit" value="login">
</form>
</body>
</html>

そしてserver.jsを大改造します。レッツゴー!

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const WebSocketServer = require('websocket').server;
const http = require('http');
const MongoClient = require('mongodb').MongoClient;
const ObjectID = require('mongodb').ObjectID;
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const session = require('express-session');

let client = null;

// MongoDBに接続に行く関数
async function connectMongoClient() {
  if(client) {
    return client;
  }

  client = await MongoClient.connect('mongodb://localhost:27017');
  return client;
}

// セッション周りの設定
app.use(session({
  secret: "hihiirokane-takusan-hosii",
  resave: false,
  saveUninitialized: false
}));

// Passportを使ったログイン認証
app.use(passport.initialize());
app.use(passport.session());

passport.use(new LocalStrategy(async (username, password, done) => {
  const client = await connectMongoClient();
  const userCollection = client.db('guraburu').collection('user');
  const user = await userCollection.findOne({ username });

  // パスワードが一致していれば通す
  if(user && user.password === password) {
    return done(null, user);
  } else {
    return done(null, false);
  }
}));

passport.serializeUser((user, done) => {
  return done(null, user.username);
});

passport.deserializeUser( async (username, done) => {
  const client = await connectMongoClient();
  const userCollection = client.db('guraburu').collection('user');
  return done(null, await userCollection.findOne({ username }));
});

// 送られてきたjsonをパースできるようにしておく
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// /loginにアクセスしてきたらログイン画面を返す
app.get('/login', (req, res) => {
  res.sendFile(__dirname + '/web/login.html');
});

// /loginにPOSTしてきたら認証
app.post('/login', passport.authenticate('local', { successRedirect: '/', failureRedirect: '/login' }));

// ここから先は認証が必要
app.use((req, res, next) => {
  if(req.isAuthenticated()) {
    next();
  } else {
    res.redirect('/login')
  }
});

// 「web/private」フォルダを公開
app.use(express.static('web/private'));

// ポート3000で待ち受ける
app.listen(3000, () => console.log('Listening on port 3000'));

いきなりだいぶ長くなりました!でもこれでログイン処理は完成です。

http://localhost:3000/にアクセスするとログイン画面が表示されるはずです。たぶん。あとは先ほど登録したユーザ情報、ユーザ名user、パスワードuserでログインできるはずです。

クエスト一覧の表示

次にクエスト一覧を表示できるようにしましょう。server.jsをいじって、クエスト一覧を取得するエンドポイントを作ります。

app.listen(最終行)の上あたりに追加します。

// /quest/listにGETでアクセスしてきたらクエストのリストをJSONで返す
app.get('/quest/list', async (req, res) => {
  const client = await connectMongoClient();
  const questCollection = client.db('guraburu').collection('quest');
  res.json(await questCollection.find({}).toArray());
});

あとはこれをウェブページ側から取得して表示します。web/privateフォルダの中に作っておいたhome.jsというファイルをいじります。

(async function main() {
  // クエストリストを取得
  const questList = await fetch('/quest/list').then((res) => res.json());

  // クエストごとにボタン作成
  for(const quest of questList) {
    const button = document.createElement('button');
    button.innerText = quest.title;
    document.body.appendChild(button);
  }
})();

これでサーバを再起動(Ctrl+Cで止めてもう一回起動)してhttp://localhost:3000/にアクセスしてログインすると、クエスト一覧が表示されるはずです。

表示されない場合は、MongoDBがちゃんと起動しているか確かめてください。

バトルの作成

次は実際にバトルを作っていきたいのですが、正直グラブルがどうやってるのかさっぱりわかりません。なのでとりあえず次のような方法でやってみます:

  • サーバにクエストIDをリクエスト
  • サーバはクエストIDに基づくバトルルームを作成してルームIDを返す
  • クライアントはルームIDに参戦

そろそろプログラムが大きくなってきたのでモジュールで作っていきましょうか。libフォルダを作ってそこにbattle.jsを作ります。

class BattleRoom {
  constructor(enemyDataList) {
    this._playerMap = new Map();

    this._enemyDataList = enemyDataList.map((e) => ({
      ...e,
      currentHp: e.hp,
      currentCt: 0,
      currentMode: 'normal',
      currentModeGauge: 0,
      status: []
    }));
  }

  get playerList() {
    return Array.from(this._playerMap.values());
  }

  get roomData() {
    const self = this;

    return {
      players: self.playerList,
      enemies: self._enemyDataList.map((e) => ({
        name: e.name,
        graphic: e.graphic,
        hp: e.currentHp / e.hp,
        maxCt: e.ct,
        currentCt: e.currentCt,
        currentMode: e.currentMode,
        currentModeGauge: e.currentModeGauge,
        status: e.status
      }))
    };
  }

  joinPlayerParty(playerParty) {
    this._playerMap.set(playerParty._id.toString(), playerParty);
  }

  getPlayerById(id) {
    return this._playerMap.get(id.toString());
  }
}

module.exports = {
  BattleRoom
};

データを保持するのと、データを返すだけですね。

次に、サーバ側に、クエストIDを受け付けてバトルIDを発行する仕組みを作りましょう。

だいぶごちゃごちゃしてきたので一旦整理します。

lib/data.jsというファイルを作ってそこにデータ処理周りを書きます。本来ならマスターデータから基礎情報を読み込んで計算式にいろいろ載せて算出して……みたいなことをやるのですが、ここでは横着して数値を直打ちしてたりします。

const MongoClient = require('mongodb').MongoClient;
const ObjectID = require('mongodb').ObjectID;

// MongoDBとの接続
const connectMongoClient = MongoClient.connect('mongodb://localhost:27017');

// クエストIDからエネミーグループを取得する関数
async function findEnemyGroupByQuestId(questId) {
  const client = await connectMongoClient;
  const db = client.db('guraburu');

  const questCollection = db.collection('quest');
  const quest = await questCollection.findOne({ _id: new ObjectID(questId)});

  const enemyGroupCollection = db.collection('enemyGroup');
  const enemyGroup = await enemyGroupCollection.findOne({ _id: quest.enemyGroupId });

  return enemyGroup;
}

// パーティのデータ内にあるジョブ名やアビリティIDなどを、
// ただのIDから実際のデータに置き換える関数
async function resolvePlayerPartyData(user, party) {
  const client = await connectMongoClient;
  const db = client.db('guraburu');

  const jobCollection = db.collection('job');
  const abilityCollection = db.collection('ability');
  const characterCollection = db.collection('character');

  const playerJob = await jobCollection.findOne({ name: party.player.job });

  const playerAbilityIds = [...playerJob.abilities, ...party.player.exAbilities];
  const playerAbilities = await Promise.all(playerAbilityIds.map((id) => abilityCollection.findOne({_id: id})));

  const playerData = {
    name: user.name,
    graphic: playerJob.graphic,
    abilities: playerAbilities,
    hp: 8000,
    currentHp: 8000,
    attack: 9000,
    charge: 0,
status: [] }; const memberData = await Promise.all(party.members.map( (id) => characterCollection.findOne({_id: id}))); const resolvedMemberData = await Promise.all(memberData.map(async (c) => { c.abilities = await Promise.all(c.abilities.map((id) => abilityCollection.findOne({_id: id}))); c.abilities = c.abilities.map((a) => { a.recast = 0; return a; }); c.currentHp = c.hp; c.charge = 0; c.status = []; return c; })); const battleParty = { _id: user._id, playerName: user.name, members: [playerData, ...resolvedMemberData] }; return battleParty; } module.exports = { connectMongoClient, findEnemyGroupByQuestId, resolvePlayerPartyData };

キャラデータの中にはアビリティ等がIDで格納されてるので、それを展開してやる処理なんかを書いてます。

また、キャラデータのままだと現在HPとか奥義ゲージの情報がないので追加してやってます。

あとはserver.jsを書き換えます。クエストIDが飛んできたらルームを作るような機能を作っておきます。

細かいところがだいぶ変わってるので全部コピペしてください。

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const WebSocketServer = require('websocket').server;
const http = require('http');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const session = require('express-session');
const crypto = require('crypto');

const { connectMongoClient, findEnemyGroupByQuestId, resolvePlayerPartyData } = require('./lib/data');
const { BattleRoom } = require('./lib/battle');

const battleMap = new Map();

// セッション周りの設定
app.use(session({
  secret: "hihiirokane-takusan-hosii",
  resave: false,
  saveUninitialized: false
}));

// Passportを使ったログイン認証
app.use(passport.initialize());
app.use(passport.session());

passport.use(new LocalStrategy(async (username, password, done) => {
  const client = await connectMongoClient;
  const userCollection = client.db('guraburu').collection('user');
  const user = await userCollection.findOne({ username });

  // パスワードが一致していれば通す
  if(user && user.password === password) {
    delete user.password;
    return done(null, user);
  } else {
    return done(null, false);
  }
}));

passport.serializeUser((user, done) => {
  return done(null, user.username);
});

passport.deserializeUser( async (username, done) => {
  const client = await connectMongoClient;
  const userCollection = client.db('guraburu').collection('user');
  return done(null, await userCollection.findOne({ username }, { password: false }));
});

// 送られてきたjsonをパースできるようにしておく
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// /loginにアクセスしてきたらログイン画面を返す
app.get('/login', (req, res) => {
  res.sendFile(__dirname + '/web/login.html');
});

// /loginにPOSTしてきたら認証
app.post('/login', passport.authenticate('local', { successRedirect: '/', failureRedirect: '/login' }));

// ここから先は認証が必要
app.use((req, res, next) => {
  if(req.isAuthenticated()) {
    next();
  } else {
    res.redirect('/login')
  }
});

// 「web/private」フォルダを公開
app.use(express.static('web/private'));

// /quest/listにGETでアクセスしてきたらクエストのリストをJSONで返す
app.get('/quest/list', async (req, res) => {
  const client = await connectMongoClient;
  const questCollection = client.db('guraburu').collection('quest');
  res.json(await questCollection.find({}, { enemyGroupId: false }).toArray());
});

// /quest/startにクエストIDをPOSTするとクエストスタート
app.post('/quest/start', async (req, res) => {
  const questId = req.body.questId;

  const enemyGroup = await findEnemyGroupByQuestId(questId);
  const battleParty = await resolvePlayerPartyData(req.user.name, req.user.parties[0]);

  const roomId = crypto.randomBytes(16).toString('hex'); // ランダムな部屋ID。この方法だと被る可能性あるので真面目にやる時は別の方法で
  const room = new BattleRoom(battleParty, enemyGroup.enemies);

  // ルームIDとバトルを紐付け
  battleMap.set(roomId, room);

  res.json({ roomId });
});

// ポート3000で待ち受ける
app.listen(3000, () => console.log('Listening on port 3000'));

// WebSocketサーバを作る
const server = http.createServer();
server.listen(4000, () => console.log('WebSocket listening on port 4000'));
const websocketServer = new WebSocketServer({
  httpServer: server
});

websocketServer.on('request', (request) => {
  const connection = request.accept('', request.origin);

  connection.on('message', (message) => {
    if (message.type === 'utf8') {
      connection.sendUTF('Hello');
    }
  });
});

/quest/startにクエストIDをPOSTすれば、サーバ側でルームが作られクエストが開始するようになりました。

あとはweb/private/home.jsをいじります。

(async function main() {
  // クエストリストを取得
  const questList = await fetch('/quest/list').then((res) => res.json());

  // クエストごとにボタン作成
  for(const quest of questList) {
    const button = document.createElement('button');
    button.innerText = quest.title;
    button.addEventListener('click', async (event) => {
      // /quest/startにクエストIDをPOSTしてルームIDを受け取る
      const { roomId } = await fetch('/quest/start', {
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        },
        method: 'POST',
        credentials: 'include',
        body: `{ "questId": "${quest._id}" }`
      }).then((res) => res.json());

      // バトル画面に移動する
      location.href = `battle.html?room=${roomId}`;
    });
    document.body.appendChild(button);
  }
})();

ボタンを押したらクエスト作成をサーバに依頼して、返ってきたルームIDを見て戦闘画面に移動します。

移動先であるweb/private/battle.htmlを作っておきます。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>グなんとかブル</title>
  <link rel="stylesheet" href="style.css">
  <script src="battle.js" defer></script>
</head>
<body>
  <div class="screen">
    <div class="enemy-info"></div>
    <div class="stage">
      <div class="stage-enemyside"></div>
      <div class="stage-playerside"></div>
    </div>
  </div>
  <div class="attack-button-container">
    <button class="attack-button">攻撃</button>
  </div>
  <div class="control-container"></div>
</body>
</html>

web/private/battle.jsも作っておいてください。中身は空っぽで大丈夫です。

これでサーバを再起動して、クエストを選ぶとバトル画面に移行するはずです。画面はほぼ真っ白ですが。

バトル画面の作成

次にバトル画面の作成です。バトル画面の通信はWebSocketでリアルタイムにやります。本家グラブルもそうしてたはずです。たぶん。

まずサーバ側でWebSocketサーバを立てます。server.jsの下の方にあるWebSocketのところをいじってください。

// WebSocketサーバを作る
const server = http.createServer();
server.listen(4000, () => console.log('WebSocket listening on port 4000'));
const websocketServer = new WebSocketServer({
  httpServer: server
});

websocketServer.on('request', (request) => {
  // 接続を無条件に受け入れる
  // 本来は認証とか入れないと垢ハックできちゃうからダメだよ!
  // 今回はお遊びだからね
  const connection = request.accept('', request.origin);

  // クライアントからメッセージが届いたとき
  connection.on('message', (message) => {
    const json = JSON.parse(message.utf8Data);

    // joinメッセージならルームデータを送る
    if(json.command === 'join') {
      const room = battleMap.get(json.payload.room);
      connection.sendUTF(JSON.stringify({
        type: 'roomdata',
        payload: {
          playerData: room.getPlayerById(json.payload.playerId),
          roomData: room.roomData
        }
      }));
    }
  });
});

これで接続してきたユーザがルームに入れるようになります。

あと自分のID取得するためのエンドポイントも追加しておきます。追加するのはどこでもいいのですが、例えばget(‘/quest/list’)の上あたりに追加しておいてください。

// ユーザ情報の取得
// とりあえずIDだけ返す
app.get('/user/authenticated', (req, res) => {
  res.json({ id: req.user._id.toString() });
});

これでサーバにWebSockerで接続してjoinコマンドを送ればルームの情報を受け取れるようになりました。

本当はexpressのセッション情報使いまわしてWebSocket側でも認証したかったのですが、やり方分からなかったので逐一プレイヤーIDを送るようにしました。ちなみにセキュリティ的に危ないので真似したらダメです。

あとはクライアント側です。web/private/battle.jsをいじって情報の取得・表示をできるようにしましょう。ほぼ表示関係のコードになります。

// URLからルームIDを取得する
const params = new URLSearchParams(location.href.split('?')[1]);
const roomId = params.get('room');

// 敵データを描画する関数
function renderEnemyDataList(enemyDataList) {
  const enemyInfo = document.querySelector('.enemy-info');
  const enemyStage = document.querySelector('.stage-enemyside');

  // 内容を一旦削除
  enemyInfo.innerHTML = '';
  enemyStage.innerHTML = '';

  // 敵ごとにHPバー、CT、ODゲージを表示
  for(const enemy of enemyDataList) {
    const enemyHpPercentage = document.createElement('div');
    enemyHpPercentage.innerText = Math.ceil(enemy.hp * 100).toString() + '%';

    // HPバー
    const enemyHpBar = document.createElement('meter');
    enemyHpBar.classList.add('enemy-hp-bar');
    enemyHpBar.min = 0;
    enemyHpBar.max = 1;
    enemyHpBar.value = enemy.hp;

    const enemyCtAndOverdriveContainer = document.createElement('div');
    enemyCtAndOverdriveContainer.classList.add('enemy-ct-and-overdrive-container');

    // CT
    const enemyCt = document.createElement('div');
    enemyCt.innerText = 'CT' + new Array(enemy.currentCt).fill('◆').join('').padEnd(enemy.maxCt, '◇');
    enemyCtAndOverdriveContainer.appendChild(enemyCt);

    // ODゲージ
    const enemyModeGaugeContainer = document.createElement('div');
    const enemyModeGaugeLabel = new Text('Mode');
    const enemyModeGauge = document.createElement('meter');
    enemyModeGauge.classList.add('enemy-mode-gauge');
    enemyModeGauge.min = 0;
    enemyModeGauge.max = 1;
    enemyModeGauge.value = enemy.currentModeGauge;
    enemyModeGaugeContainer.appendChild(enemyModeGaugeLabel);
    enemyModeGaugeContainer.appendChild(enemyModeGauge);
    enemyCtAndOverdriveContainer.appendChild(enemyModeGaugeContainer);

    enemyInfo.appendChild(enemyHpPercentage);
    enemyInfo.appendChild(enemyHpBar);
    enemyInfo.appendChild(enemyCtAndOverdriveContainer);

    // グラフィック表示
    const enemyGraphic = document.createElement('div');
    enemyGraphic.innerText = enemy.graphic;
    enemyGraphic.classList.add('enemy-graphic');
    const enemyName = document.createElement('div');
    enemyName.innerText = enemy.name;

    enemyStage.appendChild(enemyGraphic);
    enemyStage.appendChild(enemyName);
  }
}

// プレイヤーのパーティを描画する関数
function renderPlayerParty(party) {
  const playerStage = document.querySelector('.stage-playerside');
  const controlContainer = document.querySelector('.control-container');

  // 内容を一旦削除
  playerStage.innerHTML = '';
  controlContainer.innerHTML = '';

  for(const character of party.members) {
    const characterGraphic = document.createElement('div');
    characterGraphic.innerText = character.graphic;
    characterGraphic.classList.add('character-graphic');
    playerStage.appendChild(characterGraphic);

    const characterControl = document.createElement('div');

    const characterName = document.createElement('div');
    characterName.innerText = character.graphic + character.name;
    characterControl.appendChild(characterName);

    const characterHp = document.createElement('div');
    const characterHPText = document.createElement('span');
    characterHPText.innerText = character.currentHp.toString();
    const characterHpBar = document.createElement('meter');
    characterHpBar.min = 0;
    characterHpBar.max = character.hp;
    characterHpBar.value = character.currentHp;
    characterHp.appendChild(characterHPText);
    characterHp.appendChild(characterHpBar);
    characterControl.appendChild(characterHp);

    const characterCharge = document.createElement('div');
    const characterChargeText = document.createElement('span');
    characterChargeText.innerText = character.charge + '%';
    const characterChargeBar = document.createElement('meter');
    characterChargeBar.min = 0;
    characterChargeBar.max = 100;
    characterChargeBar.value = character.charge;
    characterCharge.appendChild(characterChargeText);
    characterCharge.appendChild(characterChargeBar);
    characterControl.appendChild(characterCharge);

    const abilityRow = document.createElement('div');
    abilityRow.classList.add('ability-row');
    for(const ability of character.abilities) {
      const abilityButton = document.createElement('button');
      abilityButton.classList.add('ability-button');
      abilityButton.classList.add(`ability-button-${ability.type}`);
      abilityButton.innerText = ability.icon + ability.name;
      abilityRow.appendChild(abilityButton);
    }
    characterControl.appendChild(abilityRow);

    controlContainer.appendChild(characterControl);
  }
}

// 自分のID取得する
fetch('/user/authenticated')
  .then((res) => res.json())
  .then((user) => {
    // WebSocketでサーバに接続する
    const ws = new WebSocket('ws://localhost:4000/');

    // 接続がオープンしたらjoinコマンドを送る
    ws.addEventListener('open', (event) => {
      ws.send(JSON.stringify({ command: 'join', payload: { playerId: user.id, room: roomId } }));
    });

    // サーバからメッセージが来たら処理する
    ws.addEventListener('message', (event) => {
      const response = JSON.parse(event.data);

      // roomdataが来たら画面を初期化する
      if(response.type === 'roomdata') {
        const roomData = response.payload.roomData;
        const playerData = response.payload.playerData;

        renderEnemyDataList(roomData.enemies);
        renderPlayerParty(playerData);
      }
    });
  });

自分のIDを取得して、そのIDとルームIDをサーバに送信、するとルームとの接続が始まって情報が送られてくる、という感じですね。

あとはCSSもいじって表示を整えます。we/private/style.cssをいじります。

body {
  max-width: 600px;
}

.enemy-ct-and-overdrive-container {
  display: flex;
  justify-content: space-between;
}

.enemy-hp-bar {
  width: 100%;
}

.stage {
  display: flex;
}

.stage-enemyside,
.stage-playerside {
  flex: 1;
}

.stage-playerside {
  display: flex;
  flex-wrap: wrap;
}

.enemy-graphic {
  font-size: 10em;
}

.character-graphic {
  font-size: 5em;
  width: 50%;
}

.attack-button-container {
  text-align: right;
}

.attack-button {
  border: none;
  border-radius: 3px;
  background-color: rgb(186, 57, 44);
  color: white;
  font-size: 1.5em;
  padding: 0.3em 2em;
}

.ability-row {
  display: flex;
  justify-content: space-between;
}

.ability-button {
  border: 5px solid;
  border-radius: 3px;
  font-size: 1em;
  padding: 0.3em 1em;
}

.ability-button[disabled] {
  background-color: gray;
}

.ability-button-buff {
  border-color: rgb(217, 200, 76);
}

.ability-button-attack {
  border-color: rgb(217, 44, 62);
}

これでサーバ再起動してどうなるか確認してみましょう。

 

わりとそれっぽくなりました!!まだ何も動きませんが、この戦闘画面は紛れもなくやつです。グラブルです。カツオもいるし。

ターンを回せるようにする

次はターン動かせるようにしたいですね。こっちが攻撃して敵が攻撃し返してきて、というやつです。やるべきことが結構多いので頑張りましょう。

サーバ側のロジックから作っちゃいましょう。DA/TAとかにも対応して、奥義ゲージ増加にも対応、奥義発動にも対応して、敵からの反撃に対応して、CT増加にも……大変ですね。

実装がめんどくさそうな奥義チェインは無しにしましょうか。なにより私が作りたくないんで。

lib/battle.jsをいじります。サーバ側でやることは、ダメージのやりとりの計算と、アニメーション用のパラメータ生成です。アニメーションパラメータはクライアントに送って、クライアント側で再生してもらいます。

間違ってもクライアント側で計算とかやっちゃだめです。クライアント側なんていくらでもユーザがいじれるので、チートし放題になります。なので、クライアント側は押したボタンの情報とかを送るだけにします。

class BattleRoom {
  constructor(enemyDataList) {
    this._playerMap = new Map();

    this._enemyDataList = enemyDataList.map((e) => ({
      ...e,
      currentHp: e.hp,
      currentCt: 0,
      currentMode: 'normal',
      currentModeGauge: 0,
      status: []
    }));
  }

  get playerList() {
    return Array.from(this._playerMap.values());
  }

  get roomData() {
    const self = this;

    return {
      players: self.playerList,
      enemies: self._enemyDataList.map((e) => ({
        name: e.name,
        graphic: e.graphic,
        hp: e.currentHp / e.hp,
        maxCt: e.ct,
        currentCt: e.currentCt,
        currentMode: e.currentMode,
        currentModeGauge: e.currentModeGauge,
        status: e.status
      }))
    };
  }

  joinPlayerParty(playerParty) {
    this._playerMap.set(playerParty._id.toString(), playerParty);
  }

  getPlayerById(id) {
    return this._playerMap.get(id.toString());
  }

  damageToEnemy(characterIndex, targetIndex, damage) {
    const target = this._enemyDataList[targetIndex];
    const animations = {};

    target.currentHp = Math.max(target.currentHp - damage, 0);
    animations.damageAnimation = {
      type: 'damage',
      characterIndex,
      targetIndex,
      damage,
      enemyHp: target.currentHp / target.hp,
      enemyModeGauge: target.currentModeGauge
    };

    // ODゲージ計算
    if(target.currentMode === 'normal') {
      const diff = damage / target.overdriveDamage;
      target.currentModeGauge = Math.min(target.currentModeGauge + diff, 1.0);
    } else if(target.currentMode === 'overdrive') {
      const diff = damage / target.breakDamage;
      target.currentModeGauge = Math.max(target.currentModeGauge - diff, 0.0);
    }

    // ODモード変化
    if(target.currentMode === 'normal' && target.currentModeGauge === 1) {
      target.currentMode = 'overdrive';
      animations.overdriveAnimation = { type: 'overdrive', targetIndex };
    } else if(target.currentMode === 'overdrive' && target.currentModeGauge === 0) {
      target.currentMode = 'break';
      setTimeout(() => target.currentMode = 'normal',1000 * 30);
      animations.overdriveAnimation = { type: 'break', targetIndex };
    }

    return animations;
  }

  processTurn(playerId, enableChargeAttack, targetIndex = 0) {
    const player = this.getPlayerById(playerId);
    const target = this._enemyDataList[targetIndex];
    const damageAnimations = [];
    const overdriveAnimations = [];
    const receivedDamageAnimations = [];

    // プレイヤー側処理
    player.members.forEach((character, index) => {
      // ダブルアタック、トリプルアタックの判定。確率は適当
      const isDoubleAttack = Math.random() < 0.2;
      const isTripleAttack = Math.random() < 0.1;

      const attackNum = isTripleAttack ? 3 : isDoubleAttack ? 2 : 1;

      // 奥義
      if(enableChargeAttack && character.charge >= 100) {
        // 奥義倍率3倍ぐらい
        const damage = Math.round(character.attack * 3 * (1 + Math.random()/10));
        const animation = this.damageToEnemy(index, targetIndex, damage);

        if(animation.damageAnimation) {
          damageAnimations.push(animation.damageAnimation);
        }

        if(animation.overdriveAnimation) {
          overdriveAnimations.push(animation.overdriveAnimation);
        }

        character.charge -= 100;
        return;
      }

      // 攻撃回数の数だけ攻撃する
      for(let i = 0; i < attackNum; i++) {
        // 奥義ゲージ増加
        character.charge = Math.min(character.charge + [10, 12, 15][i], 100);

        // ダメージ計算。計算式は適当。乱数混ぜてたらそれっぽくなるっしょ
        const damage = Math.round(character.attack * (1 + Math.random()/10));
        const animation = this.damageToEnemy(index, targetIndex, damage);

        // クライアントに送信するアニメーションを追加
        if(animation.damageAnimation) {
          damageAnimations.push(animation.damageAnimation);
        }

        if(animation.overdriveAnimation) {
          overdriveAnimations.push(animation.overdriveAnimation);
        }

        // 途中で死んだら終わり
        if(target.currentHp <= 0) {
          break;
        }
      }
    });

    // 敵側処理
    this._enemyDataList.forEach((e, index) => {
      // 攻撃対象をランダムに決める
      const targetIndex = Math.floor(Math.random() * player.members.length);
      const target = player.members[targetIndex];

      // 攻撃
      const damage = Math.round(e.attack * (1 + Math.random()/10));
      target.currentHp = Math.max(target.currentHp - damage, 0);

      receivedDamageAnimations.push({
        type: 'receiveddamage',
        targetIndex,
        damage
      });

      // CT進める
      e.currentCt += 1;
      if(e.currentCt > e.ct) {
        e.currentCt = 0;
      }
    });

    // アニメーションは、与ダメージ -> OD -> 被ダメージ、の順
    return [...damageAnimations, ...overdriveAnimations, ...receivedDamageAnimations];
  }
}

module.exports = {
  BattleRoom
};

とりあえず基本的な要素とODゲージ周りを実装してみました。他にも好きな要素があれば実装してみてください。私はこの辺で飽きました。

CT技も実装してないのですが、余裕のある人は実装してみてください。とりあえず攻撃力1.5倍の攻撃をしてくる、とかから始めればいいと思います。

次にserver.jsのWebSocket部分をいじります。一番下の部分です。攻撃コマンドが送信されてきたときの処理を書きます。

// WebSocketサーバを作る
const server = http.createServer();
server.listen(4000, () => console.log('WebSocket listening on port 4000'));
const websocketServer = new WebSocketServer({
  httpServer: server
});

websocketServer.on('request', (request) => {
  // 接続を無条件に受け入れる
  // 本来は認証とか入れないと垢ハックできちゃうからダメだよ!
  // 今回はお遊びだからね
  const connection = request.accept('', request.origin);

  // クライアントからメッセージが届いたとき
  connection.on('message', (message) => {
    const json = JSON.parse(message.utf8Data);
    const room = battleMap.get(json.payload.room);

    // joinメッセージならルームデータを送る
    if(json.command === 'join') {
      connection.sendUTF(JSON.stringify({
        type: 'roomdata',
        payload: {
          playerData: room.getPlayerById(json.payload.playerId),
          roomData: room.roomData
        }
      }));
    }

    if(json.command === 'attack') {
      const animations = room.processTurn(json.payload.playerId, json.payload.enableChargeAttack);

      // たぶん本来は差分結果だけ送って転送量削減とかするんだろうけど
      // 面倒なので全データ送る
      connection.sendUTF(JSON.stringify({
        type: 'turnanimation',
        payload: {
          animations,
          playerData: room.getPlayerById(json.payload.playerId),
          roomData: room.roomData
        }
      }))
    }
  });
});

攻撃コマンドが送られてきたら、ターンを進めて、アニメーションリストを送り返します。

これでサーバ側の処理はできたので次にクライアント側ですね。web/private/battle.jsをいじります。攻撃コマンドの送信と、返ってきたアニメーションの再生がメインです。

// URLからルームIDを取得する
const params = new URLSearchParams(location.href.split('?')[1]);
const roomId = params.get('room');

// 敵データを描画する関数
function renderEnemyDataList(enemyDataList) {
  const enemyInfo = document.querySelector('.enemy-info');
  const enemyStage = document.querySelector('.stage-enemyside');

  // 内容を一旦削除
  enemyInfo.innerHTML = '';
  enemyStage.innerHTML = '';

  // 敵ごとにHPバー、CT、ODゲージを表示
  for(const enemy of enemyDataList) {
    const enemyHpPercentage = document.createElement('div');
    enemyHpPercentage.innerText = Math.ceil(enemy.hp * 100).toString() + '%';

    // HPバー
    const enemyHpBar = document.createElement('meter');
    enemyHpBar.classList.add('enemy-hp-bar');
    enemyHpBar.min = 0;
    enemyHpBar.max = 1;
    enemyHpBar.value = enemy.hp;

    const enemyCtAndOverdriveContainer = document.createElement('div');
    enemyCtAndOverdriveContainer.classList.add('enemy-ct-and-overdrive-container');

    // CT
    const enemyCt = document.createElement('div');
    enemyCt.innerText = 'CT' + new Array(enemy.currentCt).fill('◆').join('').padEnd(enemy.maxCt, '◇');
    enemyCtAndOverdriveContainer.appendChild(enemyCt);

    // ODゲージ
    const enemyModeGaugeContainer = document.createElement('div');
    const enemyModeGaugeLabel = new Text('Mode');
    const enemyModeGauge = document.createElement('meter');
    enemyModeGauge.classList.add('enemy-mode-gauge');
    enemyModeGauge.min = 0;
    enemyModeGauge.max = 1;
    enemyModeGauge.value = enemy.currentModeGauge;
    enemyModeGaugeContainer.appendChild(enemyModeGaugeLabel);
    enemyModeGaugeContainer.appendChild(enemyModeGauge);
    enemyCtAndOverdriveContainer.appendChild(enemyModeGaugeContainer);

    enemyInfo.appendChild(enemyHpPercentage);
    enemyInfo.appendChild(enemyHpBar);
    enemyInfo.appendChild(enemyCtAndOverdriveContainer);

    // グラフィック表示
    const enemyGraphic = document.createElement('div');
    enemyGraphic.innerText = enemy.graphic;
    enemyGraphic.classList.add('enemy-graphic');
    const enemyName = document.createElement('div');
    enemyName.innerText = enemy.name;

    enemyStage.appendChild(enemyGraphic);
    enemyStage.appendChild(enemyName);
  }
}

// プレイヤーのパーティを描画する関数
function renderPlayerParty(party) {
  const playerStage = document.querySelector('.stage-playerside');
  const controlContainer = document.querySelector('.control-container');

  // 内容を一旦削除
  playerStage.innerHTML = '';
  controlContainer.innerHTML = '';

  for(const character of party.members) {
    const characterGraphic = document.createElement('div');
    characterGraphic.innerText = character.graphic;
    characterGraphic.classList.add('character-graphic');
    playerStage.appendChild(characterGraphic);

    const characterControl = document.createElement('div');

    const characterName = document.createElement('div');
    characterName.innerText = character.graphic + character.name;
    characterControl.appendChild(characterName);

    const characterHp = document.createElement('div');
    const characterHPText = document.createElement('span');
    characterHPText.innerText = character.currentHp.toString();
    const characterHpBar = document.createElement('meter');
    characterHpBar.min = 0;
    characterHpBar.max = character.hp;
    characterHpBar.value = character.currentHp;
    characterHp.appendChild(characterHPText);
    characterHp.appendChild(characterHpBar);
    characterControl.appendChild(characterHp);

    const characterCharge = document.createElement('div');
    const characterChargeText = document.createElement('span');
    characterChargeText.innerText = character.charge + '%';
    const characterChargeBar = document.createElement('meter');
    characterChargeBar.min = 0;
    characterChargeBar.max = 100;
    characterChargeBar.value = character.charge;
    characterCharge.appendChild(characterChargeText);
    characterCharge.appendChild(characterChargeBar);
    characterControl.appendChild(characterCharge);

    const abilityRow = document.createElement('div');
    abilityRow.classList.add('ability-row');
    for(const ability of character.abilities) {
      const abilityButton = document.createElement('button');
      abilityButton.classList.add('ability-button');
      abilityButton.classList.add(`ability-button-${ability.type}`);
      abilityButton.innerText = ability.icon + ability.name;
      abilityRow.appendChild(abilityButton);
    }
    characterControl.appendChild(abilityRow);

    controlContainer.appendChild(characterControl);
  }
}

async function playAnimation(animationList) {
  for(const animation of animationList) {
    // 与ダメージのアニメーション
    // ダメージ表示するこの方法かなりダサいし遅いので実際はもうちょっとどうにかしたほうがいい
    // とくにgetBoundingClientRectはヤバい
    if(animation.type === 'damage') {
      const target = document.querySelectorAll('.enemy-graphic')[animation.targetIndex];
      const targetRect = target.getBoundingClientRect();

      const damageIndicator = document.createElement('div');
      damageIndicator.classList.add('damage');
      damageIndicator.innerText = animation.damage;
      damageIndicator.style.left = targetRect.left + 'px';
      damageIndicator.style.top = targetRect.top + 50 + 'px';

      document.body.appendChild(damageIndicator);

      const hpBar = document.querySelectorAll('.enemy-hp-bar')[animation.targetIndex];
      hpBar.value = animation.enemyHp;

      const modeBar = document.querySelectorAll('.enemy-mode-gauge')[animation.targetIndex];
      modeBar.value = animation.enemyModeGauge;

      await new Promise((resolve, reject) => {
        setTimeout(()=> {
          document.body.removeChild(damageIndicator);
          resolve();
        },500);
      });
    }

    // 被ダメージのアニメーション
    if(animation.type === 'receiveddamage') {
      const target = document.querySelectorAll('.character-graphic')[animation.targetIndex];
      const targetRect = target.getBoundingClientRect();

      const damageIndicator = document.createElement('div');
      damageIndicator.classList.add('damage');
      damageIndicator.innerText = animation.damage;
      damageIndicator.style.left = targetRect.left + 'px';
      damageIndicator.style.top = targetRect.top + 50 + 'px';

      document.body.appendChild(damageIndicator);

      await new Promise((resolve, reject) => {
        setTimeout(()=> {
          document.body.removeChild(damageIndicator);
          resolve();
        },500);
      });
    }
  }
}

// 自分のID取得する
fetch('/user/authenticated')
  .then((res) => res.json())
  .then(async (user) => {
    const attackButton = document.querySelector('.attack-button');

    // WebSocketでサーバに接続する
    const ws = new WebSocket('ws://localhost:4000/');

    // 接続がオープンしたらjoinコマンドを送る
    ws.addEventListener('open', (event) => {
      ws.send(JSON.stringify({ command: 'join', payload: { playerId: user.id, room: roomId } }));
    });

    // サーバからメッセージが来たら処理する
    ws.addEventListener('message', async (event) => {
      const response = JSON.parse(event.data);

      // roomdataが来たら画面を初期化する
      if(response.type === 'roomdata') {
        const roomData = response.payload.roomData;
        const playerData = response.payload.playerData;

        renderEnemyDataList(roomData.enemies);
        renderPlayerParty(playerData);
      }

      // アニメーションが返ってきたら再生する
      if(response.type === 'turnanimation') {
        const roomData = response.payload.roomData;
        const playerData = response.payload.playerData;

        await playAnimation(response.payload.animations);

        renderEnemyDataList(roomData.enemies);
        renderPlayerParty(playerData);

        attackButton.style.visibility = 'visible';
      }
    });

    // 攻撃ボタンをクリックしたらサーバにコマンドを送信する
    attackButton.addEventListener('click', (event) => {
      attackButton.style.visibility = 'hidden';
      ws.send(JSON.stringify({
        command: 'attack',
        payload: {
          playerId: user.id,
          room: roomId,
          enableChargeAttack: true
        }
      }));
    });
  });

今回、とりあえず動くことを優先して書いてるので、アニメーション再生周りとかコードが死ぬほど雑なんであんまり参考にしないでくださいね。

web/private/style.cssにダメージ表示用のルールを追加しておきます。

.damage {
  position: fixed;
  font-size: 1.5em;
  background-color: rgba(0, 0, 0, 0.6);
  color: white;
}

さて、ここまでできたらサーバを再起動して実行してみましょう。

攻撃ボタンを押すと殴ったり殴られたりの世界が展開されると思います。これでだいぶグラブルらしくなりましたね!

アビリティを使えるようにする

アビリティ、使いたいですよね。使えるようにしましょう。

バトルルームにアビリティ周りの使用機能を追加します。だいぶごちゃごちゃしてきたのでそろそろ掃除どきかもしれませんが、そろそろ終わりですしめんどいのでこのまま行きます。

アビリティ追加しようとすると、バフデバフの処理も追加しないといけないですね。その辺もやっていきましょう。

まずは/lib/battle.jsにバフ・デバフとアビリティ周りの機能追加。それに伴い一部の処理も整理してます。

class BattleRoom {
  constructor(enemyDataList) {
    this._playerMap = new Map();

    this._enemyDataList = enemyDataList.map((e) => ({
      ...e,
      currentHp: e.hp,
      currentCt: 0,
      currentMode: 'normal',
      currentModeGauge: 0,
      status: []
    }));

    this._abilityMap = new Map();
  }

  get playerList() {
    return Array.from(this._playerMap.values());
  }

  get roomData() {
    const self = this;

    return {
      players: self.playerList,
      enemies: self._enemyDataList.map((e) => ({
        name: e.name,
        graphic: e.graphic,
        hp: e.currentHp / e.hp,
        maxCt: e.ct,
        currentCt: e.currentCt,
        currentMode: e.currentMode,
        currentModeGauge: e.currentModeGauge,
        status: e.status
      }))
    };
  }

  joinPlayerParty(playerParty) {
    this._playerMap.set(playerParty._id.toString(), playerParty);
  }

  getPlayerById(id) {
    return this._playerMap.get(id.toString());
  }

  getEnemyByIndex(index) {
    return this._enemyDataList[index];
  }

  calcAttackBaseToEnemy(playerId, character, target) {
    let attackBase = character.attack;

    // バフ・デバフの処理
    character.status.forEach((status) => {
      if('attackMultiplier' in status) {
        attackBase *= status.attackMultiplier;
      }
    });

    target.status.forEach((status) => {
      if('damageMultiplier' in status) {
        attackBase *= status.damageMultiplier;
      }
    });

    return attackBase;
  }

  damageToEnemy(characterIndex, targetIndex, damage) {
    const target = this._enemyDataList[targetIndex];
    const animations = {};

    target.currentHp = Math.max(target.currentHp - damage, 0);
    animations.damageAnimation = {
      type: 'damage',
      characterIndex,
      targetIndex,
      damage,
      enemyHp: target.currentHp / target.hp,
      enemyModeGauge: target.currentModeGauge
    };

    // ODゲージ計算
    if(target.currentMode === 'normal') {
      const diff = damage / target.overdriveDamage;
      target.currentModeGauge = Math.min(target.currentModeGauge + diff, 1.0);
    } else if(target.currentMode === 'overdrive') {
      const diff = damage / target.breakDamage;
      target.currentModeGauge = Math.max(target.currentModeGauge - diff, 0.0);
    }

    // ODモード変化
    if(target.currentMode === 'normal' && target.currentModeGauge === 1) {
      target.currentMode = 'overdrive';
      animations.overdriveAnimation = { type: 'overdrive', targetIndex };
    } else if(target.currentMode === 'overdrive' && target.currentModeGauge === 0) {
      target.currentMode = 'break';
      setTimeout(() => target.currentMode = 'normal',1000 * 30);
      animations.overdriveAnimation = { type: 'break', targetIndex };
    }

    return animations;
  }

  // 敵にバフ・デバフ付与
  addStatusToEnemy(targetIndex, status) {
    const target = this._enemyDataList[targetIndex];
    target.status.push(status);
    setTimeout(() => {
      const index = target.status.indexOf(status);
      if(index >= 0) {
        target.status.splice(index, 1);
      }
    }, status.duration);
  }

  // 味方にバフ・デバフ付与
  addStatusToCharacter(playerId, characterIndex, status) {
    const target = this.getPlayerById(playerId).members[characterIndex];
    target.status.push(status);
  }

  // アビリティ使用
  useAbility(playerId, characterIndex, abilityIndex, targetIndex = 0) {
    const player = this.getPlayerById(playerId);
    const target = this._enemyDataList[targetIndex];

    const ability = player.members[characterIndex].abilities[abilityIndex];
    ability.recast = ability.recastTurn;

    if(!this._abilityMap.has(ability.script)) {
      this._abilityMap.set(ability.script, require(`../ability/${ability.script}`));
    }

    const abilityScript = this._abilityMap.get(ability.script);
    const animation = abilityScript.execute(this, playerId, characterIndex, targetIndex);

    return animation;
  }

  processTurn(playerId, enableChargeAttack, targetIndex = 0) {
    const player = this.getPlayerById(playerId);
    const target = this._enemyDataList[targetIndex];
    const damageAnimations = [];
    const overdriveAnimations = [];
    const receivedDamageAnimations = [];

    // プレイヤー側処理
    player.members.forEach((character, index) => {
      // ダブルアタック、トリプルアタックの判定。確率は適当
      const isDoubleAttack = Math.random() < 0.2;
      const isTripleAttack = Math.random() < 0.1;

      const attackNum = isTripleAttack ? 3 : isDoubleAttack ? 2 : 1;
      const attackBase = this.calcAttackBaseToEnemy(playerId, character, target);

      // 奥義
      if(enableChargeAttack && character.charge >= 100) {
        // 奥義倍率3倍ぐらい
        const damage = Math.round(attackBase * 3 * (1 + Math.random()/10));
        const animation = this.damageToEnemy(index, targetIndex, damage);

        if(animation.damageAnimation) {
          damageAnimations.push(animation.damageAnimation);
        }

        if(animation.overdriveAnimation) {
          overdriveAnimations.push(animation.overdriveAnimation);
        }

        character.charge -= 100;
        return;
      }

      // 攻撃回数の数だけ攻撃する
      for(let i = 0; i < attackNum; i++) {
        // 奥義ゲージ増加
        character.charge = Math.min(character.charge + [10, 12, 15][i], 100);

        // ダメージ計算。計算式は適当。乱数混ぜてたらそれっぽくなるっしょ
        const damage = Math.round(attackBase * (1 + Math.random()/10));
        const animation = this.damageToEnemy(index, targetIndex, damage);

        // クライアントに送信するアニメーションを追加
        if(animation.damageAnimation) {
          damageAnimations.push(animation.damageAnimation);
        }

        if(animation.overdriveAnimation) {
          overdriveAnimations.push(animation.overdriveAnimation);
        }

        // 途中で死んだら終わり
        if(target.currentHp <= 0) {
          break;
        }
      }
    });

    // 敵側処理
    this._enemyDataList.forEach((e, index) => {
      // 攻撃対象をランダムに決める
      const targetIndex = Math.floor(Math.random() * player.members.length);
      const target = player.members[targetIndex];

      // 攻撃
      const damage = Math.round(e.attack * (1 + Math.random()/10));
      target.currentHp = Math.max(target.currentHp - damage, 0);

      // 死
      if(target.currentHp === 0) {
        player.members.splice(targetIndex, 1);
      }

      receivedDamageAnimations.push({
        type: 'receiveddamage',
        targetIndex,
        damage
      });

      // CT進める
      e.currentCt += 1;
      if(e.currentCt > e.ct) {
        e.currentCt = 0;
      }
    });

    // アビリティのリキャストや、バフ・デバフのターンを進める
    player.members.forEach((character) => {
      character.abilities.forEach((ability) => {
        ability.recast = Math.max(ability.recast - 1, 0);
      });

      character.status.forEach((status) => {
        status.remainTurns -= 1;
      });

      character.status = character.status.filter((status) => status.remainTurns > 0);
    });

    // アニメーションは、与ダメージ -> OD -> 被ダメージ、の順
    return [...damageAnimations, ...overdriveAnimations, ...receivedDamageAnimations];
  }
}

module.exports = {
  BattleRoom
};

アビリティはデータベースに記録されているscriptをabilityフォルダから読み込んでそこで処理するようにしています。

リキャスト周りの処理も追加。ターン進むごとに数字減らすだけです。ターン終わったバフ・デバフは剥がします。

敵側のバフ・デバフは時間制。個人バフには対応してません。また、バフは重複します。片面とか両面とかそんな難しい処理はありません。FGOかよ。

serve.jsのWebSocket部分をアビリティに対応させます。

// WebSocketサーバを作る
const server = http.createServer();
server.listen(4000, () => console.log('WebSocket listening on port 4000'));
const websocketServer = new WebSocketServer({
  httpServer: server
});

websocketServer.on('request', (request) => {
  // 接続を無条件に受け入れる
  // 本来は認証とか入れないと垢ハックできちゃうからダメだよ!
  // 今回はお遊びだからね
  const connection = request.accept('', request.origin);

  // クライアントからメッセージが届いたとき
  connection.on('message', (message) => {
    const json = JSON.parse(message.utf8Data);
    const room = battleMap.get(json.payload.room);

    // joinメッセージならルームデータを送る
    if(json.command === 'join') {
      connection.sendUTF(JSON.stringify({
        type: 'roomdata',
        payload: {
          playerData: room.getPlayerById(json.payload.playerId),
          roomData: room.roomData
        }
      }));
    }

    if(json.command === 'attack') {
      const animations = room.processTurn(json.payload.playerId, json.payload.enableChargeAttack);

      // たぶん本来は差分結果だけ送って転送量削減とかするんだろうけど
      // 面倒なので全データ送る
      connection.sendUTF(JSON.stringify({
        type: 'turnanimation',
        payload: {
          animations,
          playerData: room.getPlayerById(json.payload.playerId),
          roomData: room.roomData
        }
      }));
    }

    if(json.command === 'useability') {
      const animations = room.useAbility(json.payload.playerId, json.payload.characterIndex, json.payload.abilityIndex);

      connection.sendUTF(JSON.stringify({
        type: 'abilityanimation',
        payload: {
          animations,
          playerData: room.getPlayerById(json.payload.playerId),
          roomData: room.roomData
        }
      }));
    }
  });
});

あとは各アビリティのスクリプトを作りましょうか。まずウェポンバーストのwepon_burst_1.jsです。abilityというフォルダを作ってその中に作ります。

function execute(room, playerId, characterIndex, targetIndex) {
  const character = room.getPlayerById(playerId).members[characterIndex];
  character.charge = 100;
  return [{
    type: 'chargeup',
    characterIndices: [characterIndex]
  }];
}

module.exports = {
  execute
};

こんだけです。奥義ゲージ100%にするだけですからね。

ただ、このアビリティにroom渡して処理するの、ちょっとカッコ悪いしコード品質的にごにょごにょな感じするので、頭いい人はもうちょい賢い方法考えてみてください。

次はレイジですね。ability/rage_1.jsです。

function execute(room, playerId, characterIndex, targetIndex) {
  const members = room.getPlayerById(playerId).members;
  members.forEach((character, index) => {
    room.addStatusToCharacter(playerId, index, { icon: '💪', remainTurns: 3, attackMultiplier: 1.1 });
  });

  return [{
    type: 'attackup',
    characterIndices: members.map((c, index) => index)
  }];
}

module.exports = {
  execute
};

戦闘メンバー全員に攻撃UPを付与します。

次はアーマーブレイクです。ability/armor_break_1.jsに書きます。

function execute(room, playerId, characterIndex, targetIndex) {
  const attacker = room.getPlayerById(playerId).members[characterIndex];
  const target = room.getEnemyByIndex(targetIndex);
  const damage = Math.round(room.calcAttackBaseToEnemy(playerId, attacker, target) * (1 + Math.random()/10));
  const damageAnimations = room.damageToEnemy(characterIndex, targetIndex, damage);
  room.addStatusToEnemy(targetIndex, { icon: '🛡⬇️', damageMultiplier: 1.2, duration: 1000 * 60 });

  const animations = [
    damageAnimations.damageAnimation,
    {
      type: 'defencedown',
      targetIndices: [targetIndex]
    }
  ];

  if(damageAnimations.overdriveAnimation) {
    animations.push(damageAnimations.overdriveAnimation);
  }

  return animations;
}

module.exports = {
  execute
};

ダメージ与えた後にデバフ付与ですね。ちょいややこしめの作りしてます。

これでアビリティ揃ったので次はクライアント側いじります。バフ・デバフ命令の送信と、バフ・デバフアイコン表示対応です。

// URLからルームIDを取得する
const params = new URLSearchParams(location.href.split('?')[1]);
const roomId = params.get('room');

// 敵データを描画する関数
function renderEnemyDataList(enemyDataList) {
  const enemyInfo = document.querySelector('.enemy-info');
  const enemyStage = document.querySelector('.stage-enemyside');

  // 内容を一旦削除
  enemyInfo.innerHTML = '';
  enemyStage.innerHTML = '';

  // 敵ごとにHPバー、CT、ODゲージを表示
  for(const enemy of enemyDataList) {
    const enemyHpPercentage = document.createElement('div');
    enemyHpPercentage.innerText = Math.ceil(enemy.hp * 100).toString() + '%';

    // バフ・デバフ
    enemy.status.forEach((status) => {
      const statusElement = document.createElement('span');
      statusElement.innerText = status.icon;
      enemyHpPercentage.appendChild(statusElement);
    });

    // HPバー
    const enemyHpBar = document.createElement('meter');
    enemyHpBar.classList.add('enemy-hp-bar');
    enemyHpBar.min = 0;
    enemyHpBar.max = 1;
    enemyHpBar.value = enemy.hp;

    const enemyCtAndOverdriveContainer = document.createElement('div');
    enemyCtAndOverdriveContainer.classList.add('enemy-ct-and-overdrive-container');

    // CT
    const enemyCt = document.createElement('div');
    enemyCt.innerText = 'CT' + new Array(enemy.currentCt).fill('◆').join('').padEnd(enemy.maxCt, '◇');
    enemyCtAndOverdriveContainer.appendChild(enemyCt);

    // ODゲージ
    const enemyModeGaugeContainer = document.createElement('div');
    const enemyModeGaugeLabel = new Text('Mode');
    const enemyModeGauge = document.createElement('meter');
    enemyModeGauge.classList.add('enemy-mode-gauge');
    enemyModeGauge.min = 0;
    enemyModeGauge.max = 1;
    enemyModeGauge.value = enemy.currentModeGauge;
    enemyModeGaugeContainer.appendChild(enemyModeGaugeLabel);
    enemyModeGaugeContainer.appendChild(enemyModeGauge);
    enemyCtAndOverdriveContainer.appendChild(enemyModeGaugeContainer);

    enemyInfo.appendChild(enemyHpPercentage);
    enemyInfo.appendChild(enemyHpBar);
    enemyInfo.appendChild(enemyCtAndOverdriveContainer);

    // グラフィック表示
    const enemyGraphic = document.createElement('div');
    enemyGraphic.innerText = enemy.graphic;
    enemyGraphic.classList.add('enemy-graphic');
    const enemyName = document.createElement('div');
    enemyName.innerText = enemy.name;

    enemyStage.appendChild(enemyGraphic);
    enemyStage.appendChild(enemyName);
  }
}

// プレイヤーのパーティを描画する関数
function renderPlayerParty(party, websocket, user) {
  const playerStage = document.querySelector('.stage-playerside');
  const controlContainer = document.querySelector('.control-container');

  // 内容を一旦削除
  playerStage.innerHTML = '';
  controlContainer.innerHTML = '';

  party.members.forEach((character, index) => {
    const characterGraphic = document.createElement('div');
    characterGraphic.innerText = character.graphic;
    characterGraphic.classList.add('character-graphic');
    playerStage.appendChild(characterGraphic);

    const characterControl = document.createElement('div');

    // キャラ名表示
    const characterName = document.createElement('div');
    characterName.innerText = character.graphic + character.name;
    characterControl.appendChild(characterName);

    // HPバー表示。バフ・デバフもここに表示
    const characterHp = document.createElement('div');
    const characterHPText = document.createElement('span');
    characterHPText.innerText = character.currentHp.toString();
    const characterHpBar = document.createElement('meter');
    characterHpBar.min = 0;
    characterHpBar.max = character.hp;
    characterHpBar.value = character.currentHp;
    characterHp.appendChild(characterHPText);
    characterHp.appendChild(characterHpBar);

    // バフ・デバフ
    character.status.forEach((status) => {
      const statusElement = document.createElement('span');
      statusElement.innerText = status.icon;
      characterHp.appendChild(statusElement);
    });

    characterControl.appendChild(characterHp);

    // 奥義ゲージ
    const characterCharge = document.createElement('div');
    const characterChargeText = document.createElement('span');
    characterChargeText.innerText = character.charge + '%';
    const characterChargeBar = document.createElement('meter');
    characterChargeBar.min = 0;
    characterChargeBar.max = 100;
    characterChargeBar.value = character.charge;
    characterCharge.appendChild(characterChargeText);
    characterCharge.appendChild(characterChargeBar);
    characterControl.appendChild(characterCharge);

    // アビリティボタン
    const abilityRow = document.createElement('div');
    abilityRow.classList.add('ability-row');
    character.abilities.forEach((ability, abilityIndex) => {
      const abilityButton = document.createElement('button');
      abilityButton.classList.add('ability-button');
      abilityButton.classList.add(`ability-button-${ability.type}`);
      abilityButton.innerText = ability.icon + ability.name + (ability.recast > 0 ? `(${ability.recast})` : '');
      abilityButton.disabled = ability.recast > 0;
      abilityButton.onclick = (event) => {
        abilityButton.disabled = true;
        websocket.send(JSON.stringify({
          command: 'useability',
          payload: {
            playerId: user.id,
            room: roomId,
            characterIndex: index,
            abilityIndex,
            targetIndex: 0 // ターゲット選択対応してないから0番目固定
          }
        }));
      };
      abilityRow.appendChild(abilityButton);
    });

    characterControl.appendChild(abilityRow);

    controlContainer.appendChild(characterControl);
  });
}

async function playAnimation(animationList) {
  for(const animation of animationList) {
    // 与ダメージのアニメーション
    // ダメージ表示するこの方法かなりダサいし遅いので実際はもうちょっとどうにかしたほうがいい
    // とくにgetBoundingClientRectはヤバい
    if(animation.type === 'damage') {
      const target = document.querySelectorAll('.enemy-graphic')[animation.targetIndex];
      const targetRect = target.getBoundingClientRect();

      const damageIndicator = document.createElement('div');
      damageIndicator.classList.add('damage');
      damageIndicator.innerText = animation.damage;
      damageIndicator.style.left = targetRect.left + 'px';
      damageIndicator.style.top = targetRect.top + 50 + 'px';

      document.body.appendChild(damageIndicator);

      const hpBar = document.querySelectorAll('.enemy-hp-bar')[animation.targetIndex];
      hpBar.value = animation.enemyHp;

      const modeBar = document.querySelectorAll('.enemy-mode-gauge')[animation.targetIndex];
      modeBar.value = animation.enemyModeGauge;

      await new Promise((resolve, reject) => {
        setTimeout(()=> {
          document.body.removeChild(damageIndicator);
          resolve();
        },500);
      });
    }

    // 被ダメージのアニメーション
    if(animation.type === 'receiveddamage') {
      const target = document.querySelectorAll('.character-graphic')[animation.targetIndex];
      const targetRect = target.getBoundingClientRect();

      const damageIndicator = document.createElement('div');
      damageIndicator.classList.add('damage');
      damageIndicator.innerText = animation.damage;
      damageIndicator.style.left = targetRect.left + 'px';
      damageIndicator.style.top = targetRect.top + 50 + 'px';

      document.body.appendChild(damageIndicator);

      await new Promise((resolve, reject) => {
        setTimeout(()=> {
          document.body.removeChild(damageIndicator);
          resolve();
        },500);
      });
    }
  }
}

// 自分のID取得する
fetch('/user/authenticated')
  .then((res) => res.json())
  .then(async (user) => {
    const attackButton = document.querySelector('.attack-button');

    // WebSocketでサーバに接続する
    const ws = new WebSocket('ws://localhost:4000/');

    // 接続がオープンしたらjoinコマンドを送る
    ws.addEventListener('open', (event) => {
      ws.send(JSON.stringify({ command: 'join', payload: { playerId: user.id, room: roomId } }));
    });

    // サーバからメッセージが来たら処理する
    ws.addEventListener('message', async (event) => {
      const response = JSON.parse(event.data);

      // roomdataが来たら画面を初期化する
      if(response.type === 'roomdata') {
        const roomData = response.payload.roomData;
        const playerData = response.payload.playerData;

        renderEnemyDataList(roomData.enemies);
        renderPlayerParty(playerData, ws, user);
      }

      // アニメーションが返ってきたら再生する
      if(response.type === 'turnanimation') {
        const roomData = response.payload.roomData;
        const playerData = response.payload.playerData;

        await playAnimation(response.payload.animations);

        renderEnemyDataList(roomData.enemies);
        renderPlayerParty(playerData, ws, user);

        attackButton.style.visibility = 'visible';
      }

      // アビリティアニメーションが返ってきたら再生する
      if(response.type === 'abilityanimation') {
        const roomData = response.payload.roomData;
        const playerData = response.payload.playerData;

        await playAnimation(response.payload.animations);

        renderEnemyDataList(roomData.enemies);
        renderPlayerParty(playerData, ws, user);
      }
    });

    // 攻撃ボタンをクリックしたらサーバにコマンドを送信する
    attackButton.addEventListener('click', (event) => {
      attackButton.style.visibility = 'hidden';
      ws.send(JSON.stringify({
        command: 'attack',
        payload: {
          playerId: user.id,
          room: roomId,
          enableChargeAttack: true
        }
      }));
    });
  });

こっちもだいぶ苦しいコードになってきたので余裕ある人は綺麗に書き直しておいてください。

ちなみにこのままだとターン進行中にアビリティ押せちゃいます。今回は面倒なので対応しないですが、気になる人は押せないようにしておいてください。

ここまでできたら動かしてみましょう。

なんかそれっぽいのができました!グラブルだー!

敵倒しても戦闘終わらなかったりするけど、気にしない。

さいごに

グラブル作り、結構楽しかったですね。普段作ることのないジャンルの技術に触れるのってワクワクします。

今回とりあえず動くものを作ったので再現度等はボロボロですが、なんとかグラブルらしきおぞましいものができました。

まだまだ発展の余地があり、ちゃんとアイテムドロップするとか、リザルト画面作るとか、硬直処理入れるとか、UIもっと洗練するとか、やること盛りだくさんなので、やりたいことがあったら自分でやってみてくださいね。データベース周りも雑なので詳しい人ならもっとちゃんとできそうです。

グラフィック面に関しては、グラブルはWebGL使ってるのでそっちに手を出してもいいかもしれませんね。「WebGL2入門 基礎編」「WebGL2で2Dグラフィックスを扱う」あたりを見るとなにか思いつくかもですね。

ちなみに冒頭でも書きましたが、セキュリティ面はズタボロなので仕事でこのコードを使っちゃダメです。会社潰れますよ。マジで。あくまで参考程度に、遊びに使う程度にしておきましょう。

んじゃあ私は闇1550万チャレンジに戻ります。また古戦場で会いましょう。