C言語で海の浜辺の波打ち際を描画する

724, 2023-08-27

目次

C言語で海の浜辺の波打ち際を描画

いやー毎日、暑いですね。
ほんと今年(2023年)の夏は異常気象ですね。

ということで海が恋しいです。
なので浜辺の波打ち際をC言語で実装してみます。

動作風景

今回のプログラムを実行すると以下のような動作風景になります。

c-nami

浜辺に波が打ち寄せてるように見えないでしょうか?
え? 見えない?
・・・。
見えますよね。

このプログラムのソースコードを解説していきます。

ソースコード全文

プログラムのソースコードは以下になります。

/**
 * 波プログラム
 * LICENSE: MIT
 */
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <unistd.h>
#include <string.h>

enum {
    CANVAS_WIDTH = 120,  // キャンバスの横幅
    CANVAS_HEIGHT = 30,  // キャンバスの高さ
    CANVAS_SIZE = CANVAS_WIDTH * CANVAS_HEIGHT,  // キャンバスの要素数
    NAMIS_SIZE = CANVAS_WIDTH,  // 波の要素数
};

typedef struct {
    int x, y;  // 座標x, y
} Nami;

int canvas[CANVAS_SIZE];  // 描画先キャンバス
Nami namis[NAMIS_SIZE];  // 波打ち際の波
int time;  // 時間

/**
 * 度数法の角度degを弧度法のラディアンに変換する
 */
double deg2rad(double deg) {
    return deg * (M_PI / 180.0);
}

/**
 * 更新処理
 * キャンバスを更新する
 */
void update(void) {
    memset(canvas, 0, sizeof canvas);

    // 波際の更新
    for (int i = 0; i < NAMIS_SIZE; i++) {
        Nami *nami = &namis[i];  

        int n = i + 1;
        int oy = (CANVAS_HEIGHT * 0.6) + (int) (sin(deg2rad(time)) * 4);
        int ay = (int) (sin(deg2rad(n) * 3) * cos(deg2rad(n*2) * 3) * cos(deg2rad(time)) * 8);

        nami->x = i;
        nami->y = oy + ay;
        if (nami->y < 0) {
            nami->y = 0;
        } else if (nami->y >= CANVAS_HEIGHT) {
            nami->y = CANVAS_HEIGHT - 1;
        }

        int index = nami->y * CANVAS_WIDTH + nami->x;
        canvas[index] = '*';  // 波
    }

    // 砂浜の更新
    for (int x = 0; x < CANVAS_WIDTH; x++) {
        Nami *nami = &namis[x];
        for (int y = nami->y + 1; y < CANVAS_HEIGHT; y++) {
            int i = y * CANVAS_WIDTH + x;
            canvas[i] = '.';  // 砂
        }

    }

    time++;
}

/**
 * キャンバスを画面に描画する
 */
void draw(void) {
    system("clear");  // Windows: system("cls");

    for (int y = 0; y < CANVAS_HEIGHT; y++) {
        for (int x = 0; x < CANVAS_WIDTH; x++) {
            int i = y * CANVAS_WIDTH + x;
            int c = canvas[i];
            switch (c) {
            case 0: putchar(' '); break;
            default: putchar(c); break;
            }
        }
        putchar('\n');  // 1行ごとに改行を入れる
    }
}

int main(void) {
    for (;;) {
        update();
        draw();
        usleep(1000 * 100);
    }

    return 0;
}

定数

enum {
    CANVAS_WIDTH = 120,  // キャンバスの横幅
    CANVAS_HEIGHT = 30,  // キャンバスの高さ
    CANVAS_SIZE = CANVAS_WIDTH * CANVAS_HEIGHT,  // キャンバスの要素数
    NAMIS_SIZE = CANVAS_WIDTH,  // 波の要素数
};

定数は上記になります。
キャンバスというのは1次元配列のことで、この配列に描画する文字を代入します。
いろいろ更新処理しますが、最終的にはこのキャンバスに値をセットするための更新処理です。

NAMIS_SIZEというのは波の要素数で、今回は波打ち際の波は配列で表現します。
その配列の要素数です。

構造体

typedef struct {
    int x, y;  // 座標x, y
} Nami;

上記の構造体が波を表現する構造体です。
x, yが座標です。
キャンバスは1次元ですが波の座標は2次元になります。

グローバル変数

int canvas[CANVAS_SIZE];  // 描画先キャンバス
Nami namis[NAMIS_SIZE];  // 波打ち際の波
int time;  // 時間

canvasがキャンバス、namisが波の配列、timeが時間です。
timeは更新処理をするごとにカウントする変数です。
アニメーションさせますので、ループが回り更新と描画処理が毎フレームごとにtimeは更新されます。

main関数

int main(void) {
    for (;;) {
        update();
        draw();
        usleep(1000 * 100);
    }

    return 0;
}

プログラムはmain関数から始まります。
for文で無限ループしupdate()draw()を呼び出します。
update()は更新処理、draw()は描画処理です。

usleep()はスレッドをマイクロ秒スリープします。
1000マイクロ秒で1ミリ秒になりますので、それを100かけて100ミリ秒スリープします。

update関数

/**
 * 更新処理
 * キャンバスを更新する
 */
void update(void) {
    memset(canvas, 0, sizeof canvas);

    // 波際の更新
    for (int i = 0; i < NAMIS_SIZE; i++) {
        Nami *nami = &namis[i];  

        int n = i + 1;
        int oy = (CANVAS_HEIGHT * 0.6) + (int) (sin(deg2rad(time)) * 4);
        int ay = (int) (sin(deg2rad(n) * 3) * cos(deg2rad(n*2) * 3) * cos(deg2rad(time)) * 8);

        nami->x = i;
        nami->y = oy + ay;
        if (nami->y < 0) {
            nami->y = 0;
        } else if (nami->y >= CANVAS_HEIGHT) {
            nami->y = CANVAS_HEIGHT - 1;
        }

        int index = nami->y * CANVAS_WIDTH + nami->x;
        canvas[index] = '*';  // 波
    }

    // 砂浜の更新
    for (int x = 0; x < CANVAS_WIDTH; x++) {
        Nami *nami = &namis[x];
        for (int y = nami->y + 1; y < CANVAS_HEIGHT; y++) {
            int i = y * CANVAS_WIDTH + x;
            canvas[i] = '.';  // 砂
        }

    }

    time++;
}

update関数は更新処理です。
具体的には変数のcanvasnamistimeを更新します。

まずcanvasをmemset関数で0クリアします。

    memset(canvas, 0, sizeof canvas);

for文を回しnamisからnamiを一個ポインタで取り出して、その座標を更新します。

    // 波際の更新
    for (int i = 0; i < NAMIS_SIZE; i++) {
        Nami *nami = &namis[i];  
        ...
    }

それから、肝心の座標更新処理です。

    int n = i + 1;
    int oy = (CANVAS_HEIGHT * 0.6) + (int) (sin(deg2rad(time)) * 4);
    int ay = (int) (sin(deg2rad(n) * 3) * cos(deg2rad(n*2) * 3) * cos(deg2rad(time)) * 8);

    nami->x = i;
    nami->y = oy + ay;
    if (nami->y < 0) {
        nami->y = 0;
    } else if (nami->y >= CANVAS_HEIGHT) {
        nami->y = CANVAS_HEIGHT - 1;
    }

上記ではまずy座標を計算しています。
nはfor文のカウント変数に1を足しただけの変数です。1を足しているのは0を避けるためです。
oyは基点の座標で、この変数は波の基点位置を保存しています。

sin, cos関数は引数のラディアンをもとに-1から1の間の実数を返します。
この性質を利用すると行ったり来たりする座標が得られます。
これを使って波の位置をy軸上に上下に移動させています。

deg2rad()は度数法の角度を弧度法のラディアンに変換します。

/**
 * 度数法の角度degを弧度法のラディアンに変換する
 */
double deg2rad(double deg) {
    return deg * (M_PI / 180.0);
}

角度を表すには2つの方法が有名で、それが度数法と弧度法です。
度数法は1度、2度・・・90度・・・180度というような、我々がよく使う単位です。
弧度法は度数法とはまた別の単位で、sin関数などの三角関数はこの弧度法のラディアンという単位を引数に取ります。
時間をカウントするtime変数やループ文のカウント変数などをdeg2rad()でラディアンに変換し、三角関数に渡します。

ayoyを起点に上下に波を移動させます。
ayの値の算出はsin関数とcos関数を組み合わせて導いてます。
sin関数やcos関数を単体で使っても周期的な値しか得られませんが、これらを組み合わせると複雑な座標を得ることができます。
波打ち際の波の変化は自然現象なので、より複雑な値がないと波っぽく見えません。
またtimeをcos関数の引数にすることで時間的な表現にもしています。
あーでもない、こーでもないと言いながら試行錯誤した結果、この数式ができましたが、もっと自然な表現はあるかもしれません。
それにこの数式で求められる値も周期的であることは変わりないので、自然的というよりは機械的な表現と言えます。

nami->xの座標はカウント変数にします。
CANVAS_WIDTHNAMIS_SIZEは同じ値なので問題ありません。
nami->yにはoy + ayを代入します。この値が0より下なら0にフィックスし、CANVAS_HEIGHT以上ならCANVAS_HEIGHT - 1にフィックスします。

        int index = nami->y * CANVAS_WIDTH + nami->x;
        canvas[index] = '*';  // 波

上記では波の2次元の座標を1次元の配列のインデックスに変換しています。
波のy座標 * キャンバスの高さ + 波のx座標で変換できます。
canvas[index]の位置に*を入れて波を表現します。

    // 砂浜の更新
    for (int x = 0; x < CANVAS_WIDTH; x++) {
        Nami *nami = &namis[x];
        for (int y = nami->y + 1; y < CANVAS_HEIGHT; y++) {
            int i = y * CANVAS_WIDTH + x;
            canvas[i] = '.';  // 砂
        }

    }

上記では砂浜の砂をキャンバスに代入しています。
nami->yの1つ下の座標から、キャンバスの下端までが砂地です。
canvasに値を代入するには2次元の座標から変換した1次元の添え字が必要です。
そのためfor文を入れ子で回しています。

draw関数

/**
 * キャンバスを画面に描画する
 */
void draw(void) {
    system("clear");  // Windows: system("cls");

    for (int y = 0; y < CANVAS_HEIGHT; y++) {
        for (int x = 0; x < CANVAS_WIDTH; x++) {
            int i = y * CANVAS_WIDTH + x;
            int c = canvas[i];
            switch (c) {
            case 0: putchar(' '); break;
            default: putchar(c); break;
            }
        }
        putchar('\n');  // 1行ごとに改行を入れる
    }
}

draw関数は端末にキャンバスを出力します。
まず端末の画面をクリアするためにsystem("clear")を呼び出します。
system関数は引数の文字列のプログラムを起動します。
つまりclearというのはコマンドプログラムです。これはLinuxなどのコマンドで画面をクリアするコマンドです。
Windowsの場合はclsがそれに該当するコマンドになりますので、Windowsユーザーの人はここを変更してください。

キャンバスは1次元配列ですが、for文は2次元で2重に回します。
座標変換してfor文のカウント変数を配列の添え字に変換します。
そしてcanvasの要素を取り出して描画します。
値0の要素については半角スペースを描画します。
他の要素はそのまま出力します。

おわりに

今回は海の砂浜の波打ち際のプログラムを解説しました。
なにか参考になれば幸いです。

(^ _ ^)

海に行きたい!

(・ v ・)

涼しくなったら!



この記事のアンケートを送信する