Adsense

2018年1月20日土曜日

Spring Statemachineを使ってドラクエ11のポーカーを自動プレイするプログラムを作ってロイヤルストレートスライムを出すまでの道のり 第6回

※ ツールのソースコードはGitHubにあがっていますので参照してください。

前々回では状態遷移図に従って状態遷移マシンを実装するやり方を、前回ではテストコード内で状態遷移マシンにイベントを投げてテストする方法を見てきました。

今回は、実際にツール内で状態遷移マシンにイベントを投げるイベントディスパッチャを見ていきます。

@Scheduledによるイベントディスパッチ

今回のツールでは、定期的(デフォルトで2秒)に画面キャプチャを行い、画面内にイベントを発生させる契機となる画面部品(例:"くばる"ボタン)が検出された場合に、それに応じたイベントを状態遷移マシンに送信するという仕組みにしています。なお、画面内に特にイベントを発生させる画面部品が見つからなかった場合は、OTHER_EVENTを送信します。

AutoplayConfig

定期的な画面キャプチャの実行には、Springの@Scheduledを使用します。以下は、@Scheduledを有効にしているAutoplayConfigクラスのコードです。

/**
 * プロパティ"mode"が"AUTOPLAY"の時のみ有効となる設定。
 */
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "mode", havingValue = "AUTOPLAY")
@ConfigurationProperties(prefix = "autoplay.dq11.poker.autoplay-config")
public class AutoplayConfig implements SchedulingConfigurer {

    // 中略

    @Bean
    public TaskScheduler taskSchedulerForAutoplay() {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setThreadNamePrefix("autoplay-pool-");
        taskScheduler.setPoolSize(2);
        return taskScheduler;
    }

    // 以下略

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setTaskScheduler(taskSchedulerForAutoplay());
    }


6行目: @EnableSchedulingのアノテーションで@Scheduledによる定期実行を有効化します。

9行目: 本ツールでは@Scheduledを実行するTaskSchedulerを指定したかったので、SchedulingConfigurerインターフェースを実装します。

13-19行目: @Scheduledを実行するTaskSchedulerをBean登録します。

25行目: @Scheduledで実行するTaskSchedulerを明示的に設定します。

TaskSchedulerについての補足

上記コードではTaskSchedulerを明示的に設定していますが、明示的に設定しない場合は以下のような流れで使用するTaskSchedulerが決定されます。
  • TaskSchedulerの型のBeanが1つだけ存在する場合はそれを使う
  • "taskScheduler"という名前のBeanがあればそれを使う
  • Beanがなければシングルスレッドのスケジューラ(ConcurrentTaskScheduler)をセットアップして使う
Spring Statemachineでは、第3回で説明したようにDo Actionなどを別スレッドで実行しますが、このときにも"taskScheduler"という名前のBeanを使います。そのため、意図せず@ScheduledとSpring Statemachineで同じTaskSchedulerを共有してしまうことがあるため注意が必要です。

今回のツールでは、使用するTaskSchedulerを別にしたかったので、Bean名を"taskScheduler"にせず、"taskSchedulerForAutoplay"にしています。

EventDispatcherTask

実際にイベント送信を定期実行するクラスは下記になります。

/**
 * イベントを送信するクラス。 {@link @Scheduled}で定期的に画面キャプチャし、条件に応じたイベントを{@link StateMachine}
 * に送信する。
 */
@ConfigurationProperties(prefix = "autoplay.dq11.poker.event")
public class EventDispatcherTask {

    private static final Logger LOGGER = LoggerFactory.getLogger(EventDispatcherTask.class);

    @Autowired
    StateMachine<States, Events> stateMachine;

    @Autowired
    RobotUtil robotUtil;

    @Autowired
    ImageComparator imageComparator;

    @Autowired
    CardReader cardReader;

    @Autowired
    CaptureManager captureManager;

    @Autowired
    CaptureHistory captureHistory;

    /**
     * イベント検知の周期(ms)。 application.propertiesでの補完の有効化、および、
     * デバッグログ出力のためのフィールドであり、実際にはこのフィールドは設定には使われない。 
     * <code>@Scheduled</code>の<code>fixedDelayString</code>に指定された値が使われる。
     */
    private int timerInterval;

    /**
     * デバッグ用。指定回数目のイベント送信でロイヤルストレートスライム検出イベントを送信する。 0以下の数値が設定されている場合は無視される。
     */
    private int debugRssEventCount = 0;

    /** 画面キャプチャを連続何回取得するか。 */
    private int captureBurstCount;

    /** 画面キャプチャを連続取得する際の間隔(ms)。 */
    private int captureBurstInterval;

    /**
     * 定期的に画面をキャプチャし、条件に応じたイベントを{@link StateMachine}に送信する。
     */
    @Scheduled(fixedDelayString = "${autoplay.dq11.poker.event.timer-interval}")
    public void dispatchEvent() {
        LOGGER.debug("dispatchEvent start. timer-interval: {}ms", timerInterval);

        /*
         * 役が成立した際にカードが光るアニメーションが入るため、1回のキャプチャだとカード認識に失敗する可能性がある。
         * そのため、キャプチャを連続取得し、それぞれのキャプチャでカードの読み取りを行い、補正を行う。
         */
        List<BufferedImage> captureList = robotUtil.captureGameScreenBurst(captureBurstCount, captureBurstInterval);
        LocalDateTime captureDateTime = LocalDateTime.now();

        // それぞれのキャプチャでカード読み取りを実施。
        List<List<Card>> readCardFromAllCapture = captureList.stream()
                .map(screen -> cardReader.readAllCards(screen))
                .collect(Collectors.toList());

        // それぞれのカード読み取り結果をマージ。
        List<Card> cards = readCardFromAllCapture.stream()
                .reduce((list1, list2) -> cardReader.mergeCardList(list1, list2))
                .get();

        // キャプチャ画像には最後のキャプチャを使う。
        BufferedImage screen = captureList.get(captureList.size() - 1);

        LOGGER.info("read cards: {}", cards.stream()
                .map(c -> c.name())
                .collect(Collectors.joining(",")));

        Events event = null;
        if (shouldSendRoyalStraightSlimeEvent(cards)) {
            event = Events.ROYAL_STRAIGHT_SLIME_EVENT;
        } else if (shouldSendDealCardsEvent(cards, screen)) {
            event = Events.DEAL_CARDS_EVENT;
        } else if (shouldSendBeforeBetCoinInputEvent(screen)) {
            event = Events.BEFORE_BET_COIN_EVENT;
        } else {
            event = Events.OTHER_EVENT;
        }

50行目: @ScheduledでdispatchEventメソッドを定期実行する設定をします。実行間隔はappliation.propertiesから取ってきます。

54-72行目: ここはゲーム画面のキャプチャを取得し、5枚のカードの種別を読み取る処理を行っています。カードの読み取りは、あらかじめキャプチャしてファイルに保存しておいたカードの画像と、ゲーム画面キャプチャの指定位置の画像が一致するかで行います。 読み取る種別はロイヤルストレートスライムに必要なカードのみで、それ以外はその他のカード(またはそもそもカードが画面に表示されていない)として処理します。
ここでちょっとやっかいな問題があり、ポーカーの役が成立している状態だと、カードが光るアニメーションが定期的に入るため、光ったタイミングで画像比較をしてしまうと正しくカードを認識できないという問題があります。そのため、ここでは短い間隔で3回画面キャプチャを取り、どれかのキャプチャでカードが読み取れたらその結果を使うという緩和策を取っています。

79-87行目: 送信するイベントを決定します。shoud~ではじまるメソッドは、画面キャプチャや読み取ったカード種別から、イベントを送信するべきか判定するメソッドです。

以下、dispatchEventメソッドの続きです。


        // イベントを一意に表すID。
        String eventId = UUID.randomUUID().toString();

        // イベント送信。
        Message<Events> message = MessageBuilder
                .withPayload(event)
                .setHeader("cards", cards)
                .setHeader("eventId", eventId)
                .build();
        stateMachine.sendEvent(message);

        // ゲーム画面のキャプチャ画像を履歴に保持。
        captureHistory.add(screen, eventId, event, captureDateTime);

        LOGGER.debug("dispatchEvent end. dispatched event: {}", event);
    }

    /**
     * カード配布済みイベントを送信するべきか判定する。
     * 
     * @param cards 配布されたカードのカード種別のリスト。
     * @param screen ゲーム画面のキャプチャ。
     * @return イベントを送信するべきならtrue。それ以外はfalse。
     */
    private boolean shouldSendDealCardsEvent(List<Card> cards, BufferedImage screen) {
        CaptureRectangle captureRect = captureManager.getCaptureRectangle(DEAL_CARDS_BUTTON_CAPTURE);
        String filePath = captureManager.getCaptureFilePath(DEAL_CARDS_BUTTON_CAPTURE);
        try {
            // "くばる"ボタンの画像とキャプチャが一致したらイベント送信。
            if (imageComparator.compare(filePath, captureRect.getSubImage(screen))) {
                return true;
            }
        } catch (IOException e) {
            // ファイル読み込みに失敗したらログを出して継続。
            LOGGER.error("fail to read deal cards button image file: {}", filePath, e);
        }
        return false;
    }

    // 以下略

4行目: イベント送信時に付与するイベントを一意に表すIDを生成します。イベント送信を契機に状態遷移マシンで様々なActionが実行されるわけですが、どのイベントを契機に実行されたのかが分からないとデバッグがやりづらかったので、イベントにIDを持たせて、その先のActionや状態遷移に関するログでは必ず契機となったイベントIDを出力するように工夫しました。

7-12行目: イベントを送信しています。これまで見てきたやりかたでは、sendEventメソッドにイベントのenumを直接渡していましたが、このようにMessageという型でラップすることで、イベントに付加情報を付与して送信することができるようになります。
MessageBuilderのwithPayloadで送信するイベントを、setHeaderで付加する情報を設定して、最後にbuildすることでMessageを作成できます。
受信側でこの付加情報を取得する場合は、StateContextのgetMessageHeadersメソッドを使えばOKです。

15行目: ツール終了時に直近の画面キャプチャをファイル保存するために、キャプチャ画像を保持します。デバッグ目的です。キャプチャ画像とイベントIDの紐付けもログによって行われるため、どのキャプチャからどういうイベントを送信してその結果どういうActionが実行されたのかまで追跡することができます。

27-40行目: イベントを送信するべきか判定するshoud~メソッドの1つを参考までに掲載しています。特筆すべき事は何もやっていなくて、キャプチャした画像から"くばる"ボタンが表示される範囲を切り取って、あらかじめ保存しておいた"くばる"ボタンのキャプチャ画像ファイルと比較を行います。
画像比較については、こちらのサイト(toridgeの勉強部屋 wiki)のコードを参考にさせていただきました。画像比較についてはまったく知識がなかったので、本当にありがたいです。

以上、今回はイベントディスパッチャのコードを見てみました。

0 件のコメント:

コメントを投稿