Files
spacetris/src/gameplay/coop/CoopAIController.cpp
2025-12-22 21:26:56 +01:00

318 lines
10 KiB
C++

#include "CoopAIController.h"
#include "CoopGame.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
namespace {
static bool canPlacePieceForSide(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
const CoopGame::Piece& p,
CoopGame::PlayerSide side) {
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(p, cx, cy)) {
continue;
}
const int bx = p.x + cx;
const int by = p.y + cy;
// Keep the AI strictly in the correct half.
if (side == CoopGame::PlayerSide::Right) {
if (bx < 10 || bx >= CoopGame::COLS) {
return false;
}
} else {
if (bx < 0 || bx >= 10) {
return false;
}
}
// Above the visible board is allowed.
if (by < 0) {
continue;
}
if (by >= CoopGame::ROWS) {
return false;
}
if (board[by * CoopGame::COLS + bx].occupied) {
return false;
}
}
}
return true;
}
static int dropYFor(const std::array<CoopGame::Cell, CoopGame::COLS * CoopGame::ROWS>& board,
CoopGame::Piece p,
CoopGame::PlayerSide side) {
// Assumes p is currently placeable.
while (true) {
CoopGame::Piece next = p;
next.y += 1;
if (!canPlacePieceForSide(board, next, side)) {
return p.y;
}
p = next;
if (p.y > CoopGame::ROWS) {
return p.y;
}
}
}
static void applyPiece(std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS>& occ,
const CoopGame::Piece& p) {
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(p, cx, cy)) {
continue;
}
const int bx = p.x + cx;
const int by = p.y + cy;
if (by < 0 || by >= CoopGame::ROWS || bx < 0 || bx >= CoopGame::COLS) {
continue;
}
occ[by * CoopGame::COLS + bx] = 1;
}
}
}
struct Eval {
double score = -std::numeric_limits<double>::infinity();
int rot = 0;
int x = 10;
};
static Eval evaluateBestPlacementForSide(const CoopGame& game, CoopGame::PlayerSide side) {
const auto& board = game.boardRef();
std::array<uint8_t, CoopGame::COLS * CoopGame::ROWS> occ{};
for (int i = 0; i < CoopGame::COLS * CoopGame::ROWS; ++i) {
occ[i] = board[i].occupied ? 1 : 0;
}
const CoopGame::Piece cur = game.current(side);
Eval best{};
// Iterate rotations and x positions. IMPORTANT: allow x to go slightly out of bounds
// because our pieces are represented in a 4x4 mask and many rotations have leading
// empty columns. For example, placing a vertical I/J/L into column 0 often requires
// p.x == -1 or p.x == -2 so the filled cells land at bx==0.
// canPlacePieceForSide() enforces the actual half-board bounds.
for (int rot = 0; rot < 4; ++rot) {
int xmin = (side == CoopGame::PlayerSide::Right) ? 6 : -3;
int xmax = (side == CoopGame::PlayerSide::Right) ? 22 : 13;
for (int x = xmin; x <= xmax; ++x) {
CoopGame::Piece p = cur;
p.rot = rot;
p.x = x;
// If this rotation/x is illegal at the current y, try near the top spawn band.
if (!canPlacePieceForSide(board, p, side)) {
p.y = -2;
if (!canPlacePieceForSide(board, p, side)) {
continue;
}
}
p.y = dropYFor(board, p, side);
auto occ2 = occ;
applyPiece(occ2, p);
// Count completed full rows (all 20 cols) after placement.
int fullRows = 0;
for (int y = 0; y < CoopGame::ROWS; ++y) {
bool full = true;
for (int cx = 0; cx < CoopGame::COLS; ++cx) {
if (!occ2[y * CoopGame::COLS + cx]) {
full = false;
break;
}
}
if (full) {
++fullRows;
}
}
// Right-half column heights + holes + bumpiness.
std::array<int, 10> heights{};
int aggregateHeight = 0;
int holes = 0;
for (int c = 0; c < 10; ++c) {
const int bx = (side == CoopGame::PlayerSide::Right) ? (10 + c) : c;
int h = 0;
bool found = false;
for (int y = 0; y < CoopGame::ROWS; ++y) {
if (occ2[y * CoopGame::COLS + bx]) {
h = CoopGame::ROWS - y;
found = true;
// Count holes below the first filled cell.
for (int yy = y + 1; yy < CoopGame::ROWS; ++yy) {
if (!occ2[yy * CoopGame::COLS + bx]) {
++holes;
}
}
break;
}
}
heights[c] = found ? h : 0;
aggregateHeight += heights[c];
}
int bump = 0;
for (int i = 0; i < 9; ++i) {
bump += std::abs(heights[i] - heights[i + 1]);
}
// Reward sync potential: rows where the right half is full (10..19).
int sideHalfFullRows = 0;
for (int y = 0; y < CoopGame::ROWS; ++y) {
bool full = true;
int start = (side == CoopGame::PlayerSide::Right) ? 10 : 0;
int end = (side == CoopGame::PlayerSide::Right) ? 20 : 10;
for (int bx = start; bx < end; ++bx) {
if (!occ2[y * CoopGame::COLS + bx]) {
full = false;
break;
}
}
if (full) {
++sideHalfFullRows;
}
}
// Simple heuristic:
// - Strongly prefer completed full rows
// - Prefer making the right half complete (helps cooperative clears)
// - Penalize holes and excessive height/bumpiness
double s = 0.0;
// Strongly prefer full-line clears across the whole board (rare but best).
s += static_cast<double>(fullRows) * 12000.0;
// Heavily prefer completing the player's half — make this a primary objective.
s += static_cast<double>(sideHalfFullRows) * 6000.0;
// Penalize holes and height less aggressively so completing half-rows is prioritized.
s -= static_cast<double>(holes) * 180.0;
s -= static_cast<double>(aggregateHeight) * 4.0;
s -= static_cast<double>(bump) * 10.0;
// Reduce center bias so edge placements to complete rows are not punished.
double centerTarget = (side == CoopGame::PlayerSide::Right) ? 15.0 : 4.5;
const double centerBias = -std::abs((x + 1.5) - centerTarget) * 1.0;
s += centerBias;
if (s > best.score) {
best.score = s;
best.rot = rot;
best.x = x;
}
}
}
return best;
}
} // namespace
void CoopAIController::reset() {
m_lastPieceSeq = 0;
m_hasPlan = false;
m_targetRot = 0;
m_targetX = 10;
m_moveTimerMs = 0.0;
m_moveDir = 0;
m_rotateTimerMs = 0.0;
}
void CoopAIController::computePlan(const CoopGame& game, CoopGame::PlayerSide side) {
const Eval best = evaluateBestPlacementForSide(game, side);
m_targetRot = best.rot;
m_targetX = best.x;
m_hasPlan = true;
m_moveTimerMs = 0.0;
m_moveDir = 0;
m_rotateTimerMs = 0.0;
}
void CoopAIController::update(CoopGame& game, CoopGame::PlayerSide side, double frameMs) {
const uint64_t seq = game.currentPieceSequence(side);
if (seq != m_lastPieceSeq) {
m_lastPieceSeq = seq;
m_hasPlan = false;
m_moveTimerMs = 0.0;
m_moveDir = 0;
m_rotateTimerMs = 0.0;
}
if (!m_hasPlan) {
computePlan(game, side);
}
const CoopGame::Piece cur = game.current(side);
// Clamp negative deltas (defensive; callers should pass >= 0).
const double dt = std::max(0.0, frameMs);
// Update timers.
if (m_moveTimerMs > 0.0) {
m_moveTimerMs -= dt;
if (m_moveTimerMs < 0.0) m_moveTimerMs = 0.0;
}
if (m_rotateTimerMs > 0.0) {
m_rotateTimerMs -= dt;
if (m_rotateTimerMs < 0.0) m_rotateTimerMs = 0.0;
}
// Rotate toward target first.
const int curRot = ((cur.rot % 4) + 4) % 4;
const int tgtRot = ((m_targetRot % 4) + 4) % 4;
int diff = (tgtRot - curRot + 4) % 4;
if (diff != 0) {
// Human-ish rotation rate limiting.
if (m_rotateTimerMs <= 0.0) {
const int dir = (diff == 3) ? -1 : 1;
game.rotate(side, dir);
m_rotateTimerMs = m_rotateIntervalMs;
}
// While rotating, do not also slide horizontally in the same frame.
m_moveDir = 0;
m_moveTimerMs = 0.0;
return;
}
// Move horizontally toward target.
int desiredDir = 0;
if (cur.x < m_targetX) desiredDir = +1;
else if (cur.x > m_targetX) desiredDir = -1;
if (desiredDir == 0) {
// Aligned: do nothing. Gravity controls fall speed (no AI hard drops).
m_moveDir = 0;
m_moveTimerMs = 0.0;
return;
}
// DAS/ARR-style horizontal movement pacing.
if (m_moveDir != desiredDir) {
// New direction / initial press: move immediately, then wait DAS.
game.move(side, desiredDir);
m_moveDir = desiredDir;
m_moveTimerMs = m_dasMs;
return;
}
// Holding direction: repeat every ARR once DAS has elapsed.
if (m_moveTimerMs <= 0.0) {
game.move(side, desiredDir);
m_moveTimerMs = m_arrMs;
}
}