STEP03:input層の実装(安全な数値入力・コマンド選択の仕組み)

STEP03では、プレイヤー入力をまとめて扱う input層 を作ります。
RPGの各画面(タイトル・探索・戦闘・宿・教会など)では「数字で選ぶ」「コマンドを選ぶ」操作が大量に出てきますが、毎回 scanf や getchar を直書きし始めると、すぐにバグの温床になります。

そこでこのSTEPでは、

  • 文字列として1行入力して
  • 数値に安全に変換し
  • 範囲チェックもして
  • “番号選択” を共通化する

という土台を input層に閉じ込めます。

input層でやりたいこと(役割)

入力の課題ありがちな失敗input層での解決
数値入力が壊れるscanf("%d",&n) で不正入力→入力バッファが詰む1行読み→ strtol で変換
範囲外が来る0〜3のはずが 999 が通るmin/max を必ずチェック
Enterや空文字空入力で変換失敗空は弾いて再入力
メニュー選択が散らかる画面ごとに入力処理がバラバラchoose_menu(min,max) を共通化

STEP03で登場するファイル

ファイル役割
input.hinput層の公開関数(宣言)
input.c安全な行入力・数値変換・メニュー選択

※ common.h は既にある前提です(STEP02で用意済み)。

input.h:公開する関数を宣言する

この教材RPGで「まず欲しい入力機能」は次の3つです。

  • 1行入力(文字列)
  • 安全な整数入力(再入力つき)
  • 範囲つきのメニュー選択(コマンド選択の仕組み)

掲載:input.h

/* input.h */
#ifndef INPUT_H
#define INPUT_H

#include "common.h"

/* 1行入力(末尾の改行は取り除く) */
int read_line(char *buf, int buflen);

/* 安全な整数入力(失敗したら再入力) */
int read_int_safely(const char *prompt, int *out);

/* min〜max の範囲で選ばせるメニュー選択 */
int choose_menu(const char *prompt, int min, int max);

#endif

input.c:安全な入力の実装

ポイントは「scanf禁止」じゃなく「教材として事故りにくい形」

scanf("%d") は簡単ですが、不正入力時に入力が残ってループが壊れやすいのが弱点です。
そこで input層では必ず

  1. fgets で1行読み取る。
  2. strtol で変換する。
  3. 変換失敗・範囲外なら再入力

この流れに統一します。

掲載:input.c(read_line / read_int_safely / choose_menu)

/* input.c */
#include "input.h"
#include <ctype.h>
#include <errno.h>
#include <limits.h>

/* 末尾の改行を除去する */
static void chomp_newline(char *s) {
    size_t n = strlen(s);
    if (n > 0 && s[n - 1] == '\n') s[n - 1] = '\0';
}

/* 空白だけの文字列か? */
static int is_blank(const char *s) {
    while (*s) {
        if (!isspace((unsigned char)*s)) return 0;
        s++;
    }
    return 1;
}

int read_line(char *buf, int buflen) {
    if (!fgets(buf, buflen, stdin)) {
        return 0; /* EOFなど */
    }
    chomp_newline(buf);
    return 1;
}

int read_int_safely(const char *prompt, int *out) {
    char buf[128];

    for (;;) {
        if (prompt && prompt[0]) {
            printf("%s", prompt);
            fflush(stdout);
        }

        if (!read_line(buf, (int)sizeof(buf))) {
            return 0; /* 入力終了(EOF) */
        }

        if (buf[0] == '\0' || is_blank(buf)) {
            printf("Please enter a number.\n");
            continue;
        }

        errno = 0;
        char *end = NULL;
        long v = strtol(buf, &end, 10);

        /* 変換失敗:数字が1文字も無い、途中で止まった、オーバーフローなど */
        if (end == buf) {
            printf("Invalid input. Please enter a number.\n");
            continue;
        }
        if (errno == ERANGE || v < INT_MIN || v > INT_MAX) {
            printf("Number out of range.\n");
            continue;
        }

        /* 末尾に余計な文字がある(例: 12abc) */
        while (*end) {
            if (!isspace((unsigned char)*end)) {
                printf("Invalid input. Please enter a number.\n");
                goto retry;
            }
            end++;
        }

        *out = (int)v;
        return 1;

    retry:
        continue;
    }
}

int choose_menu(const char *prompt, int min, int max) {
    int n;

    for (;;) {
        if (!read_int_safely(prompt, &n)) {
            return min; /* EOF時はとりあえずmin扱い(ゲーム側で終了処理してもOK) */
        }

        if (n < min || n > max) {
            printf("Please choose %d - %d.\n", min, max);
            continue;
        }

        return n;
    }
}

実装の読み解き(重要ポイント)

➀ read_line:fgets を使う理由

方法問題点
scanf不正入力が残りやすい、空白で止まる、改行処理が面倒
fgets1行まるごと受け取れる、入力の事故が起きにくい

fgets は改行 \n も含めて読むので、chomp_newline で削っています。

➁ read_int_safely:strtol を使う理由

strtol は「どこまで数として読めたか」が end に返ってくるので、

  • 数字が無かった(end==buf)
  • 途中で文字が混ざった(12abc)
  • オーバーフロー(errno==ERANGE)

を判定できます。

チェックどう判定する?
数字が無いabcend == buf
途中に文字12abc*end に非空白が残る
範囲外999999999999errno == ERANGE

➂ choose_menu:コマンド選択の仕組み

ゲームでよくある「番号で選ぶ」を1つの関数にまとめます。

例:戦闘で 1=Attack, 2=Skill, 3=Item, 4=Run ならこう書けます。

int cmd = choose_menu("Command (1-4): ", 1, 4);

これだけで、

  • 数字じゃない入力を弾く
  • 1〜4以外を弾く
  • 正しく入力されるまで再入力

が全部揃います。

呼び出し例(ゲーム側のイメージ)

例1:タイトル画面の選択

printf("1) Start\n");
printf("2) How to play\n");
printf("3) Quit\n");

int sel = choose_menu("Select (1-3): ", 1, 3);

例2:移動入力(テンキー入力 2/4/6/8)

printf("Move: 8=Up 2=Down 4=Left 6=Right\n");
int dir = choose_menu("Input (2/4/6/8): ", 2, 8);
/* このままだと 3,5,7 も入るので、ゲーム側で弾くか、別関数にしてもOK */

※ この「2/4/6/8だけ許す」は、次STEP以降で choose_from_list のような関数を足すとさらに綺麗になります。

よくある落とし穴(STEP03で潰しておく)

落とし穴症状STEP03の実装でどう回避?
scanfループ地獄入力ミスすると無限に同じエラーfgets + strtol
空入力で変換失敗Enterだけで壊れる空や空白を弾く
12abcが通る想定外の入力が混ざるend以降に非空白があれば弾く
EOF(Ctrl+Z/Ctrl+D)そのまま落ちるread_line が 0 を返す

次のSTEPへのつながり(STEP04の予告)

次の STEP04 では ui層(表示の共通化)に進みます。

  • メニュー表示
  • メッセージ枠
  • 色付き表示(STEP02/03で用意した土台を活用)

input層ができたことで、ui層側は「表示→choose_menuで選択」の流れがスッキリ書けるようになります。