이번 시간에는 게임에서 빠져서는 안될 중요한 사운드를 출력해보는 시간을 갖도록 하지요.

사운드를 출력하는 부분 역시 SK-VM 자체에서 상당부분 라이브러리화 되어 있기 때문에,

우리는 단순히 라이브러리를 사용하는 방식만을 익혀주면 되겠습니다.


사운드를 재생/정지 하기 위한 SoundThread라는 클래스를 하나 작성해 보겠습니다.

* common.SoundThread.java.

package common;

import java.io.InputStream;
import com.skt.m.AudioClip;
import com.skt.m.AudioSystem;

/**
 * 소리를 낼때 사용하는 thread
 * 잠시 기다렸다가, 소리를 낼때 깨서 다시 기다리는 과정을 반복한다.
 */

public class SoundThread extends Thread
{
    private AudioClip currentClip = null;

    private byte[] sndtmp;
    private byte[][] snd;
    private int currentIndex;
    private boolean bPlaying=false;

    private String path;
    private InputStream is = null;
    private boolean bReady;
    private boolean bOn;

    /**
     * Constructor #1.
     */
    public SoundThread()
    {
        this(11);
    }

    /**
     * Constructor #2.
     * @param maxSound
     */
    public SoundThread(int maxSound)
    {
        bOn = true;

        snd = new byte[maxSound][];

        try
        {
            currentClip = AudioSystem.getAudioClip("mmf");
        } catch(Exception e) {}
    }

    /**
     * mix해 놓은 사운드 data 파일의 경로를 설정해 놓는다.
     * @param path
     */
    public void setSoundData(String path)
    {
        this.path = path;
    }

    public void cleanUp()
    {
        is = null;
        System.gc();
    }


    /**
     * mix해 놓은 사운드 data를 해당 byte 배열에 읽어 놓는다.
     * @param index
     * @param off
     * @param len
     */
    public void addSound(int index, int off, int len)
    {
        try
        {
            is = getClass().getResourceAsStream(path);
            snd[index] = new byte[len];
            is.skip(off);
            is.read( snd[index], 0, len);
            is.close();
        } catch (Exception e)
        {
        }
    }

    /**
     * 지정해 놓은 index로 사운드를 재생한다.
     * @param index
     */
    public synchronized void play(int index)
    {
        closeClip();

        waitForReady();

        currentIndex = index;
        try
        {
            currentClip.open(snd[currentIndex], 0, snd[currentIndex].length);
        } catch (Exception e) {
        }
        bPlaying = true;
        this.notify();
    }

    /**
     * 임시로 어떤 파일을 재생할때 사용한다.
     * @param path
     */
    public synchronized void play(String path)
    {

        closeClip();
        waitForReady();
        try
        {
            is = getClass().getResourceAsStream(path);
            sndtmp = new byte[is.available()];
            is.read( sndtmp, 0, is.available());
            is.close();
            currentClip.open(sndtmp,0,sndtmp.length);
        } catch(Exception e){
            is = null;
        }
        currentIndex = -1;      // 임시로 재생 하는 번호
        bPlaying = true;
        this.notify();
    }

    /**
     * 처리하고 있는 중이라면 잠시 기다린다.
     */
    private void waitForReady()
    {
        int count=0;
        try
        {
            while(bReady==false)
            {

                this.wait(100);
                Thread.yield();

                count++;
                if(count>=50) break;

                // sleep을 사용하면 안됨.
            }
        } catch(Exception e) {}
    }

    /**
     * 현재 재생중인 사운드를 정지한다.
     */
    private void closeClip()
    {
        try
        {
            currentClip.stop();
        } catch(Exception e) {}

        try
        {
            currentClip.close();
        } catch(Exception e) {}
    }

    public void run()
    {
        while (true)
        {
            synchronized (this)
            {
                bReady = true;
                try
                {
                    // 잠시 기다린다.
                    while (!bPlaying)
                    {
                        this.wait();
                    }
                } catch (Exception e) {}
                bReady = false;
            }

            try
            {
                if(bOn==true)
                {       // 사운드가 켜져 있을때만 소리를 낸다.
                    currentClip.play();
                }

                synchronized (this)
                {
                    bPlaying = false;
                }
            } catch (Exception e)
            {
                bPlaying = false;
            } finally
            {
                sndtmp = null;
                try
                {
                    currentClip.close();
                } catch (Exception e) {}
            }
        }
    }

    /**
     * 사운드 재생 : On
     */
    public void turnOn()
    {
        bOn = true;
    }

    /**
     * 사운드 재생 : Off
     */
    public void turnOff()
    {
        bOn = false;
    }



    /**
     * 볼륨을 1 증가한다.
     */
    public void volUp()
    {
        try
        {
            AudioSystem.setVolume("mmf", AudioSystem.getVolume("mmf")+1);
        } catch (Exception e)
        {}
    }

    /**
     * 볼륨을 1감소한다.
     */
    public void volDown()
    {
        try
        {
            AudioSystem.setVolume("mmf", AudioSystem.getVolume("mmf")-1);
        } catch (Exception e)
        {}
    }

    /**
     * 볼륨을 설정한다.
     * @param volume
     */
    public void setVol(int volume)
    {
        try
        {
            AudioSystem.setVolume("mmf", volume);
        } catch (Exception e)
        {}
    }

    /**
     * 현재 설정된 볼륨값을 전달한다.
     * @return 현재volume값
     */
    public int getVol()
    {
        int vol = 0;
        try
        {
            vol = AudioSystem.getVolume("mmf");
        } catch (Exception e)
        {}

        return vol;
    }
}

위의 SoundThread 클래스를 추가하는 것만으로 사운드를 출력하기 위한 모든 준비는 끝납니다.

그럼 클래스를 좀더 자세히 살펴보도록 하지요.


먼저 생성자입니다.


    /**
     * Constructor #1.
     */
    public SoundThread()
    {
        this(11);
    }

    /**
     * Constructor #2.
     * @param maxSound
     */
    public SoundThread(int maxSound)
    {
        bOn = true;

        snd = new byte[maxSound][];

        try
        {
            currentClip = AudioSystem.getAudioClip("mmf");
        } catch(Exception e) {}
    }

생성자가 두개가 있는데요. Default 생성자를 호출하면 두번째 생성자로 11이라는 값을 넘겨 줍니다.

두번째 생성자는 인자로 받은 maxSound 만큼의 사운드 배열을 생성해 주는 역할을 합니다.

또, com.skt.m.AudioSystem 클래스의 getAudioClip() 메소드를 이용해서, mmf 오디오 클립을 얻어옵니다.


그리고 다음의 setSound() 메소드는, mix된 사운드 데이터 파일의 경로를 기억하는 메소드입니다.

    /**
     * mix해 놓은 사운드 data 파일의 경로를 설정해 놓는다.
     * @param path
     */
    public void setSoundData(String path)
    {
        this.path = path;
    }

mix된 사운드 data 파일은, mmf 형식의 사운드 파일들을 LBMMIXER를 이용해서 sound.dat에 mix 해 놓았습니다.

 

LBMMIXER는 다들 기억 하고 계시리라고 생각합니다.

원래 LBM, PNG 파일을 dat 파일로 mix하는 용도의 유틸리티이지만, mmf 파일을 mix하는데도 사용될수 있습니다.

왜냐하면 이 LBMMIXER 자체가 이미지의 헤더를 별도로 수정하거나 하는 것이 아니고,

단순히 여러개의 파일들의 내용을 하나의 파일에 일렬로 기록해 주는 기능만을 하기때문에,

실제로 LBM 파일 사이즈의 합이 mix후 생성된 dat파일의 사이즈와 동일하죠.


그러므로, mmf 파일을 이 유틸리티를 이용해서 mix 한다고 해서 특별히 mmf 파일에 손상이 가거나 하는 일은 없겠죠.

그러나 LBMMIXER 자체에서 읽어들일 수 있는 확장자를 LBM, PNG로 고정시켜 놓았기 때문에

mmf 파일을 LBMMIXER에서 직접 읽어 올 수는 없습니다. 때문에, mmf 파일의 확장자를 lbm으로 변경 시킨 후,

LBMMIXER에서 읽어와서 mixing 버튼을 눌러주면 짜잔 하고 dat 파일이 생성이 되겠죠.

그렇게 해서 합쳐 놓은 파일이 바로 sound 폴더의 sound.dat 파일입니다.


    /**
     * mix해 놓은 사운드 data를 해당 byte 배열에 읽어 놓는다.
     * @param index
     * @param off
     * @param len
     */
    public void addSound(int index, int off, int len)
    {
        try
        {
            is = getClass().getResourceAsStream(path);
            snd[index] = new byte[len];
            is.skip(off);
            is.read( snd[index], 0, len);
            is.close();
        } catch (Exception e)
        {
        }
    }

다음으로 위의 addSound() 메소드는,

사운드 배열 snd[]의 index에 sound.dat 파일의 offset부터 len 만큼을 byte 배열로 저장합니다.


    /**
     * 지정해 놓은 index로 사운드를 재생한다.
     * @param index
     */
    public synchronized void play(int index)
    {
        closeClip();

        waitForReady();

        currentIndex = index;
        try
        {
            currentClip.open(snd[currentIndex], 0, snd[currentIndex].length);
        } catch (Exception e) {}
bPlaying = true; this.notify(); }

대망의 play 메소드입니다. 당연히 지정한 index의 사운드를 재생하는 역할을 하는 메소드이구요.

현재 재상할 클립(currentClip)을 오픈해서 notify()를 호출하면 사운드가 재생됩니다.


위에서 호출된 waitForReady()는 사운드를 출력할 준비가 아직 안되었다면,

사운드를 출력할 준비가 될때까지 루프를 돌면서 기다리는 역할을 하는 메소드입니다.


    /**
     * 처리하고 있는 중이라면 잠시 기다린다.
     */
    private void waitForReady()
    {
        int count=0;
try { while(bReady==false) { this.wait(100); Thread.yield(); count++; if(count>=50) break; // sleep을 사용하면 안됨. } } catch(Exception e) {} }

bReady가 false인 동안 계속해서 100 ms씩 간격을 두고 bReady가 true가 될때까지 기다린 후,

5초 이상(100ms * 50)을 기다렸는데도 bReady가 풀리지 않는 다면, 그냥 루프를 탈출합니다.


    /**
     * 현재 재생중인 사운드를 정지한다.
     */
    private void closeClip()
    {
        try
        {
            currentClip.stop();
        } catch(Exception e) {}

        try
        {
            currentClip.close();
        } catch(Exception e) {}
    }

closeClip() 메소드는 현재 재생중인 사운드를 정지시켜 줍니다.

현재 재생중인 사운드를 정지(stop())하고, 열려 있는 클립을 닫아주면(close()) 끝나죠.


이제 사운드를 출력하기 위한 준비는 모두 끝났습니다. 이제 준비된 SoundThread 클래스를 사용하기 위해

twogp1p 패키지의 Midlet 클래스에, initSound() 라는 메소드를 정의해줍니다.

/**
 * 사운드를 설정한다.
 */
private void initSound()
{
    sound.setSoundData("/sound/sound.dat");

    sound.addSound(SND_ABELL,0,199);
    sound.addSound(SND_CARD, 199,510);		
    sound.addSound(SND_CHOICE,709,203);
    sound.addSound(SND_GAMEOVER,912,2931);
    sound.addSound(SND_GAMESTARTED,3843,4016);
    sound.addSound(SND_GO,7859,1424);
    sound.addSound(SND_LONGBELL,9283,203);
    sound.addSound(SND_MATCH,9486,728);
    sound.addSound(SND_MYTURN,10214,1632);
    sound.addSound(SND_NOMATCH,11846,593);
    sound.addSound(SND_VOLUMEKEY,12439,148);
		
    sound.cleanUp();
}

addSound 메소드를 이용해서 각 인덱스 별로 오프셋과 mmf의 사이즈(byte 단위)를 지정해 줍니다.


각각의 사운드 인덱스 상수 별로 간단히 사운드의 용도를 설명드리자면,


SND_ABELL : 짧은 확인 사운드
SND_LONGBELL : 긴 확인 사운드
SND_CHOICE : 플레이어의 턴에서 고/스톱, 흔듬/폭탄, 국진등의 선택을 해야할때 출력
SND_MYTURN : 플레이어의 턴일때 출력
SND_CARD : 카드를 나눠 줄때 출력
SND_MATCH : 카드를 냈을때 바닥에 동일한 카드가 있을때 출력
SND_NOMATCH : 카드를 냈지만 바닥에 동일한 카드가 없을때 출력
SND_GO : 고를 했을때 출력
SND_RESULT : 게임이 끝나고 결과가 출력될때 출력
SND_VOLUMEKEY : 볼륨키를 눌렀을때 출력

이제 사운드를 출력하고 싶다면 다음과 같이 play() 메소드에 인덱스 상수를 넘겨주면 됩니다.

    sound.play( 사운드 인덱스 상수 );


단순히 위의 한 문장을 삽입한 것이므로 따로 설명을 할 필요는 없을 것 같네요.

위의 상황이 발생하는 곳에다가 play() 메소드를 이용해서 사운드를 출력해 주시면 되리라고 봅니다.


이제 다음으로는 진동을 출력하는 방법을 배워보죠.

진동은 사용자나 커퓨터가 흔들었을때, 폭탄했을때, 쪽/뻑/따닥이 났을때, 쓸일때 등에서 진동이 발생합니다.

진동을 하기 위해서는 SK-VM에서 제공하는 Vibration 이라는 클래스를 이용하면 간단히 해결됩니다.


이 게임에서 진동을 사용할 일은 위에서 열거한 사항들(흔듬/폭탄/쪽/뻑/따닥/쓸) 뿐이므로,

진동이 사용되는 곳이 GameView 클래스로 한정됩니다. 그러므로 GameView 클래스에,

import com.skt.m.Vibration;

위와 같이 Vibration 클래스를 import 해 주기만 하면 됩니다.


이제 진동을 사용하기 위해서는,

Vibration.start(level, timeout);

level은 진동의 강도인데, 아직 SK-VM에서 지원은 하지 않고, 임시로 만들어져 있는 것이므로,

현재는 그냥 단순히 0으로 지정을 해 주시면 됩니다.

timeout은 진동이 울릴 시간을 입력해 주시면 되며, 단위는 ms 입니다.


예를 들어 진동을 300 ms 동안 울리게 하고 싶다면,

Vibration.start(0, 300);

위와 같이 해 주시면 되겠죠?


이걸로 사운드 출력과 진동 처리는 모두 알아본 것 같네요.

이제 사운드 출력과 진동을 처리 해 주면, 게임에서 멋진 사운드가 흘러 나올겁니다.

Posted by maysent
:

두장의 카드 중에서 하나의 카드를 선택해야 할때...

















☆
☆
☆
☆
☆
☆
/**
 * 카드 둘중 하나를 선택할때...
 * @param g
 */
private void drawSelectTwoCards(Graphics g)
{
    byte index;

    drawRunningGame(g);
    /* 카드 두장 중 하나를 선택한다. */
    dialog.drawBorder(g, CyDialog.SELECTTWOCARD);

    // 두 종류의 카드를 보여줌.
    if(room.slot[0] != room.CARD_NULL)
        index = 0;
    else if(room.slot[1] != room.CARD_NULL)
        index = 1;
    else return;

    drawCard(40, 66, room.getMonth(room.slot[index])[0]);
    drawCard(65, 66, room.getMonth(room.slot[index])[1]);

    // 컴퓨터 턴이라면 AI가 선택.
    if(curTurn == User.COMPUTER)
    {
        Sleep.sleep(500);
        selectTwoCards(index, User.COMPUTER, 
                       agent.selectTwoCards(index));
    }
}

두장의 카드 중에서 하나의 카드를 선택 하는 장면을 그려주는 함수인 drawSelectTwoCards() 함수에서

처리를 해 주는 것이 가장 좋겠죠. 그림을 그려준 후에 만약 현재 턴이 컴퓨터 턴이라면,

Agent 클래스의 selectTwoCards() 함수를 호출해서 A.I.가 카드를 선택할 수 있도록 해 줍니다.


Agent 클래스의 selectTwoCards() 함수를 한번 보도록 하죠.

/**
 * A.I.가 두 장의 카드 중 하나를 선택한다.
 * @return
 */
public byte selectTwoCards(byte index)
{
    byte i;

    /* 광 체크. */
    for(i=0; i<2; i++)
    {
        // 두개의 패중에 광이 있다면,
        if(gCard.getType(
                 room.getMonth(room.slot[index])[i]) == CARDTYPE_KWANG
            || gCard.getType(
                 room.getMonth(room.slot[index])[i]) == CARDTYPE_BIKWANG)
        {
            System.out.println(" # selectTwoCards MSG : 광 발견!");
            return (byte)(i+1);
        }
    }

    /* 고도리, 청단, 홍단, 초단 체크 */
    for(i=0; i<2; i++)
    {
        // 고도리 검색 허용 상태이거나 상대방이 고도리를 두장 이상 가지고 있다면,
        if(bNoFindGodori == false
            || (room.getCntGodori(Room.PLAYER) >= 2 
                && room.getCntGodori(Room.COMPUTER) == 0))
        {
            if(room.getCntGodori(Room.PLAYER) >= 1)
            {
                bNoFindGodori = true;
            }
            else if(gCard.getType(
                     room.getMonth(room.slot[index])[i]) == CARDTYPE_GODORI)
            {
                // 두개의 패중에 고도리가 있다면,
                System.out.println(" # selectTwoCards MSG : 고도리 발견!");
                return (byte)(i+1);
            }
        }

        // 청단 검색 허용 상태이거나 상대방이 청단을 두장 이상 가지고 있다면,
        if(bNoFindChungDan == false
            || (room.getCntChungDan(Room.PLAYER) >= 2 
                && room.getCntChungDan(Room.COMPUTER) == 0))
        {
            if(room.getCntChungDan(Room.PLAYER) >= 1)
            {
                bNoFindChungDan = true;
            }
            else if(gCard.getType(
                     room.getMonth(room.slot[index])[i]) == CARDTYPE_CHUNGDAN)
            {
                // 두개의 패중에 청단이 있다면,
                System.out.println(" # selectTwoCards MSG : 청단 발견!");
                return (byte)(i+1);
            }
        }

        // 홍단 검색 허용 상태이거나 상대방이 홍단을 두장 이상 가지고 있다면,
        if(bNoFindHongDan == false
            || (room.getCntHongDan(Room.PLAYER) >= 2 
                && room.getCntHongDan(Room.COMPUTER) == 0))
        {
            if(room.getCntHongDan(Room.PLAYER) >= 1)
            {
                bNoFindHongDan = true;
            }
            else if(gCard.getType(
                     room.getMonth(room.slot[index])[i]) == CARDTYPE_HONGDAN)
            {
                // 두개의 패중에 홍단이 있다면,
                System.out.println(" # selectTwoCards MSG : 홍단 발견!");
                return (byte)(i+1);
            }
        }

        // 초단 검색 허용 상태이거나 상대방이 초단을 두장 이상 가지고 있다면,
        if(bNoFindChoDan == false
            || (room.getCntChoDan(Room.PLAYER) >= 2 
                && room.getCntChoDan(Room.COMPUTER) == 0))
        {
            if(room.getCntChoDan(Room.PLAYER) >= 1)
            {
                bNoFindChoDan = true;
            }
            else if(gCard.getType(
                     room.getMonth(room.slot[index])[i]) == CARDTYPE_CHODAN)
            {
                // 두개의 패중에 초단이 있다면,
                System.out.println(" # selectTwoCards MSG : 초단 발견!");
                return (byte)(i+1);
            }
        }
    }

    /* 쌍피 체크 */
    for(i=0; i<2; i++)
    {
        // 두개의 패중에 쌍피가 있다면,
        if(gCard.getType(
                 room.getMonth(room.slot[index])[i]) == CARDTYPE_SSANG_PEE)
        {
            System.out.println(" # selectTwoCards MSG : 쌍피 발견!");
            return (byte)(i+1);
        }
        else if(room.getMonth(room.slot[index])[i] == gCard.CARD_GOOKJIN)
        {
            // 국진이 있다면, 국진을 먹는다.
            System.out.println(" # selectTwoCards MSG : 국진 발견!");
            return (byte)(i+1);
        }
    }

    /* 열끗 체크 */
    if(room.getNumOfYeul(Room.COMPUTER) >= 5
        &&  room.getNumOfYeul(Room.PLAYER) <= 2)
    {
        /* 멍따 가능성이 있다면, 피보다는 열끗을 먹어준다. */
        for(i=0; i<2; i++)
        {
            // 두개의 패중에 열끗이 있다면,
            if(gCard.getType(
                     room.getMonth(room.slot[index])[i]) == CARDTYPE_YEUL)
            {
                System.out.println(" # selectTwoCards MSG : 열끗 발견!");
                return (byte)(i+1);
            }
        }
    }

    /* 피 체크 */
    for(i=0; i<2; i++)
    {
        // 두개의 패중에 피가 있다면,
        if(gCard.getType(room.getMonth(room.slot[index])[i]) == CARDTYPE_PEE)
        {
            System.out.println(" # selectTwoCards MSG : 피 발견!");
            return (byte)(i+1);
        }
    }

    /* 그 외에는 둘중 아무거나 선택. */
    System.out.println(" # selectTwoCards MSG : 암거나 냈음!");
    return (byte)(Etc.getRandomInt(2)+1);
}


역시 마찬가지로 주욱 여러가지의 상황을 나열해 놓고 비교 하고 있습니다.

순서는 Agent.run() 함수와 동일합니다.

두개의 카드 중 광이나 비광이 있다면 그것을 선택해 주고,

청단, 홍단, 초단이 있다면, 먹을 필요가 있는지 먼저 체크 후, 그것을 선택해 줍니다.

그리고 그 다음으로는 쌍피가 있는지 체크하고, 열끗이 있다면, 멍따 가능성이 있을대에는 열끗을 먹고,

멍따 가능성이 별로 없다면, 그냥 일반 피를 선택해 줍니다.

이전과 마찬가지로 위의 모든 상황에 해당되는 것이 없다면, 아무거나 렌덤으로선택 해 주는 것이고요.


흔듬/폭탄의 여부를 선택해야 할때...








☆
☆
☆
☆
☆
☆
/**
 * 흔듬/폭탄 여부를 선택 중일때...
 * @param g
 */
private void drawSelectShake(Graphics g)
{
    drawRunningGame(g);

    if(shakeType == SHAKE) // 흔듬 선택 중일때,
        dialog.drawBorder(g, CyDialog.SELECTSHAKE);
    else /* if(shakeType == BOMB) */ // 폭탄 선택 중일때,
        dialog.drawBorder(g, CyDialog.SELECTBOMB);

    // 컴퓨터 턴일땐 AI가 선택.
    if(curTurn == User.COMPUTER)
    {
        Sleep.sleep(500);
        selectShake(User.COMPUTER, agent.selectShake());
    }
}

역시 앞의 drawSelectTwoCards()와 마찬가지로,

현재 턴이 컴퓨터의 턴일때에는, agent.selectShake() 함수를 수행해서 A.I.가 선택할 수 있도록 합니다.

그러나... 흔듬/폭탄의 선택은 다음과 같습니다.

/**
 * A.I.가 흔듬 여부를 선택한다.
 * @return
 */
public byte selectShake()
{
    // 무조건 흔듬(or 폭탄).
    return 1;
}

너무 간단하죠?

대부분, 흔듬을 선택하고, 흔들지 않아야 할 이유가 별로 없는 것 같아서, 무조건 흔듬으로 지정했습니다.


국진의 사용 여부를 선택해야 할때...







/**
 * 국진의 사용을 선택 중일때,
 * @param g
 */
private void drawSelectGookjin(Graphics g)
{
    drawRunningGame(g);
    dialog.drawBorder(g, CyDialog.SELECTGOOKJIN);

    // 컴퓨터 턴일땐 AI가 선택.
    if(curTurn == User.COMPUTER)
    {
        Sleep.sleep(500);
        selectGookjin(User.COMPUTER, agent.selectGookjin());
    }
}

역시 이전과 마찬가지로 현재 턴이 컴퓨터의 턴일때에는 agent.selectGookjin()을 호출합니다.

/**
 * A.I.가 국진을 선택한다.
 * @return
 */
public byte selectGookjin()
{
    // 열이 다섯장 이상이라면, 멍따 가능성이 있으므로.
    if(room.getNumOfYeul(Room.COMPUTER) >= 5
        &&  room.getNumOfYeul(Room.PLAYER) <= 2)
        return 2; // 국진을 열로 사용한다.

    // 그 외의 경우에는 무조건 쌍피로 처리.
    return 1;
}

국진을 선택하는 것도 그렇게 복잡하지는 않습니다.

열이 다섯장 이상이고, 플레이어가 열을 2장 이하로 가지고 있다면, 멍따의 가능성이 있겠죠?

왜냐하면, 열이 아홉장인데, 컴퓨터가 다섯장을 가지고 있다고 해도, 플레이어가 이미 4장을 가지고 있다면,

멍따의 가능성이 전혀 없으므로, 굳이 열끗을 먹을 필요가 없겠지요.

멍따의 가능성이 없을때에는 국진패는 무조건 쌍피로 처리를 합니다.


고/스톱을 선택해야 할때...

/**
 * A.I.가 고/스톱을 선택한다.
 * @return
 */
public byte selectGoStop()
{
    room.getNumOfCard();

    // 플레이어의 광이 4장 이상일때,
    if(room.getNumOfKwang(Room.PLAYER) >= 4)
    {
        // 컴퓨터가 광이 한장도 없다면, (오광 위험!)
        if(room.getNumOfKwang(Room.COMPUTER) == 0)
            return 2; // 스톱.
    }

    // 고도리 위험이 있다면,
    if(room.getCntGodori(Room.PLAYER) >= 2
        && room.getCntGodori(Room.COMPUTER) == 0)
    {
        // 점수가 0점이 아니라면 스톱.
        if(room.getRealPoint(Room.PLAYER) != 0)
            return 2; // 스톱.
    }

    // 청단 위험이 있다면,
    if(room.getCntChungDan(Room.PLAYER) >= 2
            && room.getCntChungDan(Room.COMPUTER) == 0)
    {
        // 점수가 2점 이상이라면 스톱.
        if(room.getRealPoint(Room.PLAYER) >= 2)
            return 2; // 스톱.
    }

    // 홍단 위험이 있다면,
    if(room.getCntHongDan(Room.PLAYER) >= 2
            && room.getCntHongDan(Room.COMPUTER) == 0)
    {
        // 점수가 2점 이상이라면 스톱.
        if(room.getRealPoint(Room.PLAYER) >= 2)
            return 2; // 스톱.
    }

    // 초단 위험이 있다면,
    if(room.getCntChoDan(Room.PLAYER) >= 2
            && room.getCntChoDan(Room.COMPUTER) == 0)
    {
        // 점수가 2점 이상이라면 스톱.
        if(room.getRealPoint(Room.PLAYER) >= 2)
            return 2; // 스톱.
    }


    // 피가 10장 이상이고,
    if(room.getNumOfPee(Room.PLAYER) >= 10)
    {
        // 열이 7개 이상이라면,
        if(room.getNumOfYeul(Room.PLAYER) >= 7)
            return 2; // 스톱.

        // 플레이어의 열과 띠가 5장 이상이라면,
        if(room.getNumOfYeul(Room.PLAYER) >= 5
            && room.getNumOfDdee(Room.PLAYER) >= 5)
            return 2;

        // 플레이어의 열이 5장 이상이고, 점수가 3점 이상이라면,
        if(room.getNumOfYeul(Room.PLAYER) >= 5
            && room.getNumOfYeul(Room.COMPUTER) <= 2
            &&  room.getRealPoint(Room.PLAYER) >= 3)
            return 2; // 스톱.

        // 플레이어의 띠가 5장 이상이고, 점수가 3점 이상이라면,
        if(room.getNumOfDdee(Room.PLAYER) >= 5
            && room.getNumOfDdee(Room.COMPUTER) <= 2
            &&  room.getRealPoint(Room.PLAYER) >= 3)
            return 2; // 스톱.
    }

    // 피가 13장 이상이라면,
    if(room.getNumOfPee(Room.PLAYER) >= 13)
        return 2; // 스톱.

    // 플레이어의 점수가 4점 이상이라면,
    if(room.getRealPoint(Room.PLAYER) >= 4)
        return 2; // 스톱.

    // 남은 카드가 한장이거나 혹은 없다면,
    if(room.numOfComputerCard <= 1)
        return 2; // 스톱.

    // 그 외엔 무조건 고.
    return 1;
}

가능하다면, 컴퓨터가 먼저 고를 했다가, 고박을 당하고 지게 된다면, 안되겠죠.

그렇다고 너무 고를 안하고, 원고에서 바로 스톱을 하거나 하면 게임이 재미가 없어질 것이구요.

따라서 최대한 고를 하면서도 안전하게 게임을 진행 할 수 있도록 구현을 해야합니다.


위의 상황 체크는 지극히 제 개인적인 주관에 의해서 체크하는 상황이므로, 따로 설명은 하지 않겠습니다.

게다가 어차피 주석으로 다 설명이 되어 있으니...

마음에 들지 않는 상황이나, 추가 하고 싶은 상황이 있다면, 삭제하거나 상황을 더 추가할 수도 있겠죠.


Alpha-Beta Search에서 한가지 중요한 점은 중요한 상황일 수록 앞에 위치 해야 한다는 것입니다.

별로 중요하지도 않은 상황이 앞에 있다면, 뒤에 더 중요한 상황은 체크도 못해본 채로 함수가 끝나겠죠?


강좌 지면상이라는 이유로 A.I. 프로그래밍을 완벽하게 구현하지는 않았습니다.

딱 보시면 아시곘지만, 모든 상황을 다 체크하고 있는 것은 아니니까요.

그러나, 이렇게 별로 많이 추가하지 않았는데도 컴퓨터가 꽤 합니다. 가끔가다가 한판은 집니다^^;

상황을 더 많이 추가해준다면 컴퓨터가 그 만큼 똑똑해 지겠죠.

이게 바로 Alpha-Beta Search의 장점인 동시에 치명적인 단점이죠...


이번 시간의 화면 인터페이스 자체는 지난시간과 동일하므로, 스크린 샷은 생략하도록 하겠습니다.

Posted by maysent
:

지난 시간까지 완성한 소스 만으로도 어느 정도의 맞고 게임 진행은 가능했었습니다.

이번 시간에는 지금까지 만든 게임에 A.I.를 추가해서 좀더 그럴듯 한 게임을 만들어보도록 하지요.


A.I. 라는 것이 정말 세밀하고 복잡하게 만들 자면, 한도 끝도 없는 분야입니다.

그러나 단순한 맞고 게임에서 그 정도의 세밀한 A.I.까지 사용할 필요는 없겠지요.

간단하게 만들자면, 그냥 우리가 맞고 게임을 할때 생각하는 과정을 그대로 코딩해 주면 되겠죠?


즉, 예를 들자면 이러한 경우가 있을 수 있겠네요.

바닥에 쌍피인 똥과 일반피인 똥이 있을때, 일반적인 경우라면 일부러 일반 피를 먹을 일은 없겠죠?

당연히 쌍피를 먹어야 합니다.


또, 이런 경우도 있을 수 있습니다.

국진 패를 먹었습니다. 그렇다면 국진 패를 열끗으로 써야 할까요, 쌍피로 써야 할까요?

쌍피가 좋으니까 그냥 무조건 쌍피로 써야 할까요? 아니면 무조건 열끗으로?

이건 그냥 함부로 결정할 수 있는 문제는 아니겠죠.

그때 그때의 상황에 따라서 적절한 판단을 해 주어야 합니다.

즉, 열끗이 5장 이상 있다면, 멍따의 가능성이 있으므로, 열끗으로 사용해주는 것도 좋겠죠.

그러나 열끗이 몇장 없다면, 쌍피로 사용한다면 피 두장과 같은 효과를 가지므로 더 좋겠네요.


이와 같은 알고리즘을 Alpha-Beta Search라고 하지요.

Alpha-Beta Search는 경우의 수를 나열 해 놓고 좀 더 낳은 선택을 고르는 방식이죠.

Alpha-Beta Search는 일단 알고리즘이 간단하고, 쉽게 구현할 수 있다는 장점이 있습니다.

그러나 A.I.를 똑똑하게 하려면, 모든 상황을 전부 다 나열해야 하므로, 소스 코드가 길어지고,

모든 상황을 전부 나열한다고 해도 언제나 그 상황에서 예외는 있기 마련이므로,

어느 한계점에 이르면 더 이상 A.I.가 똑똑해지기 힘들다는 단점들이 있죠.


그러나, 소스 코드 한줄을 추가하는 것만으로도 컴퓨터가 확연히 똑똑해지는 것이 눈에 보이고,

간단한 코딩만으로도 꽤 똑똑한 A.I.를 구현해 낼 수가 있으므로, 주로 보드게임에서 많이 사용되죠.


자, 이제 본격적으로 지난 시간까지 만들었던 소스에 A.I. 코드를 추가해 보도록 하죠.

A.I.를 실행 하려면 게임 진행 중에 어느 시점에서 호출 해 주는 것이 가장 좋을까요?

아무래도 A.I.는 컴퓨터의 턴에 실행이 되는 것이므로,

당연히 턴을 변경한 직후에, A.I.를 호출 해 주는 것이 좋겠지요?


twogo1p.GameView 클래스의 턴을 변경해 주는 함수인 changeTurn() 함수에서 A.I.를 호출해 줍시다.





















☆
☆
☆
☆
☆
☆
/**
 * 턴을 변경한다.
 */
private void changeTurn()
{
    // 쓸 체크.
    if(checkSseul() == true)
    {
        curViewState = State.GAME_SHOWMESSAGE;
        curMsgState = CyDialog.MSG_SSEUL;
        repaints();
        Sleep.wait(this, 300);

        curViewState = State.GAME_RUNNING;
        repaints();
    }

    // 턴 변경.
    curTurn = (curTurn==User.PLAYER)? User.COMPUTER : User.PLAYER;

    if(curTurn == User.COMPUTER)
    {
        do {
            runAI();
        } while(agent.wasAdditionalCard() == true);
    }
}

자, 새롭게 추가된 부분(
)만 살펴 보도록 하겠습니다.

턴을 변경을 하고 난 후에, 변경된 턴이 컴퓨터의 턴이라면, runAI() 라는 함수를 호출하고 있습니다.

그런데 여기서 runAI()는 AI를 실행해 주는 함수라고 하지만,

runAI() 함수가 do ~ while() 안에서 루프를 돌고 있습니다.

while()문의 조건을 보면, agent라는 클래스의 wasAdditionalCard() 라는 함수를 호출하는데요.

agent 클래스는 A.I.를 담당하는 함수들을 모아놓은 클래스이고, 잠시 후에 살펴 볼 것입니다.

그리고 wasAdditionalCard() 함수는 A.I.가 방금 낸 패가 추가 쌍피였는지를 리턴합니다.

즉, 추가 쌍피를 내면, 중앙의 패를 뒤집어 한장을 갖고, 한번 더 내야 하죠?

그렇기 때문에, 추가피를 냈다면, 루프를 돌며 추가피가 아닌 패를 낼때까지 A.I.를 호출하는 것이죠.


그 다음으로 방금전에 열심히 루프를 돌며 호출했던 runAI() 함수를 한번 보도록 하지요.

/**
 * A.I. 실행. 
 */
public void runAI()
{
    System.out.println("A.I. 수행 시작.");
    throwCard( agent.run(room) );

    room.sortMonth();

    repaints();
}

twogo1p.GameView 클래스의 runAI() 함수는 단순히, agent.run() 함수를 호출해주는 것 뿐입니다.

agent.run() 함수는 컴퓨터가 들고 있는 10장의 카드 중에서 낼 카드의 번호를 리턴해 줍니다.

그러므로 agent.run() 함수가 리턴한 결과 값을 throwCard() 함수에 인자로 넘겨주면,

agent.run() 함수를 호출 후 선택된 카드를 바닥에 내 주겠죠.


이제 앞에서 몇번 언급이 되었던 A.I.를 담당하는 Agent 클래스를 만들어 봅시다.


/**
 * A.I. 수행.
 */
public byte run(Room room)
{
    byte result;
    wasAdditionalCard = false;

    System.out.println("A.I. 수행하고 있음...");
    this.room = room;

room.getNumOfCard();
/* 추가 쌍피 체크 */ result = findAdditionalCard(); // 추가 쌍피 체크. if(result != -1) // 추가 쌍피를 가지고 있다면, return result; // 해당 패를 냄. /* 광 체크 */ result = findCard(CARDTYPE_KWANG); if(result != -1) // 광을 먹을 수 있다면, return result; // 해당 패를 냄. /* 비광 체크 */ result = findCard(CARDTYPE_BIKWANG); if(result != -1) // 비광을 먹을 수 있다면, return result; // 해당 패를 냄. /* 고도리 체크 */ if(bNoFindGodori == false || (room.getCntGodori(Room.PLAYER) >= 2 && room.getCntGodori(Room.COMPUTER) == 0)) { if(room.getCntGodori(Room.PLAYER) >= 1) { bNoFindGodori == true; }
else {
result = findCard(CARDTYPE_GODORI);
if(result != -1) // 고도리를 먹을 수 있다면, return result; // 해당 패를 냄. } } /* 청단 체크 */ if(bNoFindChungDan == false || (room.getCntChungDan(Room.PLAYER) >= 2 && room.getCntChungDan(Room.COMPUTER) == 0)) { if(room.getCntChungDan(Room.PLAYER) >= 1) { bNoFindChungDan == true; }
else { result = findCard(CARDTYPE_CHUNGDAN); if(result != -1) // 청단을 먹을 수 있다면, return result; // 해당 패를 냄. } } /* 홍단 체크 */ if(bNoFindHongDan == false || (room.getCntHongDan(Room.PLAYER) >= 2 && room.getCntHongDan(Room.COMPUTER) == 0)) { if(room.getCntHongDan(Room.PLAYER) >= 1) { bNoFindHongDan == true; }
else { result = findCard(CARDTYPE_HONGDAN); if(result != -1) // 홍단을 먹을 수 있다면, return result; // 해당 패를 냄. } } /* 초단 체크 */ if(bNoFindChoDan == false || (room.getCntChoDan(Room.PLAYER) >= 2 && room.getCntChoDan(Room.COMPUTER) == 0)) { if(room.getCntChoDan(Room.PLAYER) >= 1) { bNoFindChoDan == true; }
else { result = findCard(CARDTYPE_CHODAN); if(result != -1) // 초단을 먹을 수 있다면, return result; // 해당 패를 냄. } } /* 쌍피 체크 */ result = findCard(CARDTYPE_SSANG_PEE); if(result != -1) // 쌍피를 먹을 수 있다면, return result; // 해당 패를 냄. /* 피 체크 */ result = findCard(CARDTYPE_PEE); if(result != -1) // 피를 먹을 수 있다면, return result; // 해당 패를 냄.
// 위의 상황에 해당 하는 것이 없다면,
for(i=0; i<room.numOfComputerCard; i++)
{
// 아무거나 먹을 수 있는 것이 있다면 패를 낸다. if(room.compareFloor(room.getUserHand(Room.COMPUTER, i)) != -1) { return converIndex(i); }
}
// 정말 먹을게 없다면, 아무거나 냄. room.getNumOfCard(); return convertIndex((byte)Etc.getRandomInt(room.numOfComputerCard)); }

run() 함수에서는 Room 클래스를 인자로 받고 있는데,

Room 클래스의 멤버들은 카드를 한번 낼때마다 각각의 데이터가 계속 변하므로,

A.I.를 수행 할때마다 업데이트 된 Room 클래스를 받아 와야 하겠지요.


일단 run() 함수의 전체적인 A.I.의 흐름만을 본다면 별것이 없습니다.

컴퓨터가 들고 있는 패 중에서, 서비스 패가 있다면, 서비스 패를 내고, 중앙의 패를 한장 먹습니다.

그러나, 컴퓨터가 들고 있는 패 중에 추가 서비스 패가 없다면,

광이 있는지를 체크하고, 광이 없다면, 비광이 있는지 체크를 하고,

일반 광과 비광이 없다면, 고도리, 청단, 홍단, 초단, 등을 체크하고,

그 다음은 쌍피, 피 순으로 체크를 해 줍니다.

위의 상황에 아무것도 해당되는 것이 없다면, 들고 있는 카드 중에서 아무거나 바닥에 내는 것이죠.


가장 먼저 컴퓨터의 패 중에서 추가 서비스 패가 있는지를 체크해서, 서비스 패의 인덱스를 리턴해주는,

findAdditionalCard() 함수를 한번 보도록 하겠습니다.

/**
 * 추가 쌍피가 있는지 체크.
 * @return
 */
private byte findAdditionalCard()
{
    byte i;

    /* 서비스 패 체크 */
    for(i=0; i<room.numOfComputerCard; i++)
    {
        // 서비스 패라면,
        if( room.isAdditionalCard(room.getUserHand(Room.COMPUTER, i)))
        {
            return convertIndex(i);
        }
    }

    return -1;
}

findAdditional() 함수는 컴퓨터의 패 중에서 추가 쌍피가 존재하는지를 체크 합니다.

numOfComputercard 는 컴퓨터가 몇개의 카드를 가지고 있는지를 기억하고 있는 변수입니다.

컴퓨터가 가지고 있는 카드의 수만 큼 루프를 돌아서 추가 쌍피라면, 해당 인덱스를 리턴합니다.

그런데 i가 인덱스인데 convertIndex()라는 함수를 거쳐서 리턴을 해 주고 있는데요.

/** 
 * 게임 설정에 맞게 인덱스를 변경한다.
 * @param convertIdx
 * @return
 */
private byte convertIndex(byte convertIdx)
{
    /*
     * 손에 든 카드의 인덱스는, 0~9가 아니라, 1~9, 0이므로,
     * 내기 전에 렌덤하게 선택된 인덱스를 변경해 준다. 
     */
    if(room.isAdditionalCard(room.getUserHand(Room.COMPUTER,convertIdx)))
    {
        wasAdditionalCard = true;
    }

    convertIdx = (++convertIdx == 10)? 0 : convertIdx;

    return convertIdx;
}

카드 배열은 0~9까지로 되어 있지만, 실제 throwCard에서는 1~9, 0과 같이 계산을 하므로,

0~9를 1~9, 0으로 변환 해 준 뒤에 리턴을 해야 하겠죠?

convertIndex() 함수가 바로 그 작업을 해줍니다.

만약 해당 카드가 서비스 패라면, 추가 서비스 패를 냈었다는 것을 wasAdditionalCard에 기억해둡니다.


그럼 다음으로 findCard() 함수를 볼까요?

findCard() 함수는 현재 컴퓨터의 패 중에서 해당 패가 있는지,

혹은, 이 패를 내면 바닥에서 해당 패를 먹을 수 있는지를 체크 합니다.

/**
 * 해당 패를 먹을수 있는지 체크.
 * @return
 */
private byte findCard(byte cardType)
{
    byte i, j, k;

    /* 패 체크 */
    for(i=0; i<room.numOfComputerCard; i++)
    {
        // 낼수 있는 패라면,
        if(room.compareFloor(room.getUserHand(Room.COMPUTER, i)) != -1)
        {
             // 낼수 있는 패가 인자로 받은 Card Type 이라면,
             if(gCard.getType(room.getUserHand(Room.COMPUTER, i)) 
                                                             == cardType)
             {
                 return convertIndex(i);
             }

            // 바닥 검사.
            for(j=0; j<12; j++)
            {
                if(room.getMonth(j)[0] == room.CARD_NULL)
                    continue;

                // 같은 종류의 패가 있는 월을 찾았다면,                 
                if( gCard.getType(room.getMonth(j)[0])
                    == gCard.getType(room.getUserHand(Room.COMPUTER, i)))
                {
                    for(k=0; k<4; k++)
                    {
                        if(room.getMonth(j)[k] == room.CARD_NULL)
                            break;

                        // 해당 월에 해당 Card Type이 있다면,
                        if(gCard.getType(room.getMonth(j)[k]) == cardType)
                        {
                            return convertIndex(i);
                        }
                    }

                    break;
                }
            }
        }
    } // for(i=0; i<room.numOfComputerCard; i++).

    return -1;
}

낼 수 없는 패는 체크 할 필요도 없겠죠. 따라서 낼 수 있는 패만을 검사합니다.

낼 수 있는 패라면, 해당 패가 인자로 받은 cardType과 동일한 카드 타입인지 체크한 후,

같다면 해당 카드의 인덱스를 리턴하고, 카드 타입이 일치하지 않는다면, 바닥을 검사해서,

바닥에 cardType과 동일한 카드타입의 카드가 존재하는지를 체크해서 있다면 인덱스를 리턴하죠.

결국, cardType과 동일한 종류의 카드를 들고 있거나, 해당 카드를 먹을 수 있는지를 체크하는거죠.


그런데 고도리, 청단, 홍단, 초단을 체크할때에는 조건이 조금 많죠?

    /* 고도리 체크 */
    if(bNoFindGodori == false
        || (room.getCntGodori(Room.PLAYER) >= 2 
            && room.getCntGodori(Room.COMPUTER) == 0))
    {
        if(room.getCntGodori(Room.PLAYER) >= 1)
        {
            bNoFindGodori == true;
        }
else {
result = findCard(CARDTYPE_GODORI); if(result != -1) // 고도리를 먹을 수 있다면, return result; // 해당 패를 냄. } } /* 청단 체크 */ if(bNoFindChungDan == false || (room.getCntChungDan(Room.PLAYER) >= 2 && room.getCntChungDan(Room.COMPUTER) == 0)) { if(room.getCntChungDan(Room.PLAYER) >= 1) { bNoFindChungDan == true; }
else { result = findCard(CARDTYPE_CHUNGDAN); if(result != -1) // 청단을 먹을 수 있다면, return result; // 해당 패를 냄. } } /* 홍단 체크 */ if(bNoFindHongDan == false || (room.getCntHongDan(Room.PLAYER) >= 2 && room.getCntHongDan(Room.COMPUTER) == 0)) { if(room.getCntHongDan(Room.PLAYER) >= 1) { bNoFindHongDan == true; }
else { result = findCard(CARDTYPE_HONGDAN); if(result != -1) // 홍단을 먹을 수 있다면, return result; // 해당 패를 냄. } } /* 초단 체크 */ if(bNoFindChoDan == false || (room.getCntChoDan(Room.PLAYER) >= 2 && room.getCntChoDan(Room.COMPUTER) == 0)) { if(room.getCntChoDan(Room.PLAYER) >= 1) { bNoFindChoDan == true; }
else { result = findCard(CARDTYPE_CHODAN); if(result != -1) // 초단을 먹을 수 있다면, return result; // 해당 패를 냄. } }

가장 먼저 bNoFind... 로 시작되는 boolean 변수를 체크하죠.

bNoFind... 변수들은 고도리, 청단, 홍단, 초단 패를 검색할 필요가 있는지를 체크하는 변수입니다.

예를 들어, 사용자가 이미 고도리 패를 가지고 있다면, 고도리 패는 이제 그냥 일반 열끗일뿐이죠.


따라서, bNoFindGodori 가 false라고 해도, 사용자가 한장이라도 고도리 패를 가지고 있다면,

고도리 패를 애써 먹으려고 노력할 필요는 없으므로 먹지 않을 수 있는 것이죠.

그러나 비록 사용자가 고도리 패를 가지고 있다고 해도, 사용자가 고도리를 할 위기에 처했다면,

고도리 패를 먹음으로써, 사용자의 고도리를 막아야 하겠지요.

만약 플레이어의 고도리가 2장인데, 우리가 한장도 가지고 있지 않다면, 그 패를 어서 먹어야 하겠지요.


청단, 홍단, 초단도 모두 마찬가지의 상황을 체크 하고 있습니다.


    // 위의 상황에 해당 하는 것이 없다면,  
for(i=0; i<room.numOfComputerCard; i++)
{
// 아무거나 먹을 수 있는 것이 있다면 패를 낸다. if(room.compareFloor(room.getUserHand(Room.COMPUTER, i)) != -1) { return converIndex(i); }
}
// 정말 먹을게 없다면, 아무거나 냄. room.getNumOfCard(); return convertIndex((byte)Etc.getRandomInt(room.numOfComputerCard));

run() 함수의 가장 마지막 부분입니다.

위에서 광, 청단, 홍단, 초단, 그리고 쌍피와 피 등등의 체크 과정을 모두 거쳤는데도

위의 상황에 아무것도 해당 되는 것이 없다면, 그냥 무작정 아무 패나 낼 수는 없고,

가능하다면 아무거나 패를 획득 할 수 있는 패를 내 주어야 하겠죠.

역시 마찬가지로 루프를 돌면서 먹을수 있는 패가 있다면, 그것을 내고,

아무것도 먹을 수 있는 것이 없다면, 렌덤하게 아무 카드나 내 줍니다.


사실 이렇게 처리를 해주면, 먹을게 없을때, 광이나 쌍피와 같이 중요한 카드를 내버리는 경우가 있겠죠.

물론 이러한 경우를 대비한 상황도 처리를 해 주어야 하지만, 강좌가 길어지므로, 일단은 생략했습니다.

그러나 이 부분은 뭐 그렇게 까다로운 부분도 아니고 해서 소스 코드에만 첨가를 시켜 놓았으므로,

소스 코드를 참조하시면 되리라고 봅니다.


이걸로 어느 정도 간단한 A.I.는 구현이 되었습니다. A.I.를 좀더 향상 시키고 싶다면,

각종 상황을 좀더 추가를 시켜 주면 되겠죠?


그러나 아직, 흔듬 여부 선택이나, 국진의 사용 여부 선택, 고/스톱 선택 등등의 상황에서

컴퓨터가 상황에 맞게 선택하는 기능이 구현되어 있지 않네요.

Posted by maysent
: