どうも。うちょと言います。趣味でゲームを作ってます。
javascriptのゲームライブラリphina.jsを使って、床がぶちぬかれたダンジョンを落ちる「ブチぬきダンジョン」というゲームを作りました。
phina.jsはjavascriptのライブラリなのでPCからでもスマホからでもインストールなしですぐ遊ぶことができます。
こちらで遊べます
https://cachacacha.com/GAME/butidan/
宣伝も兼ねてこのゲームの制作過程を書いていきます。
ゲーム開発初学者に向けて参考にでもなれば幸いです。
企画
ゲームを作るには作りたいゲームを考えるところから始めます。
このゲームを作るときには
・ダンジョンを潜るRPGっぽいゲームつくりたい
・とにかくダンジョンを延々と深く潜りたい
・潜れば潜るほどプレイヤーがつよくなりたい
・無制限にレベルをあげたい
・お宝ザクザクとりたい
だいたいこんな感じのことを考え、初期案ではふつうに1階ずつ階段を下りて、敵を倒していくゲームを作ろうと考えていました。
いちいち階段下りるのなんかめんどいな。
あとこれ、移動ボタンや攻撃ボタン作って画面に配置すんのかな。実装めんどいし操作めんどいな。
いっそのこと床をぶち抜いちゃって、ずっと落ちるようにしちゃおう。
タッチしたところにプレイヤーが突撃して攻撃するようにしよう。
敵もどんどん下から沸かせよう。
そんな感じで作りたいものが決まったので、開発にうつっていきます。
開発ツール
今回作るときに使ったツールです。
- エディタ
- Visal Studio Code
- イラスト
- Aseprite
- TexturePacker
- 開発環境
- vagrant
- 動画プレイヤー
- KeyFrameMP
VSCodeでプログラムを書いてvagrantで立てた仮想サーバとプログラムのフォルダを共有して、ブラウザでローカルホストにアクセスしてデバッグします。スマホですぐチェックできるのでイイです。
Asepriteはドット絵を描くツールで、これひとつでドット絵描いてアニメーションまでつけれます。
TexturePackerはAsepriteで出力した画像をスプライトシートに成形するときつかいます。スプライトシートは複数の絵を並べたひとつの画像で、これを使うとphina.jsでアニメーションができます。
KeyFrameMPはあれです。動画のコマ送りができるので。アニメーション描くときに色んな動画をコマ送りしまくって参考にすることができます。
絵
さて開発です。自分はとりあえず絵を描くことから始めます。目に見えるものがあるとやる気がでるからです。
とにかくシンプルな形にして、描くのも動かすのもカンタンになるように祈りを込めて描きます。頭、体、手、足とレイヤーをわけて書いておくと、位置をずらしたり絵を変えられるのでアニメーションを作るときにラクです。
絵を描くときは参考になりそうな絵を片っ端から集めるのも有効です。そのまま描くとパクリになるので、見て覚えてうろ覚えで描いたり見た絵を融合させて描きます。
良い感じで描けたら切り上げて出力します。
asepriteでそのまま出力するとサイズが小さいのでエクスポート時にサイズを上げます。今回は元サイズの300%で出力しました。他の絵を描いた時も同じ倍率で出力します。
プレイヤー
絵が描けたらさっそくこいつを動かします。
アクションゲームで最も重要なのは主人公を動かしたときにそれが気持ちいいかどうかです。主人公の動きが決まればそれに沿うように敵の動きも決められます。気合い入れて作りましょう。
さて、いよいよphina.jsでのプログラミングです。
シーンにスプライトを追加することで画面に絵が表示されるわけですが、まあ、基本的な部分は他の方が書いた記事を参照していきましょう。
- 画像の表示
- 画像の移動
- タッチイベント
- 衝突判定
- シーン
このように基本的なことは誰かが記事にしてくれています。そしてたいていのものは基本的なものの組み合わせて作ることができます。
以下がプレイヤークラスの関数の一覧です。長いので中身は省略しますが。感じはつかめると思います。たぶん。
phina.define('Player', {
superClass: 'DisplayElement',
init: function() {/*プレイヤーの初期化*/},
update: function(app) {/*メインループ*/},
setWepon:function(item){/*武器の装備*/},
setShield:function(item){/*盾の装備*/},
setGem:function(item){/*宝石の装備*/},
changeSt:function(value){/*スタミナ値の変更*/},
damage:function(enemyPower){/*ダメージを受けた時 enemyPower=敵攻撃力*/},
die: function(){/*プレイヤー死亡時*/},
attack: function(p) {/*攻撃時のうごき*/},
kaiten: function(){/*回転攻撃時のうごき*/},
tameAttack: function(p) {/*ため攻撃時のうごき*/},
resetMode:function(){/*プレイヤーの動きをリセット*/},
tameStart: function(p) {/*ためを開始*/},
tame: function(p) {/*ため中の処理*/},
tameCheck:function(p){/*ためパワーをチェック*/},
setRotation:function(p){/*プレイヤー向き設定*/},
leftFacing:function(){/*画像を左向きにする*/},
rightFacing:function(){/*画像を右向きにする*/},
getAttackPower:function(){/*攻撃力を計算して返す*/},
getDefensePower:function(){/*防御力を計算して返す*/},
move:function(app){/*毎フレームのプレイヤーの動き*/},
hitWall:function(){/*壁に当たった時*/},
addExp:function(exp){/*経験値を増加*/},
levelUp:function(){/*レベルアップ処理*/},
setStatus:function(level){/*各ステータスを設定*/},
loadLevel:function(){/*ゲーム開始時にレベルをロード*/},
loadWepon:function(){/*武器のロード*/},
loadShield:function(){/*盾のロード*/},
loadGem:function(){/*宝石のロード*/},
});
こんな感じです。プレイヤーの動きや何らかのイベントごとに関数をつくっておいてあつかいやすくカンタンにしておくのがコツです。いくつか中身について説明しましょう。
あんましキレイなコードでないかもなので初心者の人はほどほどに参考にしてください。
アタック
画面をタッチしてプレイヤーが攻撃する部分を見てみます。
タメ開始
メインシーンのonpointstart()で画面全体のタッチを検出しており、画面がタッチされたときにプレイヤーのtameStart()が発火されプレイヤーが剣を構えて「シュッ」と音を鳴らしたりします。タッチされた位置もプレイヤー側に渡します。
タッチされるとプレイヤーは、タッチされた位置に剣を向けて「タメ」を開始します。タッチ中はtame()が毎フレーム呼び出され、その中でsetRotation()を呼びます。
setRotation()にタッチされた位置を渡すことでプレイヤーの向きをタッチ位置に向けさせることができます。
//p:タッチ位置
setRotation:function(p){
var v = Vector2.sub(p, this);
var angle = v.toAngle().toDegree();
if(angle > 90 && angle < 260){
this.rotation = angle + 180;
this.leftFacing();
}else{
this.rotation = angle;
this.rightFacing();
}
},
#####タメチェック
指を放すと攻撃を開始します。タメの長さによって
- 通常攻撃 : attack()
- 回転切り : kaiten()
- 大回転切り : tameAttack()
に行動が分岐します。
分岐の判定を行うのがtameCheck()です。
tameCheck:function(p){
if(this.tameTimer < this.TAMELIMIT){
if(this.st >= this.kaitenStCost){
this.kaiten();
}else{
this.resetMode();
var damageLabel = DamageLabel("スタミナぎれ",'yellow').addChildTo(GameMain.effectGroup);
damageLabel.setPosition(this.x,this.y);
}
}else{
this.attack(p);
if(this.tameTimer > this.tameAttackLimit){
this.tameAttack(p);
}
}
},
タメ中はtameTimerを加算しておきtameCheck()で長さの判定をして、各処理を呼ぶという形です。スタミナの判定もここでしておりスタミナが足りないと回転攻撃を行えません。
#####タッチ位置に向けてダッシュ
//p:タッチ位置 this:プレイヤー位置
var v = Vector2.sub(p, this);
var angle = v.toAngle().toDegree();
this.vx = Math.cos(angle * Math.PI / 180) * this.attackSpeed;
this.vy = Math.sin(angle * Math.PI / 180) * this.attackSpeed;
上のような計算でタッチに位置に向けて突進します。
プレイヤーからタッチ位置の角度をとり、角度をもとに三角関数を使ってどの方向にどのぐらいの力で飛べばいいか割り出します。三角関数ってこういうときに使うんですね。
ダメージ計算
敵からダメージを受けた時の処理ですが。RPGつくったことなかったので、これ大変で何回も修正しました。
敵との接触判定はメインシーンで行い、敵の攻撃を受けたときplayerクラスのdamage()が呼ばれます。
そして以下のダメージ計算が呼ばれます。
var randamDamage= Math.floor( Math.random() * 66 ) + 139;
var def = this.getDefensePower();
//(攻撃力 / 2) - (防御力 /4) *乱数
var damageValue = Math.round( ((enemyPower /2) - (def /4)) * (randamDamage /156));
(敵の攻撃力 ÷ 2) - (防御力 ÷ 4) * 乱数 という式でダメージを計算しています。
enemyPowerは敵の攻撃力でメインシーン経由でプレイヤーに渡されます。getDefensePowerでプレイヤーの防御力defを取得しています。
乱数というのはランダムの数値で、これでダメージのふり幅を作ります。
ちなみにこの計算式はドラクエのダメージ計算を元にしたものです。
エネミー
こんなやつらが登場するんですが、こいつらは共通の親クラスEnemyを継承しています。
phina.define('Enemy', {
superClass: 'DisplayElement',
init: function() {/*敵の初期化*/},
setLevel: function() {/*敵レベルの設定*/},
damage: function(playerAttackPower) {/*ダメージ時の処理*/},
die:function(){/*死亡時の処理*/},
dropCheck: function(){/*アイテムドロップ判定*/},
changeHp:function(){/*体力の増減、HPゲージ可変*/}
});
どの敵でも同じ処理になるところは共通化しておいて同じように動くようにしてラクをします。これを基本として各敵キャラの独自な動きを追加することができます。
#####ステータス
敵のつよさを決めるステータス回りの話をします。なるだけシンプルでラクな実装で、なおかつ無制限に敵がつよくなるような仕組みを考える必要がありました。
このゲームは
- フロア数 = 敵のレベル
となっています。
攻撃力・防御力・HP・経験値など敵の基礎ステータスはEnemyクラスで決まっており、基礎ステータスとレベルのかけ算で実際のステータスが決まります
- 基礎ステータス × レベル = 実ステータス値
しかし、このままだとすべての敵が同じステータスを持つことになるので、スライムやゴーレムやナイトが別々の攻撃力や防御力を持つために、さらに実ステータスに各種族の補正値をかけます。種族倍率はそれぞれの敵が独自の値を持ちます。
- (基礎ステータス × レベル) × 種族補正倍率 = 真・実ステータス値
これをコードにするとこうなります。
setLevel: function(playerAttackPower) {
this.level = GameMain.enemyLevel;
this.maxHp = Math.floor((this.defaultHp * this.level) * this.hpMagnification);
this.hp = this.maxHp;
this.attack = Math.floor((this.defaultAttack * this.level) * this.attackMagnification);
this.diffence = Math.floor((this.defaultDiffence * this.level) * this.diffenceMagnification);
this.expPoint = Math.floor((this.defaultExp * this.level) * this.expMagnification);
},
このようにしておくことで、フロア数さえ増えればほっといても敵がどんどん強くなるシステムが作れました。強さの調整もわりとラクにできます。
#レベルアップ・経験値
敵を際限なく強くすることが出来たのでわれらが主人公にも強くなってもらうことにしましょう。主人公のステータスも、おおむね敵のステータスと同じロジックで決められています。違うのは以下2点です。
- アイテムによるステータス増減
- 経験値によるレベルアップ
主人公は持っているアイテムよってステータスがプラスされ、敵キャラのようにフロア数ではなく経験値を積むことによってレベルが上がります。
敵を倒すと経験値がもらえて、経験値が既定の数値に達するとレベルアップするわけですが、この数値を決めるためのものが経験値テーブルです。
var EXPTABLE = {
1 : 0 ,
2 : 10 ,
3 : 31 ,
4 : 64 ,
5 : 110 ,
6 : 170 ,
7 : 245 ,
8 : 336 ,
9 : 444 ,
//省略
7993 : 85396988196 ,
7994 : 85429008154 ,
7995 : 85461036115 ,
7996 : 85493072080 ,
7997 : 85525116050 ,
7998 : 85557168026 ,
7999 : 85589228009 ,
8000 : 85621296000
左がレベル、右がそのレベルに必要な経験値となっています。
経験値テーブルを作る際に考えるべきことは
- 敵を何体倒したときにレベルがあがるか?
- レベルアップ時、次のレベルまでに必要な経験値をどの程度増やすか
などです。どのぐらい敵を倒して、どのぐらいの頻度でレベルがあがれば楽しいかを考えて設定していきます。これはまあ遊んでみて調整するしかないです。
経験値テーブルを作るときはExcelなどを使って、レベルと経験値とレベルアップごとの経験値の上昇倍率を式で組んだら、あとはダーッとコピペして好きなところで止めましょう。
ちなみにブチぬきダンジョンはレベル8000まで上がります
#アイテム
ダンジョンをもぐってお宝を集めたいんので、アイテムまわりのシステムを作る必要がありました。つよい武器や盾をゲットするのがダンジョンの醍醐味です。
主な課題はこんな感じでした
- アイテムのデータをどう管理するか
- アイテムをどう装備するか
- アイテムのドロップをどうするか
- アイテムのレベルアップをどうするか
アイテムデータとオブジェクト
アイテムに必要なデータ構造を考えてアイテムデータを作ってみて、このデータを元にアイテムオブジェクトという実体化されたオブジェクトを作ってみることにしました。
var ITEMDATA = [
{
"id":0,
"name":"sord",
"type":"wepon",
"attack":1,
"defense":0,
"weight":1
},
{
"id":1,
"name":"shield",
"type":"shield",
"attack":0,
"defense":1,
"weight":0,
},
省略
phina.define("Item", {
// 初期化
init: function(item,level) {
this.id = item.id;
this.level = level;
this.name = item.name;
this.type = item.type;
if(item.attack > 0){
this.attack = Math.ceil(item.attack * this.level);
}else{
this.attack = Math.floor(item.attack * this.level);
}
if(item.defense > 0){
this.defense = Math.ceil(item.defense * this.level);
}else{
this.defense = Math.floor(item.defense * this.level);
}
this.weight = item.weight;
},
});
itemDataは基礎ステータス等が書かれたアイテムの一覧です。
アイテムのステータスは主人公と敵キャラのステータスのロジックと同じようにレベルと基礎ステータスで決まります。
- アイテムレベル × 基礎ステータス = 実ステータス
アイテムオブジェクトを生成するときに実ステータスを設定して使えるアイテムとして保存されます。
オブジェクトを生成するタイミングは敵キャラがアイテムをドロップした時とします。
アイテムドロップ
敵キャラを倒したときに一定確率で宝箱が出現し、その中にアイテムが入っています。
敵キャラクターは死ぬ瞬間にdropCheck()を発火し、アイテムのドロップ判定を行います。
dropCheck: function(){
var rand = Math.floor( Math.random() * 1000);
var rate = Math.ceil(this.dropRate * ((GameMain.comboUI.itemBonus.value /100) + 1));
if(rand < rate){
var treasure = Treasure(this.x,this.y).addChildTo(GameMain.objectGroup);
}
},
敵キャラのもつアイテムドロップ率とコンボボーナスによるアイテムドロップ率を追加して判定
宝箱にプレイヤーが触れるとゲームを一時停止してアイテムシーンに移行します。
アイテムのゲットと装備を行う一連の流れを、アイテムシーンとして実装します。
アイテムシーン
phina.jsでは現在のシーンからpushScene()を実行することで、現在のシーンに別のシーンを被せることができます。
こんな感じ。薄暗くなってるのがメインシーン。前面がアイテムシーン
アイテムシーンではアイテムの生成、装備、廃棄が行えます。
phina.define("ItemScene", {
superClass: 'DisplayScene',
init: function(x,y,rotation) {/*初期化*/},
createItem:function(){/*アイテムの生成*/},
viewChoiceUI:function(){/*アイテム選択ウィンドウの表示*/},
hitCheck:function(){/*アイテムアイコンのタッチ判定*/},
equipmentItem: function(){/*アイテム装備*/},
removeItem: function(){/*アイテムを捨てる*/},
sceneEnd: function(){/*アイテムシーンの終わり*/}
});
アイテムシーンが呼び出されたタイミングで新しいアイテムの生成を行います。出てくるアイテムはランダムにします。ということで、アイテムシーンの流れは以下のような感じです。
- 乱数でアイテムIDを作成
- アイテムデータから対応するアイテムIDのステータス情報を持ってくる
- 持ってきたステータスとアイテムレベルでアイテムオブジェクトを生成(アイテムレベル=現在のフロア数)
- 作成したアイテムをどうするかユーザに決めてもらう
- ユーザの操作によってアイテムの装備か破棄する
- シーンの終了
アイテムの装備
アイテムはオブジェクトとして生成しているので、プレイヤーのオブジェクトとして持たせておくことが出来ます。
たとえば宝石をゲットしたとき、それを装備するコードは
//引数としてitemオブジェクトを渡す
setGem:function(item){
this.gem = item;
},
これだけです。
this.gem.attackには宝石の攻撃力、this.gem.defenseには防御力が入っており、プレイヤーの攻撃力の計算をするときにカンタンに参照することができます。
おわりに
つかれてきちゃったのでこの辺で〆たいと思います。作るうえでポイントになった部分など好きに書いてみましたがどんな感じでしょうか。
興味を持った方はゲーム作ってみてください。
目がつかれましたね。最後はかわいい羊を見ながらお別れしましょう。