예제 소스
인베이더 게임으로 배우는 휴리스틱과 AI 브리지 · 파일 17개
gameengine.cpp
#include "gameengine.h"
#include <QPainter>
#include <QPen>
#include <QRandomGenerator>
#include <QtGlobal>
#include <cmath>
#include <algorithm>
namespace Invader {
namespace {
QImage makeBunkerMask()
{
// 집 모양 픽셀 방어막 (지붕·굴뚝·벽돌 느낌, 창문/문은 뚫림)
const int w = 58;
const int h = 42;
QImage img(w, h, QImage::Format_ARGB32_Premultiplied);
img.fill(Qt::transparent);
QPainter p(&img);
p.setRenderHint(QPainter::Antialiasing, false);
const QColor roofHi(188, 78, 68);
const QColor roofLo(132, 52, 48);
const QColor chim(96, 96, 110);
const QColor wall(58, 168, 82);
const QColor wallDark(38, 118, 58);
const QColor trim(72, 200, 105);
// 굴뚝
p.fillRect(w - 15, 0, 8, 16, chim);
p.fillRect(w - 14, 1, 6, 14, QColor(120, 120, 135));
// 지붕(계단식 지붕선)
for (int i = 0; i < 10; ++i) {
const int y = i * 2;
const int inset = i * 2;
p.fillRect(inset, y, w - 2 * inset, 2, (i % 2 == 0) ? roofHi : roofLo);
}
const int wallTop = 20;
p.setPen(Qt::NoPen);
p.fillRect(0, wallTop, w, h - wallTop, wall);
// 벽돌 줄무늬
p.setPen(QPen(wallDark, 1));
for (int y = wallTop + 4; y < h; y += 5)
p.drawLine(1, y, w - 2, y);
p.setPen(Qt::NoPen);
p.fillRect(1, wallTop + 1, w - 2, 3, trim);
// 문·창 뚫기
p.setCompositionMode(QPainter::CompositionMode_Clear);
p.fillRect(QRect(w / 2 - 7, h - 17, 14, 17), QColor(0, 0, 0, 0)); // 문
p.fillRect(QRect(9, wallTop + 7, 10, 9), QColor(0, 0, 0, 0)); // 좌 창
p.fillRect(QRect(w - 19, wallTop + 7, 10, 9), QColor(0, 0, 0, 0)); // 우 창
return img;
}
void placeInitialBunkers(QVector<Bunker> *out)
{
out->clear();
const QImage mask = makeBunkerMask();
const int y = kPlayBottom - 110;
const int pad = 18;
const int totalW = kWorldW - pad * 2;
const int kSlots = 6; // Qt 매크로 slots 충돌 방지
const int gap = (totalW - kSlots * mask.width()) / qMax(1, kSlots - 1);
for (int i = 0; i < kSlots; ++i) {
const int x = pad + i * (mask.width() + gap);
Bunker b;
b.pos = QPoint(x, y);
b.mask = mask;
out->append(b);
}
}
void placeInitialAliens(QVector<QRect> *out)
{
out->clear();
// 더 많이: 11×6 = 66
const int cols = 11;
const int rows = 6;
const int aw = 22;
const int ah = 16;
const int sx = 12;
const int sy = 10;
const int startX = 40;
const int startY = kPlayTop + 22;
for (int r = 0; r < rows; ++r) {
for (int c = 0; c < cols; ++c) {
const int x = startX + c * (aw + sx);
const int y = startY + r * (ah + sy);
out->append(QRect(x, y, aw, ah));
}
}
}
} // namespace
GameEngine::GameEngine()
{
reset();
}
void GameEngine::reset()
{
m_tick = 0;
const int pw = 34;
const int ph = 14;
m_player = QRect(kWorldW / 2 - pw / 2, kPlayBottom - ph, pw, ph);
m_playerVx = 0;
m_alienDir = 1;
m_alienStepEvery = 10;
m_alienStepPx = 6;
m_alienDropPx = 14;
m_alienShootCooldown = 16;
m_bullets.clear();
m_particles.clear();
m_ufoActive = false;
m_ufoSpawnIn = 320 + QRandomGenerator::global()->bounded(480);
m_score = 0;
m_maxPlayerBullets = kMaxPlayerBullets;
placeInitialBunkers(&m_bunkers);
placeInitialAliens(&m_aliens);
// 첫 시작·새 게임도 웨이브 간과 동일하게 10초 카운트다운 후 전투 시작.
m_status = GameStatus::Intermission;
m_intermissionTicksLeft = kIntermissionTicks;
}
void GameEngine::advanceParticles()
{
stepParticles();
}
void GameEngine::packGrid(QByteArray *out) const
{
if (!out)
return;
rebuildCoarseGrid(out);
}
void GameEngine::applyPlayer(PlayerAction a)
{
if (m_status != GameStatus::Playing)
return;
m_playerVx = 0;
switch (a) {
case PlayerAction::Left:
m_playerVx = -7;
break;
case PlayerAction::Right:
m_playerVx = 7;
break;
case PlayerAction::Fire: {
int playerShots = 0;
for (const Bullet &b : m_bullets) {
if (b.fromPlayer)
++playerShots;
}
if (playerShots >= m_maxPlayerBullets)
return;
{
const int bw = 4;
const int bh = 10;
const QPoint spawn(m_player.center().x() - bw / 2, m_player.top() - bh - 2);
Bullet b;
b.rect = QRect(spawn, QSize(bw, bh));
b.vy = -12;
b.fromPlayer = true;
m_bullets.append(b);
}
break;
}
case PlayerAction::None:
break;
}
}
void GameEngine::applyPlayerIntermission(PlayerAction a)
{
m_playerVx = 0;
switch (a) {
case PlayerAction::Left:
m_playerVx = -7;
break;
case PlayerAction::Right:
m_playerVx = 7;
break;
case PlayerAction::Fire:
case PlayerAction::None:
break;
}
}
void GameEngine::prepareNextWave()
{
placeInitialAliens(&m_aliens);
placeInitialBunkers(&m_bunkers);
m_bullets.clear();
m_ufoActive = false;
m_ufoSpawnIn = 320 + QRandomGenerator::global()->bounded(480);
m_alienDir = 1;
m_alienStepEvery = 10;
m_alienShootCooldown = 16;
}
int GameEngine::intermissionSecondsDisplay() const
{
if (m_status != GameStatus::Intermission || m_intermissionTicksLeft <= 0)
return 0;
return qMax(1, (m_intermissionTicksLeft * kGameTimerIntervalMs + 999) / 1000);
}
void GameEngine::stepBullets()
{
QVector<Bullet> next;
next.reserve(m_bullets.size());
for (Bullet b : m_bullets) {
b.rect.translate(0, b.vy);
if (b.rect.bottom() < kPlayTop || b.rect.top() > kPlayBottom)
continue;
next.append(b);
}
m_bullets.swap(next);
}
void GameEngine::stepAliens()
{
if (m_aliens.isEmpty())
return;
bool hitWall = false;
const int dir = m_alienDir;
for (const QRect &r : m_aliens) {
const int nx = r.x() + dir * m_alienStepPx;
if (nx < 8 || nx + r.width() > kWorldW - 8) {
hitWall = true;
break;
}
}
if (hitWall) {
m_alienDir = -dir;
for (QRect &r : m_aliens)
r.translate(0, m_alienDropPx);
} else {
for (QRect &r : m_aliens)
r.translate(dir * m_alienStepPx, 0);
}
}
void GameEngine::tryAlienShoot()
{
if (m_aliens.isEmpty() || m_status != GameStatus::Playing)
return;
if (--m_alienShootCooldown > 0)
return;
m_alienShootCooldown = 10 + QRandomGenerator::global()->bounded(18);
// 각 열에서 가장 아래 에일리언만 쏠 수 있게(클래식 느낌).
QVector<QRect> bottoms;
bottoms.reserve(16);
for (const QRect &a : m_aliens) {
bool blocked = false;
for (const QRect &b : m_aliens) {
if (b.x() == a.x() && b.y() > a.y()) {
blocked = true;
break;
}
}
if (!blocked)
bottoms.append(a);
}
if (bottoms.isEmpty())
bottoms = m_aliens;
const QRect pick = bottoms.at(QRandomGenerator::global()->bounded(bottoms.size()));
Bullet b;
b.fromPlayer = false;
b.vy = 10;
b.rect = QRect(pick.center().x() - 2, pick.bottom() + 2, 4, 10);
m_bullets.append(b);
}
void GameEngine::spawnHitBurst(const QPoint &worldCenter, int strength)
{
const int n = qBound(8, strength / 2, 32);
static const QColor palette[] = {
QColor(255, 235, 140),
QColor(255, 150, 70),
QColor(255, 255, 255),
QColor(200, 220, 255),
QColor(255, 90, 90),
};
auto *rng = QRandomGenerator::global();
for (int i = 0; i < n; ++i) {
ExplosionParticle q;
q.pos = QPointF(worldCenter)
+ QPointF(rng->bounded(7) - 3, rng->bounded(7) - 3);
const qreal ang = rng->generateDouble() * 6.283185307179586;
const qreal sp = 0.8 + rng->generateDouble() * (strength / 10.0);
q.vel = QPointF(std::cos(ang) * sp, std::sin(ang) * sp);
q.life = 7 + rng->bounded(12);
q.color = palette[rng->bounded(int(sizeof palette / sizeof palette[0]))];
m_particles.append(q);
}
}
void GameEngine::stepParticles()
{
for (int i = m_particles.size() - 1; i >= 0; --i) {
ExplosionParticle &ep = m_particles[i];
ep.pos += ep.vel;
ep.vel *= 0.93;
--ep.life;
if (ep.life <= 0)
m_particles.removeAt(i);
}
}
void GameEngine::stepUfo()
{
if (m_status != GameStatus::Playing)
return;
if (m_ufoActive) {
m_ufo.translate(m_ufoVx, 0);
if (m_ufo.right() < -14 || m_ufo.left() > kWorldW + 14)
m_ufoActive = false;
return;
}
if (--m_ufoSpawnIn <= 0) {
m_ufoSpawnIn = 360 + QRandomGenerator::global()->bounded(560);
m_ufoVx = QRandomGenerator::global()->bounded(2) ? 3 : -3;
const int uw = 42;
const int uh = 20;
if (m_ufoVx > 0)
m_ufo = QRect(-uw - 10, kPlayTop + 5, uw, uh);
else
m_ufo = QRect(kWorldW + 10, kPlayTop + 5, uw, uh);
m_ufoActive = true;
}
}
void GameEngine::resolveCollisions()
{
// 탄 ↔ 방어막 / 탄 ↔ 탄 / 탄 ↔ 에일리언 / 탄 ↔ 플레이어
QVector<Bullet> kept;
kept.reserve(m_bullets.size());
auto bunkerHit = [&](const QRect &bRect, QPoint *hitOut) -> bool {
for (const Bunker &b : m_bunkers) {
if (!b.rect().intersects(bRect))
continue;
const QRect inter = b.rect().intersected(bRect);
// 교차점 중심을 맞은 위치로 본다.
const QPoint hit = inter.center();
if (hitOut)
*hitOut = hit;
return true;
}
return false;
};
// bullet-bullet: 서로 교차하면 둘 다 제거.
QVector<bool> dead(m_bullets.size(), false);
for (int i = 0; i < m_bullets.size(); ++i) {
for (int j = i + 1; j < m_bullets.size(); ++j) {
if (dead[i] || dead[j])
continue;
if (m_bullets[i].fromPlayer == m_bullets[j].fromPlayer)
continue;
if (m_bullets[i].rect.intersects(m_bullets[j].rect)) {
const QRect x = m_bullets[i].rect.intersected(m_bullets[j].rect);
spawnHitBurst(x.center(), 10);
dead[i] = true;
dead[j] = true;
}
}
}
for (int i = 0; i < m_bullets.size(); ++i) {
if (dead[i])
continue;
const Bullet &b = m_bullets.at(i);
QPoint hit;
if (bunkerHit(b.rect, &hit)) {
damageBunkerAt(hit);
spawnHitBurst(hit, 14);
continue;
}
if (b.fromPlayer) {
bool killed = false;
for (int a = 0; a < m_aliens.size(); ++a) {
if (m_aliens.at(a).intersects(b.rect)) {
spawnHitBurst(m_aliens.at(a).center(), 20);
m_aliens.removeAt(a);
m_score += 10;
killed = true;
break;
}
}
if (killed)
continue;
if (m_ufoActive && m_ufo.intersects(b.rect)) {
spawnHitBurst(m_ufo.center(), 30);
// UFO 는 보너스만 — 승리/패배 조건과 무관 (승리는 인베이더 전멸 시만).
m_score += 100 + QRandomGenerator::global()->bounded(200);
m_maxPlayerBullets += 2;
m_ufoActive = false;
continue;
}
} else {
if (m_ufoActive && m_ufo.intersects(b.rect)) {
spawnHitBurst(m_ufo.center(), 14);
m_score += 30;
m_ufoActive = false;
continue;
}
if (b.rect.intersects(m_player)) {
m_status = GameStatus::Lost;
continue;
}
}
kept.append(b);
}
m_bullets.swap(kept);
}
void GameEngine::damageBunkersFromAliens()
{
if (m_status != GameStatus::Playing || m_aliens.isEmpty())
return;
// 겹치는 구간에서 위쪽 행부터 매 틱 조금씩 지움 (픽셀 마스크).
constexpr int kMaxRowsPerOverlapPerTick = 4;
for (Bunker &bunker : m_bunkers) {
const int mw = bunker.mask.width();
const int mh = bunker.mask.height();
for (const QRect &alien : m_aliens) {
if (!bunker.rect().intersects(alien))
continue;
QRect inter = bunker.rect().intersected(alien);
QRect local = inter.translated(-bunker.pos);
local = local.intersected(QRect(0, 0, mw, mh));
if (local.isEmpty())
continue;
int rowsDone = 0;
for (int y = local.top(); y <= local.bottom() && rowsDone < kMaxRowsPerOverlapPerTick; ++y) {
if (y < 0 || y >= mh)
continue;
const int x0 = std::clamp(local.left(), 0, mw - 1);
const int x1 = std::clamp(local.right(), 0, mw - 1);
bool anyOpaque = false;
for (int x = x0; x <= x1; ++x) {
if (qAlpha(bunker.mask.pixel(x, y)) > 16) {
anyOpaque = true;
break;
}
}
if (!anyOpaque)
continue;
QPainter p(&bunker.mask);
p.setRenderHint(QPainter::Antialiasing, false);
p.setCompositionMode(QPainter::CompositionMode_Clear);
p.setPen(Qt::NoPen);
p.fillRect(x0, y, x1 - x0 + 1, 1, QColor(0, 0, 0, 0));
++rowsDone;
}
}
}
}
void GameEngine::damageBunkerAt(const QPoint &worldHit)
{
// 충돌 지점 주변 픽셀을 지운다.
const int radius = 7;
for (Bunker &b : m_bunkers) {
if (!b.rect().contains(worldHit))
continue;
const QPoint local = worldHit - b.pos;
QPainter p(&b.mask);
p.setRenderHint(QPainter::Antialiasing, false);
p.setCompositionMode(QPainter::CompositionMode_Clear);
p.setPen(Qt::NoPen);
p.setBrush(Qt::transparent);
p.drawEllipse(local, radius, radius);
break;
}
}
void GameEngine::checkWinLose()
{
if (m_status != GameStatus::Playing)
return;
if (m_aliens.isEmpty()) {
prepareNextWave();
m_status = GameStatus::Intermission;
m_intermissionTicksLeft = kIntermissionTicks;
return;
}
for (const QRect &a : m_aliens) {
if (a.bottom() >= m_player.top() - 2) {
m_status = GameStatus::Lost;
return;
}
}
}
void GameEngine::tick(PlayerAction a)
{
if (m_status == GameStatus::Intermission) {
++m_tick;
applyPlayerIntermission(a);
if (m_playerVx != 0) {
m_player.translate(m_playerVx, 0);
if (m_player.left() < 8)
m_player.moveLeft(8);
if (m_player.right() > kWorldW - 8)
m_player.moveRight(kWorldW - 8);
}
if (m_intermissionTicksLeft > 0)
--m_intermissionTicksLeft;
if (m_intermissionTicksLeft <= 0) {
m_status = GameStatus::Playing;
m_intermissionTicksLeft = 0;
}
return;
}
if (m_status != GameStatus::Playing)
return;
++m_tick;
stepUfo();
applyPlayer(a);
// player move
if (m_playerVx != 0) {
m_player.translate(m_playerVx, 0);
if (m_player.left() < 8)
m_player.moveLeft(8);
if (m_player.right() > kWorldW - 8)
m_player.moveRight(kWorldW - 8);
}
stepBullets();
resolveCollisions();
checkWinLose();
if (m_status != GameStatus::Playing)
return;
if (m_tick % qMax(1, m_alienStepEvery) == 0) {
// 남은 수가 줄면 조금씩 빨라짐.
const int alive = m_aliens.size();
if (alive <= 18)
m_alienStepEvery = 6;
else if (alive <= 36)
m_alienStepEvery = 8;
else
m_alienStepEvery = 10;
stepAliens();
checkWinLose();
if (m_status != GameStatus::Playing)
return;
}
tryAlienShoot();
resolveCollisions();
damageBunkersFromAliens();
checkWinLose();
}
void GameEngine::rebuildCoarseGrid(QByteArray *out) const
{
const int n = kGridRows * kGridCols;
out->resize(n);
out->fill(char(Cell::Empty));
auto toCell = [&](int x, int y) -> QPoint {
// 플레이 영역을 14×11로 투영.
const int playH = (kPlayBottom - kPlayTop);
const int gx = qBound(0, (x * kGridCols) / qMax(1, kWorldW), kGridCols - 1);
const int gy = qBound(0, ((y - kPlayTop) * kGridRows) / qMax(1, playH), kGridRows - 1);
return QPoint(gx, gy);
};
for (const QRect &a : m_aliens) {
const QPoint g = toCell(a.center().x(), a.center().y());
(*out)[g.y() * kGridCols + g.x()] = char(Cell::Alien);
}
{
const QPoint g = toCell(m_player.center().x(), m_player.center().y());
(*out)[g.y() * kGridCols + g.x()] = char(Cell::Player);
}
for (const Bullet &b : m_bullets) {
const QPoint g = toCell(b.rect.center().x(), b.rect.center().y());
(*out)[g.y() * kGridCols + g.x()] = char(b.fromPlayer ? Cell::PlayerBullet : Cell::AlienBullet);
}
if (m_ufoActive) {
const QPoint g = toCell(m_ufo.center().x(), m_ufo.center().y());
(*out)[g.y() * kGridCols + g.x()] = char(Cell::Alien);
}
}
} // namespace Invader