Nodachisoft Nodachi Sword Icon
  
@あまじ✎ 2021年8月7日に更新

第4章5 C言語コンソールで横スクロールシューティングゲーム(2/2)

イチからゲーム作りで覚えるC言語
第4章4 C言語コンソールで横スクロールシューティングゲーム(1/2) : PREV

概要

一つ前のページでは、横スクロールシューティングの基本的なゲームエンジン部分を作成していきました。

このページでは前回作成したプログラムを拡張し、シューティングゲームとしてもう少しゲーム性をもたせた機能を実装していきましょう。

ライブラリを含むこのページのソースコードはココをクリックで圧縮された zip 形式でダウンロードできます。

また、ゲームライブラリについては詳細は前ページ「コンソール・ゲーム用ライブラリ」をご参照ください。(C言語ゲームプログラミング向けに最低限の機能を集めたライブラリで、ご自由にお使いいただけます)

このページで作成するゲームの範囲

前回プログラムで以下のような機能を作成しました。

組み込み済みの機能一覧

  • プレイヤーはキーボードの十字キーで操作できる
  • 敵キャラクタが時間経過で出現する
  • プレイヤーはスペースキーで攻撃魔法をうてる
  • 敵キャラクタはプレイヤーの弾にあたると消える
  • プレイヤーは敵キャラクタの弾にあたると消える

今回追加していくゲームの機能

今回は以下の機能を組み込みます。

  • ゲームの得点(スコア)データの読み込みと書き込み
  • プレイヤーの前進(スクロール)に合わせて動く背景(地面)
  • ゲームの進行に合わせて表示されるメッセージ表示機能、シナリオ機能
  • 時間で設定した位置に敵キャラクタを出現させる
  • ダメージを受けたとき2秒間の無敵時間
  • プレイヤーの体力を 3 にし、0 でゲームオーバー
  • 攻撃方法のパワーアップシステムを組み込み
  • ボスの追加、エンディング画面の追加
  • ゲーム音楽、効果音の追加

プログラムの実行イメージ

このページで作成していくシューティングゲームの動作イメージです。

C言語のコンソールプログラムでシューティングゲーム

画面の構成はこんな感じです。

シューティングゲームの画面構成

プログラムの流れ

プログラムを起動してから終了するまでの流れをフローチャート図にして頭を整理しておきます。

ゲームのフローチャート

わかりやすいように、前回から追加された部分を赤枠で黄色塗りの四角で表現しています。

ソースコード

ゲームの初期化から終了まで、以下のソースコードで実現しています。

ファイル 内容
entry.c ゲーム本体のプログラムを記述
gamedata.h ゲームで使用する構造体や定数の定義
ndcgamelib.h コンソールゲーム用ライブラリ(ヘッダ)
ndcgamelib.c コンソールゲーム用ライブラリ(プログラム)

ゲーム本体のプログラム(entry.c)はおおよそ 700 行弱です。

"ndcgamelib.h" には、様々なゲームでよくつかうような機能をまとめていますので、 詳細は前ページ「コンソール・ゲーム用ライブラリ」をご参照ください。

若干プログラム全部をここに貼り付けると長くなるため、 ソースコード、効果音、音楽などをまとめた zip (コチラ)をダウンロードして確認していただいくのが良いかと思います。

それでは、ゲームを実行する順番と合わせて前回から追加された機能を中心に内容を確認していきます。

ゲームデータ(MapData)の定義

前回のプログラムから追加されたゲーム進行用のデータを赤字で記載しました。

データ定義

敵キャラクタの攻撃機能を管理するデータを追加

今回、複数の敵キャラクタを登場させるため、GameObject 構造に、敵キャラクタの種類を追加しています。

また、敵キャラクタが攻撃をしてくるようになるため、敵の攻撃データを配列で管理するようにします。

敵の攻撃をプレイヤーが受ける処理が追加されるため、プレイヤーがダメージを受けたときに一定時間、ダメージを受けない、いわゆる「無敵の状態」を設定するため、ダメージを受けた時の時間を管理するようにします。

敵の出現タイミング、シナリオ(会話)の管理

ゲームの進行に合わせて、敵キャラを出現できるようにしたり、シナリオ(会話)を表示したりするにするため、 「敵キャラ出現の進行管理」データや「シナリオ進行管理」データを追加しています。

レベルアップ機能を管理するデータ

プレイヤは敵キャラを倒すと景観地(スコア)を獲得し、一定の経験値を獲得すると、レベルアップして攻撃(弾)が強いものになります。

そのため、MapData 構造体に「経験値」を追加しています。

ゲームのマスターデータを定義

ゲームで使う固定データ(敵キャラの標準的なデータや、攻撃(弾)データ、レベルアップに必要な経験値量など)は static に構造体として定義しています。

通常このようなゲームに使うキャラクタやメッセージなどのアプリデータはマスターデータと呼びます。

マスターデータとしては下のような感じです!

マスターデータ

ゲームの起動・初期化パート

main 関数からプログラムが動き出したら、 画面の初期設定や、必要なゲームデータの作成を行っています。

以下は初期化を行っているソースコードの部分です。

 
main.c
// エントリポイント
int main() {
    hideCursor();
    setConsoleWindowTitle("ぷらんく猫シューティング");
    // サウンドデータの読込を行う    readSoundEffect("shot.wav", 0);
    // BGM 再生スタート    playMediaFile("music_battle1.mp3");    setMediaVolume(0.4);
    // プレイヤの初期化
    GameObject player;
    player.posx = 5;
    player.posy = 10;
    player.hitpoint = 3;    player.weapon = fire[FIRE_LV1]; // 初期装備    player.weaponUsedTime = 0L;
    player.damageInterval = 2000L;  // 2秒の無敵時間    player.damagedTime = 0L;    player.deadTime = 0L;    player.collapseTime = 800L;
    // 地面オブジェクトの初期化    GameObject ground;    ground.posx = 0;    ground.posy = 18;
    // ゲームデータの初期化
    MapData map;
    map.player = player;
    map.screensize_x = 60;
    map.screensize_y = 21;
    map.enemycount = 0;    map.spotChecked = 0L;    map.eSpotCount = 0;    map.msgChecked = 0L;    map.msgCount = 0;    map.exp = 0L;    map.ground = ground;    map.dispMsg = &sysMsg[SYSMSG_INIT];    map.gameEndType = GAMEEND_TYPE_PLAYING;
    map.gameEndTime = 0;    map.maxScore = 0L;
    // スコアデータがあれば読み込む    char* scoreStr = readBinaryFile("score.txt");    if ( scoreStr != NULL ) {        map.maxScore = atoi(scoreStr);    }
    // 敵キャラ初期化
    for (int i = 0; i < ENEMY_LIMIT ; i++) {
        GameObject enemy;
        enemy.isAlive = FALSE;
        map.enemy[i] = enemy;
    }

    // プレイヤ弾初期化
    for (int i = 0; i < PLAYER_FIRE_LIMIT; i++) {
        GameObject pfire;
        pfire.isAlive = FALSE;
        map.pfire[i] = pfire;
    }

    // 敵弾初期化    for (int i = 0; i < ENEMY_FIRE_LIMIT ; i++) {        GameObject efire;        efire.isAlive = FALSE;        map.efire[i] = efire;    }
    long deltaTime;
	initNdcGameLib(map.screensize_x, map.screensize_y);
    BOOL isGameEnd = FALSE;

前回より追加となった部分は明るい背景色で強調されています。 変更となった部分を確認していきましょう!

ゲーム効果音の読込

572行目の readSoundEffect 関数はゲームライブラリ(ndcgamelib.h)で定義した関数で、 ゲームに必要な効果音(Waveファイル)を読み込むことができます。 実行時に同じフォルダにある "shot.wav" ファイルを読み込んで、スロットの 0 番に設定します。

これで今後、playSoundEffect(0); とすることで、スロット 0 番目に読み込んだ効果音を鳴らすことができます。

音楽の読込・再生

575~576行目では、mp3 形式の音楽を読み込んで、再生しています。 playMediaFile 関数もゲームライブラリ(ndcgamelib.h)で定義した関数で、 同じフォルダにある "music_battle1.mp3" ファイルを読み込んで再生を始めます。

576行目の setMediaVolume 関数は playMediaFile 関数で再生する音楽の音量を 0.0(最小)~1.0(最大)で調整します。 今回は 0.4 と指定したため、音量は元々の 40% 程度となります。

ゲームデータの初期化

プレイヤの初期データとして、どのうような攻撃(弾)を打てるかの設定を追加しています。 また、プレイヤの経験値を 0 で初期化します。

また、今回は背景(地面)を表示するため、背景を画面上で表示する位置データ GameObject 構造体を使って 定義し、map.ground に登録します。

ゲームステージに登場するプレイヤーやアイテム、敵キャラクタなどは 引き続き GameObject 構造体に収めるようにします。

別途 gamedata.h の中で定義している GameObject 構造体に、 今回の機能追加でメンバー変数を追加しています。

gamedata.h
// ゲーム内で扱うオブジェクトの構造体を定義 
typedef struct {
    BOOL isAlive;  // 存在している(生存している)なら TRUE
    double posx;   // オブジェクトの位置(x成分)
    double posy;   // オブジェクトの位置(y成分)
    int eneType;   // 敵キャラクタの場合の種類
    int hitpoint;  // キャラクタの体力
    MagicType weapon; // 武器
    long weaponUsedTime; // 前回武器を使用したタイミング
    long damageInterval;  // ダメージを受けた後の無敵時間    long damagedTime;   // 直近ダメージを受けたタイミング    long deadTime;      // hitpoint がマイナスになった時間    long collapseTime;  // ライフ0 から消失までの演出時間} GameObject;

スコアデータの読み込み

なんどかゲームを遊んだ人向けに、過去のベストスコアデータを読み取ります。

entry.c
// スコアデータがあれば読み込む
char* scoreStr = readBinaryFile("score.txt");
if ( scoreStr != NULL ) {
    map.maxScore = atoi(scoreStr);
}

スコアデータは "score.txt" ファイルの中に数字で保存するものとします。 もしファイルがなければ、ベストスコアは 0 です。 スコアがあれば、ファイルデータを読み取り、数値に変換したものを過去のベストスコアとして map.mapScore に 読み込みます。

ゲームのメインループ

続いて、ゲームのメインループの内容を確認していきます。

entry.c
    // ゲームのループ処理
    while ( !isKeyPushed(VK_ESCAPE) && !isGameEnd) {
        deltaTime = getDeltaTime();
        drawScreen(&map);
        readInput(&map, deltaTime);
        moveGround(&map, deltaTime);        actionEnemy(&map, deltaTime);
        actionPlayerBullet(&map, deltaTime);
        actionEnemyBullet(&map ,deltaTime);        enemySpot(&map, deltaTime);        checkCollisionEnemyBullet(&map);        if (map.gameEndType == GAMEEND_TYPE_PLAYING) {            readScenarioMessage(&map, deltaTime);  // シナリオ(会話)を読み込み        }        Sleep(1);       // CPU を占有しない
        flushScreen();  // 画面の表示を更新
        map.spotChecked += deltaTime;  // 敵キャラクタをどの時刻まで読み込んだかを更新        map.msgChecked += deltaTime;   // シナリオ(会話)をどの時刻まで読み込んだかを更新        if (map.gameEndType != GAMEEND_TYPE_PLAYING && clock() - map.gameEndTime > 7 * 1000L ) {
            isGameEnd = TRUE;  // ゲームクリア、もしくはプレイヤが体力0となってから一定時間経過ならループから抜ける        }        doLoopMediaFile();  // 音楽をループ再生させる    }

メインループは while 文で繰り返し実行されます。

while 文を繰り返す条件として !isKeyPushed(VK_ESCAPE) 以外に、isGameEnd が指定されています。

つまり、 ESC キーが押されたとき、もしくは isGameEnd が TRUE となった時にメインループから抜け出します。 isGameEnd はゲームの終了時(クリアしたときか、プレイヤーの体力が0となった場合)に TRUE となるようにプログラミングされています。

続いて、メインループ処理でどんなことをしているか確認していきます。

地面の移動

メインループの中で moveGround() 関数を使い、 地面が動いている(プレイヤーが前に進んでいるので、相対的に地面が右から左に流れる)ように見えるよう 時間経過で表示位置を設定します。

moveGround 関数の中身は以下のようになっています。

entry.c
// 地面を動かす
void moveGround(MapData* map, long deltaTime) {
    map->ground.posx += deltaTime * 3.0 / 1000.0;
}

時間経過したぶん、地面オブジェクト(map->ground)の表示位置を右方向に移動させます。 この X 成分の位置データを使って、後ほどバッファに地面を書き込むときの表示を計算します。

敵の攻撃(弾)の処理

メインループの中で actionEnemyBullet() 関数を使い、敵の攻撃を処理します。

今回敵キャラクタも攻撃をしてきます。 敵キャラクタごとに攻撃方法はことなり、それぞれグラフィックや弾のスピードがことなります。

entry.c
// 敵の弾の動きを定義
void actionEnemyBullet(MapData* map, long deltaTime ) {
    GameObject* bullet;
    for (int i = 0; i < ENEMY_FIRE_LIMIT; i++) {
        bullet = &map->efire[i];
        if (bullet->isAlive == TRUE) {
            switch (bullet->weapon.way) {
            case 0: // 左に直進
                bullet->posx -= (double)deltaTime * bullet->weapon.speed / 1000.0;
                break;
            case 1: // 上に直進
                bullet->posy -= (double)deltaTime * bullet->weapon.speed / 1000.0;
                break;
            }
            
            if (   bullet->posx < 0 
                || bullet->posx > map->screensize_x
                || bullet->posy < 0
                || bullet->posy > map->screensize_y ) { // 画面外なので削除する
                bullet->isAlive = FALSE;
            }
        }
    }
}

敵キャラの攻撃(弾)データは、map->efire 配列に GameObject 構造体で保存されています。 有効な敵の攻撃を一つ一つ確認し、一時変数 bullet に格納して処理していきます。

bullet->weapon に、具体的に敵の弾データが保存されており、 bullet->weapon.way が 0 なら、弾は左に直線で移動し、 1 なら弾は上方向に直線で移動する、という 挙動が定義されています。

敵キャラクタの出現

今回、敵キャラクタはあらかじめ出現する種類とタイミング、場所を指定できるように機能を追加しています。

メインループの中で、enemySpot 関数を呼び出すことで、適切なタイミングで敵キャラクタを画面に出現させることができます。

enemSpot 関数の中身は下のようになっています。

entry.c
// 敵の出現タイミングを確認する
void enemySpot(MapData* map, long deltaTime) {
    for (int i = map->eSpotCount; i < ENEMY_SPOT_LIMIT; i++) {
        if (map->spotChecked < eSpot[i].time && eSpot[i].time <= map->spotChecked + deltaTime) {
            map->eSpotCount++;
            addEnemy(map, &eSpot[i] );
        } else {
            return;
        }
    }
}

この関数では、ゲームが始まってからの時間が敵キャラ出現の時間に到達したら、 対象の敵キャラを addEnemy 関数で追加し、画面上に登場させるようにしています。

出現する敵キャラの種類や、いつ、どこに、というデータは別途 gamedata.h の中で 定数の配列 eSpot で定義されています。

一部 eSpot の中身を核にすると、以下のように定義されています。

gamedata.h
// 敵キャラクタの出現タイミング、出現キャラ種類のデータ構造
typedef struct {
    long time;   // 出現時刻
    int enetype; // 出現するキャラクタタイプ
    int posy;    // 出現位置
} EnemySpot;

// 敵キャラクタの出現タイミング、出現キャラ種類のデータ
#define ENEMY_SPOT_LIMIT  367
static const EnemySpot eSpot[ENEMY_SPOT_LIMIT] = {
 {6 * 1000L, ENE_SQUID, 2},
 {10 * 1000L, ENE_SQUID, 2},
 {14 * 1000L, ENE_SQUID, 2},
 {18 * 1000L, ENE_SQUID, 12},
 {22 * 1000L, ENE_SQUID, 12},
 {26 * 1000L, ENE_SQUID, 12},
 {29 * 1000L, ENE_SQUID, 5},
   : (以下、略)

eSpot は EnemySpot 構造体のデータを配列で持ちます。 このシューティングゲームでは全部で 367 体の敵キャラクタの登場が定義されています。

例えば、eSpot[0] のデータを見てみると、 ゲーム開始から、6秒(6000ミリ秒)経過したら、ENE_SQUID(イカタイプ)の敵キャラクタとして、 画面Y成分 2 の位置に表示される、という設定となっています。

なお、すでにゲームに出現した敵キャラは重複して登場させないよう、 どの eSpot 配列の添え字まで登場させたかを map->eSpotCount で管理しています。 もし敵キャラが出現したら、 map->eSpotCount++ とし、敵キャラの読み込む添え字の開始を繰り上げていく仕組みとしています。

なお、敵キャラクタ自体は定数で下のように定義されています。

gamedata.h
// 敵キャラのマスターデータ
typedef struct {
    int hitpoint;   // 体力
    double speed;   // 動くスピード
    int movetype;   // 0:直進、1:プレイヤの方に前進、2:ボスの動き
    WeaponType bullettype;  // 攻撃の方法
    int exp;            // 経験値
    long collapseTime;   // ライフ0 から消失までの演出時間
    double collisionRadius; // 当たり判定の半径
} EnemyData;

static const EnemyData eData[4] = {
    //体力, 速度, 動き方, 攻撃方法   , 経験値, 消滅ms, 当たり判定
     {   1, 6.0 , 0     , FIRE_SQUID ,    100,     0L, 0.49}  // ENE_SQUID
    ,{  10, 3.0 , 1     , FIRE_FISH  ,    200,     0L, 0.49}  // ENE_FISH
    ,{  10, 8.0 , 0     , FIRE_CANCER,    500,     0L, 0.49}  // ENE_CANCER
    ,{ 400, 2.0 , 2     , FIRE_BOSS  ,  10000,  3000L, 5.0 }  // ENE_BOSS
};

敵キャラクタごとに、体力や動くスピード(速度)、攻撃に使う弾の種類、 倒した時の経験値、体力0となった時に消えるまでの時間、 当たり判定のサイズ(円の半径)を規定しています。

シナリオ(会話)の読込

メインループの中から呼び出す readScenarioMessage 関数は、 ゲームの時間経過に応じて、表示するシナリオ(会話)文字列を取得して、 updateMessage 関数の中で画面表示用の変数(map->dispMsg)に設定します。

entry.c
// 定まったイベントにしたがってメッセージを読み込む
void readScenarioMessage (MapData* map, long deltaTime) {
    if (map->player.hitpoint <= 0) {
        // ライフポイント 0ならシナリオメッセージ読込はしない
        return;
    }
    for (int i = map->msgCount; i < MSG_DATA_LIMIT; i++) {
        if (map->msgChecked < gMsg[i].time && gMsg[i].time <= map->msgChecked + deltaTime) {
            map->msgCount++;
            updateMessage( map , &gMsg[i]);
        } else {
            return;
        }
    }
}

なお、体力が 0 以下の場合は、ゲームオーバーとなりゲームが終了するのを待っているタイミングであるため、 シナリオ(会話)の読み取りはしないように、しています。(509~512行目)

敵キャラの出現方法と同様に、 ゲームを開始してからの経過時間で、対応するシナリオ(会話)メッセージを取得して読み取ります。

シナリオ(会話)データは別途 gamedata.h の中で 定数の配列 gMsg で定義されています。

gMsg の定義を確認してみます。下のような感じで定義されています。

gamedata.h
// ゲームのメッセージ構造体
typedef struct {
    long time;
    char message[60];
} GameMessage;

// ゲームメッセージデータ
#define MSG_DATA_LIMIT 33

static const GameMessage gMsg[MSG_DATA_LIMIT] = {
    {1 * 1000L, "Stage 1 旅のはじまり"},
    {6 * 1000L, "通信猫「恐ろしい異世界スルメイカ達が攻めてきたにゃー」 "},
    {10 * 1000L, "通信猫「ぷらんくさんにお願いだにゃー」 "},
    {15 * 1000L, "通信猫「スペースキーの魔法でやっつけてにゃー」 "},
    {20 * 1000L, "通信猫「奴らはイカスミ魔法を飛ばしてくるにゃ」 "},
    {25 * 1000L, "通信猫「気を付けて、よけてほしいにゃ!」 "},
    {30 * 1000L, "通信猫「あたったら真っ黒で生臭くなるにゃー」 "},
    {35 * 1000L, "通信猫「生臭くなるのは最悪にゃー」 "},
    {40 * 1000L, "通信猫「敵を沢山たおすと」"},
    {45 * 1000L, "通信猫「魔法がパワーアップするにゃ!」 "},
    {53 * 1000L, "通信猫「イカ大隊が来るにゃ!」 "},
    {60 * 1000L, "通信猫「きたにゃー」 "},
    {78 * 1000L, "Stage 2 恐怖!追跡スケルトン魚"},
    {83 * 1000L, "通信猫「ボスの気配が近づいた気がするにゃ」"},
    {88 * 1000L, "通信猫「ホネホネ魚だにゃ」"},
    {94 * 1000L, "通信猫「無残に食べられた魚の」"},
    {99 * 1000L, "通信猫「怨念といううわさがあるにゃ・・・」"},
    {104 * 1000L, "通信猫「おそろしいにゃー・・・」"},
    {110 * 1000L, "通信猫「魚が吐き出すてっぽう水に気を付けるにゃ」"},
    {116 * 1000L, "通信猫「なにかくるにゃ!」"},
    {137 * 1000L, "Stage 3 目玉ボス"},
    {142 * 1000L, "通信猫「あと過ごしで敵のボスと戦うにゃ」"},
    {148 * 1000L, "通信猫「カニが吹く泡には注意だにゃ!」"},
    {153 * 1000L, "通信猫「茹でガニはすきにゃ」"},
    {158 * 1000L, "通信猫「けどこのカニは恐ろしいにゃ!」"},
    {164 * 1000L, "通信猫「敵の波状攻撃だにゃ!」"},
    {170 * 1000L, "通信猫「ボスは近いにゃ~~」"},
    {176 * 1000L, "通信猫「頑張って乗り越えてにゃ!」"},
    {186 * 1000L, "通信猫「ものすごい敵の数だにゃ…」"},
    {194 * 1000L, "通信猫「すごいにゃーー」"},
    {201 * 1000L, "通信猫「ボスは目前だにゃ!!」"},
    {216 * 1000L, "通信猫「ボスの反応があるにゃ!」"},
    {222 * 1000L, "通信猫「目玉ボスがくるにゃ!」"},
};

画面の下部に、gMsg 配列で定義したメッセージを決められたタイミングで表示できるように、 データを定義しています。

ゲームの内容を画面バッファに描画

続いて drawScreen 関数の中を確認します drawScreen 関数の中には、現在のゲームの状況を画面バッファに描画する処理をまとめて書いています。

entry.c
// 画面に描画する内容を定義
void drawScreen(MapData *map) {
    clearScreenBuffer();
    clock_t now = clock();
    GameObject player = map->player;

    // スコアを描画
    char statusText[64];
    sprintf(statusText, " ゲーム終了:ESCキー  HP:%d レベル:%1d スコア:%5d\0", map->player.hitpoint, getLvFromExp(map->exp)+1, map->exp);
    drawText(0, 0, statusText, (RgbColor){200, 100 , 200});

    // 地面を描画
    int lengthGoundBuf = (int)strlen(loopGround1);
    int groundViewOffset = (int)(map->ground.posx) % lengthGoundBuf;
    char groundText0[60 + 1];
    char groundText1[60 + 1];
    char groundText2[60 + 1];
    for (int g = 0; g < 60; g++) {
        groundText0[g] = loopGround0[(groundViewOffset + g) % lengthGoundBuf];
        groundText1[g] = loopGround1[(groundViewOffset + g) % lengthGoundBuf];
        groundText2[g] = loopGround2[(groundViewOffset + g) % lengthGoundBuf];
    }
    groundText0[60] = '\0';
    groundText1[60] = '\0';
    groundText2[60] = '\0';
    drawText(0, map->ground.posy - 2, groundText0, (RgbColor) { 0, 200, 0 });
    drawText(0, map->ground.posy - 1, groundText1, (RgbColor) { 0, 200, 0 });
    drawText(0, map->ground.posy, groundText2, (RgbColor) { 0, 200, 0 });

    // 敵キャラクタをバッファに描画
    GameObject enemy;
    for (int i = 0; i < ENEMY_LIMIT; i++) {
        enemy = map->enemy[i];
        if (enemy.isAlive == TRUE ) {
            switch (enemy.eneType ) {
            case ENE_SQUID:
                drawText(enemy.posx, enemy.posy, "<:=", (RgbColor) { 150, 150, 200 });
                break;
            case ENE_FISH:
                drawText(enemy.posx, enemy.posy, "<'++<", (RgbColor) { 250, 230, 200 });
                break;
            case ENE_CANCER:
                drawText(enemy.posx, enemy.posy, "n('w')r", (RgbColor) { 250, 140, 20 });
                break;
            case ENE_BOSS:
                for (int by = 0; by < 12; by++) {
                    if (enemy.posy - 8 + by >= 1) {
                        drawText(enemy.posx - 4, enemy.posy - 8 + by, bossDisp[by], (RgbColor) { 250, 20, 120 });
                    }
                }
                break;
            }
        }
    }

    // プレイヤ弾をバッファに描画
    GameObject pfire;
    for (int i = 0; i < PLAYER_FIRE_LIMIT ; i++) {
        pfire = map->pfire[i];
        if (pfire.isAlive == TRUE) {
            char* dispBullet = pfire.weapon.disp;
            drawText(pfire.posx, pfire.posy, dispBullet , (RgbColor) { 250, 250, 250 });
        }
    }

    // 敵弾をバッファに描画
    GameObject efire;
    for (int i = 0; i < ENEMY_FIRE_LIMIT; i++) {
        efire = map->efire[i];
        if (efire.isAlive == TRUE) {
            char* dispBullet = efire.weapon.disp;
            drawText(efire.posx, efire.posy, dispBullet, (RgbColor) { 250, 130, 0 });
        }
    }

    // プレイヤーをバッファに描画
    if (map->player.hitpoint <= 0) {
        if (now > map->player.deadTime + map->player.collapseTime) {
            // 何も描画しない
            drawText(19, 7, "- G A M E  O V E R -", (RgbColor) { 250, 30, 0 });
        } else if ((now - map->player.deadTime ) % 200 < 100) {
            drawText(player.posx, player.posy, "nk", (RgbColor) { 255, 255, 255 });
        } else {
            drawText(player.posx, player.posy, "  ", (RgbColor) { 255, 255, 255 });
        }
    } else if (map->player.damagedTime + map->player.damageInterval < now) {
        drawText(player.posx, player.posy, "nk", (RgbColor) { 255, 255, 255 });
    } else {
        // 無敵時間
        if ((now - map->player.damagedTime) % 200 < 100) {
            drawText(player.posx - 1, player.posy, "(nk)", (RgbColor) { 155, 155, 255 });
        }
        else {
            drawText(player.posx , player.posy, "nk", (RgbColor) { 155, 155, 255 });
        }
    }

    // GAME CLEAR
    if ( map->gameEndType == GAMEEND_TYPE_CLEAR) {
        drawText(17, 7, "- G A M E  C L E R ! ! -", (RgbColor) { 255, 155, 0 });
    }

    // メッセージを表示
    char dispMsg[64];
    int dispMsgLength = (int)strlen(map->dispMsg->message);
    long diffDispMsg = now - map->dispMsg->time;
    int dispLength = min ( dispMsgLength, 2 * (int)(diffDispMsg / 120L));
    strncpy(dispMsg, map->dispMsg->message, dispLength );
    dispMsg[dispLength] = '\0';
    drawText(0, 19, dispMsg, (RgbColor) { 155, 155, 155 });
}

大きく以下の処理を行っています。

  • 画面上部にキーボード操作、スコア、体力、レベルの状況を描画
  • 地面を描画
  • 敵キャラクタを描画
  • プレイヤの攻撃魔法(弾)を描画
  • プレイヤを描画(プレイヤは「nk」で表現)
  • ゲームクリアした場合は、画面真ん中に「- G A M E C L E R ! ! -」を表示
  • シナリオ(会話)のメッセージを表示

地面の描画について

以下、画面右側から左側に流れる地面の表示処理について確認します。

entry.c
    // 地面を描画
    int lengthGoundBuf = (int)strlen(loopGround1);
    int groundViewOffset = (int)(map->ground.posx) % lengthGoundBuf;
    char groundText0[60 + 1];
    char groundText1[60 + 1];
    char groundText2[60 + 1];
    for (int g = 0; g < 60; g++) {
        groundText0[g] = loopGround0[(groundViewOffset + g) % lengthGoundBuf];
        groundText1[g] = loopGround1[(groundViewOffset + g) % lengthGoundBuf];
        groundText2[g] = loopGround2[(groundViewOffset + g) % lengthGoundBuf];
    }
    groundText0[60] = '\0';
    groundText1[60] = '\0';
    groundText2[60] = '\0';
    drawText(0, map->ground.posy - 2, groundText0, (RgbColor) { 0, 200, 0 });
    drawText(0, map->ground.posy - 1, groundText1, (RgbColor) { 0, 200, 0 });
    drawText(0, map->ground.posy, groundText2, (RgbColor) { 0, 200, 0 });

地面のグラフィックは定数として、 gamedata.h の中で定義されています。 具体的には下のように 3 つの文字列変数を使って表現しており、横 146文字、縦 3 文字分の地面データとなっています。

 
gamedata.h
// ループする地面
static const char loopGround0[] = "                                   o             0o                    0o      o                0o           p           o    0o     o0       ";
static const char loopGround1[] = "   /'';    o0   /;       ,.  oo    |p     /'':   r|         o00    ..  |r     -+--.    /--'',,, ||         /-+--.        |    |r/'''-++'';    ";
static const char loopGround2[] = "--/    '-,.|r--/  '..---/  -.|r..--++----/    '--++--'''---_|||_--/  ;-++--../     :__/        -++-__/----       ----'''-+'''-+'          ''--";

この loopGround0,1,2 の定数から、それぞれ画面上に表示するための横 60 文字ぶんを取得して表示しています。 取得する開始位置(オフセット)は、変数 groundViewOffset により決まり、 この変数 groundViewOffset は map->ground の GameObject の位置 posx から計算しています。

posx は先ほど確認した、メインループの中から呼び出している関数 moveGround で時間経過とともに変化しく作りとなっています。 このため、変数 groundViewOffset は 0~145 の間で変化していきます。

定数 loopGround0,1,2 の横 146 文字の背景データから60文字を取得するとき、取得する開始位置(オフセット)が例えば 100 文字目であった場合には、定数 loopGround0,1,2 の 100文字目~146文字目 + 0文字目~12文字目で合計 60文字を取得できるように、102~104行目でプログラムしています。

敵キャラクタの描画

今回、敵キャラクタとして4種類が用意されています。

敵キャラクタの名前 定数名(実態は数値) 表現
宇宙スルメイカ ENE_SQUID <:=
ホネホネ魚 ENE_FISH <'++<
宇宙茹でガニ ENE_CANCER n('w')r
目玉ボス ENE_BOSS ボス

map->enemy 配列に登録された敵キャラクタを、それぞれ switch 文で 分岐してバッファに敵キャラの表現(文字列)を描画しています。

entry.c
// 敵キャラクタをバッファに描画
GameObject enemy;
for (int i = 0; i < ENEMY_LIMIT; i++) {
    enemy = map->enemy[i];
    if (enemy.isAlive == TRUE ) {
        switch (enemy.eneType ) {
        case ENE_SQUID:
            drawText(enemy.posx, enemy.posy, "<:=", (RgbColor) { 150, 150, 200 });
            break;
        case ENE_FISH:
            drawText(enemy.posx, enemy.posy, "<'++<", (RgbColor) { 250, 230, 200 });
            break;
        case ENE_CANCER:
            drawText(enemy.posx, enemy.posy, "n('w')r", (RgbColor) { 250, 140, 20 });
            break;
        case ENE_BOSS:
            for (int by = 0; by < 12; by++) {
                if (enemy.posy - 8 + by >= 1) {
                    drawText(enemy.posx - 4, enemy.posy - 8 + by, bossDisp[by], (RgbColor) { 250, 20, 120 });
                }
            }
            break;
        }
    }
}

なお、ボスキャラ(ENE_BOSS)は見た目のサイズが大きいため、 別途 gamedata.h の中で定数文字列 bossDisp として以下のように定義されています。

gamedata.h
// BOSS のグラフィック定義
static const char* bossDisp[12] = {
  "   __________"
, "  /   +"
, " /      +"
, "+-.      +"
, "|##|     +"
, "|##'|     +"
, "|####;    +"
, "|####|    +"
, "|###/    + "
, "+--'     +"
, " ;.     +"
, "  ';__+_______"
};

プレイヤーの描画

プレイヤーは「nk」で表現します。 今回は、プレイヤーがダメージを受けた時の無敵時間や、 体力が 0 となり、消滅したときの表現を持たせます。

以下のようなパターンをとります。

状況 表現
通常 nk を表示
ダメージ時で体力が 0 一定時間点滅し、GAME OVER を表示
無敵状態 一定時間「nk」 と「(nk)」を繰り返す

コードとしては以下のようなイメージです。

entry.c
// プレイヤーをバッファに描画
if (map->player.hitpoint <= 0) {
    if (now > map->player.deadTime + map->player.collapseTime) {
        // 何も描画しない
        drawText(19, 7, "- G A M E  O V E R -", (RgbColor) { 250, 30, 0 });
    } else if ((now - map->player.deadTime ) % 200 < 100) {
        drawText(player.posx, player.posy, "nk", (RgbColor) { 255, 255, 255 });
    } else {
        drawText(player.posx, player.posy, "  ", (RgbColor) { 255, 255, 255 });
    }
} else if (map->player.damagedTime + map->player.damageInterval < now) {
    drawText(player.posx, player.posy, "nk", (RgbColor) { 255, 255, 255 });
} else {
    // 無敵時間
    if ((now - map->player.damagedTime) % 200 < 100) {
        drawText(player.posx - 1, player.posy, "(nk)", (RgbColor) { 155, 155, 255 });
    }
    else {
        drawText(player.posx , player.posy, "nk", (RgbColor) { 155, 155, 255 });
    }
}

シナリオ(会話)の表示

画面下部に現在のシナリオ(会話)を表示します。 別途メインループの中から呼び出された readScenarioMessage 関数の中で、 map->dispMsg->message に、時間経過で設定されたメッセージが設定されています。

このメッセージをいい感じに表示します。

メッセージアニメーション

コードは下のようになっています。 日本語を一文字一文字取り出していくため、2バイト単位でのサイズ(dispLength)を計算して文字列を取り出しています。

 
entry.c
// メッセージを表示
char dispMsg[64];
int dispMsgLength = (int)strlen(map->dispMsg->message);
long diffDispMsg = now - map->dispMsg->time;
int dispLength = min ( dispMsgLength, 2 * (int)(diffDispMsg / 120L));
strncpy(dispMsg, map->dispMsg->message, dispLength );
dispMsg[dispLength] = '\0';
drawText(0, 19, dispMsg, (RgbColor) { 155, 155, 155 });

敵を倒した時の処理(レベルアップ処理、ゲームクリア)

衝突判定と敵の体力チェック

メインループの中で、actionPlayerBullet 関数をよび、 プレイヤーの放った攻撃(弾)の挙動を定義しています。

その中で、攻撃(弾)と敵キャラの当たり判定として、さらに checkCollisionPlayerBullet 関数を 呼び出しています。

以下ソースコードについて、前のページから追加された部分をハイライトしています。

 
entry.c
// プレイヤの弾と敵キャラの衝突判定
void checkCollisionPlayerBullet(MapData* map, GameObject* bullet) {
    GameObject* enemy;
    for (int i = 0; i < ENEMY_LIMIT; i++) {
        enemy = &map->enemy[i];
        if (enemy->isAlive == TRUE) {
            double bx = bullet->posx;
            double by = round( bullet->posy); // 縦方向は整数ぴったりでないとイライラする
            double br = bullet->weapon.collision_param1;
            double ex = enemy->posx;
            double ey = round(enemy->posy); // 縦方向は整数ぴったりでないとイライラする
            double er = eData[enemy->eneType].collisionRadius;

            // 弾の円と敵キャラの円の衝突判定(円が触れ合っている)
            if (isCollisionCircle(bx,by,br, ex,ey,er)) {
                bullet->isAlive = FALSE;    // 弾は消失
                enemy->hitpoint -= bullet->weapon.damage;   // ダメージを与える
                if (enemy->hitpoint <= 0) {
                    enemy->isAlive = FALSE; // 敵を撃破
                    int gainExp = eData[enemy->eneType].exp;  // 獲得する経験値量                    addExpToPlayer(map, gainExp);                    if (enemy->eneType == ENE_BOSS) { // ボスを倒したためゲームクリアフェーズへ                        map->gameEndTime = clock();                        map->gameEndType = GAMEEND_TYPE_CLEAR;                        updateMessage(map, &sysMsg[SYSMSG_DEFEAT_BOSS]);                    }
                }
                return;
            }
        }
    }
}

ここでは、敵の体力が 0 となった場合に、敵キャラクタに応じた経験値が入手される処理と、 経験値を得た後のレベルアップ判定処理を addExpToPlayer 関数を呼び出すことで行っています。

また、倒した相手が ENE_BOSS (ボスキャラ)だった場合には、 ゲームクリアとするべく、 map->gameEndTime にボスを倒した時刻をセットし、 map->gameEndType にクリアした状態をセットします。

また、ゲームクリアのお祝いのためのメッセージを updateMessage 関数を呼び出してセットします。

経験値の追加とレベルアップ処理

敵を倒した時、 addExpToPlayer 関数を呼びだして、レベルアップできるかの確認をしています。 レベルが上がった場合には、使える武器も変化し、お祝いのメッセージが updateMessage 関数で設定されます。

実装は以下の通りです。

 
entry.c
// プレイヤに経験値を追加し、レベルアップ判定を行う
void addExpToPlayer (MapData* map, const int gainExp ) {
    int nowLv = getLvFromExp(map->exp);       // 現在のレベルを計算
    int afterLv = getLvFromExp(map->exp + gainExp); // 経験値獲得後のレベルを計算
    if (nowLv < afterLv) {
        // レベルが上がった
        WeaponType w = dataLevelToMagic[afterLv].bulletType;
        map->player.weapon = fire[w]; // 武器を更新する
        sysMsg[SYSMSG_LVUP].time = map->msgChecked;
        if (map->gameEndType == GAMEEND_TYPE_PLAYING) {
            updateMessage(map, &sysMsg[SYSMSG_LVUP]); // レべアップメッセージを出す
        }
    }
    map->exp += gainExp; // 経験値を増加
}

getLvFromExp 関数で、引数で渡した経験値にふさわしいレベルを計算して取得しています。 もしも、現在の経験値(gainExp)と敵を倒して経験値が増えた後(map->exp + gainExp)で レベルが異なるなら、レベルアップしたことになります。

少し getLvFromExp 関数の中身も確認します。

 
entry.c
// 経験値に相当するレベルを計算して取得
int getLvFromExp(int exp) {
    for (int i = 0; i < LEVEL_AND_MAGIC_COUNT_LIMIT; i++) {
        if (exp < dataLevelToMagic[i].exp ) {
            return i;
        }
    }
    return 0;
}

dataLevelToMagic 配列の中身を確認して、経験値にふさわしいレベルを探しています。

dataLevelToMagic 配列は gamedata.h の中で以下のように定義されており、 レベルに必要な経験値と、使える武器の組合せが定義されています。

 
gamedata.h
// レベルアップの基準と、武器
typedef struct {
    int exp;               // 基準となる経験値
    WeaponType bulletType; // 武器の種類
} LevelAndMagic;

#define LEVEL_AND_MAGIC_COUNT_LIMIT 6
static const LevelAndMagic dataLevelToMagic[LEVEL_AND_MAGIC_COUNT_LIMIT] = {
     { 4000, FIRE_LV1}
    ,{ 8000, FIRE_LV2}
    ,{12000, FIRE_LV3}
    ,{14000, FIRE_LV4}
    ,{18000, FIRE_LV5}
    ,{9999000, FIRE_LV5}
};

エンディング

メインループから抜けだすパターンは3つあります。

  1. ESC キーを押す
  2. ゲームクリア(敵のボスキャラクタを撃破)
  3. プレイヤー体力0でのゲームオーバー

map.gameEndType にセットされた値により、エンディングの描画内容は分岐します。

コードは以下の通りです。

 
entry.c
// エンディングの表示
while (!isKeyPushed(VK_ESCAPE)) {   // ESC キーで終了
    clearScreenBuffer();        // 画面バッファを消去し、表示をリセットする
    updateAndPrintScore(&map);  // ゲーム終了時のスコアを表示
    switch (map.gameEndType) {
    case GAMEEND_TYPE_CLEAR:    // ゲームクリアしているなら
        printClearMsg();
        break;
    case GAMEEND_TYPE_PLAYING:  // ESC キーによる終了
    case GAMEEND_TYPE_GAMEOVER: // プレイヤ体力0でゲームオーバー
        printGameoverMsg();
        break;
    }
    printCredit();
    Sleep(1);
    flushScreen();
}

2 パターンでエンディング画面が表示されます。

以下はゲームクリアした場合の画面です。

ゲームクリアの場合

以下は ESC キーを押したときか、プレイヤー体力0でのゲームオーバーとなった場合の画面です。

ゲームオーバーの場合

おわりに

前ページと通して、シューティングゲームの基本を作成しました。

今回は C 言語をつかったゲームプログラミングがテーマですので、 シンプルな文字を組み合わせた画面のみで、綺麗な画像やアニメーションなどのやり方は記載していません。

簡単にご自身でもプログラミングに集中できるよう、コンソールプログラムで流れを作成しています。

ですが、例えば他の言語を使用したり、ゲームエンジンを使ったゲーム開発でも 根本では似たようなプログラミングの考え方が随所に使われていると思いますので、少しでもお役に立てばと思います。

謝辞

本ページの音楽には mokan 様が作曲した「music_battle1.mp3」を利用させていただいております。

使用に当たっては、ご本人に快く許諾いただきました。

その際はまことにありがとうございます!

mokan 様の Twitter : https://twitter.com/mokan_mikan

mokan 様の Youtube : https://www.youtube.com/channel/UCvCzeR_L0PbngKQqYE8VncQ/featured

参考文献

  • なし
イチからゲーム作りで覚えるC言語
第4章4 C言語コンソールで横スクロールシューティングゲーム(1/2) : PREV
 
 
送信しました!

コメント、ありがとうございます。

なんかエラーでした

ごめんなさい。エラーでうまく送信できませんでした。ご迷惑をおかけします。しばらくおいてから再度送信を試していただくか、以下から DM などでご連絡頂ければと思います。

Twitter:@NodachiSoft_jp
お名前:
 
連絡先:
 
メッセージ:
 
戻る
内容の確認!

以下の内容でコメントを送信します。よろしければ、「送信」を押してください。修正する場合は「戻る」を押してください

お名前:
 
連絡先:
 
メッセージ:
 
Roboto からの操作ではないという確認のため確認キーを入れてください。
確認キー=95
戻る
 / 
送信確認へ
コメント欄
コメント送信確認へ

関連ありそうな記事(5件)です!

第1章01 Visual Studio Community 2019 のインストール手順

#C11仕様#C言語#ゲームプログラミング✎ 2021-08-08
C言語でプログラミングをするために、無料で使える Visual Studio Community を使った開発環境を揃えていく手順や注意点をお話しています。
目次
第4章5 C言語コンソールで横スクロールシューティングゲーム(2/2)
第4章5 C言語コンソールで横スクロールシューティングゲーム(2/2)
概要
概要
このページで作成するゲームの範囲
このページで作成するゲームの範囲
組み込み済みの機能一覧
組み込み済みの機能一覧
今回追加していくゲームの機能
今回追加していくゲームの機能
プログラムの実行イメージ
プログラムの実行イメージ
プログラムの流れ
プログラムの流れ
ソースコード
ソースコード
ゲームデータ(MapData)の定義
ゲームデータ(MapData)の定義
敵キャラクタの攻撃機能を管理するデータを追加
敵キャラクタの攻撃機能を管理するデータを追加
敵の出現タイミング、シナリオ(会話)の管理
敵の出現タイミング、シナリオ(会話)の管理
レベルアップ機能を管理するデータ
レベルアップ機能を管理するデータ
ゲームのマスターデータを定義
ゲームのマスターデータを定義
ゲームの起動・初期化パート
ゲームの起動・初期化パート
ゲーム効果音の読込
ゲーム効果音の読込
音楽の読込・再生
音楽の読込・再生
ゲームデータの初期化
ゲームデータの初期化
スコアデータの読み込み
スコアデータの読み込み
ゲームのメインループ
ゲームのメインループ
地面の移動
地面の移動
敵の攻撃(弾)の処理
敵の攻撃(弾)の処理
敵キャラクタの出現
敵キャラクタの出現
シナリオ(会話)の読込
シナリオ(会話)の読込
ゲームの内容を画面バッファに描画
ゲームの内容を画面バッファに描画
地面の描画について
地面の描画について
敵キャラクタの描画
敵キャラクタの描画
プレイヤーの描画
プレイヤーの描画
シナリオ(会話)の表示
シナリオ(会話)の表示
敵を倒した時の処理(レベルアップ処理、ゲームクリア)
敵を倒した時の処理(レベルアップ処理、ゲームクリア)
衝突判定と敵の体力チェック
衝突判定と敵の体力チェック
経験値の追加とレベルアップ処理
経験値の追加とレベルアップ処理
エンディング
エンディング
おわりに
おわりに
謝辞
謝辞
参考文献
参考文献
Nodachisoft © 2021