この記事はtraP Advent Calendar 12月7日の記事です。
traPでは主にプログラマーを担当しているyuuです。
個人製作になりますが、ノベルゲームの製作サービスを作りました。
初めてのWebサービス製作でしたので、色々と苦労するところもありましたが、無事完成に至りましたので、そこで得た知見などをまとめておこうと思います。
なお、こちらはこういったWebサービスを作ってみたいけど、初めてだからよく分からないという方向けの記事になります。
筆者が作ったサービスを使ってみたいという方は、こちらの記事をご覧ください。
この記事で取り扱うこと
- Webサービスの構築
- ノベルゲームエンジンの作り方
お品書き
製作に必要なものを考える
まずは、作っていくWebサービスに必要なものを考えていきましょう。
今回製作するのは、クラウドベースノベルゲーム製作サービスですので、以下が必要になります。
- Webサービスを提供するサーバー
- ホームページや製作ページなどを提供する
- 要求に対して適切なファイル、リソースを返す
- ユーザー認証によって、適切に管理される
- クラウドとしてファイルを保存するサーバー
- ユーザーごとに独立したファイルの集合を持つ
- ノベルゲームを作るための簡単なスクリプト
- ノベルゲームの一つ一つのイベントを分割したイベント設計
- スクリプトをイベントに変換するパーサー
前半部分は、Webサービスを作るうえでは割と共通のお話になるかと思います。
後半部分は、ノベルゲームエンジンの設計ですので、また違ったお箸になります。
また、この時点である程度必要なページと遷移についても考察しておいた方が良いでしょう。
今回のサービスでは、以下のようなページ遷移が考えられます。
サーバー編
まずは、Webサービスを提供するためのサーバーを構築していきます。
ノベルゲーム製作サービスにせよ、どんなサービスにせよ、サーバーの設計は不可欠になります。
サーバーを立てる Node.js
まずは、Webサービスなのでサーバーを立てる必要があります。
サーバーを設計する言語はJava、Goなど色々とありますが、今回はNode.js(JavaScript)を使っていきます。筆者がこれを選んだ理由は、JavaScriptの勉強をついでにしたかったのと、ネットに多くの情報が落ちているためです。
まず、ローカルでテストできる環境を作っておきましょう。ローカルの方が変更も容易で、デバッグが非常に楽です。
Nodeのインストールはこちらからできます。インストール方法については、検索した方が詳しいのでここでは割愛します。
Nodeをローカルにインストール出来たら、とりあえずサーバーを立ててみましょう。
ここでは、後述するExpressを使っていきます。
以下のコマンドで導入できます。
npm install express
それではサーバーのコードを書きます。
以下のコードを、app.js
という名前で作成していきます。
// Express処理
var express = require("express");
var app = express();
// テンプレートエンジンを EJS に設定
app.set("views", "./views");
app.set("view engine", "ejs");
// ルーティング設定
app.use("/", (function () {
var router = express.Router();
// 最初のページ
router.get("/", function (request, response) {
response.render("./home/index.ejs");
});
return router;
})());
// サーバーをポート 7141 で起動
app.listen(7141);
まず、Expressを読み込みます。
次に、テンプレートエンジンをEJSにします。EJSというのは、簡単に言えばHTMLを動的に作れるものです。つまり、HTMLをそのまま記述しても問題ありません。
これで、app.js
と同じ階層にあるviews
というディレクトリ内にあるファイルを使うことができます。
次のルーティングは後述しますが、とにかくviews/home/index.ejs
にアクセスできるようにしています。
最後に、サーバーをポート7141で起動しています。
一応、簡単なindex.ejs
を示しておきます。
<html>
<head>
<title>Hello World</title>
</head>
<body>
<h1>Hello World !!</h1>
</body>
</html>
それでは、サーバーを起動してみましょう。
以下のコマンドでサーバーが起動します。
node app.js
そして、http://localhost:7141/
にアクセスしてみましょう。
Hello World !!と表示されているなら成功です。
アクセスをコントロールする Express
これでサーバーは起動しましたが、今の状態では一つのページしか表示できません。
そこで、Expressを使って、URLに応じて応答を変えていきます。実はこの時、ページを返す以外にも、サーバーに命令を投げるということもできます。
応答の種類としては、以下のものが考えられます。
- ページを返す。ホームページや、製作ページなど
- 画像などのリソースを返す(このWebサービスで使われるもの)
- 画像などのリソースを返す(ユーザーがアップロードしたもの)
- 画像などのリソースをもらう
- その他、サーバーに対して命令を行う
これら全てを、URLの後ろにくっついた文字列か、あるいは渡したデータで判断できます。
例えば、以下のようなコードを書けばよいでしょう。
// アップロードの準備
var multer = require('multer');
var upload = multer({ dest: 'uploads/' }).array('avatar', 100);
// publicにあるものには自由にアクセスできる
app.use("/public", express.static("public"));
// ルーティング設定
app.use("/", (function () {
var router = express.Router();
// 最初のページ
router.get("/", function (request, response) {
response.render("./home/index.ejs");
});
// プロジェクトを開かせるページ
router.get("/projects", function (request, response) {
response.render("./app/project.ejs", { user: request.user , projects: data});
});
// プロジェクト作成命令
router.get("/create_project/:name/:desc", function(req, res){
console.log("Create Project : " + req.params.name);
console.log("Create Project : " + req.params.desc);
});
// ファイルを開く
router.get("/open_file/:name", function(req, res){
console.log("Open file : " + req.params.name);
res.sendFile("/image/" + req.params.name, {root: "./"}, function(err){
if(err) {
console.log(err);
res.send(err.Error);
}
return;
});
});
// ファイルアップロード
router.post('/upload_file', function (req, res) {
let project = req._parsedUrl.query;
console.log("Uploading");
upload(req, res, function(err){
for(let file of req.files) {
console.log("Uploaded file : " + file.destination + file.filename);
}
res.send("");
});
})
return router;
})());
最初のコードにより、public
フォルダにあるものは自由にアクセスできるようになりました。ここに、Webサービスで必要なリソースを入れておきます。
次に、ルーティングを設定することで、URLに応じて様々な処理ができるようになりました。
まず、http://localhost:7141/projects
ならばprojects.ejs
が開かれます。このように、ページを振り分けることができます。
続いて、http://localhost:7141/create_project/test/exp
ならばコンソールにCreate Project : test
のように出力されるでしょう。
このように、URLに渡されたパラメータを取得することもできます。その場合は、req.params.name
のように取得します。
また、ファイルを開くこともできます。res.sendFile
でパスを設定すれば、ファイルを返します。返したファイルをどう取り扱うかは、サーバーのお仕事ではありません。
最後に、ファイルをアップロードする場合はmulter
を使い、POSTで実現しています。
multer
を入れたい場合は、npm install multer
コマンドを打ち込みます。
上段の方で、upload
を設定しているので、ここにアップロードされます。ここから、必要な場所にコピーしましょう。
このようにして、URLによる命令によって、サーバーの振る舞いを変えることで、アクセスのコントロールを実現できます。
ユーザーの認証サービスを作る Passport
このままですと、ファイルをアップロードしても共通のところに格納されてしまいます。
また、プロジェクトも共有なので、誰でも他人のプロジェクトを勝手に開いて編集できてしまいます。
そこで、ユーザー認証をするサービスを作成していきます。ユーザーごとにアップロード先や、編集先を振り分けていけば衝突の心配もありません。
そこで、passportという物を使います。Nodeで認証を簡単にできるものになります。
御多分に漏れず、以下のようなコマンドで入れられます。
npm install express-session
npm install passport
npm install passport-google
今回は、Google認証を使うため、passport-googleをインストールします。他にも、Twitter、Facebook等の認証を使うことができます。
これで準備ができましたので、実際に導入してみましょう。
まず、コードを書く前にいろいろと準備が必要です。詳しくはこちらをご覧ください。
簡単に説明すると、以下のことが必要になります。
- ここでGoogleクライアントIDとクライアントシークレットを取得します
- 画面上部のプルダウンを選択し、開いた画面からプロジェクトの作成を選び、プロジェクト名を決めます
- 左の認証情報を選び、認証情報を作成、OAuthクライアントIDを選びます
- もろもろ情報を設定します
- コールバック先を設定します
- 先述して作った認証情報をクリックします
- 承認済みのリダイレクト URIにコールバック先のURLを入力します。ここでは、
http://localhost:7141/auth/google/return
とします
// Passportに必要なもの
var session = require("express-session");
var passport = require("passport");
var GoogleStrategy = require('passport-google-oauth').OAuth2Strategy;
// ユーザー情報を保存時にシリアライズした後に呼ばれる
passport.serializeUser(function (user, done) {
done(null, user);
});
// ユーザー認証が終わった後に、デシリアライズする際に呼ばれる
passport.deserializeUser(function (user, done) {
done(null, user);
});
// Passport のストラテジーを設定
passport.use(
// ストラテジーを設定
new GoogleStrategy({
clientID: ここに自分のGoogleクライアントID,
clientSecret: ここに自分のGoogleクライアントシークレット,
callbackURL: ここに設定したコールバック,
scope: "profile"
}, function (token, tokenSecret, profile, done) {
process.nextTick(() => {
return done(null, profile.id);
});
});
})
);
// 認可処理
var authorize = function () {
return function (request, response, next) {
if (request.isAuthenticated()) {
return next();
}
response.redirect("/");
};
};
// ルーティング設定
app.use("/", (function () {
// 認証ページ
router.get('/auth/google', passport.authenticate('google'));
// 認証からの返りページ
router.get('/auth/google/return',
passport.authenticate('google', {
successRedirect: '/projects',
failureRedirect: '/' })
);
// プロジェクトページ
router.get("/projects", authorize(), function (request, response) {
console.log("Google ID is " + request.user);
});
return router;
})());
まず、Passportに関する設定をあらかじめやっておきます。
次に、ルーティングで、認証ページに飛べるようにしておきます。この場合は、http://localhost:7141/auth/google
にアクセスすれば認証ページに飛べます。
http://localhost:7141/auth/google/return
に帰ってくるようにしたので、成功した場合はそのままプロジェクトページに飛ばしています。
プロジェクトページでは、関数authorize()
が間に入っています。
これだけの処理で、Google認証が可能になります。
後は、取得できたGoogleのIDrequest.user
を使って色々と処理できます。
見栄えをよくする Bootstrap
せっかくWebサービスを作っても、筆者のデザインセンスが皆無なので見栄えがどうにもよくありません。
デザインセンスはともかく、何となく今どきっぽい見栄えにしたいというときは、Bootstrapを使いましょう。
Bootstrapは、Webページのデザインをそれっぽくしてくれる便利なものです。もはや、あまりサーバーとは関係がありませんが、一応ここで説明しておきます。
基本的には、こちらのExampleを見て自分で組み立てていくと良いと思っています。実例として、筆者が実装したのは以下のようなものになります。
まず、Bootstrapのスタイルシートを入手して、展開しておきます。入手方法はいろいろあるので割愛します。
<link rel="stylesheet" href="/public/third_party/bootstrap/dist/css/bootstrap.css" />
ページ上部にあるナビゲーションバーです。
ロゴをクリックすると、最初のページに戻るようになっています。
<nav class="navbar navbar-light navbar-fixed-top" style="background-color: #e3f2fd;" id="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a href="/"><img src="/public/Narratology/res/logo.png" width="150" height="41"/></a>
</div>
</div>
</nav>
製作ページのナビゲーションバーはこんな感じになっています。
<!-- ナビゲーションバー-->
<nav class="navbar navbar-light navbar-fixed-top" style="background-color: #e3f2fd;" id="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a href="/"><img src="/public/Narratology/res/logo.png" width="150" height="41"/></a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li><a class="btn btn-success" href="/projects" role="button"><span class="glyphicon glyphicon-circle-arrow-left" aria-hidden="true"></span> プロジェクト選択画面に戻る</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li><div class="btn-group" role="group" aria-label="First group">
<button type="button" class="btn btn-success btn-lg" id="release_start"><span class="glyphicon glyphicon-globe" aria-hidden="true"></span> 公開</button>
<button type="button" class="btn btn-secondary btn-lg" id="save"><span class="glyphicon glyphicon-floppy-save" aria-hidden="true"></span> 保存</button>
</div></li>
<li><div class="btn-group" role="group" aria-label="Second group">
<button type="button" class="btn btn-primary btn-lg" id="run"><span class="glyphicon glyphicon-play" aria-hidden="true"></span> 初めから実行</button>
<button type="button" class="btn btn-primary btn-lg" id="runWithInstance"><span class="glyphicon glyphicon-play" aria-hidden="true"></span> カーソル行まで実行</button>
<button type="button" class="btn btn-primary btn-lg" id="suspend"><span class="glyphicon glyphicon-pause" aria-hidden="true"></span> 一時停止</button>
<button type="button" class="btn btn-primary btn-lg" id="resume" style="display: none;"><span class="glyphicon glyphicon-forward" aria-hidden="true"></span> 再開</button>
<button type="button" class="btn btn-primary btn-lg" id="stop"><span class="glyphicon glyphicon-stop" aria-hidden="true"></span> 停止</button>
</div></li>
</ul>
</div>
</div>
</nav>
まだ、作成ボタンを押すと出るダイアログなども作ることができます。(呼び出しの処理は別途必要です)
<!-- 作成処理-->
<div class="modal" id="create" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">×</span>
</button>
<h4 class="modal-title" id="modal-label">New File</h4>
</div>
<div class="modal-body">
<form>
<div class="form-row">
<div class="form-group col-md-6">
<label class="col-form-label">File Name</label>
<input class="form-control" id="file_name" placeholder="File Name">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" data-dismiss="modal" id="create_button">Create</button>
</div>
</div>
</div>
</div>
このように、ボタンを作りたかったらbtn btn-xxx
、アイコンを使いたかったらglyphicon glyphicon-xxx
といった風に使うことができます。
他にもいろいろと便利な使い方があるので、探してみてください。
クライアント編
それでは、サーバーの準備が整ったので、いよいよノベルゲームの設計を始めます。
オリジナルのスクリプトを設計する
まずは、ノベルゲームエンジンの根幹になる、スクリプトを設計していきます。
とは言っても、一から言語設計をするのは面倒です。そこで、すでに使われているノベルゲームエンジンを参考にしましょう。既存のエンジンならば、ある程度使いやすくなっているはずですし、すでに使っている方の学習コストを下げられます。
例えば、ティラノスクリプトでは、以下のようなスクリプトを書きます。(※公式サイトチュートリアルから抜粋)
;背景画像の切り替え実行
[bg storage=room.jpg time=3000]
背景が切り替わりましたね?
;1人目のキャラクター登場
[chara_new name="yuko" storage="yuko1.png" jname="ゆうこ"]
[chara_show name="yuko"]
ゆうこが登場しました![l][r]
;2人目のキャラクター登場
[chara_new name="haruko" storage="haruko1.png" jname="はるこ"]
[chara_show name="haruko"]
はるこが登場しました![l][r]
文章の表示は、そのまま書けばいいようです。
画像の表示など、特殊な命令では[]
で囲っているようです。
これを参考にしていきます。
しかし、このスクリプトで面倒だなと感じることがいくつかあります。以下に列挙すると、
- 背景表示命令でいちいち
storage=
とか書くのは面倒 - キャラクター生成、描画に2命令も使うのは面倒
- そこでも
storage=
とか書くのは面倒
- そこでも
- 一々文章の改行、クリック待ちなどで
[l][r]
を書くのは面倒
こういった点を改造していき、自分なりのスクリプトを設計していきます。
まず、背景命令はシンプルに、[bg 背景名]
で出せるようにしました。
また、文章の改行は、実際の改行にも対応できるようにしました。クリック待ちは、二度改行する方針にします。
ここで注意してほしいのですが、
storage=
にも利点はあります。
bg命令で、[bg 背景名]
になるように強制した場合、制作者はbg命令の次には背景名を入れなくてはならないということを覚える必要があります。
一方、storage=
で指定することにすれば、制作者はstorage=
だけ覚えておけば、その順番を覚える必要がありません。ついでに言えば、chara_new
命令でも同じstorage=
が使えるので、学習コストが低くなります
これによって設計されたスクリプトについては、使い方の記事をご覧ください。
イベントを設計する ECMA6
スクリプトの方針が固まったら、イベントも作っていきましょう。
イベントというのは、ゲームを構成する成分の単位です。一つのことができるレベルに小さくしておくことが望ましいでしょう。
例えば、文章を表示するのはイベントです。人物を表示するのもイベントですし、背景を表示するのもイベントです。
このようなイベントを設計するにあたり、JavaScriptのECMA6から導入されたクラスと継承を使っていきます。
クラスという概念は、Java等のオブジェクト指向型言語をやってる方はなじみ深いかもしれません。そうでない方は、状態と操作をひとまとめにしたものと考えておいてください。例えば、文章表示イベントなら、表示する文章の中身という状態と、文章を表示するという操作を持ちます。
クラスには継承という概念があります。誤解を恐れずに噛み砕いて言うと、親から派生して作った子クラスは、親が持つすべての状態、動作を持ちながら、それを拡張することができるというものです。
まず、StoryEventという親クラスを作っておきます。これは、イベントを構成する基本となるものです。
/*
* 全てのイベントの基底クラス
*/
class StoryEvent {
constructor(drawNo) {
// バックグラウンド実行されるイベントかどうか
this.background = true;
// 次のイベント実行時にクリアされるイベントかどうか
this.clearable = false;
// 次のイベントへの遷移を禁止するかどうか
this.stoppable = false;
// 次のイベントへの遷移についてプレイヤーを待つかどうか
this.waitForPlayer = false;
// 描画処理を行えるかどうか
this.drawable = true;
// 描画順序
this.drawNo = drawNo;
// フレームを表示するかどうか
this.drawFrame = false;
//初期化したか
this.isInit = false;
}
// 初期化処理
init() { this.isInit = true;}
// 描画処理
draw(ctx, info) { }
// 更新処理
update(dt) { return true; }
// マウス処理
onMouseDown() { return false; }
// 瞬間完了処理
complete() { }
// 消去処理
clear() { }
}
状態としては、描画可能であるかであったり、プレイヤー待ちになるかであったり、一般的なイベントの状態を持っています。
操作としては、初期化、描画、更新、マウス、消去など、必要そうな処理を定義しておきます。
これを親クラスとして、実際のイベントを設計します。
例えば、画像表示イベントは以下のようになります。
/*
* 画像を管理するイベント
*/
class ImageEvent extends StoryEvent {
constructor(name) {
super(image_number);
image_number = (image_number + 1) % BASE_PUNC;
if(this.id !== undefined)
this.drawNo = IMAGE_BASE + this.id + BASE_PUNC;
let instance = this;
this.drawNo += IMAGE_BASE;
if(this.name === undefined)
this.name = name;
this.image = new Image();
let file = conv.getImage(name);
if(file === undefined)
file = name;
this.image.onload = function() {
instance.loaded = true;
};
this.image.onerror = function() {
debugLog("Error", "画像が見つかりません。ファイル名:" + file);
};
this.image.src = "open/" + projectName + "/" + file;
}
init() {
if(this.x === undefined)
this.x = 400;
if(this.y === undefined)
this.y = 300;
if(this.width === undefined)
this.width = this.image.width == 0 ? -1 : this.image.width;
if(this.height === undefined)
this.height = this.image.height == 0 ? -1 : this.image.height;
if(this.anchorX === undefined)
this.anchorX = 0.5;
if(this.anchorY === undefined)
this.anchorY = 0.5;
if(this.anchor !== undefined)
setAnchor(this, this.anchor);
if(this.opacity === undefined)
this.opacity = 1.0;
super.init();
if(this.animation !== undefined) {
this.animation.init();
game.scene.story.addAnime(this.animation);
}
}
draw(ctx, info) {
if(this.loaded) {
let reserve_opacity = ctx.globalAlpha;
if(this.opacity !== undefined)
ctx.globalAlpha = this.opacity;
if(this.width == -1)
this.width = this.image.width == 0 ? -1 : this.image.width;
if(this.height == -1)
this.height = this.image.height == 0 ? -1 : this.image.height;
ctx.drawImage(this.image, this.x - this.anchorX * this.width, this.y - this.anchorY * this.height, this.width, this.height);
ctx.globalAlpha = reserve_opacity;
}
}
update() {
return this.loaded !== undefined || (this.async !== undefined && this.async);
}
}
やや余計なことも書いていますが、詰まる所以下のようになっています。
- initで情報を初期化しておきます
- drawで描画をします
- updateで、描画が完了したかどうかを判定しています
イベントを一つ一つ作ったら、今度はそれをキューで管理します。
キューというのは、後ろから突っ込んで前から取り出せる箱のようなものです。イベントを一つ一つ突っ込んでいって、前から処理していけば、イベントが時系列順に実行されることになります。
簡略的に書くと、以下のようになります。
// イベントのキュー
this.events = [];
// イベントをpush
this.events.push(new StoryEvent());
// 次のイベントに遷移
let index = this.events.indexOf(this.runningEvent);
this.runningEvent = index == this.events.length - 1 ? null : this.events[index + 1];
これで、イベントの設計ができました。重要なのは以下の二点です。
- イベントをクラスで設計するということ。
- イベントをキューで管理すること。
図にすると、以下のような設計になります。
パーサーを書く
最後に、スクリプトとイベントを紐づける必要があります。
つまり、スクリプトを解釈して、イベントにパースする仕組みを作らなくてはなりません。
こういったものを作る場合は、以下の工程が必要なことがあります。
- 構文解析
- スクリプトを簡単なデータ構造にする
- 意味解析
- データ構造を解釈する
ですが、今回製作するのは極めて簡単な文法なので、構文をわざわざ解析しないことにします。
つまり、スクリプトを直接意味解釈することにします。
これは、有限オートマトンで書くことができます。
有限オートマトンとは何ぞやという方は、文字を読んだらその文字に応じて状態を遷移していくものだとでも思ってください。
例えば、[を読んだら、これは命令だなと判断して、命令解釈の状態に移るといった風に考えることができます。
そして、読んだ命令をもとに、情報を解釈して、イベントにパースしていきます。
筆者は面倒くさがりのため、パース時にさぼって空白区切りのsplitをやっていますが。
有限オートマトンを簡単に図にすると、以下のようになります。
後記
今回は、サーバサイド初心者が、一からクラウドベースノベルゲーム製作サービスを作った時の体験を基に記事を書きました。
前半のサーバー構築は、色々とググりながら完成させていきました。
もしも、サーバー構築が初めてという方がいらしたら、Node.jsなどの有名なものを使うことを推奨します。情報取得の容易性は大事です。
後半のノベルゲームエンジンの話題については、あくまでも簡単な一つの設計です。もっと効率的かつ保守しやすいものもあるでしょう。是非とも自分で設計してみてください。
全体的にやや分かりにくかったかもしれませんが、この記事でゲーム製作Webサービスの作り方が分かってくだされば幸いです。
明日のアドベントカレンダーはsemi君と、hatasa-y君がお送りします。お楽しみに。