人工知能に関する断創録

このブログでは人工知能のさまざまな分野について調査したことをまとめています(更新停止: 2019年12月31日)

メッセージの表示

ようやくメッセージを表示するとこまできました。メッセージ表示は文字イメージを適切な順番で並べることで実現します。指定された位置に好きなメッセージを描画できるメッセージエンジンを作ったのでそれと合わせて解説します。

rpg16.jar

文字イメージ

まずメッセージ表示に使う文字イメージです。このイメージは山亀本舗で公開されているGAMESYSのフォントを元に改造しました。

f:id:aidiary:20100404101706g:plain

見るとわかりますが(わかんないかな?)ドラクエのフォントとは少し違います。もしドラクエのフォントを使いたかったらすらいむのへや(リンク切れ)に行くと幸せになれます。ドラクエフォントといっしょにすばらしいツールを公開されてます。ただドラクエフォントにはカタカナの「ケ」や「チ」などが定義されてないので注意が必要です。ファミコン版のドラクエでは1度も使われてない文字で最初から用意されてないのかもしれません。

1文字は下図のように16×22ドット(ピクセル)で構成されてます。すべての文字がこの大きさです。統一しておくとメッセージを表示するときに簡単になります。

f:id:aidiary:20100404102611g:plain

メッセージエンジン

ここでメッセージエンジンなるものを作ります。メッセージエンジンは好きな文字列を上のイメージ文字を使って画面に描画するための補助クラスです。たとえば、

    drawMessage(10, 10, "おうさまじゃ", g);

で画面の(10,10)の位置に

f:id:aidiary:20100404101709g:plain

と表示されるようにします。コンストラクタから説明してきます。

    public MessageEngine() {
        // フォントイメージをロード
        ImageIcon icon = new ImageIcon(
           getClass().getResource("image/font.gif"));
        fontImage = icon.getImage();
        
        color = WHITE;
        
        // かな→イメージ座標へのハッシュを作成
        kana2Pos = new HashMap();
        createHash();
    }

フォントイメージをロードしてます。あとcolorにWHITEを設定してデフォルトでは白文字が表示されるようになってます。ここで重要なのは kana2Posというハッシュ(HashMap)です。このハッシュは文字を座標へと変換します。createHash()を見てみます。

    /**
     * 文字から座標へのハッシュを作成する
     */
    private void createHash() {
        kana2Pos.put(new Character('あ'), new Point(0, 0));
        kana2Pos.put(new Character('い'), new Point(16, 0));
        ・・・
    }

'あ'という文字は(0,0)にある、'い'という文字は(16,0)にある、と読みます。文字イメージをもう一度見てください。'あ'は (0,0)の位置にあり、'い'は(16,0)の位置にあることがわかります。つまり、kana2Posというハッシュは文字から座標への対応を定義してるわけです。ハッシュに要素を追加するにはput()を使います。put()の引数はオブジェクト型なので'あ'という文字をそのまま渡せません。 Character()型のオブジェクトをわざわざ作ってるのはそのためです。具体的な使い方をdrawCharacter()で見てみます。

    /**
     * 文字を描画する
     * @param x X座標
     * @param y Y座標
     * @param c 文字
     * @param g 描画オブジェクト
     */
    public void drawCharacter(int x, int y, char c, Graphics g) {
        Point pos = (Point)kana2Pos.get(new Character(c));
        g.drawImage(fontImage, x, y,
                    x + FONT_WIDTH, y + FONT_HEIGHT,
                    pos.x + color, pos.y, pos.x + color + FONT_WIDTH,
                    pos.y + FONT_HEIGHT, null);
    }

このメソッドは1文字だけ表示します。つまり、

    drawCharacter(10, 10, 'お', g);

で画面の(10,10)の位置に

f:id:aidiary:20100404101708g:plain

と表示されます。ここで先ほどのkana2Posというハッシュを使っています。引数で渡された文字cの座標をget()で取得しています。ここで取得した座標posを使って画面の対応する場所にイメージを描画しています。先ほどに"おうさまじゃ"で紹介した文字列を描画するdrawMessage()はこのdrawCharacter()を使って1文字ずつ描画しています。

    /**
     * メッセージを描画する
     * @param x X座標
     * @param y Y座標
     * @param message メッセージ
     * @param g 描画オブジェクト
     */
    public void drawMessage(int x, int y, String message, Graphics g) {
        for (int i=0; i<message.length(); i++) {
            char c = message.charAt(i);
            int dx = x + FONT_WIDTH * i;
            drawCharacter(dx, y, c, g);
        }
    }

String型のmessageを受け取ったらそれをcharAt()で文字単位にばらして1文字ずつ位置をずらしながら描画しています。後で述べるメッセージウィンドウ(MessageWindow)ではこのMessageEngineを用いて文字列を描画しています。

はなす

MessageWindowの詳細な実装を説明する前に具体的な使い方から見ていきます。前回書きましたが、メッセージウィンドウが現れるのはスペースキーを押したときです。メッセージウィンドウが現れるとともにその中に話かけたキャラクターのメッセージが表示されてほしいわけです。というわけで MainPanelクラスのmainWindowCheckInput()を改造します。

    /**
     * メインウィンドウでのキー入力をチェックする
     */
    private void mainWindowCheckInput() {

        // 移動処理

        if (spaceKey.isPressed()) {  // スペース
            // 移動中は表示できない
            if (hero.isMoving()) return;
            if (!messageWindow.isVisible()) {  // メッセージウィンドウを表示
                Chara chara = hero.talkWith();
                if (chara != null) {
                    // メッセージをセットする
                    messageWindow.setMessage(chara.getMessage());
                    // メッセージウィンドウを表示
                    messageWindow.show();
                } else {
                    messageWindow.setMessage(
                        "そのほうこうには だれもいない。");
                    messageWindow.show();
                }
            }
        }
    }

追加部分は赤字の部分です。まずCharaクラス(heroはCharaのオブジェクト)のtalkWith()を呼び出して勇者の目の前にいるキャラクターを取得します。これがcharaで話かける人になります。

次にMessageWindowクラスのsetMessage()を使ってそのキャラクターのメッセージをセットします。キャラクターのメッセージはCharaクラスのgetMessage()で取得できます。イベントファイルからキャラクターイベントを読み取ってキャラクターを作るときにメッセージを登録したのを思い出してください。それを読み取ってるわけです。setMessage()した後にメッセージウィンドウを表示すると自動的にメッセージがウィンドウ内に描画される仕様になっています。最後にCharaクラスのtalkWith()を見てみます。

    /**
     * キャラクターが向いている方向のとなりにキャラクターがいるか調べる
     * @return キャラクターがいたらそのCharaオブジェクトを返す
     */
    public Chara talkWith() {
        int nextX = 0;
        int nextY = 0;
        // キャラクターの向いている方向の1歩となりの座標
        switch (direction) {
            case LEFT:
                nextX = x - 1;
                nextY = y;
                break;
            case RIGHT:
                nextX = x + 1;
                nextY = y;
                break;
            case UP:
                nextX = x;
                nextY = y - 1;
                break;
            case DOWN:
                nextX = x;
                nextY = y + 1;
                break;
        }
        // その方向にキャラクターがいるか調べる
        Chara chara;
        chara = map.charaCheck(nextX, nextY);
        // キャラクターがいれば話しかけたキャラクターの方へ向ける
        if (chara != null) {
            switch (direction) {
                case LEFT:
                    chara.setDirection(RIGHT);
                    break;
                case RIGHT:
                    chara.setDirection(LEFT);
                    break;
                case UP:
                    chara.setDirection(DOWN);
                    break;
                case DOWN:
                    chara.setDirection(UP);
                    break;
            }
        }
        
        return chara;
    }

まず勇者の向いている方向の1歩となりの座標を求めます。そしてその座標にキャラクターがいるかcharaCheck()でマップに問い合わせます。もしいればcharaに入るのでそれをreturnしています。はなすときにキャラクターがそっぽを向いてたらいやなので勇者の方に向けさせます。たとえば、勇者が左から話しかけたならそのキャラクターをの右へ向ければいいわけです。勇者の向きと逆向きにするのがポイントです。最後にcharaCheck()です。

    /**
     * (x,y)にキャラクターがいるか調べる
     * @param x X座標
     * @param y Y座標
     * @return (x,y)にいるキャラクター、いなかったらnull
     */
    public Chara charaCheck(int x, int y) {
        for (int i=0; i<charas.size(); i++) {
            Chara chara = (Chara)charas.get(i);
            if (chara.getX() == x && chara.getY() == y) {
                return chara;
            }
        }
        
        return null;
    }

キャラクターは全員charasというVectorに登録されています。そこでそのキャラクターを1人ずつ取り出し(get)、その座標が指定した座標と等しいか調べています。

MessageWindowの詳細

最後にメッセージウィンドウの詳細について説明します。ウィンドウ自体の描画は解説済みなのでメッセージの描画のところのみ取り上げます。先ほど説明しましたが、メッセージウィンドウにメッセージを表示するにはsetMessage()を使ってメッセージをセットする必要があります。

    /**
     * メッセージをセットする
     * @param msg メッセージ
     */
    public void setMessage(String msg) {
        curPage = 0;

        // 全角スペースで初期化
        for (int i=0; i<text.length; i++) {
            text[i] = ' ';
        }

        int p = 0;  // 処理中の文字位置
        for (int i=0; i<msg.length(); i++) {
            char c = msg.charAt(i);
            if (c == '\\') {
                i++;
                if (msg.charAt(i) == 'n') {  // 改行
                    p += MAX_CHAR_IN_LINE;
                    p = (p / MAX_CHAR_IN_LINE) * MAX_CHAR_IN_LINE;
                } else if (msg.charAt(i) == 'f') {  // 改ページ
                    p += MAX_CHAR_IN_PAGE;
                    p = (p / MAX_CHAR_IN_PAGE) * MAX_CHAR_IN_PAGE;
                }
            } else {
                text[p++] = c;
            }
        }
        
        maxPage = p / MAX_CHAR_IN_PAGE;
    }

setMessage()は引数で受け取ったメッセージ(msg)をtextという文字配列に変換する処理です。textは1次元配列ですが1行20文字で128行分の大きさです。メッセージウィンドウは1行20文字(MAX_CHAR_IN_LINE = 20)入るように大きさを調整しています。たとえば、

おお ああああ!\nゆうしゃロトの ちをひくものよ!\nそなたのくるのをまっておったぞ。\nその むかし ゆうしゃロトが\nカミから ひかりのたまをさずかり\nまものたちをふうじこめたという。\fいか りゃく。

というメッセージがあるとするとtext[]には次のように格納されます。

  おお_ああああ!____________  // 1ページ目
  ゆうしゃロトの_ちをひくものよ!____    
  そなたのくるのをまっておったぞ。____
  その_むかし_ゆうしゃロトが______  // 2ページ目
  カミから_ひかりのたまをさずかり____
  まものたちをふうじこめたという。____
  いか_りゃく。_____________  // 3ページ目
  ____________________
  ____________________
  ・・・

_は全角の空白文字です。最初、textは全角空白で埋めつくし、メッセージを上書きしています。\nと\fはそれぞれ改行、改ページ文字です。メッセージ中に\nがあると強制的に次の行へ移ります。また\fがあると強制的に次のページへ移ります。メッセージウィンドウの大きさでは1ページは 3行(MAX_LINES = 3)です。setMessage()では、msgから1文字ずつ読み取ってtextにセットしています。もし改行と改ページがあった場合はpの位置を動かして調整しています。pの計算はけっこうトリッキーですが・・・text[]にメッセージをセットすると、draw()でメッセージの描画が始まります。

    public void draw(Graphics g) {
        if (isVisible == false) return;
        
        // 枠を描く
        
        // 現在のページ(curPage)の1ページ分の内容を表示
        for (int i=0; i<MAX_CHAR_IN_PAGE; i++) {
            char c = text[curPage * MAX_CHAR_IN_PAGE + i];
            int dx = textRect.x +
                     MessageEngine.FONT_WIDTH * (i % MAX_CHAR_IN_LINE);
            int dy = textRect.y +
                     (LINE_HEIGHT + MessageEngine.FONT_HEIGHT) * 
                     (i / MAX_CHAR_IN_LINE);
            messageEngine.drawCharacter(dx, dy, c, g);
        }

        // 最後のページでない場合は矢印を表示する
        if (curPage < maxPage) {
            int dx = textRect.x +
                     (MAX_CHAR_IN_LINE / 2) *
                     MessageEngine.FONT_WIDTH - 8;
            int dy = textRect.y +
                     (LINE_HEIGHT + MessageEngine.FONT_HEIGHT) * 3;
            g.drawImage(cursorImage, dx, dy, null);
        }
    }

現在表示しているページはcurPageで制御しています。curPageはsetMessage()で0にセットされ、▼が出たときにスペースを押すと+1されます。curPageにあわせてtext[]のどの位置から1ページ分(MAX_CHAR_IN_PAGE = 20x3 = 60)の文字を表示するか決めています。あとは文字の座標を計算してdrawCharacter()で描画します。もし、最後のページでない場合は▼をメッセージウィンドウの下部に描画します。最後にMainPanelのmessageWindowCheckInput()です。メッセージウィンドウが表示されているときのキー入力を処理するメソッドです。

    /**
     * メッセージウィンドウでのキー入力をチェックする
     */
    private void messageWindowCheckInput() {
        if (spaceKey.isPressed()) {
            if (messageWindow.nextMessage()) {  // 次のメッセージへ
                messageWindow.hide();  // 終了したら隠す
            }
        }
    }

ボタンが押されたらnextMessage()を呼び出します。nextMessage()は次ページを表示するメソッドです。もしtrueを返したらメッセージは終了したのでウィンドウを閉じます。

    /**
     * メッセージを先に進める
     * @return メッセージが終了したらtrueを返す
     */
    public boolean nextMessage() {
        // 現在ページが最後のページだったらメッセージを終了する
        if (curPage == maxPage) {
            return true;
        }
        curPage++;
        
        return false;
    }

curPage++してます。もしmaxPageだった場合はtrueを返します。直感的に思いついた方法をそのまま使ったのであまりよくない方法かもしれません。もっとよい方法を知ってたら教えてください。