예제 소스
인베이더 게임으로 배우는 휴리스틱과 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();
}