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

예제 소스

인베이더 게임으로 배우는 휴리스틱과 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