MXVK Vulkan Framework 0.35.0
C++20 Vulkan rendering framework for practical 2D and 3D application development with SDL3.
Loading...
Searching...
No Matches
main.cpp
Go to the documentation of this file.
1#include <SDL3/SDL.h>
2
3#include <algorithm>
4#include <array>
5#include <cmath>
6#include <cstdint>
7#include <cstdlib>
8#include <filesystem>
9#include <fstream>
10#include <limits>
11#include <memory>
12#include <random>
13#include <sstream>
14#include <string>
15#include <unordered_map>
16#include <vector>
17
18#include <glm/ext/matrix_clip_space.hpp>
19#include <glm/ext/matrix_transform.hpp>
20#include <glm/glm.hpp>
21
22#include "mxvk/argz.hpp"
23#include "mxvk/mxvk.hpp"
26#include "mxvk/mxvk_png.hpp"
27
28#ifndef POOL_DEMO_ASSET_DIR
29#define POOL_DEMO_ASSET_DIR "."
30#endif
31
32namespace {
33
34 constexpr float TABLE_W = 12.0f;
35 constexpr float TABLE_H = 6.0f;
36 constexpr float TABLE_HALF_W = TABLE_W / 2.0f;
37 constexpr float TABLE_HALF_H = TABLE_H / 2.0f;
38 constexpr float POCKET_R = 0.25f;
39 constexpr float BALL_RADIUS = 0.18f;
40 constexpr float CUE_LENGTH = 2.5f;
41 constexpr float CUE_THICKNESS = 0.03f;
42 constexpr float AIM_LENGTH = 2.0f;
43 constexpr float AIM_THICKNESS_Y = 0.008f;
44 constexpr float AIM_THICKNESS_Z = 0.015f;
45 constexpr int NUM_BALLS = 16;
46 constexpr float FRICTION = 0.9988f;
47 constexpr float MIN_SPEED = 0.001f;
48 constexpr float MAX_POWER = 1.5f;
49 constexpr float CAM_BASE_TOTAL_SCALE = 1.359411f;
50 constexpr float SINK_DURATION = 0.45f;
51 constexpr float PI = 3.14159265358979323846f;
52
53 constexpr float POCKET_INSET = 0.25f;
54 const std::array<glm::vec2, 6> POCKETS = {
56 glm::vec2{0.0f, TABLE_HALF_H - 0.15f},
59 glm::vec2{0.0f, -TABLE_HALF_H + 0.15f},
61 };
62
63 const std::array<glm::vec3, NUM_BALLS> BALL_COLORS = {
64 glm::vec3{1.0f, 1.0f, 1.0f},
65 glm::vec3{1.0f, 0.84f, 0.0f},
66 glm::vec3{0.0f, 0.0f, 0.8f},
67 glm::vec3{0.9f, 0.0f, 0.0f},
68 glm::vec3{0.5f, 0.0f, 0.5f},
69 glm::vec3{1.0f, 0.5f, 0.0f},
70 glm::vec3{0.0f, 0.5f, 0.0f},
71 glm::vec3{0.55f, 0.0f, 0.0f},
72 glm::vec3{0.1f, 0.1f, 0.1f},
73 glm::vec3{1.0f, 0.84f, 0.0f},
74 glm::vec3{0.0f, 0.0f, 0.8f},
75 glm::vec3{0.9f, 0.0f, 0.0f},
76 glm::vec3{0.5f, 0.0f, 0.5f},
77 glm::vec3{1.0f, 0.5f, 0.0f},
78 glm::vec3{0.0f, 0.5f, 0.0f},
79 glm::vec3{0.55f, 0.0f, 0.0f},
80 };
81
82 float randFloat(float mn, float mx) {
83 static std::random_device rd;
84 static std::default_random_engine eng(rd());
85 std::uniform_real_distribution<float> dist(mn, mx);
86 return dist(eng);
87 }
88
89 bool hasPoolAssets(const std::filesystem::path &root) {
90 const std::filesystem::path data = root / "data";
91 return std::filesystem::exists(data / "pooltable_felt.mxmod.z") && std::filesystem::exists(data / "pooltable_wood.mxmod.z") && std::filesystem::exists(data / "pooltable_pocket.mxmod.z") && std::filesystem::exists(data / "table.png");
92 }
93
94 std::string resolveAssetRoot(const std::string &userPath) {
95 std::vector<std::filesystem::path> candidates;
96 if (!userPath.empty() && userPath != "." && userPath != "./") {
97 candidates.emplace_back(userPath);
98 }
99
100 const char *basePath = SDL_GetBasePath();
101 if (basePath != nullptr && basePath[0] != '\0') {
102 candidates.emplace_back(std::filesystem::path(basePath).lexically_normal());
103 }
104
105 candidates.emplace_back(std::filesystem::path(POOL_DEMO_ASSET_DIR).lexically_normal());
106
107 std::error_code ec;
108 const std::filesystem::path cwd = std::filesystem::current_path(ec);
109 if (!ec) {
110 candidates.emplace_back(cwd);
111 }
112
113 for (const auto &candidate : candidates) {
114 if (hasPoolAssets(candidate)) {
115 return candidate.lexically_normal().string();
116 }
117 }
118
119 if (!candidates.empty()) {
120 return candidates.front().lexically_normal().string();
121 }
122
123 return std::string(POOL_DEMO_ASSET_DIR);
124 }
125
126 enum class GameScreen { Intro, Scores, Game, Start };
127 enum class GamePhase { Aiming, Charging, Rolling, Placing, GameOver };
128
129 struct PoolBall {
130 glm::vec2 pos{0.0f};
131 glm::vec2 vel{0.0f};
132 bool active = true;
133 bool pocketed = false;
134 int number = 0;
135 float spinAngle = 0.0f;
136
137 bool isMoving() const { return glm::length(vel) > MIN_SPEED; }
138 };
139
140 struct SinkAnim {
141 glm::vec2 pocketPos{0.0f};
142 glm::vec3 color{1.0f};
143 float spinAngle = 0.0f;
144 float timer = 0.0f;
145 int ballIndex = 0;
146 };
147
148 struct Score {
149 std::string name;
150 int shots = 0;
151 };
152
153 class HighScores {
154 public:
155 explicit HighScores(std::string filePath) : filePath(std::move(filePath)) { read(); }
156
158
159 void addScore(const std::string &name, int shots) {
160 entries.push_back({name, shots});
161 sort();
162 if (entries.size() > 10) {
163 entries.resize(10);
164 }
165 }
166
167 [[nodiscard]] bool qualifies(int shots) const {
168 if (entries.size() < 10) {
169 return true;
170 }
171 return shots < entries.back().shots;
172 }
173
174 [[nodiscard]] const std::vector<Score> &list() const { return entries; }
175
176 void write() const {
177 std::ofstream out(filePath);
178 if (!out.is_open()) {
179 return;
180 }
181 for (const auto &entry : entries) {
182 out << entry.name << ':' << entry.shots << '\n';
183 }
184 }
185
186 private:
187 void sort() {
188 std::sort(entries.begin(), entries.end(), [](const Score &a, const Score &b) { return a.shots < b.shots; });
189 }
190
191 void initDefaults() {
192 entries.clear();
193 for (int i = 1; i <= 10; ++i) {
194 entries.push_back({"Anonymous", i * 20});
195 }
196 }
197
198 void read() {
199 std::ifstream in(filePath);
200 if (!in.is_open()) {
201 initDefaults();
202 return;
203 }
204
205 std::string line;
206 int count = 0;
207 while (std::getline(in, line) && count < 10) {
208 const std::size_t pos = line.find(':');
209 if (pos == std::string::npos) {
210 continue;
211 }
212
213 const std::string name = line.substr(0, pos);
214 const int shots = static_cast<int>(std::strtol(line.substr(pos + 1).c_str(), nullptr, 10));
215 entries.push_back({name, shots});
216 ++count;
217 }
218
219 if (entries.empty()) {
220 initDefaults();
221 }
222 sort();
223 }
224
225 std::string filePath;
226 std::vector<Score> entries;
227 };
228
229} // namespace
230
231namespace demo {
232
233 class PoolWindow final : public mxvk::VK_Window {
234 public:
235 PoolWindow(int width, int height, bool fullscreen, bool enable_vsync, std::string asset_root) : mxvk::VK_Window("3D Pool / MXVK", width, height, fullscreen, MXVK_VALIDATION, enable_vsync), assetRoot(std::move(asset_root)), highScores((std::filesystem::path(assetRoot) / "pool_scores.dat").string()), fallbackWidth(width), fallbackHeight(height) {
236 setFont(assetRoot + "/font.ttf", 24);
237 initSprites();
238 initModels();
239 resetGame();
240 tryOpenFirstGamepad();
241 }
242
243 ~PoolWindow() override {
244 if (device != VK_NULL_HANDLE) {
245 vkDeviceWaitIdle(device);
246 }
247 closeGamepad();
248 cleanupModels();
249 }
250
251 void proc() override {
252 const VkExtent2D extent = getSwapchainExtent();
253 const int renderW = (extent.width > 0U) ? static_cast<int>(extent.width) : fallbackWidth;
254 const int renderH = (extent.height > 0U) ? static_cast<int>(extent.height) : fallbackHeight;
255 fallbackWidth = renderW;
256 fallbackHeight = renderH;
257
258 switch (screen) {
260 procIntro(renderW, renderH);
261 break;
263 procStart(renderW, renderH);
264 break;
265 case GameScreen::Game:
266 procGame(renderW, renderH);
267 break;
269 procScores(renderW, renderH);
270 break;
271 }
272 }
273
274 void event(SDL_Event &e) override {
275 if (e.type == SDL_EVENT_QUIT) {
276 exit();
277 return;
278 }
279
280 if (e.type == SDL_EVENT_GAMEPAD_ADDED) {
281 openGamepad(e.gdevice.which);
282 return;
283 }
284
285 if (e.type == SDL_EVENT_GAMEPAD_REMOVED) {
286 if (gamepad != nullptr && e.gdevice.which == gamepadId) {
287 closeGamepad();
288 tryOpenFirstGamepad();
289 }
290 return;
291 }
292
293 if (e.type == SDL_EVENT_KEY_DOWN) {
294 if (e.key.key == SDLK_ESCAPE) {
295 if (screen == GameScreen::Game && mouseCaptured) {
296 setMouseCapture(false);
297 return;
298 }
299 if (screen == GameScreen::Scores) {
300 setScreen(GameScreen::Intro);
301 } else {
302 exit();
303 }
304 return;
305 }
306
307 if (screen == GameScreen::Scores) {
308 if (enteringName) {
309 if (e.key.key == SDLK_RETURN && !playerName.empty()) {
310 commitScoreEntry();
311 } else if (e.key.key == SDLK_BACKSPACE && !playerName.empty()) {
312 playerName.pop_back();
313 }
314 } else if (e.key.key == SDLK_RETURN) {
315 setScreen(GameScreen::Intro);
316 }
317 return;
318 }
319
320 if (screen == GameScreen::Start) {
321 if (e.key.key == SDLK_RETURN) {
322 resetGame();
323 setScreen(GameScreen::Game);
324 } else if (e.key.key == SDLK_SPACE) {
325 setScreen(GameScreen::Scores);
326 }
327 return;
328 }
329
330 if (screen == GameScreen::Game) {
331 if (e.key.key == SDLK_R) {
332 resetGame();
333 } else if (e.key.key == SDLK_SPACE && phase == GamePhase::Aiming) {
334 phase = GamePhase::Charging;
335 chargeAmount = 0.0f;
336 } else if (e.key.key == SDLK_RETURN && phase == GamePhase::Placing && canPlaceCueBall()) {
337 phase = GamePhase::Aiming;
338 }
339 }
340 return;
341 }
342
343 if (e.type == SDL_EVENT_KEY_UP && screen == GameScreen::Game && e.key.key == SDLK_SPACE && phase == GamePhase::Charging) {
344 shootCueBall();
345 return;
346 }
347
348 if (e.type == SDL_EVENT_TEXT_INPUT && screen == GameScreen::Scores && enteringName) {
349 if (playerName.size() < 15U) {
350 playerName += e.text.text;
351 }
352 return;
353 }
354
355 if (e.type == SDL_EVENT_MOUSE_WHEEL && screen == GameScreen::Game) {
356 const float dy = (e.wheel.y != 0.0f) ? e.wheel.y : e.wheel.integer_y;
357 camZoom -= dy * 1.2f;
358 camZoom = glm::clamp(camZoom, 5.0f, 25.0f);
359 return;
360 }
361
362 if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_RIGHT && screen == GameScreen::Game) {
363 if (!mouseCaptured) {
364 setMouseCapture(true);
365 }
366 mouseCamDragging = true;
367 mouseCamLastX = static_cast<int>(e.button.x);
368 mouseCamLastY = static_cast<int>(e.button.y);
369 return;
370 }
371
372 if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT) {
373 if (screen == GameScreen::Game && !mouseCaptured) {
374 setMouseCapture(true);
375 consumeNextMouseLeftUp = true;
376 return;
377 }
378 onPointerDown(static_cast<int>(e.button.x), static_cast<int>(e.button.y), -1);
379 return;
380 }
381
382 if (e.type == SDL_EVENT_MOUSE_BUTTON_UP && e.button.button == SDL_BUTTON_RIGHT) {
383 mouseCamDragging = false;
384 return;
385 }
386
387 if (e.type == SDL_EVENT_MOUSE_BUTTON_UP && e.button.button == SDL_BUTTON_LEFT) {
388 if (consumeNextMouseLeftUp) {
389 consumeNextMouseLeftUp = false;
390 return;
391 }
392 if (screen == GameScreen::Game && phase == GamePhase::Charging && pointerCharging) {
393 shootCueBall();
394 return;
395 }
396 onPointerUp(static_cast<int>(e.button.x), static_cast<int>(e.button.y), -1);
397 return;
398 }
399
400 if (e.type == SDL_EVENT_MOUSE_MOTION) {
401 if (screen == GameScreen::Game && mouseCaptured) {
402 if (mouseCamDragging) {
403 camAngle += static_cast<float>(e.motion.xrel) * 0.01f;
404 camPitch -= static_cast<float>(e.motion.yrel) * 0.006f;
405 camPitch = glm::clamp(camPitch, 0.30f, 1.25f);
406 return;
407 }
408 onMouseRelativeMove(e.motion.xrel, e.motion.yrel);
409 return;
410 }
411 if (mouseCamDragging && screen == GameScreen::Game) {
412 const int nx = static_cast<int>(e.motion.x);
413 const int ny = static_cast<int>(e.motion.y);
414 const int dx = nx - mouseCamLastX;
415 const int dy = ny - mouseCamLastY;
416 camAngle += static_cast<float>(dx) * 0.01f;
417 camPitch -= static_cast<float>(dy) * 0.006f;
418 camPitch = glm::clamp(camPitch, 0.30f, 1.25f);
419 mouseCamLastX = nx;
420 mouseCamLastY = ny;
421 return;
422 }
423 onPointerMove(static_cast<int>(e.motion.x), static_cast<int>(e.motion.y), -1);
424 return;
425 }
426
427 if (e.type == SDL_EVENT_FINGER_DOWN) {
428 const int px = static_cast<int>(e.tfinger.x * static_cast<float>(fallbackWidth));
429 const int py = static_cast<int>(e.tfinger.y * static_cast<float>(fallbackHeight));
430 touchPoints[static_cast<int64_t>(e.tfinger.fingerID)] = SDL_FPoint{static_cast<float>(px), static_cast<float>(py)};
431 if (screen == GameScreen::Game && touchPoints.size() == 2U) {
432 auto it = touchPoints.begin();
433 const SDL_FPoint a = it->second;
434 ++it;
435 const SDL_FPoint b = it->second;
436 const float dx = a.x - b.x;
437 const float dy = a.y - b.y;
438 touchPinchDistance = std::sqrt(dx * dx + dy * dy);
439 touchPinchActive = true;
440 if (phase == GamePhase::Charging) {
441 phase = GamePhase::Aiming;
442 chargeAmount = 0.0f;
443 }
444 pointerCharging = false;
445 pointerDown = false;
446 activePointerId = std::numeric_limits<int64_t>::min();
447 return;
448 }
449 onPointerDown(px, py, static_cast<int64_t>(e.tfinger.fingerID));
450 return;
451 }
452
453 if (e.type == SDL_EVENT_FINGER_MOTION) {
454 const int px = static_cast<int>(e.tfinger.x * static_cast<float>(fallbackWidth));
455 const int py = static_cast<int>(e.tfinger.y * static_cast<float>(fallbackHeight));
456 touchPoints[static_cast<int64_t>(e.tfinger.fingerID)] = SDL_FPoint{static_cast<float>(px), static_cast<float>(py)};
457 if (screen == GameScreen::Game && touchPinchActive && touchPoints.size() >= 2U) {
458 auto it = touchPoints.begin();
459 const SDL_FPoint a = it->second;
460 ++it;
461 const SDL_FPoint b = it->second;
462 const float dx = a.x - b.x;
463 const float dy = a.y - b.y;
464 const float dist = std::sqrt(dx * dx + dy * dy);
465 const float delta = dist - touchPinchDistance;
466 touchPinchDistance = dist;
467 camZoom -= delta * 0.02f;
468 camZoom = glm::clamp(camZoom, 5.0f, 25.0f);
469 return;
470 }
471 onPointerMove(px, py, static_cast<int64_t>(e.tfinger.fingerID));
472 return;
473 }
474
475 if (e.type == SDL_EVENT_FINGER_UP) {
476 const int px = static_cast<int>(e.tfinger.x * static_cast<float>(fallbackWidth));
477 const int py = static_cast<int>(e.tfinger.y * static_cast<float>(fallbackHeight));
478 const bool wasPinching = touchPinchActive;
479 touchPoints.erase(static_cast<int64_t>(e.tfinger.fingerID));
480 if (touchPinchActive && touchPoints.size() < 2U) {
481 touchPinchActive = false;
482 touchPinchDistance = 0.0f;
483 }
484 if (wasPinching) {
485 pointerCharging = false;
486 pointerDown = false;
487 activePointerId = std::numeric_limits<int64_t>::min();
488 return;
489 }
490 onPointerUp(px, py, static_cast<int64_t>(e.tfinger.fingerID));
491 return;
492 }
493
494 if (e.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) {
495 handleGamepadButtonDown(e.gbutton.button);
496 return;
497 }
498
499 if (e.type == SDL_EVENT_GAMEPAD_BUTTON_UP) {
500 handleGamepadButtonUp(e.gbutton.button);
501 return;
502 }
503 }
504
505 void onSwapchainRecreated() override {
506 feltModel.resize(this);
507 woodModel.resize(this);
508 pocketModel.resize(this);
509 for (auto &ballModel : ballModels) {
510 ballModel.resize(this);
511 }
512 cueStickModel.resize(this);
513 cueAimModel.resize(this);
514 }
515
516 void onRecordCustomRendering(VkCommandBuffer cmd, uint32_t imageIndex) override {
517 if (screen != GameScreen::Game) {
518 return;
519 }
520
521 const VkExtent2D extent = getSwapchainExtent();
522 if (extent.width == 0U || extent.height == 0U) {
523 return;
524 }
525
526 if (backgroundSprite != nullptr) {
527 backgroundSprite->setShaderParams(1.0f, 1.0f, 1.0f, 1.0f);
528 backgroundSprite->drawSpriteRect(0, 0, static_cast<int>(extent.width), static_cast<int>(extent.height));
529 backgroundSprite->renderSprites(cmd, backgroundSprite->getPipelineLayout(), extent.width, extent.height);
530 backgroundSprite->clearQueue();
531 }
532
533 const float aspect = static_cast<float>(extent.width) / static_cast<float>(extent.height);
534 const float time = static_cast<float>(SDL_GetTicks()) / 1000.0f;
535
536 const glm::vec3 camPos = getCameraPosition();
537 const glm::mat4 view = glm::lookAt(camPos, glm::vec3{0.0f, 0.0f, 0.0f}, glm::vec3{0.0f, 1.0f, 0.0f});
538 glm::mat4 proj = glm::perspective(glm::radians(45.0f), aspect, 0.1f, 100.0f);
539 proj[1][1] *= -1.0f;
540
541 drawModel(feltModel, imageIndex, cmd, view, proj, composeTransform(glm::vec3{0.0f}, glm::vec3{1.0f}), glm::vec4{1.0f, 1.0f, 1.0f, 1.0f}, time);
542 drawModel(woodModel, imageIndex, cmd, view, proj, composeTransform(glm::vec3{0.0f}, glm::vec3{1.0f}), glm::vec4{0.4f, 0.22f, 0.05f, 1.0f}, time);
543 drawModel(pocketModel, imageIndex, cmd, view, proj, composeTransform(glm::vec3{0.0f}, glm::vec3{1.0f}), glm::vec4{0.08f, 0.08f, 0.08f, 1.0f}, time);
544
545 for (int i = 0; i < NUM_BALLS; ++i) {
546 if (!balls[i].active || balls[i].pocketed) {
547 continue;
548 }
549 glm::mat4 m{1.0f};
550 m = glm::translate(m, glm::vec3{balls[i].pos.x, BALL_RADIUS, balls[i].pos.y});
551 m = glm::rotate(m, balls[i].spinAngle, glm::vec3{0.0f, 1.0f, 0.0f});
552 m = glm::scale(m, glm::vec3{BALL_RADIUS});
553 drawModel(ballModels[static_cast<std::size_t>(i)], imageIndex, cmd, view, proj, m, glm::vec4{BALL_COLORS[static_cast<std::size_t>(i)], 1.0f}, time);
554 }
555
556 for (const auto &anim : sinkAnims) {
557 const float t = anim.timer / SINK_DURATION;
558 const float y = glm::mix(BALL_RADIUS, -BALL_RADIUS * 3.5f, t);
559 const float sc = BALL_RADIUS * (1.0f - t * 0.75f);
560 glm::mat4 m{1.0f};
561 m = glm::translate(m, glm::vec3{anim.pocketPos.x, y, anim.pocketPos.y});
562 m = glm::rotate(m, anim.spinAngle + t * 6.0f, glm::vec3{0.0f, 1.0f, 0.0f});
563 m = glm::scale(m, glm::vec3{sc});
564 const std::size_t sinkIndex = static_cast<std::size_t>(glm::clamp(anim.ballIndex, 0, NUM_BALLS - 1));
565 drawModel(ballModels[sinkIndex], imageIndex, cmd, view, proj, m, glm::vec4{anim.color, 1.0f}, time);
566 }
567
568 if ((phase == GamePhase::Aiming || phase == GamePhase::Charging) && balls[0].active) {
569 const float offset = (phase == GamePhase::Charging) ? (chargeAmount / MAX_POWER) : 0.0f;
570 const float stickDist = BALL_RADIUS + 0.3f + offset;
571 const float worldCueAngle = cueAngle + camAngle;
572 const glm::vec2 dir{std::cos(worldCueAngle), std::sin(worldCueAngle)};
573
574 const glm::vec2 stickCenter = balls[0].pos - dir * (stickDist + CUE_LENGTH * 0.5f);
575 glm::mat4 cueTransform{1.0f};
576 cueTransform = glm::translate(cueTransform, glm::vec3{stickCenter.x, BALL_RADIUS + 0.05f, stickCenter.y});
577 cueTransform = glm::rotate(cueTransform, -worldCueAngle, glm::vec3{0.0f, 1.0f, 0.0f});
578 cueTransform = glm::scale(cueTransform, glm::vec3{CUE_LENGTH * 0.5f, CUE_THICKNESS, CUE_THICKNESS});
579
580 const float pct = chargeAmount / MAX_POWER;
581 const glm::vec4 cueColor = (phase == GamePhase::Charging) ? glm::vec4{0.55f + pct * 0.45f, 0.27f * (1.0f - pct), 0.07f * (1.0f - pct), 1.0f} : glm::vec4{0.55f, 0.27f, 0.07f, 1.0f};
582 drawModel(cueStickModel, imageIndex, cmd, view, proj, cueTransform, cueColor, time);
583
584 const glm::vec2 aimCenter = balls[0].pos + dir * (BALL_RADIUS + AIM_LENGTH * 0.5f);
585 glm::mat4 aimTransform{1.0f};
586 aimTransform = glm::translate(aimTransform, glm::vec3{aimCenter.x, BALL_RADIUS + 0.06f, aimCenter.y});
587 aimTransform = glm::rotate(aimTransform, -worldCueAngle, glm::vec3{0.0f, 1.0f, 0.0f});
588 aimTransform = glm::scale(aimTransform, glm::vec3{AIM_LENGTH * 0.5f, AIM_THICKNESS_Y, AIM_THICKNESS_Z});
589 drawModel(cueAimModel, imageIndex, cmd, view, proj, aimTransform, glm::vec4{1.0f, 1.0f, 0.0f, 1.0f}, time);
590 }
591 }
592
593 private:
594 static glm::mat4 composeTransform(const glm::vec3 &pos, const glm::vec3 &scale) {
595 glm::mat4 m{1.0f};
596 m = glm::translate(m, pos);
597 m = glm::scale(m, scale);
598 return m;
599 }
600
601 void initSprites() {
602 backgroundSprite = createSprite(assetRoot + "/data/background.png", assetRoot + "/data/sprite_vert.spv", assetRoot + "/data/sprite_frag.spv");
603 startSprite = createSprite(assetRoot + "/data/start.png", assetRoot + "/data/sprite_vert.spv", assetRoot + "/data/sprite_frag.spv");
604 scoresSprite = createSprite(assetRoot + "/data/scores.png", assetRoot + "/data/sprite_vert.spv", assetRoot + "/data/sprite_frag.spv");
605 introSprite = createSprite(assetRoot + "/data/logo.png", assetRoot + "/data/sprite_vert.spv", assetRoot + "/data/bend_dir.spv");
606 }
607
608 void initModels() {
609 loadModel(feltModel, assetRoot + "/data/pooltable_felt.mxmod.z");
610 loadModel(woodModel, assetRoot + "/data/pooltable_wood.mxmod.z");
611 loadModel(pocketModel, assetRoot + "/data/pooltable_pocket.mxmod.z");
612
613 for (auto &ballModel : ballModels) {
614 loadModel(ballModel, assetRoot + "/data/geosphere.mxmod.z");
615 }
616 loadModel(cueStickModel, assetRoot + "/data/cube.mxmod.z", 2.0f);
617 loadModel(cueAimModel, assetRoot + "/data/cube.mxmod.z", 2.0f);
618
619 applyPrimaryTexture(feltModel, assetRoot + "/data/table.png");
620 }
621
622 void cleanupModels() {
623 feltModel.cleanup(this);
624 woodModel.cleanup(this);
625 pocketModel.cleanup(this);
626 for (auto &ballModel : ballModels) {
627 ballModel.cleanup(this);
628 }
629 cueStickModel.cleanup(this);
630 cueAimModel.cleanup(this);
631 }
632
633 void loadModel(mxvk::VKAbstractModel &model, const std::string &path, float scale = 1.0f) {
634 model.load(this, path, "", assetRoot + "/data", scale);
635 model.setShaders(this, assetRoot + "/data/model.vert.spv", assetRoot + "/data/model.frag.spv");
636 }
637
638 void drawModel(mxvk::VKAbstractModel &model, uint32_t imageIndex, VkCommandBuffer cmd, const glm::mat4 &view, const glm::mat4 &proj, const glm::mat4 &transform, const glm::vec4 &fx, float time) {
639 mxvk::UniformBufferObject ubo{};
640 // Keep pool-world coordinates aligned with legacy gameplay physics units.
641 ubo.model = transform;
642 ubo.view = view;
643 ubo.proj = proj;
644 ubo.fx = glm::vec4(fx.r, fx.g, fx.b, time);
645 model.updateUBO(imageIndex, ubo);
646 model.render(cmd, imageIndex, false);
647 }
648
649 void applyPrimaryTexture(mxvk::VKAbstractModel &model, const std::string &texturePath) {
650 SDL_Surface *surface = mxvk::LoadPNG(texturePath.c_str());
651 if (surface == nullptr) {
652 return;
653 }
654
655 SDL_Surface *rgba = SDL_ConvertSurface(surface, SDL_PIXELFORMAT_RGBA32);
656 SDL_DestroySurface(surface);
657 if (rgba == nullptr) {
658 return;
659 }
660
661 const int pitch = static_cast<int>(rgba->pitch);
662 [[maybe_unused]] const bool texture_updated = model.updatePrimaryTexture(rgba->pixels, rgba->w, rgba->h, pitch);
663 SDL_DestroySurface(rgba);
664 }
665
666 void setScreen(GameScreen next) {
667 if (screen == GameScreen::Scores && next != GameScreen::Scores) {
668 SDL_StopTextInput(window.get());
669 setFont(assetRoot + "/font.ttf", 24);
670 scoreFontSize = 0;
671 }
672 if (screen != GameScreen::Scores && next == GameScreen::Scores && enteringName) {
673 SDL_StartTextInput(window.get());
674 }
675 screen = next;
676 if (screen == GameScreen::Game) {
677 setMouseCapture(true);
678 } else {
679 setMouseCapture(false);
680 }
681 }
682
683 void setMouseCapture(bool enabled) {
684 if (mouseCaptured == enabled) {
685 return;
686 }
687 mouseCaptured = enabled;
688 [[maybe_unused]] const bool mouse_grabbed = SDL_SetWindowMouseGrab(window.get(), enabled);
689 [[maybe_unused]] const bool relative_mouse_mode = SDL_SetWindowRelativeMouseMode(window.get(), enabled);
690 if (enabled) {
691 SDL_HideCursor();
692 } else {
693 SDL_ShowCursor();
694 }
695 mouseCamDragging = false;
696 }
697
698 void procIntro(int width, int height) {
699 if (startTicks == 0U) {
700 startTicks = SDL_GetTicks();
701 }
702
703 const Uint64 elapsed = SDL_GetTicks() - startTicks;
704 constexpr Uint64 TOTAL = 5000;
705 constexpr Uint64 FADE_DUR = 1500;
706
707 float fadeOut = 1.0f;
708 if (elapsed > (TOTAL - FADE_DUR)) {
709 fadeOut = 1.0f - static_cast<float>(elapsed - (TOTAL - FADE_DUR)) / static_cast<float>(FADE_DUR);
710 }
711
712 if (startSprite != nullptr) {
713 startSprite->setShaderParams(1.0f, 1.0f, 1.0f, 1.0f);
714 startSprite->drawSpriteRect(0, 0, width, height);
715 }
716 if (introSprite != nullptr) {
717 introSprite->setShaderParams(fadeOut, 1.0f, 1.0f, static_cast<float>(SDL_GetTicks()) / 1000.0f);
718 introSprite->drawSpriteRect(0, 0, width, height);
719 }
720
721 if (elapsed >= TOTAL) {
722 startTicks = 0U;
723 setScreen(GameScreen::Start);
724 }
725 }
726
727 void procStart(int width, int height) {
728 if (startSprite != nullptr) {
729 startSprite->setShaderParams(1.0f, 1.0f, 1.0f, 1.0f);
730 startSprite->drawSpriteRect(0, 0, width, height);
731 }
732
733 const char *hint = "ENTER / A - Play";
734 int tw = 0;
735 int th = 0;
736 [[maybe_unused]] const bool hint_dims = getTextDimensions(hint, tw, th);
737 const int x = width / 2 - tw / 2;
738 const int y = height - (th * 3) + 20;
739 printText(hint, x, y, SDL_Color{220, 220, 100, 255});
740 updateStartClickTargets();
741 }
742
743 void procScores(int width, int height) {
744 if (scoresSprite != nullptr) {
745 scoresSprite->setShaderParams(1.0f, 1.0f, 1.0f, 1.0f);
746 scoresSprite->drawSpriteRect(0, 0, width, height);
747 }
748
749 const int feltL = static_cast<int>(width * 0.125f);
750 const int feltT = static_cast<int>(height * 0.19f);
751 const int feltB = static_cast<int>(height * 0.87f);
752 const int feltH = feltB - feltT;
753 const int feltCX = static_cast<int>(width * 0.48f);
754 const int fs = std::max(10, feltH / 15);
755
756 if (fs != scoreFontSize) {
757 setFont(assetRoot + "/font.ttf", fs);
758 scoreFontSize = fs;
759 }
760
761 const int lineH = fs + fs / 3;
762 const auto &list = highScores.list();
763 for (std::size_t i = 0; i < list.size() && i < 10U; ++i) {
764 std::ostringstream ss;
765 ss << (i + 1U) << ". " << list[i].name << " " << list[i].shots << " shots";
766 SDL_Color color{255, 255, 255, 255};
767 printText(ss.str(), feltL + fs / 2, feltT + static_cast<int>(i) * lineH, color);
768 }
769
770 if (enteringName) {
771 const int entryY = feltT + 10 * lineH + lineH / 2;
772 std::ostringstream ys;
773 ys << "Your score: " << finalScore << " shots";
774
775 int tw = 0;
776 int th = 0;
777 [[maybe_unused]] const bool score_dims = getTextDimensions(ys.str(), tw, th);
778 printText(ys.str(), feltCX - tw / 2, entryY, SDL_Color{255, 255, 0, 255});
779
780 const std::string dn = playerName + "_";
781 [[maybe_unused]] const bool name_dims = getTextDimensions(dn, tw, th);
782 printText(dn, feltCX - tw / 2, entryY + lineH, SDL_Color{0, 255, 255, 255});
783
784 const std::string confirm = "ENTER to confirm";
785 const std::string del = "BACKSPACE to delete";
786 int cw = 0;
787 int ch = 0;
788 [[maybe_unused]] const bool confirm_dims = getTextDimensions(confirm, cw, ch);
789 printText(confirm, feltCX - cw - fs / 3, entryY + lineH * 2, SDL_Color{200, 200, 200, 255});
790 int dw = 0;
791 int dh = 0;
792 [[maybe_unused]] const bool delete_dims = getTextDimensions(del, dw, dh);
793 printText(del, feltCX + fs / 3, entryY + lineH * 2, SDL_Color{200, 200, 200, 255});
794
795 scoresConfirmRect = makeTextRect(confirm, feltCX - cw - fs / 3, entryY + lineH * 2, 10);
796 scoresDeleteRect = makeTextRect(del, feltCX + fs / 3, entryY + lineH * 2, 10);
797 scoresReturnRect = SDL_Rect{0, 0, 0, 0};
798 } else {
799 const std::string ret = "Press ENTER to return to intro";
800 int tw = 0;
801 int th = 0;
802 [[maybe_unused]] const bool return_dims = getTextDimensions(ret, tw, th);
803 const int x = feltCX - tw / 2;
804 const int y = feltB - lineH;
805 printText(ret, x, y, SDL_Color{255, 255, 0, 255});
806 scoresReturnRect = makeTextRect(ret, x, y, 12);
807 scoresConfirmRect = SDL_Rect{0, 0, 0, 0};
808 scoresDeleteRect = SDL_Rect{0, 0, 0, 0};
809 }
810 }
811
812 void procGame(int width, int height) {
813 const float now = static_cast<float>(SDL_GetTicks()) / 1000.0f;
814 float dt = now - lastTime;
815 if (dt > 0.05f) {
816 dt = 0.05f;
817 }
818 lastTime = now;
819
820 handleGameState(dt);
821 handleCameraAndInput(dt);
822
823 printText("Shots: " + std::to_string(shotCount), 15, 45, SDL_Color{255, 255, 0, 255});
824 int rem = 0;
825 for (int i = 1; i < NUM_BALLS; ++i) {
826 if (balls[i].active && !balls[i].pocketed) {
827 ++rem;
828 }
829 }
830 printText("Balls: " + std::to_string(rem), 15, 75, SDL_Color{200, 200, 200, 255});
831
832 if (phase == GamePhase::Aiming) {
833 printText("Mouse: move aim + hold/release | Right-drag: rotate table | Wheel/Pinch: zoom", 15, height - 40, SDL_Color{180, 180, 180, 255});
834 } else if (phase == GamePhase::Charging) {
835 const int pct = static_cast<int>(chargeAmount / MAX_POWER * 100.0f);
836 printText("Power: " + std::to_string(pct) + "%", 15, 105, SDL_Color{255, static_cast<Uint8>(std::max(0, 255 - pct * 2)), 0, 255});
837 } else if (phase == GamePhase::Placing) {
838 printText("Mouse move: place cue by camera direction | Click/Enter/A/B: place", 15, height - 40, SDL_Color{255, 100, 100, 255});
839 }
840 }
841
842 void handleGameState(float dt) {
843 switch (phase) {
845 handleAiming(dt);
846 break;
848 handleCharging(dt);
849 break;
851 updatePhysics(dt);
852 if (!anyBallMoving()) {
853 if (balls[0].pocketed) {
854 phase = GamePhase::Placing;
855 balls[0].active = true;
856 balls[0].pocketed = false;
857 balls[0].pos = glm::vec2{-TABLE_HALF_W * 0.5f, 0.0f};
858 balls[0].vel = glm::vec2{0.0f};
859 } else {
860 phase = GamePhase::Aiming;
861 }
862 checkGameOver();
863 }
864 break;
866 handlePlacing(dt);
867 break;
869 break;
870 }
871
872 for (auto &ball : balls) {
873 if (ball.active && ball.isMoving()) {
874 ball.spinAngle += glm::length(ball.vel) * 5.0f * dt;
875 }
876 }
877
878 for (auto &anim : sinkAnims) {
879 anim.timer += dt;
880 }
881 sinkAnims.erase(std::remove_if(sinkAnims.begin(), sinkAnims.end(), [](const SinkAnim &anim) { return anim.timer >= SINK_DURATION; }), sinkAnims.end());
882 }
883
884 void handleCameraAndInput(float dt) {
885 const bool *keys = SDL_GetKeyboardState(nullptr);
886 if (keys[SDL_SCANCODE_A]) {
887 camAngle -= 1.2f * dt;
888 }
889 if (keys[SDL_SCANCODE_S]) {
890 camAngle += 1.2f * dt;
891 }
892 if (keys[SDL_SCANCODE_W]) {
893 camZoom -= 5.0f * dt;
894 }
895 if (keys[SDL_SCANCODE_E]) {
896 camZoom += 5.0f * dt;
897 }
898 camZoom = glm::clamp(camZoom, 5.0f, 25.0f);
899
900 if (gamepad == nullptr) {
901 return;
902 }
903
904 constexpr float dead = 0.15f;
905 const auto readAxis = [this](SDL_GamepadAxis axis) { return static_cast<float>(SDL_GetGamepadAxis(gamepad, axis)) / 32767.0f; };
906
907 const float rx = readAxis(SDL_GAMEPAD_AXIS_RIGHTX);
908 const float ry = readAxis(SDL_GAMEPAD_AXIS_RIGHTY);
909 if (std::fabs(rx) > dead) {
910 camAngle += rx * 1.5f * dt;
911 }
912 if (std::fabs(ry) > dead) {
913 camZoom += ry * 5.0f * dt;
914 }
915 camZoom = glm::clamp(camZoom, 5.0f, 25.0f);
916
917 const float lx = readAxis(SDL_GAMEPAD_AXIS_LEFTX);
918 const float ly = readAxis(SDL_GAMEPAD_AXIS_LEFTY);
919 if (phase == GamePhase::Aiming || phase == GamePhase::Charging) {
920 if (std::fabs(lx) > dead) {
921 cueAngle += lx * 1.5f * dt;
922 }
923 } else if (phase == GamePhase::Placing) {
924 const float s = 3.0f * dt;
925 const glm::vec2 camRight{std::cos(camAngle), -std::sin(camAngle)};
926 const glm::vec2 camFwd{-std::sin(camAngle), -std::cos(camAngle)};
927 if (std::fabs(lx) > dead) {
928 balls[0].pos += camRight * (lx * s);
929 }
930 if (std::fabs(ly) > dead) {
931 balls[0].pos -= camFwd * (ly * s);
932 }
933 clampCueBall();
934 }
935 }
936
937 void handleGamepadButtonDown(Uint8 button) {
938 if (screen == GameScreen::Intro) {
939 if (button == SDL_GAMEPAD_BUTTON_SOUTH || button == SDL_GAMEPAD_BUTTON_START) {
940 setScreen(GameScreen::Start);
941 }
942 return;
943 }
944
945 if (screen == GameScreen::Scores) {
946 if (!enteringName && (button == SDL_GAMEPAD_BUTTON_SOUTH || button == SDL_GAMEPAD_BUTTON_EAST || button == SDL_GAMEPAD_BUTTON_START)) {
947 setScreen(GameScreen::Intro);
948 }
949 return;
950 }
951
952 if (screen == GameScreen::Start) {
953 if (button == SDL_GAMEPAD_BUTTON_SOUTH || button == SDL_GAMEPAD_BUTTON_START) {
954 resetGame();
955 setScreen(GameScreen::Game);
956 } else if (button == SDL_GAMEPAD_BUTTON_NORTH) {
957 setScreen(GameScreen::Scores);
958 } else if (button == SDL_GAMEPAD_BUTTON_BACK) {
959 exit();
960 }
961 return;
962 }
963
964 if (screen != GameScreen::Game) {
965 return;
966 }
967
968 if (button == SDL_GAMEPAD_BUTTON_BACK) {
969 exit();
970 return;
971 }
972
973 if ((button == SDL_GAMEPAD_BUTTON_SOUTH || button == SDL_GAMEPAD_BUTTON_EAST) && phase == GamePhase::Aiming) {
974 phase = GamePhase::Charging;
975 chargeAmount = 0.0f;
976 ctrlChargeButton = static_cast<int>(button);
977 } else if ((button == SDL_GAMEPAD_BUTTON_SOUTH || button == SDL_GAMEPAD_BUTTON_EAST) && phase == GamePhase::Placing && canPlaceCueBall()) {
978 phase = GamePhase::Aiming;
979 }
980 }
981
982 void handleGamepadButtonUp(Uint8 button) {
983 if (screen == GameScreen::Game && phase == GamePhase::Charging && static_cast<int>(button) == ctrlChargeButton) {
984 shootCueBall();
985 ctrlChargeButton = -1;
986 }
987 }
988
989 bool openGamepad(SDL_JoystickID id) {
990 if (gamepad != nullptr && gamepadId == id) {
991 return true;
992 }
993
994 closeGamepad();
995 gamepad = SDL_OpenGamepad(id);
996 if (gamepad == nullptr) {
997 return false;
998 }
999 gamepadId = id;
1000 return true;
1001 }
1002
1003 void tryOpenFirstGamepad() {
1004 if (gamepad != nullptr) {
1005 return;
1006 }
1007 int count = 0;
1008 SDL_JoystickID *ids = SDL_GetGamepads(&count);
1009 if (ids == nullptr || count <= 0) {
1010 if (ids != nullptr) {
1011 SDL_free(ids);
1012 }
1013 return;
1014 }
1015 openGamepad(ids[0]);
1016 SDL_free(ids);
1017 }
1018
1019 void closeGamepad() {
1020 if (gamepad != nullptr) {
1021 SDL_CloseGamepad(gamepad);
1022 gamepad = nullptr;
1023 }
1024 gamepadId = 0;
1025 }
1026
1027 void resetGame() {
1028 rackBalls();
1029 sinkAnims.clear();
1030 phase = GamePhase::Aiming;
1031 cueAngle = 0.0f;
1032 chargeAmount = 0.0f;
1033 shotCount = 0;
1034 pointerDown = false;
1035 pointerCharging = false;
1036 activePointerId = std::numeric_limits<int64_t>::min();
1037 mouseCamDragging = false;
1038 touchPinchActive = false;
1039 touchPinchDistance = 0.0f;
1040 touchPoints.clear();
1041 lastTime = static_cast<float>(SDL_GetTicks()) / 1000.0f;
1042 }
1043
1044 void rackBalls() {
1045 balls[0] = {};
1046 balls[0].pos = glm::vec2{-TABLE_HALF_W * 0.5f, 0.0f};
1047 balls[0].active = true;
1048 balls[0].number = 0;
1049
1050 const int order[15] = {1, 2, 3, 8, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15};
1051 const float sx = TABLE_HALF_W * 0.3f;
1052 const float sp = BALL_RADIUS * 2.1f;
1053 int idx = 0;
1054
1055 for (int row = 0; row < 5; ++row) {
1056 for (int col = 0; col <= row; ++col) {
1057 if (idx >= 15) {
1058 break;
1059 }
1060 const int bn = order[idx++];
1061 balls[bn] = {};
1062 balls[bn].pos = glm::vec2{sx + row * sp * 0.866f, (col - row * 0.5f) * sp};
1063 balls[bn].active = true;
1064 balls[bn].number = bn;
1065 balls[bn].spinAngle = randFloat(0.0f, 2.0f * PI);
1066 }
1067 }
1068 }
1069
1070 void handleAiming(float dt) {
1071 const bool *keys = SDL_GetKeyboardState(nullptr);
1072 if (keys[SDL_SCANCODE_LEFT]) {
1073 cueAngle -= 1.5f * dt;
1074 }
1075 if (keys[SDL_SCANCODE_RIGHT]) {
1076 cueAngle += 1.5f * dt;
1077 }
1078 }
1079
1080 void handleCharging(float dt) {
1081 const bool *keys = SDL_GetKeyboardState(nullptr);
1082 if (keys[SDL_SCANCODE_LEFT]) {
1083 cueAngle -= 1.5f * dt;
1084 }
1085 if (keys[SDL_SCANCODE_RIGHT]) {
1086 cueAngle += 1.5f * dt;
1087 }
1088 chargeAmount += MAX_POWER * 0.8f * dt;
1089 if (chargeAmount > MAX_POWER) {
1090 chargeAmount = MAX_POWER;
1091 }
1092 }
1093
1094 void handlePlacing(float dt) {
1095 const bool *keys = SDL_GetKeyboardState(nullptr);
1096 const float s = 3.0f * dt;
1097 const glm::vec2 camRight{std::cos(camAngle), -std::sin(camAngle)};
1098 const glm::vec2 camFwd{-std::sin(camAngle), -std::cos(camAngle)};
1099
1100 if (keys[SDL_SCANCODE_LEFT]) {
1101 balls[0].pos -= camRight * s;
1102 }
1103 if (keys[SDL_SCANCODE_RIGHT]) {
1104 balls[0].pos += camRight * s;
1105 }
1106 if (keys[SDL_SCANCODE_UP]) {
1107 balls[0].pos += camFwd * s;
1108 }
1109 if (keys[SDL_SCANCODE_DOWN]) {
1110 balls[0].pos -= camFwd * s;
1111 }
1112
1113 clampCueBall();
1114 }
1115
1116 void clampCueBall() {
1117 const float m = BALL_RADIUS + 0.1f;
1118 balls[0].pos.x = glm::clamp(balls[0].pos.x, -TABLE_HALF_W + m, TABLE_HALF_W - m);
1119 balls[0].pos.y = glm::clamp(balls[0].pos.y, -TABLE_HALF_H + m, TABLE_HALF_H - m);
1120 }
1121
1122 void updatePhysics(float dt) {
1123 float maxSpeed = 0.0f;
1124 for (const auto &ball : balls) {
1125 if (ball.active && !ball.pocketed) {
1126 maxSpeed = std::max(maxSpeed, glm::length(ball.vel));
1127 }
1128 }
1129
1130 const float maxMovePerStep = BALL_RADIUS * 0.75f;
1131 const int steps = std::max(8, static_cast<int>(std::ceil(maxSpeed * dt * 60.0f / maxMovePerStep)));
1132 const float sub = dt / static_cast<float>(steps);
1133 const float stepFriction = std::pow(FRICTION, 4.0f / static_cast<float>(steps));
1134
1135 for (int s = 0; s < steps; ++s) {
1136 for (auto &ball : balls) {
1137 if (!ball.active || ball.pocketed) {
1138 continue;
1139 }
1140 ball.pos += ball.vel * sub * 60.0f;
1141 }
1142
1143 for (int i = 0; i < NUM_BALLS; ++i) {
1144 if (!balls[i].active || balls[i].pocketed) {
1145 continue;
1146 }
1147 for (int j = i + 1; j < NUM_BALLS; ++j) {
1148 if (!balls[j].active || balls[j].pocketed) {
1149 continue;
1150 }
1151 resolveBall(balls[i], balls[j]);
1152 }
1153 }
1154
1155 for (auto &ball : balls) {
1156 if (!ball.active || ball.pocketed) {
1157 continue;
1158 }
1159 resolveWall(ball);
1160 }
1161
1162 for (auto &ball : balls) {
1163 if (!ball.active || ball.pocketed) {
1164 continue;
1165 }
1166 checkPocket(ball);
1167 }
1168
1169 for (auto &ball : balls) {
1170 if (!ball.active || ball.pocketed) {
1171 continue;
1172 }
1173 ball.vel *= stepFriction;
1174 if (glm::length(ball.vel) < MIN_SPEED) {
1175 ball.vel = glm::vec2{0.0f};
1176 }
1177 }
1178 }
1179 }
1180
1181 void resolveBall(PoolBall &a, PoolBall &b) {
1182 const glm::vec2 d = b.pos - a.pos;
1183 const float dist = glm::length(d);
1184 const float minD = BALL_RADIUS * 2.0f;
1185 if (dist >= minD || dist <= 0.0001f) {
1186 return;
1187 }
1188
1189 const glm::vec2 n = d / dist;
1190 const float overlap = minD - dist;
1191 a.pos -= n * (overlap * 0.5f);
1192 b.pos += n * (overlap * 0.5f);
1193
1194 const float rv = glm::dot(a.vel - b.vel, n);
1195 if (rv > 0.0f) {
1196 a.vel -= n * rv;
1197 b.vel += n * rv;
1198 }
1199 }
1200
1201 void resolveWall(PoolBall &ball) {
1202 const float l = -TABLE_HALF_W + BALL_RADIUS;
1203 const float r = TABLE_HALF_W - BALL_RADIUS;
1204 const float t = -TABLE_HALF_H + BALL_RADIUS;
1205 const float b = TABLE_HALF_H - BALL_RADIUS;
1206
1207 if (ball.pos.x < l) {
1208 ball.pos.x = l;
1209 ball.vel.x = -ball.vel.x * 0.8f;
1210 }
1211 if (ball.pos.x > r) {
1212 ball.pos.x = r;
1213 ball.vel.x = -ball.vel.x * 0.8f;
1214 }
1215 if (ball.pos.y < t) {
1216 ball.pos.y = t;
1217 ball.vel.y = -ball.vel.y * 0.8f;
1218 }
1219 if (ball.pos.y > b) {
1220 ball.pos.y = b;
1221 ball.vel.y = -ball.vel.y * 0.8f;
1222 }
1223 }
1224
1225 void checkPocket(PoolBall &ball) {
1226 for (const auto &pocket : POCKETS) {
1227 if (glm::length(ball.pos - pocket) < POCKET_R) {
1228 const int idx = static_cast<int>(&ball - &balls[0]);
1229 sinkAnims.push_back(SinkAnim{pocket, BALL_COLORS[static_cast<std::size_t>(idx)], ball.spinAngle, 0.0f, idx});
1230 ball.pocketed = true;
1231 ball.vel = glm::vec2{0.0f};
1232 return;
1233 }
1234 }
1235 }
1236
1237 [[nodiscard]] bool anyBallMoving() const {
1238 for (const auto &ball : balls) {
1239 if (ball.active && !ball.pocketed && ball.isMoving()) {
1240 return true;
1241 }
1242 }
1243 return !sinkAnims.empty();
1244 }
1245
1246 [[nodiscard]] bool allObjectBallsPocketed() const {
1247 for (int i = 1; i < NUM_BALLS; ++i) {
1248 if (balls[i].active && !balls[i].pocketed) {
1249 return false;
1250 }
1251 }
1252 return true;
1253 }
1254
1255 void checkGameOver() {
1256 if (!allObjectBallsPocketed()) {
1257 return;
1258 }
1259 finalScore = shotCount;
1260 playerName.clear();
1261 enteringName = highScores.qualifies(finalScore);
1262 if (enteringName) {
1263 SDL_StartTextInput(window.get());
1264 }
1265 setScreen(GameScreen::Scores);
1266 }
1267
1268 void commitScoreEntry() {
1269 if (playerName.empty()) {
1270 return;
1271 }
1272 highScores.addScore(playerName, finalScore);
1273 highScores.write();
1274 enteringName = false;
1275 SDL_StopTextInput(window.get());
1276 }
1277
1278 [[nodiscard]] glm::vec3 getCameraPosition() const {
1279 const float orbitDist = camZoom * CAM_BASE_TOTAL_SCALE;
1280 const float horiz = orbitDist * std::cos(camPitch);
1281 const float y = orbitDist * std::sin(camPitch);
1282 return glm::vec3{std::sin(camAngle) * horiz, y, std::cos(camAngle) * horiz};
1283 }
1284
1285 bool screenPointToTable(int px, int py, glm::vec2 &out) const {
1286 if (fallbackWidth <= 0 || fallbackHeight <= 0) {
1287 return false;
1288 }
1289
1290 const float aspect = static_cast<float>(fallbackWidth) / static_cast<float>(fallbackHeight);
1291 const glm::vec3 camPos = getCameraPosition();
1292 const glm::mat4 view = glm::lookAt(camPos, glm::vec3{0.0f}, glm::vec3{0.0f, 1.0f, 0.0f});
1293 glm::mat4 proj = glm::perspective(glm::radians(45.0f), aspect, 0.1f, 100.0f);
1294 proj[1][1] *= -1.0f;
1295
1296 const float nx = (2.0f * (static_cast<float>(px) + 0.5f) / static_cast<float>(fallbackWidth)) - 1.0f;
1297 const float ny = 1.0f - (2.0f * (static_cast<float>(py) + 0.5f) / static_cast<float>(fallbackHeight));
1298 const glm::vec4 nearClip{nx, ny, 0.0f, 1.0f};
1299 const glm::vec4 farClip{nx, ny, 1.0f, 1.0f};
1300 const glm::mat4 invVP = glm::inverse(proj * view);
1301
1302 glm::vec4 nearWorld = invVP * nearClip;
1303 glm::vec4 farWorld = invVP * farClip;
1304 if (std::fabs(nearWorld.w) < 1e-6f || std::fabs(farWorld.w) < 1e-6f) {
1305 return false;
1306 }
1307
1308 nearWorld /= nearWorld.w;
1309 farWorld /= farWorld.w;
1310
1311 const glm::vec3 ro{nearWorld.x, nearWorld.y, nearWorld.z};
1312 const glm::vec3 rf{farWorld.x, farWorld.y, farWorld.z};
1313 const glm::vec3 rd = glm::normalize(rf - ro);
1314 if (std::fabs(rd.y) < 1e-6f) {
1315 return false;
1316 }
1317
1318 const float t = -ro.y / rd.y;
1319 if (t < 0.0f) {
1320 return false;
1321 }
1322
1323 const glm::vec3 hit = ro + rd * t;
1324 out = glm::vec2{hit.x, hit.z};
1325 return true;
1326 }
1327
1328 void updateCueFromPointer(int px, int py) {
1329 if (screen != GameScreen::Game || !balls[0].active || balls[0].pocketed) {
1330 return;
1331 }
1332
1333 glm::vec2 tablePos{0.0f};
1334 if (!screenPointToTable(px, py, tablePos)) {
1335 return;
1336 }
1337
1338 const glm::vec2 d = tablePos - balls[0].pos;
1339 if (glm::dot(d, d) < 0.00001f) {
1340 return;
1341 }
1342
1343 const float worldAngle = std::atan2(d.y, d.x);
1344 cueAngle = worldAngle - camAngle;
1345 }
1346
1347 void onMouseRelativeMove(int dx, int dy) {
1348 if (screen != GameScreen::Game) {
1349 return;
1350 }
1351 if (phase == GamePhase::Aiming || phase == GamePhase::Charging) {
1352 cueAngle += static_cast<float>(dx) * 0.012f;
1353 return;
1354 }
1355 if (phase == GamePhase::Placing) {
1356 constexpr float MOVE_SCALE = 0.02f;
1357 const glm::vec2 camRight{std::cos(camAngle), -std::sin(camAngle)};
1358 const glm::vec2 camFwd{-std::sin(camAngle), -std::cos(camAngle)};
1359 balls[0].pos += camRight * (static_cast<float>(dx) * MOVE_SCALE);
1360 balls[0].pos -= camFwd * (static_cast<float>(dy) * MOVE_SCALE);
1361 clampCueBall();
1362 }
1363 }
1364
1365 void placeCueBallFromPointer(int px, int py) {
1366 if (phase != GamePhase::Placing || !balls[0].active || balls[0].pocketed) {
1367 return;
1368 }
1369
1370 glm::vec2 tablePos{0.0f};
1371 if (!screenPointToTable(px, py, tablePos)) {
1372 return;
1373 }
1374
1375 balls[0].pos = tablePos;
1376 clampCueBall();
1377 }
1378
1379 [[nodiscard]] bool canPlaceCueBall() const {
1380 for (int i = 1; i < NUM_BALLS; ++i) {
1381 if (!balls[i].active || balls[i].pocketed) {
1382 continue;
1383 }
1384 if (glm::length(balls[0].pos - balls[i].pos) < BALL_RADIUS * 2.5f) {
1385 return false;
1386 }
1387 }
1388 return true;
1389 }
1390
1391 void shootCueBall() {
1392 const float worldCueAngle = cueAngle + camAngle;
1393 const glm::vec2 dir{std::cos(worldCueAngle), std::sin(worldCueAngle)};
1394 balls[0].vel = dir * chargeAmount;
1395 phase = GamePhase::Rolling;
1396 ++shotCount;
1397 chargeAmount = 0.0f;
1398 pointerCharging = false;
1399 pointerDown = false;
1400 activePointerId = std::numeric_limits<int64_t>::min();
1401 }
1402
1403 SDL_Rect makeTextRect(const std::string &txt, int x, int y, int pad) {
1404 int tw = 0;
1405 int th = 0;
1406 [[maybe_unused]] const bool text_dims = getTextDimensions(txt, tw, th);
1407 SDL_Rect r{};
1408 r.x = x - pad;
1409 r.y = y - pad;
1410 r.w = tw + pad * 2;
1411 r.h = th + pad * 2;
1412 return r;
1413 }
1414
1415 SDL_Rect makeNormRect(float cxN, float cyN, float wN, float hN) const {
1416 SDL_Rect r{};
1417 r.w = std::max(1, static_cast<int>(fallbackWidth * wN));
1418 r.h = std::max(1, static_cast<int>(fallbackHeight * hN));
1419 const int cx = static_cast<int>(fallbackWidth * cxN);
1420 const int cy = static_cast<int>(fallbackHeight * cyN);
1421 r.x = cx - r.w / 2;
1422 r.y = cy - r.h / 2;
1423 return r;
1424 }
1425
1426 void updateStartClickTargets() { startPlayRect = makeNormRect(0.50f, 0.78f, 0.28f, 0.11f); }
1427
1428 static bool pointInRect(int x, int y, const SDL_Rect &r) { return x >= r.x && x <= (r.x + r.w) && y >= r.y && y <= (r.y + r.h); }
1429
1430 void onPointerDown(int px, int py, int64_t pointerId) {
1431 if (screen == GameScreen::Intro) {
1432 setScreen(GameScreen::Start);
1433 return;
1434 }
1435
1436 if (screen == GameScreen::Start) {
1437 updateStartClickTargets();
1438 if (pointInRect(px, py, startPlayRect)) {
1439 resetGame();
1440 setScreen(GameScreen::Game);
1441 }
1442 return;
1443 }
1444
1445 if (screen == GameScreen::Scores) {
1446 if (!enteringName) {
1447 if (pointInRect(px, py, scoresReturnRect)) {
1448 setScreen(GameScreen::Intro);
1449 }
1450 return;
1451 }
1452 if (pointInRect(px, py, scoresConfirmRect) && !playerName.empty()) {
1453 commitScoreEntry();
1454 return;
1455 }
1456 if (pointInRect(px, py, scoresDeleteRect) && !playerName.empty()) {
1457 playerName.pop_back();
1458 }
1459 return;
1460 }
1461
1462 if (screen != GameScreen::Game) {
1463 return;
1464 }
1465 if (pointerId == -1 && !mouseCaptured) {
1466 return;
1467 }
1468 if (activePointerId != std::numeric_limits<int64_t>::min() && activePointerId != pointerId) {
1469 return;
1470 }
1471
1472 pointerDown = true;
1473 activePointerId = pointerId;
1474 const bool isCapturedMousePointer = (pointerId == -1 && mouseCaptured);
1475 if (phase == GamePhase::Aiming) {
1476 // Keep the current cue direction when mouse press starts charging.
1477 // Relative mouse motion updates the cue after the player moves.
1478 if (!isCapturedMousePointer) {
1479 updateCueFromPointer(px, py);
1480 }
1481 phase = GamePhase::Charging;
1482 chargeAmount = 0.0f;
1483 pointerCharging = true;
1484 } else if (phase == GamePhase::Charging) {
1485 if (!isCapturedMousePointer) {
1486 updateCueFromPointer(px, py);
1487 }
1488 pointerCharging = true;
1489 } else if (phase == GamePhase::Placing) {
1490 if (!(pointerId == -1 && mouseCaptured)) {
1491 placeCueBallFromPointer(px, py);
1492 }
1493 }
1494 }
1495
1496 void onPointerMove(int px, int py, int64_t pointerId) {
1497 if (screen != GameScreen::Game) {
1498 return;
1499 }
1500 if (activePointerId != std::numeric_limits<int64_t>::min() && activePointerId != pointerId) {
1501 return;
1502 }
1503
1504 if (phase == GamePhase::Aiming || phase == GamePhase::Charging) {
1505 updateCueFromPointer(px, py);
1506 } else if (phase == GamePhase::Placing && (pointerId != -1 || !mouseCaptured)) {
1507 placeCueBallFromPointer(px, py);
1508 }
1509 }
1510
1511 void onPointerUp(int px, int py, int64_t pointerId) {
1512 if (screen == GameScreen::Start || screen == GameScreen::Scores) {
1513 onPointerDown(px, py, pointerId);
1514 return;
1515 }
1516
1517 if (screen != GameScreen::Game) {
1518 return;
1519 }
1520 if (activePointerId != pointerId) {
1521 return;
1522 }
1523
1524 pointerDown = false;
1525 if (phase == GamePhase::Charging && pointerCharging) {
1526 shootCueBall();
1527 return;
1528 }
1529
1530 if (phase == GamePhase::Placing) {
1531 if (!(pointerId == -1 && mouseCaptured)) {
1532 placeCueBallFromPointer(px, py);
1533 }
1534 if (canPlaceCueBall()) {
1535 phase = GamePhase::Aiming;
1536 }
1537 }
1538
1539 pointerCharging = false;
1540 activePointerId = std::numeric_limits<int64_t>::min();
1541 }
1542
1543 std::string assetRoot;
1544 HighScores highScores;
1545
1546 mxvk::VK_Sprite *backgroundSprite = nullptr;
1547 mxvk::VK_Sprite *startSprite = nullptr;
1548 mxvk::VK_Sprite *introSprite = nullptr;
1549 mxvk::VK_Sprite *scoresSprite = nullptr;
1550
1551 mxvk::VKAbstractModel feltModel{};
1552 mxvk::VKAbstractModel woodModel{};
1553 mxvk::VKAbstractModel pocketModel{};
1554 std::array<mxvk::VKAbstractModel, NUM_BALLS> ballModels{};
1555 mxvk::VKAbstractModel cueStickModel{};
1556 mxvk::VKAbstractModel cueAimModel{};
1557
1558 std::array<PoolBall, NUM_BALLS> balls{};
1559 std::vector<SinkAnim> sinkAnims{};
1560
1563
1564 float cueAngle = 0.0f;
1565 float chargeAmount = 0.0f;
1566 int shotCount = 0;
1567 float lastTime = 0.0f;
1568 float camAngle = 0.4f;
1569 float camPitch = 0.744604f;
1570 float camZoom = 13.0f;
1571
1572 SDL_Gamepad *gamepad = nullptr;
1573 SDL_JoystickID gamepadId = 0;
1574 int ctrlChargeButton = -1;
1575
1576 bool enteringName = false;
1577 std::string playerName;
1578 int finalScore = 0;
1579 int scoreFontSize = 0;
1580
1581 bool pointerDown = false;
1582 bool pointerCharging = false;
1583 int64_t activePointerId = std::numeric_limits<int64_t>::min();
1584 bool consumeNextMouseLeftUp = false;
1585 bool mouseCaptured = false;
1586 bool mouseCamDragging = false;
1587 int mouseCamLastX = 0;
1588 int mouseCamLastY = 0;
1589 bool touchPinchActive = false;
1590 float touchPinchDistance = 0.0f;
1591 std::unordered_map<int64_t, SDL_FPoint> touchPoints;
1592
1593 Uint64 startTicks = 0U;
1594
1595 int fallbackWidth = 1280;
1596 int fallbackHeight = 720;
1597
1598 SDL_Rect startPlayRect{0, 0, 0, 0};
1599 SDL_Rect scoresReturnRect{0, 0, 0, 0};
1600 SDL_Rect scoresConfirmRect{0, 0, 0, 0};
1601 SDL_Rect scoresDeleteRect{0, 0, 0, 0};
1602 };
1603
1604} // namespace demo
1605
1606int main(int argc, char **argv) {
1607 try {
1608 Arguments args = proc_args(argc, argv);
1609 std::string assets = resolveAssetRoot(args.path);
1610 SDL_Log("pool_demo: using asset root: %s", assets.c_str());
1611 demo::PoolWindow window(args.width, args.height, args.fullscreen, args.enable_vsync, assets);
1612 window.loop();
1613 } catch (const mxvk::Exception &e) {
1614 SDL_Log("mxvk: Exception: %s", e.text().c_str());
1615 return EXIT_FAILURE;
1616 }
1617
1618 return EXIT_SUCCESS;
1619}
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:854
void sort()
void addScore(const std::string &name, int shots)
Definition main.cpp:159
const std::vector< Score > & list() const
Definition main.cpp:174
void onRecordCustomRendering(VkCommandBuffer cmd, uint32_t imageIndex) override
Optional hook for derived classes to record extra draw commands.
Definition main.cpp:516
~PoolWindow() override
Definition main.cpp:243
void event(SDL_Event &e) override
Handle one SDL event.
Definition main.cpp:274
PoolWindow(int width, int height, bool fullscreen, bool enable_vsync, std::string asset_root)
Definition main.cpp:235
void onSwapchainRecreated() override
Called after swapchain and render resources are recreated.
Definition main.cpp:505
void proc() override
Execute one processing/update step.
Definition main.cpp:251
std::string text() const
void updateUBO(uint32_t imageIndex, const UniformBufferObject &ubo)
Update one per-frame UBO payload.
void load(VK_Window *window, const std::string &modelPath, const std::string &textureManifestPath, const std::string &textureBasePath, float scale=1.0f)
Load mesh/texture resources and build Vulkan state.
void setShaders(VK_Window *window, const std::string &vertSpv, const std::string &fragSpv)
Configure custom shader paths and rebuild pipelines.
void render(VkCommandBuffer cmd, uint32_t imageIndex, bool wireframe=false) const
Record draw commands for this model.
bool updatePrimaryTexture(const void *pixels, int width, int height, int pitch=0)
Upload raw RGBA pixels into the primary model texture.
Main Vulkan window wrapper for MXVK.
Definition mxvk.hpp:39
VkExtent2D getSwapchainExtent() const noexcept
Get the current swapchain extent.
Definition mxvk.hpp:222
void loop()
Run the main event/render loop.
Definition mxvk.cpp:651
VkDevice device
Definition mxvk.hpp:606
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:4455
bool getTextDimensions(const std::string &text, int &width, int &height)
Measure text dimensions in pixels.
Definition mxvk.cpp:4004
std::unique_ptr< SDL_Window, SDLWindowDeleter > window
Definition mxvk.hpp:601
void exit()
Request loop termination.
Definition mxvk.cpp:1378
VkSurfaceKHR surface
Definition mxvk.hpp:604
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:3872
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:3919
#define POOL_DEMO_ASSET_DIR
Definition main.cpp:29
#define MXVK_VALIDATION
Definition mxvk.hpp:28
High-level model wrapper integrated with MXVK dynamic rendering.
PNG image loading and saving utilities via SDL3.
constexpr float MAX_POWER
Definition main.cpp:48
const std::array< glm::vec2, 6 > POCKETS
Definition main.cpp:54
constexpr float CAM_BASE_TOTAL_SCALE
Definition main.cpp:49
constexpr float BALL_RADIUS
Definition main.cpp:35
constexpr float SINK_DURATION
Definition main.cpp:50
constexpr float AIM_THICKNESS_Z
Definition main.cpp:44
float randFloat(float mn, float mx)
Definition main.cpp:82
constexpr float POCKET_R
Definition main.cpp:38
constexpr float POCKET_INSET
Definition main.cpp:53
constexpr float CUE_LENGTH
Definition main.cpp:40
bool hasPoolAssets(const std::filesystem::path &root)
Definition main.cpp:89
constexpr int NUM_BALLS
Definition main.cpp:45
constexpr float CUE_THICKNESS
Definition main.cpp:41
constexpr float TABLE_W
Definition main.cpp:34
constexpr float MIN_SPEED
Definition main.cpp:47
constexpr float TABLE_H
Definition main.cpp:35
constexpr float AIM_THICKNESS_Y
Definition main.cpp:43
const std::array< glm::vec3, NUM_BALLS > BALL_COLORS
Definition main.cpp:63
constexpr float TABLE_HALF_H
Definition main.cpp:37
constexpr float AIM_LENGTH
Definition main.cpp:42
constexpr float TABLE_HALF_W
Definition main.cpp:36
constexpr float FRICTION
Definition main.cpp:46
Definition main.cpp:231
int main()
Definition main.py:165
Utilities for loading and saving PNG images.
Definition mxvk.hpp:31
SDL_Surface * LoadPNG(const char *file)
Load a PNG file into an SDL_Surface.
Definition mxvk_png.cpp:89
Plain data structure returned by proc_args() with all common libmx2 CLI options.
Definition argz.hpp:718
bool fullscreen
Whether fullscreen mode was requested.
Definition argz.hpp:724
bool enable_vsync
Enable FIFO present mode / v-sync (--enable-vsync).
Definition argz.hpp:738
int height
Viewport height in pixels (default: 720).
Definition argz.hpp:721
std::string path
Asset root; proc_args() defaults it to the executable directory.
Definition argz.hpp:723
int width
Viewport width in pixels (default: 1280).
Definition argz.hpp:720