MXVK Vulkan Framework 0.24.0
C++20 Vulkan rendering framework for practical 2D and 3D application development with SDL3.
Loading...
Searching...
No Matches
tetris.cpp
Go to the documentation of this file.
1#include <SDL3/SDL.h>
2
3#include <algorithm>
4#include <array>
5#include <chrono>
6#include <cmath>
7#include <cstdint>
8#include <cstdlib>
9#include <filesystem>
10#include <format>
11#include <fstream>
12#include <initializer_list>
13#include <iostream>
14#include <memory>
15#include <random>
16#include <sstream>
17#include <string>
18#include <vector>
19
20#include <glm/ext/matrix_clip_space.hpp>
21#include <glm/ext/matrix_transform.hpp>
22#include <glm/glm.hpp>
23
24#include "mxnetwork/socket.hpp"
25#include "mxvk/argz.hpp"
26#include "mxvk/mxvk.hpp"
29#if defined(MXVK_WITH_MIXER) || defined(WITH_MIXER)
30#include "mxvk/mxvk_sound.hpp"
31#endif
32
33#ifndef tetris_ASSET_DIR
34#define tetris_ASSET_DIR "."
35#endif
36
37namespace {
38
39 constexpr int boardWidth = 10;
40 constexpr int boardHeight = 20;
41 constexpr float cubeScale = 0.085f;
42 constexpr float cubeSpacing = cubeScale * 1.0f;
43 constexpr std::size_t nameMaxLength = 10;
44 constexpr char nameCharacters[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
45 constexpr std::size_t nameCharacterCount = sizeof(nameCharacters) - 1;
46 constexpr std::string_view multiplayerPort = "37373";
47
48 struct Cell {
49 int color = -1;
50 };
51
52 struct Block {
53 int x = 0;
54 int y = 0;
55 };
56
58 std::array<Block, 4> blocks;
59 int color = 0;
60 };
61
62 struct ActivePiece {
63 std::array<Block, 4> blocks;
64 int x = 0;
65 int y = 0;
66 int color = 0;
67 };
68
70 std::array<Block, 4> blocks{};
71 int color = 0;
72 };
73
74 struct BlockModel {
75 std::unique_ptr<mxvk::VKAbstractModel> model;
76 int color = 0;
77 };
78
79 struct LockedBlock {
81 int x = 0;
82 int y = 0;
83 };
84
86 std::string name;
87 int score = 0;
88 };
89
91 std::array<std::array<int, boardWidth>, boardHeight> cells{};
92 std::array<Block, 4> activeBlocks{};
93 int activeX = 0;
94 int activeY = 0;
95 int activeColor = 7;
96 int score = 0;
97 int lines = 0;
98 int level = 1;
99 bool gameOver = false;
100 bool hasState = false;
101
103 for (auto &row : cells) {
104 row.fill(-1);
105 }
106 }
107 };
108
116
118 public:
119 explicit HighScores(std::filesystem::path filePath)
120 : filePath(std::move(filePath)) {
121 load();
122 }
123
124 void addScore(std::string name, int score) {
125 normalizeName(name);
126 scoreEntries.push_back({std::move(name), score});
127 sortAndTrim();
128 write();
129 }
130
131 [[nodiscard]] bool qualifies(int score) const {
132 if (scoreEntries.size() < MAX_ENTRIES) {
133 return true;
134 }
135 return score > scoreEntries.back().score;
136 }
137
138 [[nodiscard]] const std::vector<HighScoreEntry> &entries() const {
139 return scoreEntries;
140 }
141
142 [[nodiscard]] int bestScore() const {
143 return scoreEntries.empty() ? 0 : scoreEntries.front().score;
144 }
145
146 void write() const {
147 std::ofstream out(filePath, std::ios::trunc);
148 if (!out.is_open()) {
149 return;
150 }
151
152 for (const HighScoreEntry &entry : scoreEntries) {
153 out << entry.name << ':' << entry.score << '\n';
154 }
155 }
156
157 private:
158 static constexpr std::size_t MAX_ENTRIES = 10;
159 static constexpr std::size_t MAX_NAME_BYTES = 16;
160
161 std::filesystem::path filePath;
162 std::vector<HighScoreEntry> scoreEntries{};
163
164 static void normalizeName(std::string &name) {
165 if (name.empty()) {
166 name = "Anonymous";
167 }
168
169 for (char &ch : name) {
170 if (ch == '\n' || ch == '\r' || ch == ':') {
171 ch = '_';
172 }
173 }
174
175 if (name.size() > MAX_NAME_BYTES) {
176 name.resize(MAX_NAME_BYTES);
177 }
178 }
179
180 void sortAndTrim() {
181 std::sort(scoreEntries.begin(), scoreEntries.end(), [](const HighScoreEntry &a, const HighScoreEntry &b) {
182 if (a.score != b.score) {
183 return a.score > b.score;
184 }
185 return a.name < b.name;
186 });
187
188 if (scoreEntries.size() > MAX_ENTRIES) {
189 scoreEntries.resize(MAX_ENTRIES);
190 }
191 }
192
193 void load() {
194 scoreEntries.clear();
195
196 std::ifstream in(filePath);
197 if (!in.is_open()) {
198 return;
199 }
200
201 std::string line;
202 while (std::getline(in, line)) {
203 const std::size_t separator = line.find(':');
204 if (separator == std::string::npos) {
205 continue;
206 }
207
208 HighScoreEntry entry{};
209 entry.name = line.substr(0, separator);
210 entry.score = static_cast<int>(std::strtol(line.substr(separator + 1).c_str(), nullptr, 10));
211 normalizeName(entry.name);
212 scoreEntries.push_back(std::move(entry));
213 }
214
215 sortAndTrim();
216 }
217 };
218
219 [[nodiscard]] std::filesystem::path resolveScoreFilePath() {
220 if (const char *basePath = SDL_GetBasePath(); basePath != nullptr && basePath[0] != '\0') {
221 return std::filesystem::path(basePath) / "scores.dat";
222 }
223
224 std::error_code ec;
225 const std::filesystem::path cwd = std::filesystem::current_path(ec);
226 if (!ec) {
227 return cwd / "scores.dat";
228 }
229
230 return std::filesystem::path("scores.dat");
231 }
232
233 const std::array<PieceDefinition, 7> pieceDefinitions{{
234 {{{{-1, 0}, {0, 0}, {1, 0}, {2, 0}}}, 0},
235 {{{{-1, 0}, {0, 0}, {1, 0}, {-1, 1}}}, 1},
236 {{{{-1, 0}, {0, 0}, {1, 0}, {1, 1}}}, 2},
237 {{{{0, 0}, {1, 0}, {0, 1}, {1, 1}}}, 3},
238 {{{{-1, 0}, {0, 0}, {0, 1}, {1, 1}}}, 4},
239 {{{{-1, 0}, {0, 0}, {1, 0}, {0, 1}}}, 5},
240 {{{{-1, 1}, {0, 1}, {0, 0}, {1, 0}}}, 6},
241 }};
242
243 const std::array<std::string, 8> textureManifests{{
244 "manifest_cyan.txt",
245 "manifest_blue.txt",
246 "manifest_orange.txt",
247 "manifest_yellow.txt",
248 "manifest_green.txt",
249 "manifest_purple.txt",
250 "manifest_red.txt",
251 "manifest_gray.txt",
252 }};
253
254 const std::array<std::string, 8> blockTextureFiles{{
255 "block_ltblue.png",
256 "block_dblue.png",
257 "block_orange.png",
258 "block_yellow.png",
259 "block_green.png",
260 "block_purple.png",
261 "block_red.png",
262 "block_gray.png",
263 }};
264
265 const std::array<glm::vec3, 8> colorTints{{
266 {0.65f, 0.95f, 1.0f},
267 {0.45f, 0.56f, 1.0f},
268 {1.0f, 0.63f, 0.25f},
269 {1.0f, 0.92f, 0.35f},
270 {0.48f, 1.0f, 0.45f},
271 {0.82f, 0.48f, 1.0f},
272 {1.0f, 0.42f, 0.42f},
273 {0.55f, 0.58f, 0.62f},
274 }};
275
276 [[nodiscard]] std::array<Block, 4> rotatedBlocks(const ActivePiece &piece) {
277 std::array<Block, 4> rotated = piece.blocks;
278 for (Block &block : rotated) {
279 const int oldX = block.x;
280 block.x = -block.y;
281 block.y = oldX;
282 }
283 return rotated;
284 }
285
286 enum class AppScreen {
287 Intro,
288 Menu,
289 Game,
290 GameOver,
293 Credits,
294 };
295
296 class TetrisWindow final : public mxvk::VK_Window {
297 public:
298 TetrisWindow(const std::string &path, int width, int height, bool fullscreen, bool enable_vsync)
299 : mxvk::VK_Window("-[ MXVK 3D Tetris ]-", width, height, fullscreen, MXVK_VALIDATION, enable_vsync),
300 assetRoot((path.empty() || path == ".") ? std::string(tetris_ASSET_DIR) : path),
301 dataRoot(assetRoot + "/data"),
302 shaderRoot(dataRoot),
303 highScores(resolveScoreFilePath()) {
304 std::random_device rd;
305 rng.seed(rd());
306 setFont(dataRoot + "/font.ttf", 22);
307 setClearColor(0.0f, 0.0f, 0.0f, 1.0f);
308 tryOpenFirstGamepad();
309#if defined(MXVK_WITH_MIXER) || defined(WITH_MIXER)
310 music = std::make_unique<mxvk::VK_Mixer>();
311 musicTrack = music->loadMusic(dataRoot + "/music.ogg");
312 ensureMusicPlaying();
313#endif
314 background = createSprite(dataRoot + "/psychedelic_background.png");
315 backgroundTransitionSprite = createSprite(
316 dataRoot + "/psychedelic_background.png",
317 shaderRoot + "/sprite.vert.spv",
318 shaderRoot + "/tetris_background_transition.frag.spv");
319 menuBackgroundSprite = createSprite(
320 dataRoot + "/start_screen.png",
321 shaderRoot + "/sprite.vert.spv",
322 shaderRoot + "/tetris_screen_fade.frag.spv");
323 highScoresBackgroundSprite = createSprite(
324 dataRoot + "/high_scores_screen.png",
325 shaderRoot + "/sprite.vert.spv",
326 shaderRoot + "/tetris_screen_fade.frag.spv");
327 creditsBackgroundSprite = createSprite(
328 dataRoot + "/credits_screen.png",
329 shaderRoot + "/sprite.vert.spv",
330 shaderRoot + "/tetris_screen_fade.frag.spv");
331 multiplayerBackgroundSprite = createSprite(
332 dataRoot + "/multiplayer_screen.png",
333 shaderRoot + "/sprite.vert.spv",
334 shaderRoot + "/tetris_screen_fade.frag.spv");
335 previewBorderSprite = createSprite(1, 1);
336 const uint32_t whitePixel = 0xFFFFFFFFu;
337 previewBorderSprite->updateTexture(&whitePixel, 1, 1);
338 for (std::size_t i = 0; i < blockPreviewSprites.size(); ++i) {
339 blockPreviewSprites[i] = createSprite(dataRoot + "/" + blockTextureFiles[i]);
340 }
341 gameOverSprite = createSprite(dataRoot + "/gameover.png");
342 introSprite = createSprite(dataRoot + "/intro.png",
343 shaderRoot + "/tetris_intro.vert.spv",
344 shaderRoot + "/tetris_intro.frag.spv");
345 introStart = std::chrono::steady_clock::now();
346 initModels();
347 initCreditsModel();
348 resetGame();
349 }
350
351 ~TetrisWindow() override {
352 if (device != VK_NULL_HANDLE) {
353 vkDeviceWaitIdle(device);
354 }
355#if defined(MXVK_WITH_MIXER) || defined(WITH_MIXER)
356 if (music) {
357 music->stopMusic();
358 }
359#endif
360 stopNameEntry();
361 closeMultiplayerSession();
362 closeGamepad();
363 cleanupModels();
364 }
365
366 void onSwapchainRecreated() override {
367 forEachModel([this](mxvk::VKAbstractModel &model) {
368 model.resize(this);
369 });
370 }
371
372 void event(SDL_Event &e) override {
373 if (e.type == SDL_EVENT_QUIT) {
374 exit();
375 return;
376 }
377
378 if (e.type == SDL_EVENT_KEY_DOWN) {
379 if (e.key.repeat) {
380 return;
381 }
382 if (screen == AppScreen::GameOver && enteringName) {
383 handleNameEntryKey(e.key.key);
384 return;
385 }
386 }
387
388 if (e.type == SDL_EVENT_GAMEPAD_ADDED) {
389 if (!openGamepad(e.gdevice.which)) {
390 tryOpenFirstGamepad();
391 }
392 return;
393 }
394
395 if (e.type == SDL_EVENT_GAMEPAD_REMOVED) {
396 if (gamepad != nullptr && e.gdevice.which == gamepadId) {
397 closeGamepad();
398 tryOpenFirstGamepad();
399 }
400 return;
401 }
402
403 if (e.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) {
404 if (screen == AppScreen::GameOver && enteringName) {
405 handleNameEntryButton(e.gbutton.button);
406 return;
407 }
408 handleGamepadButtonDown(e.gbutton.button);
409 }
410 }
411
412 void proc() override {
413 ensureMusicPlaying();
414 tryOpenFirstGamepad();
415 updateIntroState();
416 updateScreenTransition();
417 updateMultiplayerNetwork();
418 updateInput();
419 if (screen == AppScreen::Game) {
420 updateGame();
421 }
422 drawHud();
423 }
424
425 void onRecordCustomRendering(VkCommandBuffer cmd, uint32_t imageIndex) override {
426 const VkExtent2D extent = getSwapchainExtent();
427 drawScreenBackdrop(cmd, extent);
428
429 if (screen == AppScreen::Game || screen == AppScreen::GameOver) {
430 const float aspect = (extent.height > 0U) ? static_cast<float>(extent.width) / static_cast<float>(extent.height) : 1.0f;
431 glm::mat4 view = glm::lookAt(glm::vec3(0.0f, 0.2f, cameraDistance), glm::vec3(0.0f, 0.1f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
432 view = glm::rotate(view, glm::radians(gridPitch), glm::vec3(1.0f, 0.0f, 0.0f));
433 view = glm::rotate(view, glm::radians(gridYaw), glm::vec3(0.0f, 1.0f, 0.0f));
434 view = glm::rotate(view, glm::radians(gridRoll), glm::vec3(0.0f, 0.0f, 1.0f));
435
436 glm::mat4 proj = glm::perspective(glm::radians(46.0f), aspect, 0.1f, 100.0f);
437 proj[1][1] *= -1.0f;
438 const bool blinkVisible = !lineClearActive || isLineClearVisible();
439
440 for (LockedBlock &locked : lockedBlocks) {
441 if (blinkVisible || !isClearingRow(locked.y)) {
442 drawBlock(cmd, imageIndex, locked.block, locked.x, locked.y, locked.block.color, view, proj);
443 }
444 }
445
446 if (!lineClearActive) {
447 for (size_t i = 0; i < activeModels.size(); ++i) {
448 const int x = active.x + active.blocks[i].x;
449 const int y = active.y + active.blocks[i].y;
450 drawBlock(cmd, imageIndex, activeModels[i], x, y, active.color, view, proj);
451 }
452 }
453
454 drawFrame(cmd, imageIndex, view, proj);
455 drawNextPiecePreview(cmd, imageIndex, extent);
456 drawOpponentGrid(cmd, extent);
457 }
458 if (screen == AppScreen::Credits) {
459 drawCreditsModel(cmd, imageIndex, extent);
460 }
461 drawGameScreenTransitionOverlay(cmd, extent);
462 drawIntroOverlay(cmd, extent);
463 drawGameOverOverlay(cmd, extent);
464 }
465
466 private:
467 std::string assetRoot;
468 std::string dataRoot;
469 std::string shaderRoot;
470 std::array<std::array<Cell, boardWidth>, boardHeight> board{};
471 ActivePiece active{};
472 std::mt19937 rng{};
473 std::vector<LockedBlock> lockedBlocks{};
474 std::array<BlockModel, 4> activeModels{};
475 std::vector<BlockModel> frameModels{};
476 mxvk::VK_Sprite *background = nullptr;
477 mxvk::VK_Sprite *backgroundTransitionSprite = nullptr;
478 mxvk::VK_Sprite *menuBackgroundSprite = nullptr;
479 mxvk::VK_Sprite *highScoresBackgroundSprite = nullptr;
480 mxvk::VK_Sprite *creditsBackgroundSprite = nullptr;
481 mxvk::VK_Sprite *multiplayerBackgroundSprite = nullptr;
482 mxvk::VK_Sprite *previewBorderSprite = nullptr;
483 std::array<mxvk::VK_Sprite *, 8> blockPreviewSprites{};
484 mxvk::VK_Sprite *introSprite = nullptr;
485 mxvk::VK_Sprite *gameOverSprite = nullptr;
486 std::unique_ptr<mxvk::VKAbstractModel> creditsTuxModel{};
487 HighScores highScores;
488#if defined(MXVK_WITH_MIXER) || defined(WITH_MIXER)
489 std::unique_ptr<mxvk::VK_Mixer> music{};
490 int musicTrack = -1;
491#endif
492 SDL_Gamepad *gamepad = nullptr;
493 SDL_JoystickID gamepadId = 0;
494 std::chrono::steady_clock::time_point introStart{std::chrono::steady_clock::now()};
495 std::chrono::steady_clock::time_point lastFall{std::chrono::steady_clock::now()};
496 std::chrono::steady_clock::time_point lastInputUpdate{std::chrono::steady_clock::now()};
497 std::chrono::steady_clock::time_point lineClearStart{std::chrono::steady_clock::now()};
498 std::chrono::steady_clock::time_point backgroundTransitionStart{std::chrono::steady_clock::now()};
499 std::chrono::steady_clock::time_point screenTransitionStart{std::chrono::steady_clock::now()};
500 std::chrono::steady_clock::time_point gameOverTransitionStart{std::chrono::steady_clock::now()};
501 bool introActive = true;
502 float gridYaw = 0.0f;
503 float gridPitch = 0.0f;
504 float gridRoll = 0.0f;
505 float cameraDistance = 2.45f;
506 int score = 0;
507 int linesCleared = 0;
508 int level = 1;
509 int cursorPos = 0;
510 float fallSeconds = 0.65f;
511 float moveRepeatTimer = 0.0f;
512 float softDropRepeatTimer = 0.0f;
513 float gamepadMoveRepeatTimer = 0.0f;
514 float gamepadSoftDropRepeatTimer = 0.0f;
515 bool gameOver = false;
516 bool enteringName = false;
517 bool highScoresAfterSave = false;
518 std::size_t nameCharIndex = 0;
519 std::string playerName{};
520 bool hardDropHeld = false;
521 bool rotateHeld = false;
522 bool resetHeld = false;
523 bool enterHeld = false;
524 bool escapeHeld = false;
525 bool backspaceHeld = false;
526 bool menuUpHeld = false;
527 bool menuDownHeld = false;
528 bool menuEnterHeld = false;
529 bool introSkipHeld = false;
530 bool introFadeStarted = false;
531 bool screenTransitionActive = false;
532 bool gameOverTransitionActive = false;
533 bool lineClearActive = false;
534 bool backgroundTransitionActive = false;
535 std::array<bool, boardHeight> clearingRows{};
536 PieceQueueEntry nextPiece{};
538 AppScreen transitionFromScreen = AppScreen::Intro;
539 mxnetwork::MXNetworkInit networkInit{};
540 mxnetwork::Socket listenSocket{};
541 mxnetwork::Socket peerSocket{};
542 MultiplayerSnapshot opponentSnapshot{};
543 MultiplayerMode multiplayerMode = MultiplayerMode::Idle;
544 std::string multiplayerHost{"127.0.0.1"};
545 std::string multiplayerStatus{"Enter host IP, then press J. Press H to host."};
546 std::string multiplayerResult{};
547 std::string networkReceiveBuffer{};
548 std::chrono::steady_clock::time_point lastNetworkSend{std::chrono::steady_clock::now()};
549 std::uint32_t networkSequence = 0;
550 int multiplayerCursor = 0;
551 bool multiplayerActive = false;
552 bool multiplayerHostSide = false;
553 bool hostHeld = false;
554 bool joinHeld = false;
555 static constexpr float introHoldSeconds = 5.0f;
556 static constexpr float introFadeSeconds = 1.0f;
557 static constexpr float screenTransitionSeconds = 0.36f;
558 static constexpr float gameOverTransitionSeconds = 0.5f;
559 static constexpr float backgroundTransitionSeconds = 1.25f;
560 static constexpr Sint16 gamepadDeadzone = 10000;
561 static constexpr float gamepadMoveInitialDelaySeconds = 0.22f;
562 static constexpr float gamepadMoveRepeatSeconds = 0.12f;
563 static constexpr float gamepadSoftDropInitialDelaySeconds = 0.18f;
564 static constexpr float gamepadSoftDropRepeatSeconds = 0.08f;
565 static constexpr float gamepadStickRotateSpeed = 120.0f;
566 static constexpr float gamepadStickPitchSpeed = 100.0f;
567 static constexpr float gamepadStickScale = 1.0f / 32768.0f;
568 int gamepadMoveDirection = 0;
569 float gamepadMoveHeldSeconds = 0.0f;
570 bool gamepadSoftDropHeld = false;
571
572 void initModels() {
573 frameModels.reserve(boardWidth + (boardHeight * 2) + 2);
574 for (int i = 0; i < boardWidth + (boardHeight * 2) + 2; ++i) {
575 frameModels.push_back(loadBlockModel(7));
576 }
577 }
578
579 [[nodiscard]] BlockModel loadBlockModel(int color) {
580 BlockModel block{};
581 block.color = color;
582 block.model = std::make_unique<mxvk::VKAbstractModel>();
583 block.model->load(this, dataRoot + "/cube.mxmod.z", dataRoot + "/" + textureManifests[color], dataRoot, 1.0f);
584 block.model->setShaders(this,
585 shaderRoot + "/tetris_piece.vert.spv",
586 shaderRoot + "/tetris_piece.frag.spv");
587 return block;
588 }
589
590 void cleanupModels() {
591 forEachModel([this](mxvk::VKAbstractModel &model) {
592 model.cleanup(this);
593 });
594 lockedBlocks.clear();
595 }
596
597 template <typename Fn>
598 void forEachModel(Fn fn) {
599 for (LockedBlock &locked : lockedBlocks) {
600 if (locked.block.model) {
601 fn(*locked.block.model);
602 }
603 }
604 for (BlockModel &block : activeModels) {
605 if (block.model) {
606 fn(*block.model);
607 }
608 }
609 for (BlockModel &block : frameModels) {
610 if (block.model) {
611 fn(*block.model);
612 }
613 }
614 if (creditsTuxModel) {
615 fn(*creditsTuxModel);
616 }
617 }
618
619 void initCreditsModel() {
620 creditsTuxModel = std::make_unique<mxvk::VKAbstractModel>();
621 creditsTuxModel->load(this,
622 dataRoot + "/tux/tux.obj",
623 dataRoot + "/tux/tux.mtl",
624 dataRoot + "/tux",
625 0.35f);
626 creditsTuxModel->setShaders(this,
627 shaderRoot + "/tetris_model.vert.spv",
628 shaderRoot + "/tetris_model.frag.spv");
629 }
630
631 void resetGame() {
632 stopNameEntry();
633 clearLockedBlocks();
634 for (auto &row : board) {
635 for (Cell &cell : row) {
636 cell.color = -1;
637 }
638 }
639 gameOver = false;
640 gameOverTransitionActive = false;
641 multiplayerResult.clear();
642 lineClearActive = false;
643 backgroundTransitionActive = false;
644 clearingRows.fill(false);
645 score = 0;
646 linesCleared = 0;
647 updateDifficulty();
648 lastFall = std::chrono::steady_clock::now();
649 nextPiece = randomPiece();
650 spawnPiece();
651 }
652
653 void resetOpponentSnapshot() {
654 opponentSnapshot = MultiplayerSnapshot{};
655 networkReceiveBuffer.clear();
656 }
657
658 void closeMultiplayerSession(std::string status = "Enter host IP, then press J. Press H to host.") {
659 if (peerSocket.valid()) {
660 peerSocket.close();
661 }
662 if (listenSocket.valid()) {
663 listenSocket.close();
664 }
665 multiplayerActive = false;
666 multiplayerHostSide = false;
667 multiplayerMode = MultiplayerMode::Idle;
668 multiplayerStatus = std::move(status);
669 resetOpponentSnapshot();
670 }
671
672 [[nodiscard]] std::string multiplayerOutcomeText() const {
673 if (gameOver && !opponentSnapshot.gameOver) {
674 return "You lost. Opponent won.";
675 }
676 if (!gameOver && opponentSnapshot.gameOver) {
677 return "You won. Opponent lost.";
678 }
679 if (score > opponentSnapshot.score) {
680 return "You won by score.";
681 }
682 if (score < opponentSnapshot.score) {
683 return "You lost by score.";
684 }
685 return "Draw.";
686 }
687
688 void enterMultiplayerGameOver(std::string reason) {
689 const std::string outcome = multiplayerOutcomeText();
690 closeMultiplayerSession(std::move(reason));
691 multiplayerResult = outcome;
692 gameOver = true;
693 screen = AppScreen::GameOver;
694 gameOverTransitionActive = true;
695 gameOverTransitionStart = std::chrono::steady_clock::now();
696 enteringName = false;
697 highScoresAfterSave = false;
698 resetMenuLatchState();
699 }
700
701 void finishLocalMultiplayerGameOver() {
702 multiplayerResult = "You lost. Opponent won.";
703 sendMultiplayerSnapshot(true);
704 closeMultiplayerSession("Game over.");
705 enteringName = false;
706 highScoresAfterSave = false;
707 }
708
709 void beginHosting() {
710 closeMultiplayerSession();
711 listenSocket = mxnetwork::Socket(mxnetwork::SocketType::TYPE_INET);
712 if (!listenSocket.listen(multiplayerPort, 1)) {
713 multiplayerMode = MultiplayerMode::Error;
714 multiplayerStatus = std::format("Could not host on port {}", multiplayerPort);
715 return;
716 }
717 listenSocket.setblocking(false);
718 multiplayerMode = MultiplayerMode::Hosting;
719 multiplayerHostSide = true;
720 multiplayerStatus = std::format("Hosting on port {}. Waiting for peer...", multiplayerPort);
721 }
722
723 void joinHost() {
724 closeMultiplayerSession();
725 if (multiplayerHost.empty()) {
726 multiplayerMode = MultiplayerMode::Error;
727 multiplayerStatus = "Enter an IP address before joining.";
728 return;
729 }
730 peerSocket = mxnetwork::Socket(mxnetwork::SocketType::TYPE_INET);
731 multiplayerMode = MultiplayerMode::Joining;
732 multiplayerStatus = std::format("Connecting to {}:{}...", multiplayerHost, multiplayerPort);
733 if (!peerSocket.connect(multiplayerHost, multiplayerPort)) {
734 peerSocket.close();
735 multiplayerMode = MultiplayerMode::Error;
736 multiplayerStatus = std::format("Could not connect to {}:{}", multiplayerHost, multiplayerPort);
737 return;
738 }
739 peerSocket.setblocking(false);
740 multiplayerHostSide = false;
741 finishMultiplayerConnection("Connected as guest.");
742 }
743
744 void finishMultiplayerConnection(const std::string &status) {
745 if (listenSocket.valid()) {
746 listenSocket.close();
747 }
748 multiplayerMode = MultiplayerMode::Connected;
749 multiplayerStatus = status;
750 multiplayerActive = true;
751 resetOpponentSnapshot();
752 resetGame();
753 introActive = false;
754 screen = AppScreen::Game;
755 screenTransitionActive = false;
756 transitionFromScreen = AppScreen::Game;
757 lastInputUpdate = std::chrono::steady_clock::now();
758 lastNetworkSend = std::chrono::steady_clock::now();
759 resetMenuLatchState();
760 sendMultiplayerSnapshot(true);
761 }
762
763 void updateMultiplayerNetwork() {
764 if (multiplayerMode == MultiplayerMode::Hosting && listenSocket.valid()) {
765 try {
766 std::optional<mxnetwork::Socket> accepted = listenSocket.accept();
767 if (accepted) {
768 peerSocket = std::move(*accepted);
769 peerSocket.setblocking(false);
770 finishMultiplayerConnection("Peer connected. Multiplayer started.");
771 }
772 } catch (const mxnetwork::Exception &ex) {
773 multiplayerMode = MultiplayerMode::Error;
774 multiplayerStatus = std::format("Accept failed: {}", ex.text());
775 }
776 }
777
778 if (multiplayerMode != MultiplayerMode::Connected || !peerSocket.valid()) {
779 return;
780 }
781
782 receiveMultiplayerData();
783
784 const auto now = std::chrono::steady_clock::now();
785 const float elapsed = std::chrono::duration<float>(now - lastNetworkSend).count();
786 if (elapsed >= 0.05f) {
787 sendMultiplayerSnapshot(false);
788 lastNetworkSend = now;
789 }
790 }
791
792 std::string makeMultiplayerSnapshotLine() {
793 std::ostringstream out;
794 out << "S " << networkSequence++ << ' ' << score << ' ' << linesCleared << ' ' << level << ' ' << (gameOver ? 1 : 0) << ' ';
795 for (int y = 0; y < boardHeight; ++y) {
796 for (int x = 0; x < boardWidth; ++x) {
797 const int color = board[y][x].color;
798 out << ((color >= 0 && color <= 7) ? static_cast<char>('0' + color) : '.');
799 }
800 }
801 out << ' ' << active.color << ' ' << active.x << ' ' << active.y;
802 for (const Block &block : active.blocks) {
803 out << ' ' << block.x << ' ' << block.y;
804 }
805 out << '\n';
806 return out.str();
807 }
808
809 void sendMultiplayerSnapshot(bool force) {
810 if (!force && (screen != AppScreen::Game && screen != AppScreen::GameOver)) {
811 return;
812 }
813 if (multiplayerMode != MultiplayerMode::Connected || !peerSocket.valid()) {
814 return;
815 }
816 const std::string line = makeMultiplayerSnapshotLine();
817 const ssize_t written = peerSocket.write(line.data(), line.size(), 0);
818 if (written == 0) {
819 enterMultiplayerGameOver("Peer disconnected.");
820 }
821 }
822
823 void receiveMultiplayerData() {
824 std::array<char, 2048> buffer{};
825 while (peerSocket.valid()) {
826 const ssize_t received = peerSocket.read(buffer.data(), buffer.size(), 0);
827 if (received > 0) {
828 networkReceiveBuffer.append(buffer.data(), static_cast<std::size_t>(received));
829 consumeMultiplayerLines();
830 continue;
831 }
832 if (received == 0) {
833 enterMultiplayerGameOver("Peer disconnected.");
834 }
835 break;
836 }
837 }
838
839 void consumeMultiplayerLines() {
840 std::size_t newline = networkReceiveBuffer.find('\n');
841 while (newline != std::string::npos) {
842 const std::string line = networkReceiveBuffer.substr(0, newline);
843 networkReceiveBuffer.erase(0, newline + 1);
844 parseMultiplayerSnapshot(line);
845 newline = networkReceiveBuffer.find('\n');
846 }
847 constexpr std::size_t maxBufferedBytes = 8192;
848 if (networkReceiveBuffer.size() > maxBufferedBytes) {
849 networkReceiveBuffer.clear();
850 }
851 }
852
853 void parseMultiplayerSnapshot(const std::string &line) {
854 std::istringstream in(line);
855 char type = 0;
856 [[maybe_unused]] std::uint32_t sequence = 0;
857 std::string cells;
858 int remoteGameOver = 0;
859 MultiplayerSnapshot snapshot{};
860
861 in >> type >> sequence >> snapshot.score >> snapshot.lines >> snapshot.level >> remoteGameOver >> cells;
862 if (type != 'S' || cells.size() != static_cast<std::size_t>(boardWidth * boardHeight)) {
863 return;
864 }
865
866 snapshot.gameOver = remoteGameOver != 0;
867 for (int y = 0; y < boardHeight; ++y) {
868 for (int x = 0; x < boardWidth; ++x) {
869 const char ch = cells[static_cast<std::size_t>(y * boardWidth + x)];
870 snapshot.cells[y][x] = (ch >= '0' && ch <= '7') ? ch - '0' : -1;
871 }
872 }
873
874 in >> snapshot.activeColor >> snapshot.activeX >> snapshot.activeY;
875 for (Block &block : snapshot.activeBlocks) {
876 in >> block.x >> block.y;
877 }
878 if (!in) {
879 return;
880 }
881 snapshot.activeColor = std::clamp(snapshot.activeColor, 0, 7);
882 snapshot.hasState = true;
883 opponentSnapshot = snapshot;
884 }
885
886 void clearLockedBlocks() {
887 for (LockedBlock &locked : lockedBlocks) {
888 if (locked.block.model) {
889 locked.block.model->cleanup(this);
890 }
891 }
892 lockedBlocks.clear();
893 }
894
895 void spawnPiece() {
896 active.blocks = nextPiece.blocks;
897 active.x = boardWidth / 2;
898 active.y = boardHeight - 2;
899 active.color = nextPiece.color;
900 reloadActiveModels(active.color);
901 gameOver = collides(active.x, active.y, active.blocks);
902 if (gameOver) {
903 enterGameOverState();
904 }
905 nextPiece = randomPiece();
906 }
907
908 void reloadActiveModels(int color) {
909 for (BlockModel &block : activeModels) {
910 if (block.model) {
911 block.model->cleanup(this);
912 }
913 block = loadBlockModel(color);
914 }
915 }
916
917 [[nodiscard]] PieceQueueEntry randomPiece() {
918 std::uniform_int_distribution<int> dist(0, static_cast<int>(pieceDefinitions.size()) - 1);
919 const PieceDefinition &definition = pieceDefinitions[dist(rng)];
920 return PieceQueueEntry{definition.blocks, definition.color};
921 }
922
923 void updateDifficulty() {
924 const int previousLevel = level;
925 level = (linesCleared / 8) + 1;
926 static constexpr std::array<float, 16> arcadeFallSeconds{
927 0.72f,
928 0.66f,
929 0.60f,
930 0.54f,
931 0.48f,
932 0.43f,
933 0.38f,
934 0.34f,
935 0.30f,
936 0.26f,
937 0.23f,
938 0.20f,
939 0.18f,
940 0.16f,
941 0.14f,
942 0.12f,
943 };
944
945 const std::size_t index = std::min<std::size_t>(arcadeFallSeconds.size() - 1, static_cast<std::size_t>(std::max(level - 1, 0)));
946 fallSeconds = arcadeFallSeconds[index];
947 if (level > previousLevel) {
948 triggerBackgroundTransition();
949 }
950 }
951
952#if defined(MXVK_WITH_MIXER) || defined(WITH_MIXER)
953 void ensureMusicPlaying() {
954 if (!music) {
955 return;
956 }
957 if (musicTrack < 0) {
958 return;
959 }
960 if (!music->isMusicPlaying(musicTrack)) {
961 if (music->playMusic(musicTrack, -1) != 0) {
962 throw mxvk::Exception("Could not start Tetris background music");
963 }
964 }
965 }
966#else
967 void ensureMusicPlaying() {}
968#endif
969
970 void updateInput() {
971 const auto now = std::chrono::steady_clock::now();
972 float deltaSeconds = std::chrono::duration<float>(now - lastInputUpdate).count();
973 lastInputUpdate = now;
974 deltaSeconds = std::clamp(deltaSeconds, 0.0f, 0.05f);
975
976 const bool *keys = SDL_GetKeyboardState(nullptr);
977 if (keys == nullptr) {
978 return;
979 }
980
981 if (introActive) {
982 const bool enterDown = keys[SDL_SCANCODE_RETURN];
983 const bool spaceDown = keys[SDL_SCANCODE_SPACE];
984 const bool escapeDown = keys[SDL_SCANCODE_ESCAPE];
985 const bool skipDown = enterDown || spaceDown;
986 if (skipDown && !introSkipHeld) {
987 const auto now = std::chrono::steady_clock::now();
988 introStart = now - std::chrono::duration_cast<std::chrono::steady_clock::duration>(std::chrono::duration<float>(introHoldSeconds));
989 introFadeStarted = false;
990 requestScreen(AppScreen::Menu);
991 }
992 if (escapeDown && !escapeHeld) {
993 exit();
994 }
995 introSkipHeld = skipDown;
996 escapeHeld = escapeDown;
997 return;
998 }
999
1000 if (screenTransitionActive) {
1001 return;
1002 }
1003
1004 if (screen == AppScreen::GameOver) {
1005 if (enteringName) {
1006 return;
1007 }
1008 handleGameOverKeys(keys);
1009 return;
1010 }
1011
1012 if (screen == AppScreen::NetworkMultiplayer) {
1013 handleMultiplayerKeys(keys);
1014 return;
1015 }
1016
1017 if (screen != AppScreen::Game) {
1018 handleMenuKeys(keys);
1019 return;
1020 }
1021
1022 handleHeldViewRotation(keys, deltaSeconds);
1023 handleHeldPieceMovement(keys, deltaSeconds);
1024 handleOneShotKeys(keys);
1025 handleGamepadInput(deltaSeconds);
1026 }
1027
1028 void handleMenuKeys(const bool *keys) {
1029 if (screenTransitionActive) {
1030 menuUpHeld = keys[SDL_SCANCODE_UP];
1031 menuDownHeld = keys[SDL_SCANCODE_DOWN];
1032 menuEnterHeld = keys[SDL_SCANCODE_RETURN];
1033 escapeHeld = keys[SDL_SCANCODE_ESCAPE];
1034 return;
1035 }
1036
1037 const bool upDown = keys[SDL_SCANCODE_UP];
1038 const bool downDown = keys[SDL_SCANCODE_DOWN];
1039 const bool enterDown = keys[SDL_SCANCODE_RETURN];
1040 const bool escapeDown = keys[SDL_SCANCODE_ESCAPE];
1041
1042 if (screen == AppScreen::Menu) {
1043 if (upDown && !menuUpHeld) {
1044 cursorPos = (cursorPos + 3) % 4;
1045 }
1046 if (downDown && !menuDownHeld) {
1047 cursorPos = (cursorPos + 1) % 4;
1048 }
1049 }
1050 if (enterDown && !menuEnterHeld) {
1051 if (screen == AppScreen::Menu) {
1052 if (cursorPos == 0) {
1053 startGame();
1054 } else if (cursorPos == 1) {
1055 goToNetworkMultiplayer();
1056 } else if (cursorPos == 2) {
1057 goToHighScores();
1058 } else if (cursorPos == 3) {
1059 goToCredits();
1060 }
1061 } else if (screen == AppScreen::HighScores && highScoresAfterSave) {
1062 highScoresAfterSave = false;
1063 restartIntroSequence();
1064 } else {
1065 goToMenu();
1066 }
1067 }
1068 if (escapeDown && !escapeHeld) {
1069 if (screen == AppScreen::Menu) {
1070 exit();
1071 } else {
1072 highScoresAfterSave = false;
1073 goToMenu();
1074 }
1075 }
1076
1077 menuUpHeld = upDown;
1078 menuDownHeld = downDown;
1079 menuEnterHeld = enterDown;
1080 escapeHeld = escapeDown;
1081 }
1082
1083 void handleMultiplayerKeys(const bool *keys) {
1084 const bool escapeDown = keys[SDL_SCANCODE_ESCAPE];
1085 const bool enterDown = keys[SDL_SCANCODE_RETURN];
1086 const bool hostDown = keys[SDL_SCANCODE_H];
1087 const bool joinDown = keys[SDL_SCANCODE_J];
1088 const bool backspaceDown = keys[SDL_SCANCODE_BACKSPACE];
1089
1090 if (escapeDown && !escapeHeld) {
1091 closeMultiplayerSession();
1092 goToMenu();
1093 }
1094 if (hostDown && !hostHeld) {
1095 beginHosting();
1096 }
1097 if (joinDown && !joinHeld) {
1098 joinHost();
1099 }
1100 if (enterDown && !menuEnterHeld) {
1101 joinHost();
1102 }
1103 if (backspaceDown && !backspaceHeld && !multiplayerHost.empty()) {
1104 multiplayerHost.pop_back();
1105 }
1106
1107 appendMultiplayerAddressCharacters(keys);
1108
1109 escapeHeld = escapeDown;
1110 menuEnterHeld = enterDown;
1111 hostHeld = hostDown;
1112 joinHeld = joinDown;
1113 backspaceHeld = backspaceDown;
1114 }
1115
1116 void appendMultiplayerAddressCharacters(const bool *keys) {
1117 static constexpr std::array<SDL_Scancode, 10> digitKeys{{
1118 SDL_SCANCODE_0,
1119 SDL_SCANCODE_1,
1120 SDL_SCANCODE_2,
1121 SDL_SCANCODE_3,
1122 SDL_SCANCODE_4,
1123 SDL_SCANCODE_5,
1124 SDL_SCANCODE_6,
1125 SDL_SCANCODE_7,
1126 SDL_SCANCODE_8,
1127 SDL_SCANCODE_9,
1128 }};
1129 static constexpr std::array<SDL_Scancode, 10> keypadDigitKeys{{
1130 SDL_SCANCODE_KP_0,
1131 SDL_SCANCODE_KP_1,
1132 SDL_SCANCODE_KP_2,
1133 SDL_SCANCODE_KP_3,
1134 SDL_SCANCODE_KP_4,
1135 SDL_SCANCODE_KP_5,
1136 SDL_SCANCODE_KP_6,
1137 SDL_SCANCODE_KP_7,
1138 SDL_SCANCODE_KP_8,
1139 SDL_SCANCODE_KP_9,
1140 }};
1141
1142 auto appendChar = [this](char ch) {
1143 constexpr std::size_t maxAddressLength = 64;
1144 if (multiplayerHost.size() < maxAddressLength) {
1145 multiplayerHost.push_back(ch);
1146 }
1147 };
1148
1149 for (int i = 0; i < 10; ++i) {
1150 if ((keys[digitKeys[static_cast<std::size_t>(i)]] || keys[keypadDigitKeys[static_cast<std::size_t>(i)]]) && multiplayerCursor != (i + 1)) {
1151 appendChar(static_cast<char>('0' + i));
1152 multiplayerCursor = i + 1;
1153 return;
1154 }
1155 }
1156 if ((keys[SDL_SCANCODE_PERIOD] || keys[SDL_SCANCODE_KP_PERIOD]) && multiplayerCursor != 11) {
1157 appendChar('.');
1158 multiplayerCursor = 11;
1159 return;
1160 }
1161 if (keys[SDL_SCANCODE_MINUS] && multiplayerCursor != 12) {
1162 appendChar('-');
1163 multiplayerCursor = 12;
1164 return;
1165 }
1166 if (!keys[SDL_SCANCODE_PERIOD] && !keys[SDL_SCANCODE_KP_PERIOD] && !keys[SDL_SCANCODE_MINUS]) {
1167 bool anyDigitDown = false;
1168 for (int i = 0; i < 10; ++i) {
1169 anyDigitDown = anyDigitDown || keys[digitKeys[static_cast<std::size_t>(i)]] || keys[keypadDigitKeys[static_cast<std::size_t>(i)]];
1170 }
1171 if (!anyDigitDown) {
1172 multiplayerCursor = 0;
1173 }
1174 }
1175 }
1176
1177 void handleGamepadInput(float deltaSeconds) {
1178 if (gamepad == nullptr || introActive || (screen != AppScreen::Game && screen != AppScreen::GameOver) || gameOver || lineClearActive) {
1179 gamepadMoveDirection = 0;
1180 gamepadMoveHeldSeconds = 0.0f;
1181 gamepadSoftDropHeld = false;
1182 gamepadMoveRepeatTimer = 0.0f;
1183 gamepadSoftDropRepeatTimer = 0.0f;
1184 return;
1185 }
1186
1187 const Sint16 leftX = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX);
1188 const Sint16 leftY = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY);
1189 const Sint16 rightX = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTX);
1190 const Sint16 rightY = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTY);
1191
1192 const int moveDirection = (leftX < -gamepadDeadzone) ? -1 : (leftX > gamepadDeadzone) ? 1
1193 : 0;
1194 if (moveDirection == 0) {
1195 gamepadMoveDirection = 0;
1196 gamepadMoveHeldSeconds = 0.0f;
1197 gamepadMoveRepeatTimer = 0.0f;
1198 } else {
1199 if (moveDirection != gamepadMoveDirection) {
1200 gamepadMoveDirection = moveDirection;
1201 gamepadMoveHeldSeconds = 0.0f;
1202 gamepadMoveRepeatTimer = 0.0f;
1203 movePiece(gamepadMoveDirection, 0);
1204 } else {
1205 gamepadMoveHeldSeconds += deltaSeconds;
1206 const float threshold = (gamepadMoveHeldSeconds < gamepadMoveInitialDelaySeconds)
1207 ? gamepadMoveInitialDelaySeconds
1208 : gamepadMoveRepeatSeconds;
1209 gamepadMoveRepeatTimer += deltaSeconds;
1210 if (gamepadMoveRepeatTimer >= threshold) {
1211 movePiece(gamepadMoveDirection, 0);
1212 gamepadMoveRepeatTimer = 0.0f;
1213 }
1214 }
1215 }
1216
1217 const bool softDropDown = leftY > gamepadDeadzone;
1218 if (!softDropDown) {
1219 gamepadSoftDropHeld = false;
1220 gamepadSoftDropRepeatTimer = 0.0f;
1221 } else {
1222 const float threshold = gamepadSoftDropHeld ? gamepadSoftDropRepeatSeconds : gamepadSoftDropInitialDelaySeconds;
1223 gamepadSoftDropRepeatTimer += deltaSeconds;
1224 if (gamepadSoftDropRepeatTimer >= threshold) {
1225 softDrop();
1226 gamepadSoftDropRepeatTimer = 0.0f;
1227 lastFall = std::chrono::steady_clock::now();
1228 gamepadSoftDropHeld = true;
1229 }
1230 }
1231
1232 if (std::abs(rightX) > gamepadDeadzone) {
1233 gridYaw += static_cast<float>(rightX) * gamepadStickScale * gamepadStickRotateSpeed * deltaSeconds;
1234 }
1235 if (std::abs(rightY) > gamepadDeadzone) {
1236 gridPitch = std::clamp(gridPitch - static_cast<float>(rightY) * gamepadStickScale * gamepadStickPitchSpeed * deltaSeconds,
1237 -70.0f,
1238 70.0f);
1239 }
1240
1241 constexpr float gamepadZoomSpeed = 3.2f;
1242 if (SDL_GetGamepadButton(gamepad, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER)) {
1243 cameraDistance = std::min(9.0f, cameraDistance + gamepadZoomSpeed * deltaSeconds);
1244 }
1245 if (SDL_GetGamepadButton(gamepad, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER)) {
1246 cameraDistance = std::max(1.65f, cameraDistance - gamepadZoomSpeed * deltaSeconds);
1247 }
1248 }
1249
1250 void handleGamepadButtonDown(Uint8 button) {
1251 if (gamepad == nullptr) {
1252 return;
1253 }
1254 if (screenTransitionActive) {
1255 return;
1256 }
1257
1258 if (introActive) {
1259 if (button == SDL_GAMEPAD_BUTTON_SOUTH || button == SDL_GAMEPAD_BUTTON_START) {
1260 introActive = false;
1261 requestScreen(AppScreen::Menu);
1262 }
1263 return;
1264 }
1265
1266 switch (screen) {
1267 case AppScreen::Menu:
1268 if (button == SDL_GAMEPAD_BUTTON_DPAD_UP) {
1269 cursorPos = (cursorPos + 3) % 4;
1270 } else if (button == SDL_GAMEPAD_BUTTON_DPAD_DOWN) {
1271 cursorPos = (cursorPos + 1) % 4;
1272 } else if (button == SDL_GAMEPAD_BUTTON_SOUTH || button == SDL_GAMEPAD_BUTTON_START) {
1273 if (cursorPos == 0) {
1274 startGame();
1275 } else if (cursorPos == 1) {
1276 goToNetworkMultiplayer();
1277 } else if (cursorPos == 2) {
1278 goToHighScores();
1279 } else if (cursorPos == 3) {
1280 goToCredits();
1281 }
1282 } else if (button == SDL_GAMEPAD_BUTTON_BACK || button == SDL_GAMEPAD_BUTTON_EAST) {
1283 exit();
1284 }
1285 break;
1286 case AppScreen::Game:
1287 if (gameOver) {
1288 if (button == SDL_GAMEPAD_BUTTON_START || button == SDL_GAMEPAD_BUTTON_SOUTH) {
1289 restartIntroSequence();
1290 } else if (button == SDL_GAMEPAD_BUTTON_BACK || button == SDL_GAMEPAD_BUTTON_EAST) {
1291 goToMenu();
1292 }
1293 return;
1294 }
1295 if (lineClearActive) {
1296 return;
1297 }
1298 if (button == SDL_GAMEPAD_BUTTON_SOUTH) {
1299 rotatePiece();
1300 } else if (button == SDL_GAMEPAD_BUTTON_EAST) {
1301 hardDrop();
1302 } else if (button == SDL_GAMEPAD_BUTTON_BACK) {
1303 goToMenu();
1304 }
1305 break;
1307 if (button == SDL_GAMEPAD_BUTTON_SOUTH || button == SDL_GAMEPAD_BUTTON_START) {
1308 if (enteringName) {
1309 commitScoreEntry();
1310 } else {
1311 restartIntroSequence();
1312 }
1313 } else if (button == SDL_GAMEPAD_BUTTON_BACK || button == SDL_GAMEPAD_BUTTON_EAST) {
1314 highScoresAfterSave = false;
1315 goToMenu();
1316 }
1317 break;
1320 case AppScreen::Credits:
1321 if (button == SDL_GAMEPAD_BUTTON_SOUTH || button == SDL_GAMEPAD_BUTTON_START || button == SDL_GAMEPAD_BUTTON_BACK || button == SDL_GAMEPAD_BUTTON_EAST) {
1322 highScoresAfterSave = false;
1323 goToMenu();
1324 }
1325 break;
1326 case AppScreen::Intro:
1327 break;
1328 }
1329 }
1330
1331 bool openGamepad(SDL_JoystickID id) {
1332 if (gamepad != nullptr && gamepadId == id) {
1333 return true;
1334 }
1335 closeGamepad();
1336 gamepad = SDL_OpenGamepad(id);
1337 if (gamepad == nullptr) {
1338 return false;
1339 }
1340 gamepadId = id;
1341 return true;
1342 }
1343
1344 void closeGamepad() {
1345 if (gamepad != nullptr) {
1346 SDL_CloseGamepad(gamepad);
1347 gamepad = nullptr;
1348 gamepadId = 0;
1349 }
1350 }
1351
1352 void tryOpenFirstGamepad() {
1353 if (gamepad != nullptr) {
1354 return;
1355 }
1356 int count = 0;
1357 SDL_JoystickID *ids = SDL_GetGamepads(&count);
1358 if (ids == nullptr || count <= 0) {
1359 if (ids != nullptr) {
1360 SDL_free(ids);
1361 }
1362 return;
1363 }
1364 openGamepad(ids[0]);
1365 SDL_free(ids);
1366 }
1367
1368 void updateIntroState() {
1369 if (!introActive) {
1370 return;
1371 }
1372
1373 const auto now = std::chrono::steady_clock::now();
1374 const float elapsed = std::chrono::duration<float>(now - introStart).count();
1375 if (!introFadeStarted && elapsed >= introHoldSeconds) {
1376 introFadeStarted = true;
1377 requestScreen(AppScreen::Menu);
1378 }
1379 if (elapsed >= (introHoldSeconds + introFadeSeconds)) {
1380 introActive = false;
1381 introFadeStarted = false;
1382 lastFall = now;
1383 }
1384 }
1385
1386 void startGame() {
1387 const AppScreen previousScreen = screen;
1388 closeMultiplayerSession();
1389 resetGame();
1390 introActive = false;
1391 screen = AppScreen::Game;
1392 screenTransitionActive = previousScreen != AppScreen::Game;
1393 transitionFromScreen = previousScreen;
1394 screenTransitionStart = std::chrono::steady_clock::now();
1395 lastInputUpdate = std::chrono::steady_clock::now();
1396 resetMenuLatchState();
1397 }
1398
1399 void restartIntroSequence() {
1400 resetGame();
1401 highScoresAfterSave = false;
1402 introActive = true;
1403 introFadeStarted = false;
1404 introStart = std::chrono::steady_clock::now();
1405 screen = AppScreen::Intro;
1406 screenTransitionActive = false;
1407 transitionFromScreen = AppScreen::Intro;
1408 lastInputUpdate = std::chrono::steady_clock::now();
1409 resetMenuLatchState();
1410 introSkipHeld = true;
1411 }
1412
1413 void enterGameOverState() {
1414 screen = AppScreen::GameOver;
1415 gameOverTransitionActive = true;
1416 gameOverTransitionStart = std::chrono::steady_clock::now();
1417 nameCharIndex = 0;
1418 playerName.clear();
1419 if (multiplayerActive) {
1420 finishLocalMultiplayerGameOver();
1421 resetMenuLatchState();
1422 return;
1423 }
1424 enteringName = highScores.qualifies(score);
1425 highScoresAfterSave = false;
1426 resetMenuLatchState();
1427 }
1428
1429 void stopNameEntry() {
1430 enteringName = false;
1431 nameCharIndex = 0;
1432 playerName.clear();
1433 }
1434
1435 void commitScoreEntry() {
1436 highScores.addScore(playerName, score);
1437 stopNameEntry();
1438 highScoresAfterSave = true;
1439 menuEnterHeld = true;
1440 goToHighScores();
1441 }
1442
1443 [[nodiscard]] char currentNameCharacter() const {
1444 return nameCharacters[nameCharIndex % nameCharacterCount];
1445 }
1446
1447 void cycleNameCharacter(int delta) {
1448 if (!enteringName) {
1449 return;
1450 }
1451 const int count = static_cast<int>(nameCharacterCount);
1452 const int next = (static_cast<int>(nameCharIndex) + delta + count) % count;
1453 nameCharIndex = static_cast<std::size_t>(next);
1454 }
1455
1456 void appendCurrentNameCharacter() {
1457 if (!enteringName || playerName.size() >= nameMaxLength) {
1458 return;
1459 }
1460 playerName += currentNameCharacter();
1461 }
1462
1463 void deleteNameCharacter() {
1464 if (!enteringName || playerName.empty()) {
1465 return;
1466 }
1467 playerName.pop_back();
1468 }
1469
1470 void handleNameEntryKey(SDL_Keycode key) {
1471 if (key == SDLK_UP) {
1472 cycleNameCharacter(-1);
1473 } else if (key == SDLK_DOWN) {
1474 cycleNameCharacter(1);
1475 } else if (key == SDLK_SPACE) {
1476 appendCurrentNameCharacter();
1477 } else if (key == SDLK_BACKSPACE) {
1478 deleteNameCharacter();
1479 } else if (key == SDLK_RETURN) {
1480 commitScoreEntry();
1481 } else if (key == SDLK_ESCAPE) {
1482 highScoresAfterSave = false;
1483 goToMenu();
1484 }
1485 }
1486
1487 void handleNameEntryButton(Uint8 button) {
1488 if (button == SDL_GAMEPAD_BUTTON_DPAD_UP) {
1489 cycleNameCharacter(-1);
1490 } else if (button == SDL_GAMEPAD_BUTTON_DPAD_DOWN) {
1491 cycleNameCharacter(1);
1492 } else if (button == SDL_GAMEPAD_BUTTON_SOUTH) {
1493 appendCurrentNameCharacter();
1494 } else if (button == SDL_GAMEPAD_BUTTON_WEST) {
1495 commitScoreEntry();
1496 } else if (button == SDL_GAMEPAD_BUTTON_EAST || button == SDL_GAMEPAD_BUTTON_BACK) {
1497 deleteNameCharacter();
1498 }
1499 }
1500
1501 void goToMenu() {
1502 if (screen == AppScreen::Game || screen == AppScreen::GameOver) {
1503 gameOver = false;
1504 }
1505 if (multiplayerActive || screen == AppScreen::NetworkMultiplayer) {
1506 closeMultiplayerSession();
1507 }
1508 gameOverTransitionActive = false;
1509 highScoresAfterSave = false;
1510 stopNameEntry();
1511 cursorPos = 0;
1512 requestScreen(AppScreen::Menu);
1513 }
1514
1515 void goToHighScores() {
1516 gameOver = false;
1517 stopNameEntry();
1518 requestScreen(AppScreen::HighScores);
1519 }
1520
1521 void goToCredits() {
1522 requestScreen(AppScreen::Credits);
1523 }
1524
1525 void goToNetworkMultiplayer() {
1526 requestScreen(AppScreen::NetworkMultiplayer);
1527 }
1528
1529 void requestScreen(AppScreen nextScreen) {
1530 if (screen == nextScreen && !screenTransitionActive) {
1531 return;
1532 }
1533 if (nextScreen == AppScreen::Game) {
1534 screenTransitionActive = false;
1535 screen = nextScreen;
1536 return;
1537 }
1538 if (screen == AppScreen::Game || screen == AppScreen::GameOver) {
1539 screenTransitionActive = false;
1540 screen = nextScreen;
1541 return;
1542 }
1543 transitionFromScreen = screen;
1544 screen = nextScreen;
1545 screenTransitionActive = true;
1546 screenTransitionStart = std::chrono::steady_clock::now();
1547 resetMenuLatchState();
1548 }
1549
1550 void updateScreenTransition() {
1551 if (!screenTransitionActive) {
1552 return;
1553 }
1554 const auto now = std::chrono::steady_clock::now();
1555 const float elapsed = std::chrono::duration<float>(now - screenTransitionStart).count();
1556 if (elapsed >= screenTransitionSeconds) {
1557 screenTransitionActive = false;
1558 transitionFromScreen = screen;
1559 resetMenuLatchState();
1560 }
1561 }
1562
1563 float screenFadeAlpha() const {
1564 if (!screenTransitionActive) {
1565 return 1.0f;
1566 }
1567 const auto now = std::chrono::steady_clock::now();
1568 const float elapsed = std::chrono::duration<float>(now - screenTransitionStart).count();
1569 return std::clamp(elapsed / screenTransitionSeconds, 0.0f, 1.0f);
1570 }
1571
1572 float gameOverFadeAlpha() const {
1573 if (!gameOverTransitionActive) {
1574 return 1.0f;
1575 }
1576 const auto now = std::chrono::steady_clock::now();
1577 const float elapsed = std::chrono::duration<float>(now - gameOverTransitionStart).count();
1578 return std::clamp(elapsed / gameOverTransitionSeconds, 0.0f, 1.0f);
1579 }
1580
1581 void resetMenuLatchState() {
1582 menuUpHeld = false;
1583 menuDownHeld = false;
1584 menuEnterHeld = false;
1585 introSkipHeld = false;
1586 backspaceHeld = false;
1587 hostHeld = false;
1588 joinHeld = false;
1589 multiplayerCursor = 0;
1590 }
1591
1592 void updateMenuInputLatchState() {
1593 const bool *keys = SDL_GetKeyboardState(nullptr);
1594 if (keys == nullptr) {
1595 return;
1596 }
1597 menuUpHeld = keys[SDL_SCANCODE_UP];
1598 menuDownHeld = keys[SDL_SCANCODE_DOWN];
1599 menuEnterHeld = keys[SDL_SCANCODE_RETURN];
1600 escapeHeld = keys[SDL_SCANCODE_ESCAPE];
1601 }
1602
1603 void handleHeldViewRotation(const bool *keys, float deltaSeconds) {
1604 constexpr float yawSpeed = 115.0f;
1605 constexpr float pitchSpeed = 90.0f;
1606 constexpr float rollSpeed = 100.0f;
1607 constexpr float zoomSpeed = 3.2f;
1608
1609 if (keys[SDL_SCANCODE_A]) {
1610 gridYaw -= yawSpeed * deltaSeconds;
1611 }
1612 if (keys[SDL_SCANCODE_D]) {
1613 gridYaw += yawSpeed * deltaSeconds;
1614 }
1615 if (keys[SDL_SCANCODE_W]) {
1616 gridPitch = std::clamp(gridPitch + pitchSpeed * deltaSeconds, -70.0f, 70.0f);
1617 }
1618 if (keys[SDL_SCANCODE_S]) {
1619 gridPitch = std::clamp(gridPitch - pitchSpeed * deltaSeconds, -70.0f, 70.0f);
1620 }
1621 if (keys[SDL_SCANCODE_Q]) {
1622 gridRoll -= rollSpeed * deltaSeconds;
1623 }
1624 if (keys[SDL_SCANCODE_E]) {
1625 gridRoll += rollSpeed * deltaSeconds;
1626 }
1627 if (keys[SDL_SCANCODE_PAGEUP]) {
1628 cameraDistance = std::max(1.65f, cameraDistance - zoomSpeed * deltaSeconds);
1629 }
1630 if (keys[SDL_SCANCODE_PAGEDOWN]) {
1631 cameraDistance = std::min(9.0f, cameraDistance + zoomSpeed * deltaSeconds);
1632 }
1633 }
1634
1635 void handleHeldPieceMovement(const bool *keys, float deltaSeconds) {
1636 if (gameOver || lineClearActive) {
1637 moveRepeatTimer = 0.0f;
1638 softDropRepeatTimer = 0.0f;
1639 return;
1640 }
1641
1642 constexpr float horizontalRepeatSeconds = 0.14f;
1643 constexpr float softDropRepeatSeconds = 0.075f;
1644
1645 moveRepeatTimer += deltaSeconds;
1646 softDropRepeatTimer += deltaSeconds;
1647
1648 int horizontalDirection = 0;
1649 if (keys[SDL_SCANCODE_LEFT] && !keys[SDL_SCANCODE_RIGHT]) {
1650 horizontalDirection = -1;
1651 } else if (keys[SDL_SCANCODE_RIGHT] && !keys[SDL_SCANCODE_LEFT]) {
1652 horizontalDirection = 1;
1653 }
1654
1655 if (horizontalDirection != 0) {
1656 if (moveRepeatTimer >= horizontalRepeatSeconds) {
1657 movePiece(horizontalDirection, 0);
1658 moveRepeatTimer = 0.0f;
1659 }
1660 } else {
1661 moveRepeatTimer = horizontalRepeatSeconds;
1662 }
1663
1664 if (keys[SDL_SCANCODE_DOWN]) {
1665 if (softDropRepeatTimer >= softDropRepeatSeconds) {
1666 softDrop();
1667 softDropRepeatTimer = 0.0f;
1668 lastFall = std::chrono::steady_clock::now();
1669 }
1670 } else {
1671 softDropRepeatTimer = softDropRepeatSeconds;
1672 }
1673 }
1674
1675 void handleOneShotKeys(const bool *keys) {
1676 const bool escapeDown = keys[SDL_SCANCODE_ESCAPE];
1677 const bool hardDropDown = keys[SDL_SCANCODE_Z];
1678 const bool rotateDown = keys[SDL_SCANCODE_UP];
1679 const bool resetDown = keys[SDL_SCANCODE_R];
1680 const bool enterDown = keys[SDL_SCANCODE_RETURN];
1681
1682 if (escapeDown && !escapeHeld) {
1683 goToMenu();
1684 }
1685 if (gameOver && enterDown && !enterHeld) {
1686 restartIntroSequence();
1687 escapeHeld = escapeDown;
1688 hardDropHeld = hardDropDown;
1689 rotateHeld = rotateDown;
1690 resetHeld = resetDown;
1691 enterHeld = enterDown;
1692 return;
1693 }
1694 if (hardDropDown && !hardDropHeld) {
1695 hardDrop();
1696 }
1697 if (rotateDown && !rotateHeld) {
1698 rotatePiece();
1699 }
1700 if (resetDown && !resetHeld) {
1701 resetGame();
1702 }
1703
1704 escapeHeld = escapeDown;
1705 hardDropHeld = hardDropDown;
1706 rotateHeld = rotateDown;
1707 resetHeld = resetDown;
1708 enterHeld = enterDown;
1709 }
1710
1711 void handleGameOverKeys(const bool *keys) {
1712 const bool enterDown = keys[SDL_SCANCODE_RETURN];
1713 const bool escapeDown = keys[SDL_SCANCODE_ESCAPE];
1714 const bool resetDown = keys[SDL_SCANCODE_R];
1715 const bool highScoresDown = keys[SDL_SCANCODE_H];
1716 const bool backspaceDown = keys[SDL_SCANCODE_BACKSPACE];
1717
1718 if (enteringName) {
1719 if (backspaceDown && !backspaceHeld && !playerName.empty()) {
1720 playerName.pop_back();
1721 }
1722 if (enterDown && !enterHeld) {
1723 commitScoreEntry();
1724 backspaceHeld = backspaceDown;
1725 enterHeld = enterDown;
1726 escapeHeld = escapeDown;
1727 resetHeld = resetDown;
1728 menuEnterHeld = highScoresDown;
1729 return;
1730 }
1731
1732 backspaceHeld = backspaceDown;
1733 enterHeld = enterDown;
1734 escapeHeld = escapeDown;
1735 resetHeld = resetDown;
1736 menuEnterHeld = highScoresDown;
1737 return;
1738 } else {
1739 if (enterDown && !enterHeld) {
1740 restartIntroSequence();
1741 backspaceHeld = backspaceDown;
1742 enterHeld = enterDown;
1743 escapeHeld = escapeDown;
1744 resetHeld = resetDown;
1745 menuEnterHeld = highScoresDown;
1746 return;
1747 }
1748 }
1749
1750 if (resetDown && !resetHeld) {
1751 restartIntroSequence();
1752 }
1753 if (highScoresDown && !menuEnterHeld) {
1754 stopNameEntry();
1755 goToHighScores();
1756 }
1757 if (escapeDown && !escapeHeld) {
1758 goToMenu();
1759 }
1760
1761 backspaceHeld = backspaceDown;
1762 enterHeld = enterDown;
1763 escapeHeld = escapeDown;
1764 resetHeld = resetDown;
1765 menuEnterHeld = highScoresDown;
1766 }
1767
1768 [[nodiscard]] bool collides(int pieceX, int pieceY, const std::array<Block, 4> &blocks) const {
1769 for (const Block &block : blocks) {
1770 const int x = pieceX + block.x;
1771 const int y = pieceY + block.y;
1772 if (x < 0 || x >= boardWidth || y < 0) {
1773 return true;
1774 }
1775 if (y >= boardHeight) {
1776 continue;
1777 }
1778 if (board[y][x].color >= 0) {
1779 return true;
1780 }
1781 }
1782 return false;
1783 }
1784
1785 void movePiece(int dx, int dy) {
1786 if (!gameOver && !lineClearActive && !collides(active.x + dx, active.y + dy, active.blocks)) {
1787 active.x += dx;
1788 active.y += dy;
1789 }
1790 }
1791
1792 void softDrop() {
1793 if (gameOver || lineClearActive) {
1794 return;
1795 }
1796 if (collides(active.x, active.y - 1, active.blocks)) {
1797 lockPiece();
1798 } else {
1799 --active.y;
1800 }
1801 }
1802
1803 void hardDrop() {
1804 if (gameOver || lineClearActive) {
1805 return;
1806 }
1807 while (!collides(active.x, active.y - 1, active.blocks)) {
1808 --active.y;
1809 }
1810 lockPiece();
1811 }
1812
1813 void rotatePiece() {
1814 if (gameOver || lineClearActive) {
1815 return;
1816 }
1817 const auto rotated = rotatedBlocks(active);
1818 if (!collides(active.x, active.y, rotated)) {
1819 active.blocks = rotated;
1820 return;
1821 }
1822 if (!collides(active.x - 1, active.y, rotated)) {
1823 --active.x;
1824 active.blocks = rotated;
1825 return;
1826 }
1827 if (!collides(active.x + 1, active.y, rotated)) {
1828 ++active.x;
1829 active.blocks = rotated;
1830 }
1831 }
1832
1833 void updateGame() {
1834 if (screen != AppScreen::Game || introActive) {
1835 return;
1836 }
1837 if (gameOver) {
1838 return;
1839 }
1840
1841 if (lineClearActive) {
1842 const auto now = std::chrono::steady_clock::now();
1843 const float elapsed = std::chrono::duration<float>(now - lineClearStart).count();
1844 if (elapsed >= 1.0f) {
1845 finalizeLineClear();
1846 }
1847 return;
1848 }
1849
1850 const auto now = std::chrono::steady_clock::now();
1851 const float elapsed = std::chrono::duration<float>(now - lastFall).count();
1852 if (elapsed >= fallSeconds) {
1853 softDrop();
1854 lastFall = now;
1855 }
1856 }
1857
1858 void lockPiece() {
1859 for (const Block &block : active.blocks) {
1860 const int x = active.x + block.x;
1861 const int y = active.y + block.y;
1862 if (x >= 0 && x < boardWidth && y >= 0 && y < boardHeight) {
1863 board[y][x].color = active.color;
1864 LockedBlock locked{};
1865 locked.block = loadBlockModel(active.color);
1866 locked.x = x;
1867 locked.y = y;
1868 lockedBlocks.push_back(std::move(locked));
1869 }
1870 }
1871 const auto fullRows = findFullRows();
1872 if (std::any_of(fullRows.begin(), fullRows.end(), [](bool value) { return value; })) {
1873 startLineClear(fullRows);
1874 } else {
1875 spawnPiece();
1876 }
1877 lastFall = std::chrono::steady_clock::now();
1878 }
1879
1880 [[nodiscard]] std::array<bool, boardHeight> findFullRows() const {
1881 std::array<bool, boardHeight> fullRows{};
1882 for (int y = 0; y < boardHeight; ++y) {
1883 bool full = true;
1884 for (int x = 0; x < boardWidth; ++x) {
1885 full = full && board[y][x].color >= 0;
1886 }
1887 fullRows[y] = full;
1888 }
1889 return fullRows;
1890 }
1891
1892 void startLineClear(const std::array<bool, boardHeight> &fullRows) {
1893 clearingRows = fullRows;
1894 lineClearActive = true;
1895 lineClearStart = std::chrono::steady_clock::now();
1896 }
1897
1898 void finalizeLineClear() {
1899 int cleared = 0;
1900 for (bool row : clearingRows) {
1901 if (row) {
1902 ++cleared;
1903 }
1904 }
1905
1906 int writeY = 0;
1907 for (int readY = 0; readY < boardHeight; ++readY) {
1908 if (clearingRows[readY]) {
1909 continue;
1910 }
1911 if (writeY != readY) {
1912 board[writeY] = board[readY];
1913 }
1914 ++writeY;
1915 }
1916 for (; writeY < boardHeight; ++writeY) {
1917 for (Cell &cell : board[writeY]) {
1918 cell.color = -1;
1919 }
1920 }
1921
1922 eraseClearedModels(clearingRows);
1923 clearingRows.fill(false);
1924 lineClearActive = false;
1925 if (cleared > 0) {
1926 score += cleared * 10;
1927 if (cleared > 1) {
1928 score += 5;
1929 }
1930 linesCleared += cleared;
1931 updateDifficulty();
1932 }
1933 spawnPiece();
1934 lastFall = std::chrono::steady_clock::now();
1935 }
1936
1937 void eraseClearedModels(const std::array<bool, boardHeight> &fullRows) {
1938 std::vector<LockedBlock> remaining{};
1939 remaining.reserve(lockedBlocks.size());
1940 for (LockedBlock &locked : lockedBlocks) {
1941 if (locked.y >= 0 && locked.y < boardHeight && fullRows[locked.y]) {
1942 if (locked.block.model) {
1943 locked.block.model->cleanup(this);
1944 }
1945 continue;
1946 }
1947
1948 int rowsBelow = 0;
1949 for (int y = 0; y < locked.y; ++y) {
1950 if (fullRows[y]) {
1951 ++rowsBelow;
1952 }
1953 }
1954 locked.y -= rowsBelow;
1955 remaining.push_back(std::move(locked));
1956 }
1957 lockedBlocks = std::move(remaining);
1958 }
1959
1960 [[nodiscard]] bool isClearingRow(int y) const {
1961 return lineClearActive && y >= 0 && y < boardHeight && clearingRows[y];
1962 }
1963
1964 [[nodiscard]] bool isLineClearVisible() const {
1965 const auto now = std::chrono::steady_clock::now();
1966 const float elapsed = std::chrono::duration<float>(now - lineClearStart).count();
1967 constexpr float blinkIntervalSeconds = 0.12f;
1968 return (static_cast<int>(elapsed / blinkIntervalSeconds) % 2) == 0;
1969 }
1970
1971 void drawIntroOverlay(VkCommandBuffer cmd, const VkExtent2D &extent) {
1972 if (!introActive || introSprite == nullptr || sprite_pipeline == VK_NULL_HANDLE || sprite_pipeline_layout == VK_NULL_HANDLE) {
1973 return;
1974 }
1975
1976 const auto now = std::chrono::steady_clock::now();
1977 const float elapsed = std::chrono::duration<float>(now - introStart).count();
1978 const float fadeProgress = std::clamp((elapsed - introHoldSeconds) / introFadeSeconds, 0.0f, 1.0f);
1979 const float alpha = 1.0f - fadeProgress;
1980
1981 introSprite->setShaderParams(elapsed, 0.0f, 0.0f, alpha);
1982 introSprite->drawSpriteRect(0, 0, static_cast<int>(extent.width), static_cast<int>(extent.height));
1983 vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, sprite_pipeline);
1984 introSprite->renderSprites(cmd, sprite_pipeline_layout, extent.width, extent.height);
1985 introSprite->clearQueue();
1986 }
1987
1988 void drawGameOverOverlay(VkCommandBuffer cmd, const VkExtent2D &extent) {
1989 const bool shouldShow = (screen == AppScreen::Game && gameOver) || screen == AppScreen::GameOver;
1990 if (!shouldShow || gameOverSprite == nullptr || sprite_pipeline == VK_NULL_HANDLE || sprite_pipeline_layout == VK_NULL_HANDLE) {
1991 return;
1992 }
1993
1994 const float alpha = gameOverFadeAlpha();
1995 gameOverSprite->setShaderParams(0.0f, 0.0f, 0.0f, alpha);
1996 gameOverSprite->drawSpriteRect(0, 0, static_cast<int>(extent.width), static_cast<int>(extent.height));
1997 vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, sprite_pipeline);
1998 gameOverSprite->renderSprites(cmd, sprite_pipeline_layout, extent.width, extent.height);
1999 gameOverSprite->clearQueue();
2000 }
2001
2002 [[nodiscard]] glm::vec3 blockPosition(int x, int y) const {
2003 const float worldX = (static_cast<float>(x) - (static_cast<float>(boardWidth) - 1.0f) * 0.5f) * cubeSpacing;
2004 const float worldY = (static_cast<float>(y) - (static_cast<float>(boardHeight) - 1.0f) * 0.5f) * cubeSpacing;
2005 return glm::vec3(worldX, worldY, 0.0f);
2006 }
2007
2008 [[nodiscard]] glm::mat4 blockMatrix(int x, int y, float scale = cubeScale) const {
2009 glm::mat4 model(1.0f);
2010 model = glm::translate(model, blockPosition(x, y));
2011 model = glm::scale(model, glm::vec3(scale));
2012 return model;
2013 }
2014
2015 void drawBlock(VkCommandBuffer cmd,
2016 uint32_t imageIndex,
2017 BlockModel &block,
2018 int x,
2019 int y,
2020 int color,
2021 const glm::mat4 &view,
2022 const glm::mat4 &proj) {
2023 mxvk::UniformBufferObject ubo{};
2024 ubo.model = blockMatrix(x, y);
2025 ubo.view = view;
2026 ubo.proj = proj;
2027 ubo.fx = glm::vec4(colorTints[color], 1.0f);
2028 block.model->updateUBO(imageIndex, ubo);
2029 block.model->render(cmd, imageIndex, false);
2030 }
2031
2032 void drawFrame(VkCommandBuffer cmd, uint32_t imageIndex, const glm::mat4 &view, const glm::mat4 &proj) {
2033 size_t index = 0;
2034 for (int y = 0; y < boardHeight; ++y) {
2035 drawFrameBlock(cmd, imageIndex, frameModels[index++], -1, y, view, proj);
2036 drawFrameBlock(cmd, imageIndex, frameModels[index++], boardWidth, y, view, proj);
2037 }
2038 for (int x = -1; x <= boardWidth; ++x) {
2039 drawFrameBlock(cmd, imageIndex, frameModels[index++], x, -1, view, proj);
2040 }
2041 }
2042
2043 void drawFrameBlock(VkCommandBuffer cmd,
2044 uint32_t imageIndex,
2045 BlockModel &block,
2046 int x,
2047 int y,
2048 const glm::mat4 &view,
2049 const glm::mat4 &proj) {
2050 mxvk::UniformBufferObject ubo{};
2051 ubo.model = blockMatrix(x, y, cubeScale * 0.82f);
2052 ubo.view = view;
2053 ubo.proj = proj;
2054 ubo.fx = glm::vec4(colorTints[7], 1.0f);
2055 block.model->updateUBO(imageIndex, ubo);
2056 block.model->render(cmd, imageIndex, false);
2057 }
2058
2059 void drawSpriteRect(mxvk::VK_Sprite *sprite, VkCommandBuffer cmd, const VkExtent2D &extent, int x, int y, int w, int h) {
2060 if (sprite == nullptr) {
2061 return;
2062 }
2063 sprite->drawSpriteRect(x, y, w, h);
2064 vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, sprite_pipeline);
2065 sprite->renderSprites(cmd, sprite_pipeline_layout, extent.width, extent.height);
2066 sprite->clearQueue();
2067 }
2068
2069 void drawNextPiecePreview(VkCommandBuffer cmd, [[maybe_unused]] uint32_t imageIndex, const VkExtent2D &extent) {
2070 if (screen != AppScreen::Game || introActive || previewBorderSprite == nullptr || sprite_pipeline == VK_NULL_HANDLE || sprite_pipeline_layout == VK_NULL_HANDLE) {
2071 return;
2072 }
2073
2074 const int panelSize = std::min({220, static_cast<int>(static_cast<float>(extent.width) * 0.28f), static_cast<int>(static_cast<float>(extent.height) * 0.34f)});
2075 const int panelW = panelSize;
2076 const int panelH = panelSize;
2077 const int margin = 24;
2078 const int panelX = static_cast<int>(extent.width) - panelW - margin;
2079 const int panelY = 88;
2080 const int border = 4;
2081
2082 drawSpriteRect(previewBorderSprite, cmd, extent, panelX, panelY, panelW, border);
2083 drawSpriteRect(previewBorderSprite, cmd, extent, panelX, panelY + panelH - border, panelW, border);
2084 drawSpriteRect(previewBorderSprite, cmd, extent, panelX, panelY, border, panelH);
2085 drawSpriteRect(previewBorderSprite, cmd, extent, panelX + panelW - border, panelY, border, panelH);
2086
2087 printText("Next", panelX + 12, panelY - 28, SDL_Color{255, 255, 255, 255});
2088
2089 int minX = nextPiece.blocks[0].x;
2090 int maxX = nextPiece.blocks[0].x;
2091 int minY = nextPiece.blocks[0].y;
2092 int maxY = nextPiece.blocks[0].y;
2093 for (const Block &block : nextPiece.blocks) {
2094 minX = std::min(minX, block.x);
2095 maxX = std::max(maxX, block.x);
2096 minY = std::min(minY, block.y);
2097 maxY = std::max(maxY, block.y);
2098 }
2099
2100 const float innerPadding = 28.0f;
2101 const float innerSize = static_cast<float>(panelSize) - innerPadding * 2.0f;
2102 const int blockSize = static_cast<int>(std::min(34.0f, innerSize / static_cast<float>(std::max(maxX - minX + 1, maxY - minY + 1))));
2103 const float pieceW = static_cast<float>(maxX - minX + 1) * blockSize;
2104 const float pieceH = static_cast<float>(maxY - minY + 1) * blockSize;
2105 const float centerX = static_cast<float>(panelX) + static_cast<float>(panelW) * 0.5f;
2106 const float centerY = static_cast<float>(panelY) + static_cast<float>(panelH) * 0.5f;
2107 const float originX = centerX - pieceW * 0.5f;
2108 const float originY = centerY - pieceH * 0.5f;
2109 mxvk::VK_Sprite *blockSprite = blockPreviewSprites[static_cast<std::size_t>(nextPiece.color)];
2110
2111 for (std::size_t i = 0; i < nextPiece.blocks.size(); ++i) {
2112 const Block &pieceBlock = nextPiece.blocks[i];
2113 const float x = originX + static_cast<float>(pieceBlock.x - minX) * blockSize;
2114 const float y = originY + static_cast<float>(pieceBlock.y - minY) * blockSize;
2115 drawSpriteRect(blockSprite, cmd, extent, static_cast<int>(x), static_cast<int>(y), blockSize, blockSize);
2116 }
2117 }
2118
2119 void drawOpponentGrid(VkCommandBuffer cmd, const VkExtent2D &extent) {
2120 if (!multiplayerActive || screen != AppScreen::Game || introActive || sprite_pipeline == VK_NULL_HANDLE || sprite_pipeline_layout == VK_NULL_HANDLE || previewBorderSprite == nullptr) {
2121 return;
2122 }
2123
2124 const int margin = 24;
2125 const int previewSize = std::min({220, static_cast<int>(static_cast<float>(extent.width) * 0.28f), static_cast<int>(static_cast<float>(extent.height) * 0.34f)});
2126 const int previewBottom = 88 + previewSize;
2127 const int panelTop = previewBottom + 56;
2128 const int textAreaHeight = 78;
2129 const int panelBottom = static_cast<int>(extent.height) - margin - textAreaHeight;
2130 const int availableHeight = panelBottom - panelTop;
2131 const int availableWidth = std::min(260, static_cast<int>(static_cast<float>(extent.width) * 0.24f));
2132 if (availableWidth < 70 || availableHeight < 120) {
2133 return;
2134 }
2135 const int blockSize = std::max(4, std::min(availableWidth / boardWidth, availableHeight / boardHeight));
2136 const int gridW = blockSize * boardWidth;
2137 const int gridH = blockSize * boardHeight;
2138 const int panelX = static_cast<int>(extent.width) - gridW - margin;
2139 const int panelY = panelTop + std::max(0, (availableHeight - gridH) / 2);
2140 const int border = std::max(2, blockSize / 5);
2141 const auto textXInsideRightEdge = [this, &extent, margin, panelX](const std::initializer_list<std::string> &lines) {
2142 int maxWidth = 0;
2143 for (const std::string &line : lines) {
2144 int width = 0;
2145 int height = 0;
2146 if (getTextDimensions(line, width, height)) {
2147 maxWidth = std::max(maxWidth, width);
2148 }
2149 }
2150 return std::max(margin, std::min(panelX, static_cast<int>(extent.width) - margin - maxWidth));
2151 };
2152
2153 drawSpriteRect(previewBorderSprite, cmd, extent, panelX - border, panelY - border, gridW + border * 2, border);
2154 drawSpriteRect(previewBorderSprite, cmd, extent, panelX - border, panelY + gridH, gridW + border * 2, border);
2155 drawSpriteRect(previewBorderSprite, cmd, extent, panelX - border, panelY - border, border, gridH + border * 2);
2156 drawSpriteRect(previewBorderSprite, cmd, extent, panelX + gridW, panelY - border, border, gridH + border * 2);
2157
2158 printText("Opponent", panelX, panelY - 30, SDL_Color{255, 255, 255, 255});
2159 if (!opponentSnapshot.hasState) {
2160 const std::string waitingText = "Waiting...";
2161 printText(waitingText, textXInsideRightEdge({waitingText}), panelY + gridH + 12, SDL_Color{255, 220, 120, 255});
2162 return;
2163 }
2164
2165 for (int y = 0; y < boardHeight; ++y) {
2166 for (int x = 0; x < boardWidth; ++x) {
2167 const int color = opponentSnapshot.cells[y][x];
2168 if (color < 0 || color >= static_cast<int>(blockPreviewSprites.size())) {
2169 continue;
2170 }
2171 const int screenX = panelX + x * blockSize;
2172 const int screenY = panelY + (boardHeight - 1 - y) * blockSize;
2173 drawSpriteRect(blockPreviewSprites[static_cast<std::size_t>(color)], cmd, extent, screenX, screenY, blockSize, blockSize);
2174 }
2175 }
2176
2177 if (!opponentSnapshot.gameOver) {
2178 for (const Block &block : opponentSnapshot.activeBlocks) {
2179 const int x = opponentSnapshot.activeX + block.x;
2180 const int y = opponentSnapshot.activeY + block.y;
2181 if (x < 0 || x >= boardWidth || y < 0 || y >= boardHeight) {
2182 continue;
2183 }
2184 const int screenX = panelX + x * blockSize;
2185 const int screenY = panelY + (boardHeight - 1 - y) * blockSize;
2186 drawSpriteRect(blockPreviewSprites[static_cast<std::size_t>(opponentSnapshot.activeColor)], cmd, extent, screenX, screenY, blockSize, blockSize);
2187 }
2188 }
2189
2190 const std::string scoreText = std::format("Score {}", opponentSnapshot.score);
2191 const std::string linesText = std::format("Lines {} Lv {}", opponentSnapshot.lines, opponentSnapshot.level);
2192 const std::string gameOverText = "Game Over";
2193 const int statsX = textXInsideRightEdge({scoreText, linesText, gameOverText});
2194 printText(scoreText, statsX, panelY + gridH + 12, SDL_Color{255, 255, 255, 255});
2195 printText(linesText, statsX, panelY + gridH + 38, SDL_Color{180, 220, 255, 255});
2196 if (opponentSnapshot.gameOver) {
2197 printText(gameOverText, statsX, panelY + gridH + 64, SDL_Color{255, 120, 120, 255});
2198 }
2199 }
2200
2201 void drawCreditsModel(VkCommandBuffer cmd, uint32_t imageIndex, const VkExtent2D &extent) {
2202 if (creditsTuxModel == nullptr) {
2203 return;
2204 }
2205
2206 const float aspect = (extent.height > 0U) ? static_cast<float>(extent.width) / static_cast<float>(extent.height) : 1.0f;
2207 glm::mat4 view = glm::lookAt(glm::vec3(0.0f, 0.25f, 2.8f), glm::vec3(0.0f, 0.05f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
2208 view = glm::rotate(view, glm::radians(-10.0f), glm::vec3(1.0f, 0.0f, 0.0f));
2209 glm::mat4 proj = glm::perspective(glm::radians(40.0f), aspect, 0.1f, 100.0f);
2210 proj[1][1] *= -1.0f;
2211
2212 const float elapsed = static_cast<float>(SDL_GetTicks()) / 1000.0f;
2213 glm::mat4 model(1.0f);
2214 model = glm::translate(model, glm::vec3(0.0f, -0.34f, 0.0f));
2215 model = glm::rotate(model, elapsed * 0.75f, glm::vec3(0.0f, 1.0f, 0.0f));
2216 model = glm::rotate(model, std::sin(elapsed * 0.6f) * 0.08f, glm::vec3(1.0f, 0.0f, 0.0f));
2217 model = glm::scale(model, glm::vec3(0.228f));
2218
2219 mxvk::UniformBufferObject ubo{};
2220 ubo.model = model;
2221 ubo.view = view;
2222 ubo.proj = proj;
2223 ubo.fx = glm::vec4(1.0f, 1.0f, 1.0f, elapsed);
2224 creditsTuxModel->updateUBO(imageIndex, ubo);
2225 creditsTuxModel->render(cmd, imageIndex, false);
2226 }
2227
2228 void drawHud() {
2229 if (introActive) {
2230 return;
2231 }
2232 const VkExtent2D extent = getSwapchainExtent();
2233 const int centerX = static_cast<int>(extent.width) / 2;
2234 const float alpha = screenFadeAlpha();
2235 const auto withAlpha = [alpha](SDL_Color color) {
2236 color.a = static_cast<Uint8>(static_cast<float>(color.a) * alpha);
2237 return color;
2238 };
2239
2240 switch (screen) {
2241 case AppScreen::Menu: {
2242 const int titleY = static_cast<int>(static_cast<float>(extent.height) * 0.18f);
2243 const int menuY = static_cast<int>(static_cast<float>(extent.height) * 0.40f);
2244 const int spacing = static_cast<int>(std::max(34.0f, static_cast<float>(extent.height) * 0.07f));
2245 printCenteredText("MXVK 3D Tetris", centerX, titleY, withAlpha(SDL_Color{255, 255, 0, 255}));
2246 printCenteredText("Choose a mode", centerX, titleY + 42, withAlpha(SDL_Color{255, 255, 255, 255}));
2247
2248 const char *items[] = {"New Game", "Network Multiplayer", "High Scores", "Credits"};
2249 for (int i = 0; i < 4; ++i) {
2250 const SDL_Color color = (i == cursorPos) ? SDL_Color{255, 255, 0, 255} : SDL_Color{255, 255, 255, 255};
2251 printCenteredText(items[i], centerX, menuY + i * spacing, withAlpha(color));
2252 }
2253 printCenteredText("Use arrows and Enter", centerX, static_cast<int>(static_cast<float>(extent.height) * 0.86f), withAlpha(SDL_Color{180, 180, 180, 255}));
2254 break;
2255 }
2256 case AppScreen::HighScores: {
2257 const int baseY = static_cast<int>(static_cast<float>(extent.height) * 0.16f);
2258 printCenteredText("High Scores", centerX, baseY, withAlpha(SDL_Color{255, 255, 0, 255}));
2259 const auto &scores = highScores.entries();
2260 const int listLeftX = static_cast<int>(static_cast<float>(extent.width) * 0.34f);
2261 const int listRightX = static_cast<int>(static_cast<float>(extent.width) * 0.70f);
2262 const int lineHeight = 30;
2263 for (std::size_t i = 0; i < 10U; ++i) {
2264 const int rowY = baseY + 54 + static_cast<int>(i) * lineHeight;
2265 const bool hasScore = i < scores.size();
2266 const std::string nameText = hasScore ? scores[i].name : "---";
2267 const std::string scoreText = hasScore ? std::format("{}", scores[i].score) : "---";
2268 printText(std::format("{:>2}. {}", i + 1U, nameText),
2269 listLeftX,
2270 rowY,
2271 withAlpha(SDL_Color{255, 255, 255, 255}));
2272 int scoreWidth = 0;
2273 int scoreHeight = 0;
2274 if (getTextDimensions(scoreText, scoreWidth, scoreHeight)) {
2275 printText(scoreText, listRightX - scoreWidth, rowY, withAlpha(SDL_Color{255, 255, 255, 255}));
2276 } else {
2277 printText(scoreText, listRightX, rowY, withAlpha(SDL_Color{255, 255, 255, 255}));
2278 }
2279 }
2280 printCenteredText("Press Enter or Escape to return", centerX, static_cast<int>(static_cast<float>(extent.height) * 0.82f), withAlpha(SDL_Color{200, 200, 200, 255}));
2281 break;
2282 }
2283 case AppScreen::Credits: {
2284 const int baseY = static_cast<int>(static_cast<float>(extent.height) * 0.2f);
2285 printCenteredText("Credits", centerX, baseY, withAlpha(SDL_Color{255, 255, 0, 255}));
2286 printCenteredText("MXVK 3D Tetris", centerX, baseY + 48, withAlpha(SDL_Color{255, 255, 255, 255}));
2287 printCenteredText("Built with Vulkan and SDL3", centerX, baseY + 84, withAlpha(SDL_Color{255, 220, 120, 255}));
2288 printCenteredText("Press Enter or Escape to return", centerX, static_cast<int>(static_cast<float>(extent.height) * 0.82f), withAlpha(SDL_Color{200, 200, 200, 255}));
2289 break;
2290 }
2292 const int baseY = static_cast<int>(static_cast<float>(extent.height) * 0.16f);
2293 printCenteredText("Network Multiplayer", centerX, baseY, withAlpha(SDL_Color{255, 255, 0, 255}));
2294 printCenteredText(std::format("Port: {}", multiplayerPort), centerX, baseY + 42, withAlpha(SDL_Color{255, 220, 120, 255}));
2295 printCenteredText(std::format("Peer IP: {}", multiplayerHost.empty() ? "_" : multiplayerHost), centerX, baseY + 86, withAlpha(SDL_Color{255, 255, 255, 255}));
2296 printCenteredText(multiplayerStatus, centerX, baseY + 126, withAlpha(SDL_Color{180, 220, 255, 255}));
2297 printCenteredText("H host J/Enter join Backspace edit", centerX, baseY + 178, withAlpha(SDL_Color{255, 255, 255, 255}));
2298 printCenteredText("Run one copy as host, then connect from the other machine by IP.", centerX, baseY + 216, withAlpha(SDL_Color{200, 200, 200, 255}));
2299 printCenteredText("Escape returns to menu", centerX, static_cast<int>(static_cast<float>(extent.height) * 0.82f), withAlpha(SDL_Color{200, 200, 200, 255}));
2300 break;
2301 }
2302 case AppScreen::GameOver: {
2303 const int baseY = static_cast<int>(static_cast<float>(extent.height) * 0.20f);
2304 const float gameOverAlpha = gameOverFadeAlpha();
2305 const auto withGameOverAlpha = [gameOverAlpha](SDL_Color color) {
2306 color.a = static_cast<Uint8>(static_cast<float>(color.a) * gameOverAlpha);
2307 return color;
2308 };
2309
2310 printCenteredText("Game Over", centerX, baseY, withGameOverAlpha(SDL_Color{255, 255, 0, 255}));
2311 printCenteredText(std::format("Final Score: {}", score), centerX, baseY + 42, withGameOverAlpha(SDL_Color{255, 255, 255, 255}));
2312 if (!multiplayerResult.empty()) {
2313 printCenteredText(multiplayerResult, centerX, baseY + 82, withGameOverAlpha(SDL_Color{255, 220, 120, 255}));
2314 printCenteredText("Enter to restart", centerX, baseY + 124, withGameOverAlpha(SDL_Color{200, 200, 200, 255}));
2315 printCenteredText("Escape for menu", centerX, baseY + 158, withGameOverAlpha(SDL_Color{200, 200, 200, 255}));
2316 break;
2317 }
2318 if (enteringName) {
2319 printCenteredText("New high score", centerX, baseY + 82, withGameOverAlpha(SDL_Color{255, 220, 120, 255}));
2320 printCenteredText(std::format("Name: {}", playerName.empty() ? "_" : playerName),
2321 centerX,
2322 baseY + 118,
2323 withGameOverAlpha(SDL_Color{255, 255, 255, 255}));
2324 printCenteredText(std::format("Pick: [{}]", currentNameCharacter()), centerX, baseY + 154, withGameOverAlpha(SDL_Color{120, 255, 255, 255}));
2325 printCenteredText("Up/Down choose, A/Space add", centerX, baseY + 190, withGameOverAlpha(SDL_Color{200, 200, 200, 255}));
2326 printCenteredText("X/Enter save, B/Backspace delete", centerX, baseY + 224, withGameOverAlpha(SDL_Color{200, 200, 200, 255}));
2327 } else {
2328 printCenteredText("R to restart from intro", centerX, baseY + 92, withGameOverAlpha(SDL_Color{255, 220, 120, 255}));
2329 printCenteredText("H for high scores", centerX, baseY + 126, withGameOverAlpha(SDL_Color{200, 200, 200, 255}));
2330 printCenteredText("Enter to restart", centerX, baseY + 160, withGameOverAlpha(SDL_Color{200, 200, 200, 255}));
2331 }
2332 break;
2333 }
2334 case AppScreen::Game:
2335 printText(std::format("Score: {}", score), 15, 15, withAlpha(SDL_Color{255, 255, 255, 255}));
2336 printText(std::format("Lines Cleared: {}", linesCleared), 15, 45, withAlpha(SDL_Color{255, 220, 120, 255}));
2337 printText(std::format("Level: {}", level), 15, 75, withAlpha(SDL_Color{120, 220, 255, 255}));
2338 if (multiplayerActive) {
2339 printText(multiplayerHostSide ? "Multiplayer: Host" : "Multiplayer: Guest", 15, 105, withAlpha(SDL_Color{180, 255, 180, 255}));
2340 }
2341 if (gameOver) {
2342 const int baseY = static_cast<int>(static_cast<float>(extent.height) * 0.58f);
2343 const float gameOverAlpha = gameOverFadeAlpha();
2344 const auto withGameOverAlpha = [gameOverAlpha](SDL_Color color) {
2345 color.a = static_cast<Uint8>(static_cast<float>(color.a) * gameOverAlpha);
2346 return color;
2347 };
2348 printCenteredText(std::format("Final Score: {}", score), centerX, baseY, withGameOverAlpha(SDL_Color{255, 255, 255, 255}));
2349 printCenteredText(multiplayerResult.empty() ? "Game over" : multiplayerResult, centerX, baseY + 40, withGameOverAlpha(SDL_Color{255, 220, 120, 255}));
2350 }
2351 break;
2352 case AppScreen::Intro:
2353 break;
2354 }
2355 }
2356
2357 void printCenteredText(const std::string &text, int centerX, int y, const SDL_Color &color) {
2358 int textWidth = 0;
2359 int textHeight = 0;
2360 if (getTextDimensions(text, textWidth, textHeight)) {
2361 printText(text, centerX - textWidth / 2, y, color);
2362 return;
2363 }
2364 printText(text, centerX, y, color);
2365 }
2366
2367 void triggerBackgroundTransition() {
2368 backgroundTransitionActive = true;
2369 backgroundTransitionStart = std::chrono::steady_clock::now();
2370 }
2371
2372 void updateBackgroundTransitionState() {
2373 if (!backgroundTransitionActive) {
2374 return;
2375 }
2376 const auto now = std::chrono::steady_clock::now();
2377 const float elapsed = std::chrono::duration<float>(now - backgroundTransitionStart).count();
2378 if (elapsed >= backgroundTransitionSeconds) {
2379 backgroundTransitionActive = false;
2380 }
2381 }
2382
2383 [[nodiscard]] mxvk::VK_Sprite *screenSpriteFor(AppScreen screen) const {
2384 switch (screen) {
2385 case AppScreen::Menu:
2386 return menuBackgroundSprite;
2388 return highScoresBackgroundSprite;
2389 case AppScreen::Credits:
2390 return creditsBackgroundSprite;
2392 return multiplayerBackgroundSprite;
2393 case AppScreen::Intro:
2394 case AppScreen::Game:
2396 return nullptr;
2397 }
2398 return nullptr;
2399 }
2400
2401 void drawFadedSprite(mxvk::VK_Sprite *sprite, VkCommandBuffer cmd, const VkExtent2D &extent, float alpha) {
2402 if (sprite == nullptr || sprite_pipeline == VK_NULL_HANDLE || sprite_pipeline_layout == VK_NULL_HANDLE) {
2403 return;
2404 }
2405 sprite->setShaderParams(0.0f, 0.0f, 0.0f, alpha);
2406 sprite->drawSpriteRect(0, 0, static_cast<int>(extent.width), static_cast<int>(extent.height));
2407 vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, sprite_pipeline);
2408 sprite->renderSprites(cmd, sprite_pipeline_layout, extent.width, extent.height);
2409 sprite->clearQueue();
2410 }
2411
2412 void drawGameScreenTransitionOverlay(VkCommandBuffer cmd, const VkExtent2D &extent) {
2413 if (screen != AppScreen::Game || !screenTransitionActive) {
2414 return;
2415 }
2416 drawFadedSprite(screenSpriteFor(transitionFromScreen), cmd, extent, 1.0f - screenFadeAlpha());
2417 }
2418
2419 void drawScreenBackdrop(VkCommandBuffer cmd, const VkExtent2D &extent) {
2420 if (screen == AppScreen::Game) {
2421 if (sprite_pipeline == VK_NULL_HANDLE || sprite_pipeline_layout == VK_NULL_HANDLE) {
2422 return;
2423 }
2424 updateBackgroundTransitionState();
2425
2426 mxvk::VK_Sprite *sprite = background;
2427 if (backgroundTransitionActive && backgroundTransitionSprite != nullptr) {
2428 sprite = backgroundTransitionSprite;
2429 }
2430 if (sprite == nullptr) {
2431 return;
2432 }
2433
2434 if (sprite == backgroundTransitionSprite) {
2435 const auto now = std::chrono::steady_clock::now();
2436 const float elapsed = std::chrono::duration<float>(now - backgroundTransitionStart).count();
2437 sprite->setShaderParams(elapsed, 0.0f, 0.0f, 0.0f);
2438 }
2439
2440 sprite->drawSpriteRect(0, 0, static_cast<int>(extent.width), static_cast<int>(extent.height));
2441 vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, sprite_pipeline);
2442 sprite->renderSprites(cmd, sprite_pipeline_layout, extent.width, extent.height);
2443 sprite->clearQueue();
2444 return;
2445 }
2446
2447 const float alpha = screenFadeAlpha();
2448 if (screenTransitionActive) {
2449 drawFadedSprite(screenSpriteFor(transitionFromScreen), cmd, extent, 1.0f - alpha);
2450 }
2451 drawFadedSprite(screenSpriteFor(screen), cmd, extent, alpha);
2452 }
2453 };
2454
2455} // namespace
2456
2457int main(int argc, char **argv) {
2458 try {
2459 const Arguments args = proc_args(argc, argv);
2460 TetrisWindow window(args.path, args.width, args.height, args.fullscreen, args.enable_vsync);
2461 window.loop();
2462 } catch (const mxnetwork::Exception &e) {
2463 std::cerr << std::format("mxnetwork: Exception: {}\n", e.text());
2464 return EXIT_FAILURE;
2465 } catch (mxvk::Exception &e) {
2466 std::cerr << std::format("mxvk: Exception: {}\n", e.text());
2467 return EXIT_FAILURE;
2468 } catch (ArgException<std::string> &e) {
2469 std::cerr << std::format("mxvk: Argument Exception: {}\n", e.text());
2470 return EXIT_FAILURE;
2471 }
2472
2473 return EXIT_SUCCESS;
2474}
Lightweight, header-only, template command-line argument parser.
Arguments proc_args(int &argc, char **argv)
Parse standard libmx2 command-line options from main()'s argv.
Definition argz.hpp:872
Exception thrown by Argz::proc() on unrecognised or malformed options.
Definition argz.hpp:178
void write()
HighScores(std::filesystem::path filePath)
Definition tetris.cpp:119
void addScore(std::string name, int score)
Definition tetris.cpp:124
const std::vector< HighScoreEntry > & entries() const
Definition tetris.cpp:138
void onSwapchainRecreated() override
Called after swapchain and render resources are recreated.
Definition tetris.cpp:366
void onRecordCustomRendering(VkCommandBuffer cmd, uint32_t imageIndex) override
Optional hook for derived classes to record extra draw commands.
Definition tetris.cpp:425
void event(SDL_Event &e) override
Handle one SDL event.
Definition tetris.cpp:372
TetrisWindow(const std::string &path, int width, int height, bool fullscreen, bool enable_vsync)
Definition tetris.cpp:298
void proc() override
Execute one processing/update step.
Definition tetris.cpp:412
Lightweight exception wrapper for MXNetwork failures.
Definition exception.hpp:11
std::string text() const
Return the stored error text.
Definition exception.cpp:5
Convenience wrapper that owns mesh, textures, descriptors, and pipeline state.
void cleanup(VK_Window *window)
Destroy all owned Vulkan resources.
void resize(VK_Window *window)
Rebuild swapchain-dependent resources after resize.
void renderSprites(VkCommandBuffer cmdBuffer, VkPipelineLayout pipelineLayout, uint32_t screenWidth, uint32_t screenHeight)
Record all queued draw commands into the given command buffer.
void setShaderParams(float p1=0.0f, float p2=0.0f, float p3=0.0f, float p4=0.0f)
Set up to four custom shader float parameters.
void clearQueue()
Discard all pending draw commands without rendering.
void drawSpriteRect(int x, int y, int w, int h)
Queue a draw into an explicit destination rectangle.
Main Vulkan window wrapper for MXVK.
Definition mxvk.hpp:37
VkPipelineLayout sprite_pipeline_layout
Definition mxvk.hpp:543
VkExtent2D getSwapchainExtent() const noexcept
Get the current swapchain extent.
Definition mxvk.hpp:186
VkDevice device
Definition mxvk.hpp:485
VK_Sprite * createSprite(const std::string &pngPath, const std::string &vertexShaderPath="", const std::string &fragmentShaderPath="")
Create a sprite from a PNG file and register it with this window.
Definition mxvk.cpp:3477
bool getTextDimensions(const std::string &text, int &width, int &height)
Measure text dimensions in pixels.
Definition mxvk.cpp:3069
void setClearColor(float r, float g, float b, float a=1.0f)
Set the per-frame color attachment clear color.
Definition mxvk.cpp:593
VkPipeline sprite_pipeline
Definition mxvk.hpp:544
void exit()
Request loop termination.
Definition mxvk.cpp:1126
VK_Window()=default
Construct an empty window object.
void setFont(const std::string &fontPath, int fontSize=24)
Set the active text-render font.
Definition mxvk.cpp:2991
void printText(const std::string &text, int x, int y, const SDL_Color &col)
Queue a text string for rendering during the current frame.
Definition mxvk.cpp:3018
int main(void)
Definition main.cpp:7
#define MXVK_VALIDATION
Definition mxvk.hpp:27
High-level model wrapper integrated with MXVK dynamic rendering.
SDL3_mixer audio subsystem wrapper.
constexpr std::string_view multiplayerPort
Definition tetris.cpp:46
std::array< Block, 4 > rotatedBlocks(const ActivePiece &piece)
Definition tetris.cpp:276
constexpr char nameCharacters[]
Definition tetris.cpp:44
constexpr std::size_t nameCharacterCount
Definition tetris.cpp:45
std::filesystem::path resolveScoreFilePath()
Definition tetris.cpp:219
const std::array< std::string, 8 > blockTextureFiles
Definition tetris.cpp:254
const std::array< glm::vec3, 8 > colorTints
Definition tetris.cpp:265
const std::array< std::string, 8 > textureManifests
Definition tetris.cpp:243
constexpr float cubeSpacing
Definition tetris.cpp:42
constexpr std::size_t nameMaxLength
Definition tetris.cpp:43
const std::array< PieceDefinition, 7 > pieceDefinitions
Definition tetris.cpp:233
@ TYPE_INET
IPv4 stream socket.
Definition socket.hpp:41
Utilities for loading and saving PNG images.
Definition mxvk.hpp:30
std::atomic< bool > active
Definition relay.cpp:12
Plain data structure returned by proc_args() with all common libmx2 CLI options.
Definition argz.hpp:730
bool fullscreen
Whether fullscreen mode was requested.
Definition argz.hpp:736
bool enable_vsync
Enable FIFO present mode / v-sync (--enable-vsync).
Definition argz.hpp:750
int height
Viewport height in pixels (default: 720).
Definition argz.hpp:733
std::string path
Asset root; proc_args() defaults it to the executable directory.
Definition argz.hpp:735
int width
Viewport width in pixels (default: 1280).
Definition argz.hpp:732
std::unique_ptr< mxvk::VKAbstractModel > model
Definition tetris.cpp:75
std::array< std::array< int, boardWidth >, boardHeight > cells
Definition tetris.cpp:91
#define tetris_ASSET_DIR
Definition tetris.cpp:34