sync line added in cooperate mode

This commit is contained in:
2025-12-22 17:13:35 +01:00
parent 18463774e9
commit a729dc089e
6 changed files with 217 additions and 32 deletions

View File

@ -51,6 +51,7 @@ set(TETRIS_SOURCES
src/graphics/ui/Font.cpp
src/graphics/ui/HelpOverlay.cpp
src/graphics/renderers/GameRenderer.cpp
src/graphics/renderers/SyncLineRenderer.cpp
src/graphics/renderers/UIRenderer.cpp
src/audio/Audio.cpp
src/gameplay/effects/LineEffect.cpp

View File

@ -188,11 +188,13 @@ void LineEffect::initAudio() {
}
}
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols) {
void LineEffect::startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols, int gapPx, int gapAfterCol) {
if (rows.empty()) return;
clearingRows = rows;
effectGridCols = std::max(1, gridCols);
effectGapPx = std::max(0, gapPx);
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
state = AnimationState::FLASH_WHITE;
timer = 0.0f;
dropProgress = 0.0f;
@ -231,6 +233,9 @@ void LineEffect::createParticles(int row, int gridX, int gridY, int blockSize) {
const float centerY = gridY + row * blockSize + blockSize * 0.5f;
for (int col = 0; col < effectGridCols; ++col) {
float centerX = gridX + col * blockSize + blockSize * 0.5f;
if (effectGapPx > 0 && effectGapAfterCol > 0 && col >= effectGapAfterCol) {
centerX += static_cast<float>(effectGapPx);
}
SDL_Color tint = pickFireColor();
spawnGlowPulse(centerX, centerY, static_cast<float>(blockSize), tint);
spawnShardBurst(centerX, centerY, tint);
@ -338,9 +343,13 @@ void LineEffect::updateGlowPulses(float dt) {
glowPulses.end());
}
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize) {
void LineEffect::render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx, int gapAfterCol) {
if (state == AnimationState::IDLE) return;
// Allow caller to override gap mapping (useful for Coop renderer that inserts a mid-gap).
effectGapPx = std::max(0, gapPx);
effectGapAfterCol = std::clamp(gapAfterCol, 0, effectGridCols);
switch (state) {
case AnimationState::FLASH_WHITE:
renderFlash(gridX, gridY, blockSize);
@ -384,10 +393,11 @@ void LineEffect::renderFlash(int gridX, int gridY, int blockSize) {
for (int row : clearingRows) {
SDL_SetRenderDrawColor(renderer, 255, 255, 255, alpha);
const int gapW = (effectGapPx > 0 && effectGapAfterCol > 0 && effectGapAfterCol < effectGridCols) ? effectGapPx : 0;
SDL_FRect flashRect = {
static_cast<float>(gridX - 4),
static_cast<float>(gridY + row * blockSize - 4),
static_cast<float>(effectGridCols * blockSize + 8),
static_cast<float>(effectGridCols * blockSize + gapW + 8),
static_cast<float>(blockSize + 8)
};
SDL_RenderFillRect(renderer, &flashRect);

View File

@ -69,11 +69,11 @@ public:
void shutdown();
// Start line clear effect for the specified rows
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols = Game::COLS);
void startLineClear(const std::vector<int>& rows, int gridX, int gridY, int blockSize, int gridCols = Game::COLS, int gapPx = 0, int gapAfterCol = 0);
// Update and render the effect
bool update(float deltaTime); // Returns true if effect is complete
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize);
void render(SDL_Renderer* renderer, SDL_Texture* blocksTex, int gridX, int gridY, int blockSize, int gapPx = 0, int gapAfterCol = 0);
float getRowDropOffset(int row) const;
// Audio
@ -121,4 +121,6 @@ private:
float dropProgress = 0.0f;
int dropBlockSize = 0;
int effectGridCols = Game::COLS;
int effectGapPx = 0;
int effectGapAfterCol = 0;
};

View File

@ -1,4 +1,6 @@
#include "GameRenderer.h"
#include "SyncLineRenderer.h"
#include "../../gameplay/core/Game.h"
#include "../../gameplay/coop/CoopGame.h"
#include "../../app/Fireworks.h"
@ -1852,6 +1854,9 @@ void GameRenderer::renderCoopPlayingState(
) {
if (!renderer || !game || !pixelFont) return;
static SyncLineRenderer s_syncLine;
static bool s_lastHadCompletedLines = false;
static Uint32 s_lastCoopTick = SDL_GetTicks();
Uint32 nowTicks = SDL_GetTicks();
float deltaMs = static_cast<float>(nowTicks - s_lastCoopTick);
@ -1860,6 +1865,9 @@ void GameRenderer::renderCoopPlayingState(
deltaMs = 16.0f;
}
const float deltaSeconds = std::clamp(deltaMs / 1000.0f, 0.0f, 0.033f);
s_syncLine.Update(deltaSeconds);
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{};
@ -1889,15 +1897,19 @@ void GameRenderer::renderCoopPlayingState(
SDL_RenderFillRect(renderer, &fr);
};
static constexpr float COOP_GAP_PX = 10.0f;
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 usableGridWidth = std::max(0.0f, availableWidth - COOP_GAP_PX);
const float maxBlockSizeW = usableGridWidth / 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 HALF_W = 10.0f * finalBlockSize;
const float GRID_W = CoopGame::COLS * finalBlockSize + COOP_GAP_PX;
const float GRID_H = CoopGame::ROWS * finalBlockSize;
const float totalContentHeight = NEXT_PANEL_HEIGHT + GRID_H;
@ -1923,7 +1935,7 @@ void GameRenderer::renderCoopPlayingState(
// 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), CoopGame::COLS);
lineEffect->startLineClear(completedLines, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), CoopGame::COLS, static_cast<int>(COOP_GAP_PX), 10);
if (completedLines.size() == 4) {
AppFireworks::spawn(gridX + GRID_W * 0.5f, gridY + GRID_H * 0.5f);
}
@ -1935,28 +1947,39 @@ void GameRenderer::renderCoopPlayingState(
rowDropOffsets[y] = (lineEffect ? lineEffect->getRowDropOffset(y) : 0.0f);
}
// Grid backdrop and border
// Grid backdrop and border (one border around both halves)
drawRectWithOffset(gridX - 3 - contentOffsetX, gridY - 3 - contentOffsetY, GRID_W + 6, GRID_H + 6, {100, 120, 200, 255});
// Background for left+right halves
drawRectWithOffset(gridX - contentOffsetX, gridY - contentOffsetY, GRID_W, GRID_H, {20, 25, 35, 255});
// Gap background (slightly darker so the 10px separation reads clearly)
drawRectWithOffset(gridX + HALF_W - contentOffsetX, gridY - contentOffsetY, COOP_GAP_PX, GRID_H, {12, 14, 18, 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);
// Sync divider line centered in the gap between halves.
const float dividerCenterX = gridX + HALF_W + (COOP_GAP_PX * 0.5f);
s_syncLine.SetRect(SDL_FRect{ dividerCenterX - 2.0f, gridY, 4.0f, GRID_H });
// Grid lines
auto cellX = [&](int col) -> float {
float x = gridX + col * finalBlockSize;
if (col >= 10) {
x += COOP_GAP_PX;
}
return x;
};
// Grid lines (draw per-half so the gap is clean)
SDL_SetRenderDrawColor(renderer, 40, 45, 60, 255);
for (int x = 1; x < CoopGame::COLS; ++x) {
for (int x = 1; x < 10; ++x) {
float lineX = gridX + x * finalBlockSize;
SDL_RenderLine(renderer, lineX, gridY, lineX, gridY + GRID_H);
}
for (int x = 1; x < 10; ++x) {
float lineX = gridX + HALF_W + COOP_GAP_PX + 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);
SDL_RenderLine(renderer, gridX, lineY, gridX + HALF_W, lineY);
SDL_RenderLine(renderer, gridX + HALF_W + COOP_GAP_PX, lineY, gridX + HALF_W + COOP_GAP_PX + HALF_W, lineY);
}
// In-grid 3D starfield + ambient sparkles (match classic feel, per-half)
@ -1970,9 +1993,9 @@ void GameRenderer::renderCoopPlayingState(
static float s_leftSparkleSpawnAcc = 0.0f;
static float s_rightSparkleSpawnAcc = 0.0f;
float halfW = GRID_W * 0.5f;
float halfW = HALF_W;
const float leftGridX = gridX;
const float rightGridX = gridX + halfW;
const float rightGridX = gridX + HALF_W + COOP_GAP_PX;
Uint32 sparkNow = nowTicks;
float sparkDeltaMs = static_cast<float>(sparkNow - s_lastCoopSparkTick);
@ -2185,24 +2208,57 @@ void GameRenderer::renderCoopPlayingState(
// 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();
bool leftReady = false;
bool rightReady = false;
bool synced = false;
for (int y = 0; y < CoopGame::ROWS; ++y) {
const auto& rs = rowStates[y];
float rowY = gridY + y * finalBlockSize;
if (rs.leftFull && rs.rightFull) {
synced = true;
} else {
leftReady = leftReady || (rs.leftFull && !rs.rightFull);
rightReady = rightReady || (rs.rightFull && !rs.leftFull);
}
if (rs.leftFull && rs.rightFull) {
SDL_SetRenderDrawColor(renderer, 140, 210, 255, 45);
SDL_FRect fr{gridX, rowY, GRID_W, finalBlockSize};
SDL_RenderFillRect(renderer, &fr);
SDL_FRect frL{gridX, rowY, HALF_W, finalBlockSize};
SDL_RenderFillRect(renderer, &frL);
SDL_FRect frR{gridX + HALF_W + COOP_GAP_PX, rowY, HALF_W, finalBlockSize};
SDL_RenderFillRect(renderer, &frR);
} 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;
float w = HALF_W;
float x = rs.leftFull ? gridX : (gridX + HALF_W + COOP_GAP_PX);
SDL_FRect fr{x, rowY, w, finalBlockSize};
SDL_RenderFillRect(renderer, &fr);
}
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
// Trigger a brief flash exactly when cooperative lines are actually cleared:
// `completedLines` remains populated during the LineEffect, then becomes empty
// immediately after `CoopGame::clearCompletedLines()` is invoked.
const bool hasCompletedLines = game->hasCompletedLines();
if (s_lastHadCompletedLines && !hasCompletedLines) {
s_syncLine.TriggerClearFlash();
}
s_lastHadCompletedLines = hasCompletedLines;
if (synced) {
s_syncLine.SetState(SyncState::Synced);
} else if (leftReady) {
s_syncLine.SetState(SyncState::LeftReady);
} else if (rightReady) {
s_syncLine.SetState(SyncState::RightReady);
} else {
s_syncLine.SetState(SyncState::Idle);
}
// Hard-drop impact shake (match classic feel)
float impactStrength = 0.0f;
float impactEased = 0.0f;
@ -2243,7 +2299,7 @@ void GameRenderer::renderCoopPlayingState(
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 px = cellX(x);
float py = gridY + y * finalBlockSize + dropOffset;
const int cellIdx = y * CoopGame::COLS + x;
@ -2398,7 +2454,7 @@ void GameRenderer::renderCoopPlayingState(
// cases like vertical I where some blocks are already visible at spawn.
const bool pinToFirstVisibleRow = (livePiece.y + maxCy) < 0;
const float baseX = gridX + static_cast<float>(livePiece.x) * sf.tileSize + offsets.first;
const float baseX = cellX(livePiece.x) + offsets.first;
float baseY = 0.0f;
if (pinToFirstVisibleRow) {
// Keep the piece visible (topmost filled cell at row 0), but also
@ -2439,7 +2495,7 @@ void GameRenderer::renderCoopPlayingState(
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 px = cellX(pxIdx) + offsets.first;
float py = gridY + (float)pyIdx * finalBlockSize + offsets.second;
if (isGhost) {
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
@ -2505,15 +2561,19 @@ void GameRenderer::renderCoopPlayingState(
// Draw line clearing effects above pieces (matches single-player)
if (lineEffect && lineEffect->isActive()) {
lineEffect->render(renderer, blocksTex, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize));
lineEffect->render(renderer, blocksTex, static_cast<int>(gridX), static_cast<int>(gridY), static_cast<int>(finalBlockSize), static_cast<int>(COOP_GAP_PX), 10);
}
// Render the SYNC divider last so it stays visible above effects/blocks.
s_syncLine.Render(renderer);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
// Next panels (two)
const float nextPanelPad = 12.0f;
const float nextPanelW = (GRID_W * 0.5f) - finalBlockSize * 1.5f;
const float nextPanelW = (HALF_W) - finalBlockSize * 1.5f;
const float nextPanelH = NEXT_PANEL_HEIGHT - nextPanelPad * 2.0f;
float nextLeftX = gridX + finalBlockSize;
float nextRightX = gridX + GRID_W - finalBlockSize - nextPanelW;
float nextRightX = gridX + HALF_W + COOP_GAP_PX + (HALF_W - finalBlockSize - nextPanelW);
float nextY = contentStartY + contentOffsetY;
auto drawNextPanel = [&](float panelX, float panelY, const CoopGame::Piece& piece) {

View File

@ -0,0 +1,79 @@
#include "SyncLineRenderer.h"
#include <cmath>
SyncLineRenderer::SyncLineRenderer()
: m_state(SyncState::Idle),
m_flashTimer(0.0f),
m_time(0.0f) {}
void SyncLineRenderer::SetRect(const SDL_FRect& rect) {
m_rect = rect;
}
void SyncLineRenderer::SetState(SyncState state) {
if (state != SyncState::ClearFlash) {
m_state = state;
}
}
void SyncLineRenderer::TriggerClearFlash() {
m_state = SyncState::ClearFlash;
m_flashTimer = FLASH_DURATION;
}
void SyncLineRenderer::Update(float deltaTime) {
m_time += deltaTime;
if (m_state == SyncState::ClearFlash) {
m_flashTimer -= deltaTime;
if (m_flashTimer <= 0.0f) {
m_state = SyncState::Idle;
m_flashTimer = 0.0f;
}
}
}
SDL_Color SyncLineRenderer::GetBaseColor() const {
switch (m_state) {
case SyncState::LeftReady:
case SyncState::RightReady:
return SDL_Color{255, 220, 100, 235};
case SyncState::Synced:
return SDL_Color{100, 255, 120, 240};
case SyncState::ClearFlash:
return SDL_Color{255, 255, 255, 255};
default:
return SDL_Color{80, 180, 255, 235};
}
}
void SyncLineRenderer::Render(SDL_Renderer* renderer) {
if (!renderer) {
return;
}
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_Color color = GetBaseColor();
if (m_state == SyncState::Synced) {
float pulse = 0.5f + 0.5f * std::sinf(m_time * 6.0f);
color.a = static_cast<Uint8>(180.0f + pulse * 75.0f);
}
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
SDL_RenderFillRect(renderer, &m_rect);
if (m_state == SyncState::ClearFlash) {
SDL_FRect glow = m_rect;
glow.x -= 3;
glow.w += 6;
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 180);
SDL_RenderFillRect(renderer, &glow);
}
}

View File

@ -0,0 +1,33 @@
#pragma once
#include <SDL3/SDL.h>
enum class SyncState {
Idle,
LeftReady,
RightReady,
Synced,
ClearFlash
};
class SyncLineRenderer {
public:
SyncLineRenderer();
void SetRect(const SDL_FRect& rect);
void SetState(SyncState state);
void TriggerClearFlash();
void Update(float deltaTime);
void Render(SDL_Renderer* renderer);
private:
SDL_FRect m_rect{};
SyncState m_state;
float m_flashTimer;
float m_time;
static constexpr float FLASH_DURATION = 0.15f;
SDL_Color GetBaseColor() const;
};