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