게시판인베이더 게임으로 배우는 휴리스틱과 AI 브리지예제 소스

예제 소스

인베이더 게임으로 배우는 휴리스틱과 AI 브리지 · 파일 17개

mainwindow.cpp

#include "mainwindow.h"
#include "aiplayer.h"
#include "gamecanvas.h"
#include "tcpvlmclient.h"

#include <QComboBox>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QKeyEvent>
#include <QShowEvent>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QSpinBox>
#include <QThread>
#include <QTimer>
#include <QVBoxLayout>

using namespace Invader;

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
{
    auto *central = new QWidget(this);
    setCentralWidget(central);
    auto *root = new QVBoxLayout(central);
    root->setContentsMargins(10, 10, 12, 10);
    root->setSpacing(8);

    auto *top = new QHBoxLayout();
    top->addWidget(new QLabel(QStringLiteral("조작 주체:"), this));
    m_modeCombo = new QComboBox(this);
    m_modeCombo->addItem(QStringLiteral("사람 (방향키·스페이스)"),
                         QVariant::fromValue(static_cast<int>(PlayMode::Human)));
    m_modeCombo->addItem(QStringLiteral("로컬 AI"), QVariant::fromValue(static_cast<int>(PlayMode::Ai)));
    m_modeCombo->addItem(QStringLiteral("VLM (TCP)"), QVariant::fromValue(static_cast<int>(PlayMode::Vlm)));
    connect(m_modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
            &MainWindow::onModeChanged);
    top->addWidget(m_modeCombo, 1);

    m_newGameBtn = new QPushButton(QStringLiteral("새 게임"), this);
    connect(m_newGameBtn, &QPushButton::clicked, this, &MainWindow::onNewGame);
    top->addWidget(m_newGameBtn);

    m_pauseBtn = new QPushButton(QStringLiteral("포즈(P)"), this);
    connect(m_pauseBtn, &QPushButton::clicked, this, &MainWindow::onTogglePause);
    top->addWidget(m_pauseBtn);

    m_restartBtn = new QPushButton(QStringLiteral("리스타트(R)"), this);
    connect(m_restartBtn, &QPushButton::clicked, this, &MainWindow::onRestart);
    top->addWidget(m_restartBtn);
    root->addLayout(top);

    m_vlmConfigRow = new QWidget(this);
    auto *vlmLay = new QHBoxLayout(m_vlmConfigRow);
    vlmLay->setContentsMargins(0, 0, 0, 0);
    vlmLay->setSpacing(6);
    vlmLay->addWidget(new QLabel(QStringLiteral("VLM 서버:"), m_vlmConfigRow));
    m_vlmHostEdit = new QLineEdit(QStringLiteral("127.0.0.1"), m_vlmConfigRow);
    m_vlmPortEdit = new QLineEdit(QStringLiteral("9300"), m_vlmConfigRow);
    m_vlmPortEdit->setMaximumWidth(72);
    vlmLay->addWidget(m_vlmHostEdit, 1);
    vlmLay->addWidget(m_vlmPortEdit);
    vlmLay->addWidget(new QLabel(QStringLiteral("타임아웃(s):"), m_vlmConfigRow));
    m_vlmTimeoutSpin = new QSpinBox(m_vlmConfigRow);
    m_vlmTimeoutSpin->setRange(5, 300);
    m_vlmTimeoutSpin->setValue(45);
    m_vlmTimeoutSpin->setSuffix(QStringLiteral("s"));
    m_vlmTimeoutSpin->setMaximumWidth(88);
    vlmLay->addWidget(m_vlmTimeoutSpin);
    m_vlmConnectBtn = new QPushButton(QStringLiteral("서버 접속 확인"), m_vlmConfigRow);
    connect(m_vlmConnectBtn, &QPushButton::clicked, this, &MainWindow::onPingServer);
    vlmLay->addWidget(m_vlmConnectBtn);
    m_vlmCancelBtn = new QPushButton(QStringLiteral("VLM 응답 취소"), m_vlmConfigRow);
    m_vlmCancelBtn->setEnabled(false);
    connect(m_vlmCancelBtn, &QPushButton::clicked, this, &MainWindow::onCancelVlm);
    vlmLay->addWidget(m_vlmCancelBtn);
    m_vlmConnLabel = new QLabel(QStringLiteral("● 미접속"), m_vlmConfigRow);
    m_vlmConnLabel->setStyleSheet(QStringLiteral("color:#888;"));
    vlmLay->addWidget(m_vlmConnLabel);
    vlmLay->addStretch();
    root->addWidget(m_vlmConfigRow);
    m_vlmConfigRow->hide();

    m_statusLabel = new QLabel(QStringLiteral(""), this);
    m_statusLabel->setWordWrap(true);
    root->addWidget(m_statusLabel);

    auto *logRow = new QHBoxLayout();
    logRow->addWidget(new QLabel(QStringLiteral("로컬 학습 로그(JSONL):"), this));
    m_trainLogEdit = new QLineEdit(this);
    m_trainLogEdit->setPlaceholderText(
        QStringLiteral("비우면 생략 — 서버가 invader/logs/invader_train.jsonl 에 기록(기본)"));
    logRow->addWidget(m_trainLogEdit, 1);
    root->addLayout(logRow);

    m_canvas = new GameCanvas(this);
    m_canvas->setEngine(&m_game);
    root->addWidget(m_canvas, 0, Qt::AlignHCenter);

    m_gameTimer = new QTimer(this);
    m_gameTimer->setInterval(kGameTimerIntervalMs);
    connect(m_gameTimer, &QTimer::timeout, this, &MainWindow::onGameTick);

    m_vlmThread = new QThread(this);
    m_vlmThread->setObjectName(QStringLiteral("InvaderVlmThread"));
    auto *worker = new TcpVlmClient::Worker();
    worker->moveToThread(m_vlmThread);
    m_vlmWorker = worker;
    connect(m_vlmThread, &QThread::finished, worker, &QObject::deleteLater);
    connect(worker, &TcpVlmClient::Worker::moveReady, this, &MainWindow::onVlmMoveReady,
            Qt::QueuedConnection);
    connect(worker, &TcpVlmClient::Worker::pingReady, this, &MainWindow::onVlmPingReady,
            Qt::QueuedConnection);
    connect(worker, &TcpVlmClient::Worker::connectionStateChanged, this,
            &MainWindow::onVlmConnectionStateChanged, Qt::QueuedConnection);
    m_vlmThread->start();

    m_vlmProgressTimer = new QTimer(this);
    m_vlmProgressTimer->setInterval(400);
    connect(m_vlmProgressTimer, &QTimer::timeout, this, &MainWindow::onVlmTickProgress);

    setFocusPolicy(Qt::StrongFocus);
    onModeChanged(0);
    restartMatch();
    m_gameTimer->start();
}

MainWindow::~MainWindow()
{
    if (m_vlmWorker) {
        QMetaObject::invokeMethod(m_vlmWorker.data(), "cancel", Qt::QueuedConnection);
        QMetaObject::invokeMethod(m_vlmWorker.data(), "closeConnection", Qt::QueuedConnection);
    }
    if (m_vlmThread) {
        m_vlmThread->quit();
        m_vlmThread->wait(2000);
    }
}

void MainWindow::selectPlayMode(PlayMode mode)
{
    if (!m_modeCombo)
        return;
    const int needle = static_cast<int>(mode);
    for (int i = 0; i < m_modeCombo->count(); ++i) {
        if (m_modeCombo->itemData(i).toInt() == needle) {
            m_modeCombo->setCurrentIndex(i);
            return;
        }
    }
}

void MainWindow::setVlmUiVisible(bool visible)
{
    if (m_vlmConfigRow)
        m_vlmConfigRow->setVisible(visible);
}

void MainWindow::showEvent(QShowEvent *event)
{
    QMainWindow::showEvent(event);
    setFocus(Qt::OtherFocusReason);
}

void MainWindow::keyPressEvent(QKeyEvent *event)
{
    // 전역 단축키(모드 무관)
    if (event->key() == Qt::Key_P) {
        onTogglePause();
        event->accept();
        return;
    }
    if (event->key() == Qt::Key_R) {
        onRestart();
        event->accept();
        return;
    }

    if (m_game.playMode() != PlayMode::Human) {
        QMainWindow::keyPressEvent(event);
        return;
    }
    switch (event->key()) {
    case Qt::Key_Left:
        m_humanPending = PlayerAction::Left;
        break;
    case Qt::Key_Right:
        m_humanPending = PlayerAction::Right;
        break;
    case Qt::Key_Space:
        if (m_game.status() == GameStatus::Intermission) {
            event->accept();
            return;
        }
        m_humanPending = PlayerAction::Fire;
        break;
    default:
        QMainWindow::keyPressEvent(event);
        return;
    }
    event->accept();
}

PlayerAction MainWindow::clampAction(int raw) const
{
    if (raw < 0 || raw > 3)
        return PlayerAction::None;
    return static_cast<PlayerAction>(raw);
}

void MainWindow::pushView()
{
    if (m_canvas)
        m_canvas->refreshView();
}

void MainWindow::updateStatus()
{
    const int sc = m_game.score();
    QString state;
    switch (m_game.status()) {
    case GameStatus::Playing:
        state = QStringLiteral("%1점 · 연사 %2발%3")
                   .arg(sc)
                   .arg(m_game.maxPlayerBullets())
                   .arg(m_paused ? QStringLiteral(" · PAUSE") : QString());
        break;
    case GameStatus::Intermission:
        // 카운트다운은 화면 중앙 숫자로만 표현한다.
        state = QStringLiteral("%1점 · 연사 %2발%3")
                    .arg(sc)
                    .arg(m_game.maxPlayerBullets())
                    .arg(m_paused ? QStringLiteral(" · PAUSE") : QString());
        break;
    case GameStatus::Won:
        state = QStringLiteral(
                   "<span style=\"color:#2e7d32;\"><b>승리!</b></span> "
                   "(인베이더 전멸) · 최종 점수 %1")
                   .arg(sc);
        break;
    case GameStatus::Lost:
        state = QStringLiteral("<span style=\"color:#c62828;\"><b>패배</b></span> · 점수 %1").arg(sc);
        break;
    }

    QString modeHint;
    switch (m_game.playMode()) {
    case PlayMode::Human:
        modeHint = QStringLiteral(
            "← → 이동, 스페이스 발사(연사 기본 5발). P=포즈, R=리스타트. "
            "<b>UFO</b> 플레이어 탄 격추 시 점수 + 연사 <b>+2발</b>. "
            "게임 시작·웨이브 클리어 후 모두 10초 카운트다운(이동만) 뒤 전투.");
        break;
    case PlayMode::Ai:
        modeHint = QStringLiteral("로컬 휴리스틱이 자동 조작합니다.");
        break;
    case PlayMode::Vlm:
        modeHint = QStringLiteral(
            "VLM 서버(<b>invader_vlm_server.py</b>)에 연결합니다. "
            "응답 대기 중에는 적이 멈춥니다. 실패 시 로컬 AI 로 대체합니다.");
        break;
    }

    m_statusLabel->setText(QStringLiteral("<b>%1</b><br><small>%2</small>").arg(state).arg(modeHint));
}

void MainWindow::restartMatch()
{
    m_moveSeq = 0;
    m_humanPending = PlayerAction::None;
    m_paused = false;
    if (m_canvas)
        m_canvas->setPaused(false);
    if (m_pauseBtn)
        m_pauseBtn->setText(QStringLiteral("포즈(P)"));
    m_vlmHasBufferedAction = false;
    m_vlmBufferedAction = PlayerAction::None;
    m_vlmPendingLogActor = 2;
    if (m_vlmWorker)
        m_vlmWorker->cancel();
    finishVlmCleanup();
    m_game.reset();
    m_game.setPlayMode(static_cast<PlayMode>(m_modeCombo->currentData().toInt()));
    pushView();
    updateStatus();
}

void MainWindow::onNewGame()
{
    restartMatch();
}

void MainWindow::onRestart()
{
    restartMatch();
}

void MainWindow::onTogglePause()
{
    // 패배 화면은 포즈 의미가 없으니 무시.
    if (m_game.status() == GameStatus::Lost)
        return;

    m_paused = !m_paused;
    if (m_canvas)
        m_canvas->setPaused(m_paused);
    if (m_pauseBtn)
        m_pauseBtn->setText(m_paused ? QStringLiteral("재개(P)") : QStringLiteral("포즈(P)"));

    // 포즈를 켤 때, VLM 요청이 진행 중이면 취소해서 게임이 멈추게 유지.
    if (m_paused && m_vlmInFlight) {
        if (m_vlmWorker)
            m_vlmWorker->cancel();
        finishVlmCleanup();
    }

    pushView();
    updateStatus();
}

void MainWindow::onModeChanged(int index)
{
    Q_UNUSED(index);
    m_game.setPlayMode(static_cast<PlayMode>(m_modeCombo->currentData().toInt()));
    const bool vlm = (m_game.playMode() == PlayMode::Vlm);
    setVlmUiVisible(vlm);
    restartMatch();
}

void MainWindow::onGameTick()
{
    if (m_paused) {
        pushView();
        return;
    }

    m_game.advanceParticles();
    const GameStatus st = m_game.status();
    if (st == GameStatus::Lost) {
        pushView();
        updateStatus();
        return;
    }

    if (st == GameStatus::Intermission) {
        const PlayMode mode = m_game.playMode();
        PlayerAction a = PlayerAction::None;
        int actor = 0;
        if (mode == PlayMode::Human) {
            a = m_humanPending;
            m_humanPending = PlayerAction::None;
            actor = 0;
        } else if (mode == PlayMode::Ai) {
            a = AiPlayer::suggestAction(m_game);
            actor = 1;
        } else {
            // 카운트다운 구간은 VLM 호출 없이 휴리스틱(대기·서버 부담 방지).
            a = AiPlayer::suggestAction(m_game);
            actor = 1;
        }
        m_game.tick(a);
        ++m_moveSeq;
        logStepToServer(a, actor);
        maybeAppendLocalTrainJsonl(a, actor);
        pushView();
        updateStatus();
        return;
    }

    if (st != GameStatus::Playing) {
        pushView();
        updateStatus();
        return;
    }

    const PlayMode mode = m_game.playMode();

    if (mode == PlayMode::Vlm) {
        if (m_vlmInFlight) {
            pushView();
            return;
        }
        if (!m_vlmHasBufferedAction) {
            startVlmTcpRequest();
            pushView();
            return;
        }
    }

    PlayerAction a = PlayerAction::None;
    int actor = 0;
    if (mode == PlayMode::Human) {
        a = m_humanPending;
        m_humanPending = PlayerAction::None;
        actor = 0;
    } else if (mode == PlayMode::Ai) {
        a = AiPlayer::suggestAction(m_game);
        actor = 1;
    } else {
        a = m_vlmBufferedAction;
        m_vlmHasBufferedAction = false;
        actor = m_vlmPendingLogActor;
    }

    m_game.tick(a);
    ++m_moveSeq;
    logStepToServer(a, actor);
    maybeAppendLocalTrainJsonl(a, actor);
    pushView();
    updateStatus();

    if (mode == PlayMode::Vlm && m_game.status() == GameStatus::Playing)
        startVlmTcpRequest();
}

void MainWindow::logStepToServer(PlayerAction a, int actorKind)
{
    if (actorKind == 2)
        return;
    if (!m_vlmWorker || !m_vlmThread || !m_vlmThread->isRunning())
        return;
    if (!m_vlmHostEdit || !m_vlmPortEdit)
        return;
    bool okPort = false;
    const quint16 port = m_vlmPortEdit->text().trimmed().toUShort(&okPort);
    if (!okPort || port == 0)
        return;
    const QString host = m_vlmHostEdit->text().trimmed();
    if (host.isEmpty())
        return;

    QByteArray packed;
    m_game.packGrid(&packed);
    int statusCode = static_cast<int>(m_game.status());
    int playMode = static_cast<int>(m_game.playMode());
    QMetaObject::invokeMethod(
        m_vlmWorker.data(), "requestLog", Qt::QueuedConnection, Q_ARG(int, kGridRows),
        Q_ARG(int, kGridCols), Q_ARG(int, static_cast<int>(a)), Q_ARG(int, actorKind),
        Q_ARG(int, playMode), Q_ARG(int, statusCode), Q_ARG(int, m_moveSeq),
        Q_ARG(QByteArray, packed), Q_ARG(QString, host), Q_ARG(quint16, port), Q_ARG(int, 5000));
}

void MainWindow::maybeAppendLocalTrainJsonl(PlayerAction a, int actorKind)
{
    if (!m_trainLogEdit)
        return;
    const QString path = m_trainLogEdit->text().trimmed();
    if (path.isEmpty())
        return;

    QFileInfo fi(path);
    if (!QDir().mkpath(fi.absolutePath()))
        return;

    QByteArray packed;
    m_game.packGrid(&packed);
    QJsonArray board;
    for (int i = 0; i < packed.size(); ++i)
        board.append(static_cast<int>(static_cast<unsigned char>(packed.at(i))));

    const char *actorStr = "human";
    if (actorKind == 1)
        actorStr = "local_ai";
    else if (actorKind == 2)
        actorStr = "vlm";

    const char *modeStr = "Human";
    const int pm = static_cast<int>(m_game.playMode());
    if (pm == 1)
        modeStr = "Ai";
    else if (pm == 2)
        modeStr = "Vlm";

    const char *statusStr = "Playing";
    const int st = static_cast<int>(m_game.status());
    if (st == 1)
        statusStr = "Won";
    else if (st == 2)
        statusStr = "Lost";
    else if (st == 3)
        statusStr = "Intermission";

    QJsonObject o;
    o[QStringLiteral("kind")] = QStringLiteral("move");
    o[QStringLiteral("game")] = QStringLiteral("invader");
    o[QStringLiteral("ts")] = QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs);
    o[QStringLiteral("action")] = static_cast<int>(a);
    o[QStringLiteral("actor_id")] = actorKind;
    o[QStringLiteral("actor")] = QString::fromUtf8(actorStr);
    o[QStringLiteral("mode")] = QString::fromUtf8(modeStr);
    o[QStringLiteral("status")] = QString::fromUtf8(statusStr);
    o[QStringLiteral("seq")] = m_moveSeq;
    o[QStringLiteral("board")] = board;
    o[QStringLiteral("source")] = QStringLiteral("qt_client_file");

    QFile f(path);
    if (!f.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text))
        return;
    f.write(QJsonDocument(o).toJson(QJsonDocument::Compact));
    f.write("\n", 1);
}

void MainWindow::startVlmTcpRequest()
{
    if (m_game.playMode() != PlayMode::Vlm || m_game.status() != GameStatus::Playing)
        return;
    if (m_vlmInFlight)
        return;
    if (!m_vlmWorker || !m_vlmThread || !m_vlmThread->isRunning()) {
        playAiFallbackForVlm();
        return;
    }
    bool okPort = false;
    const quint16 port = m_vlmPortEdit->text().trimmed().toUShort(&okPort);
    if (!okPort || port == 0) {
        playAiFallbackForVlm();
        return;
    }
    const QString host = m_vlmHostEdit->text().trimmed();
    if (host.isEmpty()) {
        playAiFallbackForVlm();
        return;
    }

    QByteArray packed;
    m_game.packGrid(&packed);
    QImage shot = m_canvas->grab().toImage();

    const int timeoutSec = m_vlmTimeoutSpin ? m_vlmTimeoutSpin->value() : 45;
    const int timeoutMs = qMax(2000, timeoutSec * 1000);

    m_vlmInFlight = true;
    m_vlmCancelBtn->setEnabled(true);
    m_vlmConnectBtn->setEnabled(false);
    m_vlmProgressClock.restart();
    m_vlmProgressTimer->start();
    updateStatus();
    m_statusLabel->setText(m_statusLabel->text()
        + QStringLiteral("<br><span style=\"color:#555;\">VLM 응답 대기…</span>"));

    QMetaObject::invokeMethod(m_vlmWorker.data(), "requestMove", Qt::QueuedConnection,
                              Q_ARG(QImage, shot), Q_ARG(QByteArray, packed), Q_ARG(int, kGridRows),
                              Q_ARG(int, kGridCols), Q_ARG(QString, host), Q_ARG(quint16, port),
                              Q_ARG(int, timeoutMs));
}

void MainWindow::finishVlmCleanup()
{
    m_vlmInFlight = false;
    if (m_vlmCancelBtn)
        m_vlmCancelBtn->setEnabled(false);
    if (m_vlmConnectBtn)
        m_vlmConnectBtn->setEnabled(true);
    if (m_vlmProgressTimer)
        m_vlmProgressTimer->stop();
}

void MainWindow::onVlmTickProgress()
{
    if (!m_vlmInFlight)
        return;
    const int sec = int(m_vlmProgressClock.elapsed() / 1000);
    updateStatus();
    m_statusLabel->setText(m_statusLabel->text()
        + QStringLiteral("<br><span style=\"color:#555;\">VLM 대기 <b>%1초</b></span>").arg(sec));
}

void MainWindow::onCancelVlm()
{
    if (!m_vlmInFlight)
        return;
    if (m_vlmWorker)
        QMetaObject::invokeMethod(m_vlmWorker.data(), "cancel", Qt::DirectConnection);
}

void MainWindow::onPingServer()
{
    bool okPort = false;
    const quint16 port = m_vlmPortEdit->text().trimmed().toUShort(&okPort);
    if (!okPort || port == 0) {
        m_statusLabel->setText(QStringLiteral("<span style=\"color:#b00020;\">포트 오류</span>"));
        return;
    }
    const QString host = m_vlmHostEdit->text().trimmed();
    if (!m_vlmWorker) {
        return;
    }
    m_vlmConnectBtn->setEnabled(false);
    QMetaObject::invokeMethod(m_vlmWorker.data(), "doPing", Qt::QueuedConnection,
                              Q_ARG(QString, host), Q_ARG(quint16, port), Q_ARG(int, 3000));
}

void MainWindow::onVlmPingReady(bool success, QString errorMessage)
{
    m_vlmConnectBtn->setEnabled(true);
    updateStatus();
    if (success) {
        m_statusLabel->setText(m_statusLabel->text()
            + QStringLiteral("<br><span style=\"color:#0a7d2c;\"><b>연결 OK</b></span>"));
    } else {
        m_statusLabel->setText(
            m_statusLabel->text()
            + QStringLiteral("<br><span style=\"color:#b00020;\">실패: %1</span>")
                  .arg(errorMessage.toHtmlEscaped()));
    }
}

void MainWindow::onVlmConnectionStateChanged(bool connected, QString endpoint)
{
    if (!m_vlmConnLabel)
        return;
    if (connected) {
        m_vlmConnLabel->setText(QStringLiteral("● 연결됨 %1").arg(endpoint));
        m_vlmConnLabel->setStyleSheet(QStringLiteral("color:#0a7d2c;font-weight:600;"));
    } else {
        m_vlmConnLabel->setText(QStringLiteral("● 미접속"));
        m_vlmConnLabel->setStyleSheet(QStringLiteral("color:#888;"));
    }
}

void MainWindow::playAiFallbackForVlm()
{
    m_vlmBufferedAction = AiPlayer::suggestAction(m_game);
    m_vlmHasBufferedAction = true;
    m_vlmPendingLogActor = 1;
}

void MainWindow::onVlmMoveReady(bool success, int action, int unusedCol, QString errorMessage)
{
    Q_UNUSED(unusedCol);
    finishVlmCleanup();
    if (m_game.playMode() != PlayMode::Vlm || m_game.status() != GameStatus::Playing) {
        updateStatus();
        return;
    }
    if (!success) {
        updateStatus();
        m_statusLabel->setText(m_statusLabel->text()
            + QStringLiteral("<br><span style=\"color:#b00020;\">VLM 실패: %1 — AI 대체</span>")
                  .arg(errorMessage.isEmpty() ? QStringLiteral("?")
                                               : errorMessage.toHtmlEscaped()));
        playAiFallbackForVlm();
        return;
    }
    m_vlmBufferedAction = clampAction(action);
    m_vlmHasBufferedAction = true;
    m_vlmPendingLogActor = 2;
    updateStatus();
}