basic gameplay for cooperative

This commit is contained in:
2025-12-21 15:33:37 +01:00
parent 5b9eb5f0e3
commit afd7fdf18d
20 changed files with 1534 additions and 263 deletions

View File

@ -1,5 +1,7 @@
#include "GameRenderer.h"
#include "../../gameplay/core/Game.h"
#include "../../gameplay/coop/CoopGame.h"
#include "../../app/Fireworks.h"
#include "../ui/Font.h"
#include "../../gameplay/effects/LineEffect.h"
#include <algorithm>
@ -693,6 +695,11 @@ void GameRenderer::renderPlayingState(
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
auto completedLines = game->getCompletedLines();
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
// Trigger fireworks visually for a 4-line clear (TETRIS)
if (completedLines.size() == 4) {
// spawn near center of grid
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
}
}
// Draw game grid border
@ -1356,6 +1363,24 @@ void GameRenderer::renderPlayingState(
activePiecePixelOffsetY = std::min(activePiecePixelOffsetY, maxAllowed);
}
// Debug: log single-player smoothing/fall values when enabled
if (Settings::instance().isDebugEnabled()) {
float sp_targetX = static_cast<float>(game->current().x);
double sp_gravityMs = game->getGravityMs();
double sp_fallAcc = game->getFallAccumulator();
int sp_soft = game->isSoftDropping() ? 1 : 0;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "SP OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
(unsigned long long)s_activePieceSmooth.sequence,
s_activePieceSmooth.visualX,
sp_targetX,
activePiecePixelOffsetX,
activePiecePixelOffsetY,
sp_gravityMs,
sp_fallAcc,
sp_soft
);
}
// Draw ghost piece (where current piece will land)
if (allowActivePieceRender) {
Game::Piece ghostPiece = game->current();
@ -1806,6 +1831,362 @@ void GameRenderer::renderPlayingState(
// Exit popup logic moved to renderExitPopup
}
void GameRenderer::renderCoopPlayingState(
SDL_Renderer* renderer,
CoopGame* game,
FontAtlas* pixelFont,
LineEffect* lineEffect,
SDL_Texture* blocksTex,
SDL_Texture* statisticsPanelTex,
SDL_Texture* scorePanelTex,
SDL_Texture* nextPanelTex,
SDL_Texture* holdPanelTex,
float logicalW,
float logicalH,
float logicalScale,
float winW,
float winH
) {
if (!renderer || !game || !pixelFont) return;
static Uint32 s_lastCoopTick = SDL_GetTicks();
Uint32 nowTicks = SDL_GetTicks();
float deltaMs = static_cast<float>(nowTicks - s_lastCoopTick);
s_lastCoopTick = nowTicks;
if (deltaMs < 0.0f || deltaMs > 100.0f) {
deltaMs = 16.0f;
}
const bool smoothScrollEnabled = Settings::instance().isSmoothScrollEnabled();
struct SmoothState { bool initialized{false}; uint64_t seq{0}; float visualX{0.0f}; float visualY{0.0f}; };
static SmoothState s_leftSmooth{};
static SmoothState s_rightSmooth{};
struct SpawnFadeState { bool active{false}; uint64_t seq{0}; Uint32 startTick{0}; float durationMs{200.0f}; CoopGame::Piece piece; float targetX{0.0f}; float targetY{0.0f}; float tileSize{0.0f}; };
static SpawnFadeState s_leftSpawnFade{};
static SpawnFadeState s_rightSpawnFade{};
// Layout constants (reuse single-player feel but sized for 20 cols)
const float MIN_MARGIN = 40.0f;
const float TOP_MARGIN = 60.0f;
const float PANEL_WIDTH = 180.0f;
const float PANEL_SPACING = 30.0f;
const float NEXT_PANEL_HEIGHT = 120.0f;
const float BOTTOM_MARGIN = 60.0f;
// Content offset (centered logical viewport inside window)
float contentScale = logicalScale;
float contentW = logicalW * contentScale;
float contentH = logicalH * contentScale;
float contentOffsetX = (winW - contentW) * 0.5f / contentScale;
float contentOffsetY = (winH - contentH) * 0.5f / contentScale;
auto drawRectWithOffset = [&](float x, float y, float w, float h, SDL_Color c) {
SDL_SetRenderDrawColor(renderer, c.r, c.g, c.b, c.a);
SDL_FRect fr{x + contentOffsetX, y + contentOffsetY, w, h};
SDL_RenderFillRect(renderer, &fr);
};
const float availableWidth = logicalW - (MIN_MARGIN * 2) - (PANEL_WIDTH * 2) - (PANEL_SPACING * 2);
const float availableHeight = logicalH - TOP_MARGIN - BOTTOM_MARGIN - NEXT_PANEL_HEIGHT;
const float maxBlockSizeW = availableWidth / CoopGame::COLS;
const float maxBlockSizeH = availableHeight / CoopGame::ROWS;
const float BLOCK_SIZE = std::min(maxBlockSizeW, maxBlockSizeH);
const float finalBlockSize = std::max(16.0f, std::min(BLOCK_SIZE, 36.0f));
const float GRID_W = CoopGame::COLS * finalBlockSize;
const float GRID_H = CoopGame::ROWS * finalBlockSize;
const float totalContentHeight = NEXT_PANEL_HEIGHT + GRID_H;
const float availableVerticalSpace = logicalH - TOP_MARGIN - BOTTOM_MARGIN;
const float verticalCenterOffset = (availableVerticalSpace - totalContentHeight) * 0.5f;
const float contentStartY = TOP_MARGIN + verticalCenterOffset;
const float totalLayoutWidth = PANEL_WIDTH + PANEL_SPACING + GRID_W + PANEL_SPACING + PANEL_WIDTH;
const float layoutStartX = (logicalW - totalLayoutWidth) * 0.5f;
const float statsX = layoutStartX + contentOffsetX;
const float gridX = layoutStartX + PANEL_WIDTH + PANEL_SPACING + contentOffsetX;
const float gridY = contentStartY + NEXT_PANEL_HEIGHT + contentOffsetY;
const float statsY = gridY;
const float statsW = PANEL_WIDTH;
const float statsH = GRID_H;
// Shared score panel (reuse existing art)
SDL_FRect scorePanelBg{ statsX - 20.0f, gridY - 26.0f, statsW + 40.0f, GRID_H + 52.0f };
if (statisticsPanelTex) {
SDL_RenderTexture(renderer, statisticsPanelTex, nullptr, &scorePanelBg);
} else if (scorePanelTex) {
SDL_RenderTexture(renderer, scorePanelTex, nullptr, &scorePanelBg);
} else {
drawRectWithOffset(scorePanelBg.x - contentOffsetX, scorePanelBg.y - contentOffsetY, scorePanelBg.w, scorePanelBg.h, SDL_Color{12,18,32,205});
}
// Handle line clearing effects (defer to LineEffect like single-player)
if (game->hasCompletedLines() && lineEffect && !lineEffect->isActive()) {
auto completedLines = game->getCompletedLines();
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
if (completedLines.size() == 4) {
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
}
}
// Grid backdrop and border
drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255});
drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255});
// Divider line between halves (between columns 9 and 10)
float dividerX = gridX + finalBlockSize * 10.0f;
SDL_SetRenderDrawColor(renderer, 180, 210, 255, 235);
SDL_FRect divider{ dividerX - 2.0f, gridY, 4.0f, GRID_H };
SDL_RenderFillRect(renderer, &divider);
SDL_SetRenderDrawColor(renderer, 40, 80, 150, 140);
SDL_FRect dividerGlow{ dividerX - 4.0f, gridY, 8.0f, GRID_H };
SDL_RenderFillRect(renderer, &dividerGlow);
// Grid lines
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255);
for (int x = 1; x < CoopGame::COLS; ++x) {
float lineX = gridX + x * finalBlockSize;
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
}
for (int y = 1; y < CoopGame::ROWS; ++y) {
float lineY = gridY + y * finalBlockSize;
SDL_RenderLine(renderer, gridX, lineY, gridX + GRID_W, lineY);
}
// Half-row feedback: lightly tint rows where one side is filled, brighter where both are pending clear
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
const auto& rowStates = game->rowHalfStates();
for (int y = 0; y < CoopGame::ROWS; ++y) {
const auto& rs = rowStates[y];
float rowY = gridY + y * finalBlockSize;
if (rs.leftFull && rs.rightFull) {
SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45);
SDL_FRect fr{gridX, rowY, GRID_W, finalBlockSize};
SDL_RenderFillRect(renderer, &fr);
} else if (rs.leftFull ^ rs.rightFull) {
SDL_SetRenderDrawColor(renderer, 90, 140, 220, 35);
float w = GRID_W * 0.5f;
float x = rs.leftFull ? gridX : gridX + w;
SDL_FRect fr{x, rowY, w, finalBlockSize};
SDL_RenderFillRect(renderer, &fr);
}
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
// Draw settled blocks
const auto& board = game->boardRef();
for (int y = 0; y < CoopGame::ROWS; ++y) {
for (int x = 0; x < CoopGame::COLS; ++x) {
const auto& cell = board[y * CoopGame::COLS + x];
if (!cell.occupied || cell.value <= 0) continue;
float px = gridX + x * finalBlockSize;
float py = gridY + y * finalBlockSize;
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, cell.value - 1);
}
}
// Active pieces (per-side smoothing)
auto computeOffsets = [&](CoopGame::PlayerSide side, SmoothState& ss) {
float offsetX = 0.0f;
float offsetY = 0.0f;
if (smoothScrollEnabled) {
const uint64_t seq = game->currentPieceSequence(side);
const float targetX = static_cast<float>(game->current(side).x);
if (!ss.initialized || ss.seq != seq) {
ss.initialized = true;
ss.seq = seq;
ss.visualX = targetX;
// Trigger a short spawn fade so the newly spawned piece visually
// fades into the first visible row (like classic mode).
SpawnFadeState &sf = (side == CoopGame::PlayerSide::Left) ? s_leftSpawnFade : s_rightSpawnFade;
sf.active = true;
sf.startTick = nowTicks;
sf.durationMs = 200.0f;
sf.seq = seq;
sf.piece = game->current(side);
sf.tileSize = finalBlockSize;
// Target to first visible row (row 0)
sf.targetX = gridX + static_cast<float>(sf.piece.x) * finalBlockSize;
sf.targetY = gridY + 0.0f * finalBlockSize;
} else {
// Reuse exact horizontal smoothing from single-player
constexpr float HORIZONTAL_SMOOTH_MS = 55.0f;
const float lerpFactor = std::clamp(deltaMs / HORIZONTAL_SMOOTH_MS, 0.0f, 1.0f);
ss.visualX = std::lerp(ss.visualX, targetX, lerpFactor);
}
offsetX = (ss.visualX - targetX) * finalBlockSize;
// Reuse exact single-player fall offset computation (per-side getters)
double gravityMs = game->getGravityMs();
if (gravityMs > 0.0) {
double effectiveMs = game->isSoftDropping(side) ? std::max(5.0, gravityMs / 5.0) : gravityMs;
double accumulator = std::clamp(game->getFallAccumulator(side), 0.0, effectiveMs);
float progress = static_cast<float>(accumulator / effectiveMs);
progress = std::clamp(progress, 0.0f, 1.0f);
offsetY = progress * finalBlockSize;
// Clamp vertical offset to avoid overlapping settled blocks (same logic as single-player)
const auto& boardRef = game->boardRef();
const CoopGame::Piece& piece = game->current(side);
float maxAllowed = finalBlockSize;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
int gx = piece.x + cx;
int gy = piece.y + cy;
if (gx < 0 || gx >= CoopGame::COLS) continue;
int testY = gy + 1;
int emptyRows = 0;
if (testY < 0) {
emptyRows -= testY;
testY = 0;
}
while (testY >= 0 && testY < CoopGame::ROWS) {
if (boardRef[testY * CoopGame::COLS + gx].occupied) break;
++emptyRows;
++testY;
}
float cellLimit = (emptyRows > 0) ? finalBlockSize : 0.0f;
maxAllowed = std::min(maxAllowed, cellLimit);
}
}
offsetY = std::min(offsetY, maxAllowed);
}
} else {
ss.initialized = true;
ss.seq = game->currentPieceSequence(side);
ss.visualX = static_cast<float>(game->current(side).x);
}
if (Settings::instance().isDebugEnabled()) {
float dbg_targetX = static_cast<float>(game->current(side).x);
double gMsDbg = game->getGravityMs();
double accDbg = game->getFallAccumulator(side);
int softDbg = game->isSoftDropping(side) ? 1 : 0;
SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "COOP %s OFFSETS: seq=%llu visX=%.3f targX=%.3f offX=%.2f offY=%.2f gravMs=%.2f fallAcc=%.2f soft=%d",
(side == CoopGame::PlayerSide::Left) ? "L" : "R",
(unsigned long long)ss.seq,
ss.visualX,
dbg_targetX,
offsetX,
offsetY,
gMsDbg,
accDbg,
softDbg
);
}
return std::pair<float, float>{ offsetX, offsetY };
};
// Draw any active spawn fades (alpha ramp into first row). Draw before
// the regular active pieces; while the spawn fade is active the piece's
// real position is above the grid and will not be drawn by drawPiece.
auto drawSpawnFadeIfActive = [&](SpawnFadeState &sf) {
if (!sf.active) return;
Uint32 now = SDL_GetTicks();
float elapsed = static_cast<float>(now - sf.startTick);
float t = sf.durationMs <= 0.0f ? 1.0f : std::clamp(elapsed / sf.durationMs, 0.0f, 1.0f);
Uint8 alpha = static_cast<Uint8>(std::lround(255.0f * t));
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, alpha);
// Draw piece at target (first row)
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(sf.piece, cx, cy)) continue;
float px = sf.targetX + static_cast<float>(cx) * sf.tileSize;
float py = sf.targetY + static_cast<float>(cy) * sf.tileSize;
drawBlockTexturePublic(renderer, blocksTex, px, py, sf.tileSize, sf.piece.type);
}
}
if (blocksTex) SDL_SetTextureAlphaMod(blocksTex, 255);
if (t >= 1.0f) sf.active = false;
};
auto drawPiece = [&](const CoopGame::Piece& p, CoopGame::PlayerSide side, const std::pair<float,float>& offsets) {
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(p, cx, cy)) continue;
int pxIdx = p.x + cx;
int pyIdx = p.y + cy;
if (pyIdx < 0) continue; // don't draw parts above the visible grid
float px = gridX + (float)pxIdx * finalBlockSize + offsets.first;
float py = gridY + (float)pyIdx * finalBlockSize + offsets.second;
drawBlockTexturePublic(renderer, blocksTex, px, py, finalBlockSize, p.type);
}
}
};
const auto leftOffsets = computeOffsets(CoopGame::PlayerSide::Left, s_leftSmooth);
const auto rightOffsets = computeOffsets(CoopGame::PlayerSide::Right, s_rightSmooth);
// Draw transient spawn fades (if active) into the first visible row
drawSpawnFadeIfActive(s_leftSpawnFade);
drawSpawnFadeIfActive(s_rightSpawnFade);
// If a spawn fade is active for a side and matches the current piece
// sequence, only draw the fade visual and skip the regular piece draw
// to avoid a double-draw that appears as a jump when falling starts.
if (!(s_leftSpawnFade.active && s_leftSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Left))) {
drawPiece(game->current(CoopGame::PlayerSide::Left), CoopGame::PlayerSide::Left, leftOffsets);
}
if (!(s_rightSpawnFade.active && s_rightSpawnFade.seq == game->currentPieceSequence(CoopGame::PlayerSide::Right))) {
drawPiece(game->current(CoopGame::PlayerSide::Right), CoopGame::PlayerSide::Right, rightOffsets);
}
// Next panels (two)
const float nextPanelPad = 12.0f;
const float nextPanelW = (GRID_W * 0.5f) - finalBlockSize * 1.5f;
const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f;
float nextLeftX = gridX + finalBlockSize;
float nextRightX = gridX + GRID_W - finalBlockSize - nextPanelW;
float nextY = contentStartY + contentOffsetY;
auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) {
SDL_FRect panel{ panelX, panelY, nextPanelW, nextPanelH };
if (nextPanelTex) {
SDL_RenderTexture(renderer, nextPanelTex, nullptr, &panel);
} else {
drawRectWithOffset(panel.x - contentOffsetX, panel.y - contentOffsetY, panel.w, panel.h, SDL_Color{18,22,30,200});
}
// Center piece inside panel
int minCx = 4, minCy = 4, maxCx = -1, maxCy = -1;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
minCx = std::min(minCx, cx);
minCy = std::min(minCy, cy);
maxCx = std::max(maxCx, cx);
maxCy = std::max(maxCy, cy);
}
}
if (maxCx >= minCx && maxCy >= minCy) {
float tile = finalBlockSize * 0.8f;
float pieceW = (maxCx - minCx + 1) * tile;
float pieceH = (maxCy - minCy + 1) * tile;
float startX = panel.x + (panel.w - pieceW) * 0.5f - minCx * tile;
float startY = panel.y + (panel.h - pieceH) * 0.5f - minCy * tile;
for (int cy = 0; cy < 4; ++cy) {
for (int cx = 0; cx < 4; ++cx) {
if (!CoopGame::cellFilled(piece, cx, cy)) continue;
float px = startX + cx * tile;
float py = startY + cy * tile;
drawBlockTexturePublic(renderer, blocksTex, px, py, tile, piece.type);
}
}
}
};
drawNextPanel(nextLeftX, nextY, game->next(CoopGame::PlayerSide::Left));
drawNextPanel(nextRightX, nextY, game->next(CoopGame::PlayerSide::Right));
// Simple shared score text
char buf[128];
std::snprintf(buf, sizeof(buf), "SCORE %d LINES %d LEVEL %d", game->score(), game->lines(), game->level());
pixelFont->draw(renderer, gridX + GRID_W * 0.5f - 140.0f, gridY + GRID_H + 24.0f, buf, 1.2f, SDL_Color{220, 230, 255, 255});
}
void GameRenderer::renderExitPopup(
SDL_Renderer* renderer,
FontAtlas* pixelFont,