← index
libmx2/libmx/vk_stickball/main.cpp
Source: libmx2/libmx/vk_stickball/main.cpp
#include "vk.hpp"
#include "vk_model.hpp"
#include "loadpng.hpp"
#include "SDL.h"
#include "argz.hpp"
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <random>
#include <cmath>
#include <string>
#include <algorithm>
#include <array>
#include <cstring>
#include <memory>
#include <fstream>
#include <sstream>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
static constexpr float TABLE_W = 12.0f;
static constexpr float TABLE_H = 6.0f;
static constexpr float TABLE_HALF_W = TABLE_W / 2.0f;
static constexpr float TABLE_HALF_H = TABLE_H / 2.0f;
static constexpr float BUMPER_W = 0.3f;
static constexpr float POCKET_R = 0.25f;
static constexpr float BALL_RADIUS = 0.18f;
static constexpr float CUE_LENGTH = 2.5f;
static constexpr int NUM_BALLS = 16;
static constexpr float FRICTION = 0.9988f;
static constexpr float MIN_SPEED = 0.001f;
static constexpr float MAX_POWER = 1.0f;
static constexpr int MAX_DRAWS = 48;
static constexpr float PKT_INS = 0.25f;
static const glm::vec2 POCKETS[6] = {
    { -TABLE_HALF_W + PKT_INS, TABLE_HALF_H - PKT_INS },
    { 0.0f, TABLE_HALF_H - 0.15f },
    { TABLE_HALF_W - PKT_INS, TABLE_HALF_H - PKT_INS },
    { -TABLE_HALF_W + PKT_INS, -TABLE_HALF_H + PKT_INS },
    { 0.0f, -TABLE_HALF_H + 0.15f },
    { TABLE_HALF_W - PKT_INS, -TABLE_HALF_H + PKT_INS },
};
static const glm::vec3 BALL_COLORS[NUM_BALLS] = {
    {1.0f, 1.0f, 1.0f},
    {1.0f, 0.84f, 0.0f},
    {0.0f, 0.0f, 0.8f},
    {0.9f, 0.0f, 0.0f},
    {0.5f, 0.0f, 0.5f},
    {1.0f, 0.5f, 0.0f},
    {0.0f, 0.5f, 0.0f},
    {0.55f, 0.0f, 0.0f},
    {0.1f, 0.1f, 0.1f},
    {1.0f, 0.84f, 0.0f},
    {0.0f, 0.0f, 0.8f},
    {0.9f, 0.0f, 0.0f},
    {0.5f, 0.0f, 0.5f},
    {1.0f, 0.5f, 0.0f},
    {0.0f, 0.5f, 0.0f},
    {0.55f, 0.0f, 0.0f},
};
enum class GameScreen { Intro, Scores, Game, Start };
static float randFloat(float mn, float mx) {
    static std::random_device rd;
    static std::default_random_engine eng(rd());
    std::uniform_real_distribution<float> d(mn, mx);
    return d(eng);
}
struct PoolBall {
    glm::vec2 pos{0.0f};
    glm::vec2 vel{0.0f};
    bool active = true;
    bool pocketed = false;
    int number = 0;
    float spinAngle = 0.0f;
    bool isMoving() const { return glm::length(vel) > MIN_SPEED; }
};
enum class GamePhase { Aiming, Charging, Rolling, Placing, GameOver };
static constexpr float SINK_DURATION = 0.45f;
struct SinkAnim {
    glm::vec2 pocketPos;
    glm::vec3 color;
    float spinAngle;
    float timer = 0.0f;
};
struct Score {
    std::string name;
    int shots; 
    Score() : name{}, shots{0} {}
    Score(const std::string &n, int s) : name(n), shots(s) {}
};
class HighScores {
public:
    HighScores() { read(); }
    ~HighScores() { write(); }
    void addScore(const std::string &name, int shots) {
        entries.push_back({name, shots});
        sort();
        if (entries.size() > 10) entries.resize(10);
    }
    void sort() {
        std::sort(entries.begin(), entries.end(),
            [](const Score &a, const Score &b){ return a.shots < b.shots; });
    }
    bool qualifies(int shots) const {
        if (entries.size() < 10) return true;
        return shots < entries.back().shots;
    }
    const std::vector<Score>& list() const { return entries; }
    void read() {
        std::fstream f("./pool_scores.dat", std::ios::in);
        if (!f.is_open()) { initDefaults(); return; }
        std::string line;
        int count = 0;
        while (std::getline(f, line) && count < 10) {
            auto p = line.find(':');
            if (p != std::string::npos) {
                entries.push_back({line.substr(0, p),
                    (int)std::strtol(line.substr(p+1).c_str(), nullptr, 10)});
                count++;
            }
        }
        sort();
    }
    void write() const {
        std::fstream f("./pool_scores.dat", std::ios::out);
        if (!f.is_open()) return;
        for (auto &e : entries) f << e.name << ":" << e.shots << "\n";
    }
private:
    void initDefaults() {
        for (int i = 1; i <= 10; i++)
            entries.push_back({"Anonymous", i * 20});
    }
    std::vector<Score> entries;
};
class PoolWindow : public mx::VKWindow {
    GameScreen screen = GameScreen::Intro;
public:
    PoolWindow(const std::string &path, int wx, int wy, bool full)
        : mx::VKWindow("-[ 3D Pool / Vulkan ]-", wx, wy, full) {
        setPath(path);
    }
    ~PoolWindow() override = default;
    void initVulkan() override {
        mx::VKWindow::initVulkan();
        if (enableControllerInput()) {
            for (int i = 0; i < SDL_NumJoysticks(); i++) {
                if (SDL_IsGameController(i)) {
                    gameController = SDL_GameControllerOpen(i);
                    if (gameController) {
                        SDL_Log("Controller opened: %s", SDL_GameControllerName(gameController));
                        break;
                    }
                }
            }
        } else {
            SDL_Log("Controller subsystem unavailable: %s", SDL_GetError());
        }
        {
            uint32_t white = 0xFFFFFFFF;
            VkBuffer stg; VkDeviceMemory stgMem;
            createBuffer(4, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
                         VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
                         VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
                         stg, stgMem);
            void *d; vkMapMemory(device, stgMem, 0, 4, 0, &d);
            memcpy(d, &white, 4);
            vkUnmapMemory(device, stgMem);
            copyBufferToImage(stg, textureImage, 1, 1);
            transitionImageLayout(textureImage, VK_FORMAT_R8G8B8A8_UNORM,
                                  VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                                  VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
            vkDestroyBuffer(device, stg, nullptr);
            vkFreeMemory(device, stgMem, nullptr);
        }
        loadTableTexture();
        createObjectPool();
        sphereModel.reset(new mx::MXModel());
        sphereModel->load(util.path + "/data/geosphere.mxmod.z", 1.0f);
        sphereModel->upload(device, physicalDevice, commandPool, graphicsQueue);
        cylinderModel.reset(new mx::MXModel());
        cylinderModel->load(util.path + "/data/pocket.mxmod.z", 0.285714f);
        cylinderModel->upload(device, physicalDevice, commandPool, graphicsQueue);
        cubeModel.reset(new mx::MXModel());
        cubeModel->load(util.path + "/data/cube.mxmod.z", 2.0f);
        cubeModel->upload(device, physicalDevice, commandPool, graphicsQueue);
        tableModel.reset(new mx::MXModel());
        tableModel->load(util.path + "/data/pooltable_felt.mxmod.z", 1.0f);
        tableModel->upload(device, physicalDevice, commandPool, graphicsQueue);
        tableWoodModel.reset(new mx::MXModel());
        tableWoodModel->load(util.path + "/data/pooltable_wood.mxmod.z", 1.0f);
        tableWoodModel->upload(device, physicalDevice, commandPool, graphicsQueue);
        tablePocketModel.reset(new mx::MXModel());
        tablePocketModel->load(util.path + "/data/pooltable_pocket.mxmod.z", 1.0f);
        tablePocketModel->upload(device, physicalDevice, commandPool, graphicsQueue);
        backgroundSprite = createSprite(util.path + "/data/background.png", "", "");
        startscreenSprite = createSprite(util.path + "/data/start.png", "", "");
        scoresBackground = createSprite(util.path + "/data/scores.png", "", "");
        introSprite = createSprite(util.path + "/data/logo.png", util.path + "/data/sprite_vert.spv", util.path + "/data/bend_dir.spv");
    }
    void procIntro() {
        uint32_t cur_time = SDL_GetTicks();
        static uint32_t start_t = 0;
        if(start_t == 0)
            start_t = SDL_GetTicks();
        uint32_t elapsed = cur_time - start_t;
        constexpr uint32_t TOTAL = 5000;
        constexpr uint32_t FADE_DUR = 1500;
        if(elapsed >= TOTAL) {
            start_t = 0;
            setScreen(GameScreen::Start);
            return;
        }
        float fadeOut = 1.0f;
        if(elapsed > TOTAL - FADE_DUR) {
            fadeOut = 1.0f - static_cast<float>(elapsed - (TOTAL - FADE_DUR)) / static_cast<float>(FADE_DUR);
        }
        if(fadeOut < 1.0f && startscreenSprite != nullptr) {
            startscreenSprite->setShaderParams(1.0f, 1.0f, 1.0f, 1.0f);
            startscreenSprite->drawSpriteRect(0, 0, getWidth(), getHeight());
        }
        if(introSprite != nullptr) {
            float time = static_cast<float>(SDL_GetTicks() / 1000.0f);
            introSprite->setShaderParams(fadeOut, 1.0f, 1.0f, time);
            introSprite->drawSpriteRect(0, 0, getWidth(), getHeight());
        }
    }
    void procStart() {
        if(startscreenSprite != nullptr) {
            startscreenSprite->setShaderParams(1.0f, 1.0f, 1.0f, 1.0f);
            startscreenSprite->drawSpriteRect(0, 0, getWidth(), getHeight());
        }
        const char *hint = "ENTER / A  -  Play      SPACE /Y  -  Scores      ESC / Back  -  Quit";
        int tw, th;
        getTextDimensions(hint, tw, th);
        printText(hint, w / 2 - tw / 2, (h - th * 3)+20, {220, 220, 100, 255});
        const Uint8 *keys = SDL_GetKeyboardState(nullptr);
        if(keys[SDL_SCANCODE_RETURN]) {
            resetGame();
            setScreen(GameScreen::Game);
        } else if(keys[SDL_SCANCODE_SPACE]) {
            setScreen(GameScreen::Scores);
        }
    }
    void procGame() {
        float now = SDL_GetTicks() / 1000.0f;
        float dt = now - lastTime;
        if (dt > 0.05f) dt = 0.05f;
        lastTime = now;
        switch (phase) {
        case GamePhase::Aiming: handleAiming(dt); break;
        case GamePhase::Charging: handleCharging(dt); break;
        case GamePhase::Rolling:
            updatePhysics(dt);
            if (!anyBallMoving()) {
                if (balls[0].pocketed) {
                    phase = GamePhase::Placing;
                    balls[0].active = true;
                    balls[0].pocketed = false;
                    balls[0].pos = glm::vec2(-TABLE_HALF_W * 0.5f, 0.0f);
                    balls[0].vel = glm::vec2(0.0f);
                } else {
                    phase = GamePhase::Aiming;
                }
                checkGameOver();
            }
            break;
        case GamePhase::Placing: handlePlacing(dt); break;
        case GamePhase::GameOver: break;
        }
        for (auto &b : balls) {
            if (b.active && b.isMoving()) {
                b.spinAngle += glm::length(b.vel) * 5.0f * dt;
            }
        }
        for (auto &a : sinkAnims) a.timer += dt;
        sinkAnims.erase(
            std::remove_if(sinkAnims.begin(), sinkAnims.end(),
                           [](const SinkAnim &a){ return a.timer >= SINK_DURATION; }),
            sinkAnims.end());
        {
            const Uint8 *k = SDL_GetKeyboardState(nullptr);
            if (k[SDL_SCANCODE_A]) camAngle -= 1.2f * dt;
            if (k[SDL_SCANCODE_S]) camAngle += 1.2f * dt;
            if (k[SDL_SCANCODE_W]) camZoom -= 5.0f * dt;
            if (k[SDL_SCANCODE_E]) camZoom += 5.0f * dt;
            camZoom = glm::clamp(camZoom, 5.0f, 25.0f);
        }
        if (gameController) {
            constexpr float DEADZONE = 0.15f;
            auto readAxis = [&](SDL_GameControllerAxis a) -> float {
                return SDL_GameControllerGetAxis(gameController, a) / 32767.0f;
            };
            float rx = readAxis(SDL_CONTROLLER_AXIS_RIGHTX);
            float ry = readAxis(SDL_CONTROLLER_AXIS_RIGHTY);
            if (fabsf(rx) > DEADZONE) camAngle += rx * 1.5f * dt;
            if (fabsf(ry) > DEADZONE) camZoom += ry * 5.0f * dt;
            camZoom = glm::clamp(camZoom, 5.0f, 25.0f);
            float lx = readAxis(SDL_CONTROLLER_AXIS_LEFTX);
            float ly = readAxis(SDL_CONTROLLER_AXIS_LEFTY);
            if (phase == GamePhase::Aiming || phase == GamePhase::Charging) {
                if (fabsf(lx) > DEADZONE) cueAngle += lx * 1.5f * dt;
            } else if (phase == GamePhase::Placing) {
                float s = 3.0f * dt;
                glm::vec2 camRight( cosf(camAngle), -sinf(camAngle));
                glm::vec2 camFwd (-sinf(camAngle), -cosf(camAngle));
                if (fabsf(lx) > DEADZONE) balls[0].pos += camRight * (lx * s);
                if (fabsf(ly) > DEADZONE) balls[0].pos -= camFwd * (ly * s);
                float m = BALL_RADIUS + 0.1f;
                balls[0].pos.x = glm::clamp(balls[0].pos.x, -TABLE_HALF_W + m, TABLE_HALF_W - m);
                balls[0].pos.y = glm::clamp(balls[0].pos.y, -TABLE_HALF_H + m, TABLE_HALF_H - m);
            }
        }
        printText("Shots: " + std::to_string(shotCount), 15, 45, {255, 255, 0, 255});
        int rem = 0;
        for (int i = 1; i < NUM_BALLS; i++)
            if (balls[i].active && !balls[i].pocketed) rem++;
        printText("Balls: " + std::to_string(rem), 15, 75, {200, 200, 200, 255});
        if (phase == GamePhase::Aiming)
            printText("Arrows/L-Stick: Aim | Space/A/B: Charge & Shoot | R-Stick: Cam", 15, h - 40, {180, 180, 180, 255});
        else if (phase == GamePhase::Charging) {
            int pct = static_cast<int>(chargeAmount / MAX_POWER * 100.0f);
            printText("Power: " + std::to_string(pct) + "%", 15, 105,
                      {255, static_cast<Uint8>(255 - pct * 2), 0, 255});
        } else if (phase == GamePhase::Placing)
            printText("Arrows/L-Stick: move cue ball | Enter/A/B: place", 15, h - 40, {255, 100, 100, 255});
        else if (phase == GamePhase::GameOver) {
        }
    }
    void setScreen(GameScreen scr) {
        if (screen == GameScreen::Scores && scr != GameScreen::Scores) {
            setFont("font.ttf", 24);
            lastScoreFontSize = 0;
        }
        screen = scr;
    }
    void goToScoresScreen() {
        finalScore = shotCount;
        playerName = "";
        enteringName = highScores.qualifies(finalScore);
        setScreen(GameScreen::Scores);
    }
    void procScores() {
        if (scoresBackground != nullptr) {
            scoresBackground->setShaderParams(1.0f, 1.0f, 1.0f, 1.0f);
            scoresBackground->drawSpriteRect(0, 0, getWidth(), getHeight());
        }

        // Felt area as fractions of the window, matched to the start.png image layout.
        // Adjust these if the image layout differs.
        int feltL  = (int)(w * 0.125f);   // left rail edge
        int feltT  = (int)(h * 0.19f);    // below the decorative HIGH SCORES banner
        int feltB  = (int)(h * 0.87f);    // above the bottom rail
        int feltH  = feltB - feltT;
        int feltCX = (int)(w * 0.48f);    // horizontal centre of felt

        // Scale font so 10 entries + prompt fit comfortably inside the felt
        int fs = std::max(10, feltH / 15);
        if (fs != lastScoreFontSize) {
            setFont("font.ttf", fs);
            lastScoreFontSize = fs;
        }
        int lineH = fs + fs / 3;  // line height with inter-line spacing

        const auto &scoreList = highScores.list();
        for (size_t i = 0; i < scoreList.size() && i < 10; i++) {
            std::ostringstream ss;
            ss << (i+1) << ". " << scoreList[i].name << "  " << scoreList[i].shots << " shots";
            std::string row = ss.str();
            SDL_Color col = {255, 255, 255, 255};
            if (enteringName && finalScore > 0) {
                int rank = (int)scoreList.size();
                for (int j = 0; j < (int)scoreList.size(); j++) {
                    if (finalScore < scoreList[j].shots) { rank = j; break; }
                }
                if ((int)i == rank) col = {0, 255, 0, 255};
            }
            printText(row, feltL + fs / 2, feltT + (int)i * lineH, col);
        }

        if (enteringName) {
            int entryY = feltT + 10 * lineH + lineH / 2;
            std::ostringstream ss;
            ss << "Your score: " << finalScore << " shots";
            std::string ys = ss.str();
            int tw, th;
            getTextDimensions(ys, tw, th);
            printText(ys, feltCX - tw / 2, entryY, {255, 255, 0, 255});
            std::string dn = playerName + "_";
            getTextDimensions(dn, tw, th);
            printText(dn, feltCX - tw / 2, entryY + lineH, {0, 255, 255, 255});
            const char *hint = "ENTER to confirm  |  BACKSPACE to delete";
            getTextDimensions(hint, tw, th);
            printText(hint, feltCX - tw / 2, entryY + lineH * 2, {200, 200, 200, 255});
        } else {
            const char *ret = "Press ENTER to return to intro";
            int tw, th;
            getTextDimensions(ret, tw, th);
            printText(ret, feltCX - tw / 2, feltB - lineH, {255, 255, 0, 255});
        }
    }
    void proc() override {
        switch(screen) {
            case GameScreen::Intro:
                procIntro();
                break;
            case GameScreen::Start:
                procStart();
                break;
            case GameScreen::Game:
                procGame();
                break;
            case GameScreen::Scores:
                procScores();
                break;
        }
    }
    void drawIntro() {
        VKWindow::draw();
    }
    void drawStart() {
        VKWindow::draw();
    }
    void drawScores() {
        VKWindow::draw();
    }
    void drawGame() {
        uint32_t imageIndex = 0;
        VkResult result = vkAcquireNextImageKHR(device, swapChain, UINT64_MAX,
                                                 imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex);
        if (result == VK_ERROR_OUT_OF_DATE_KHR) { recreateSwapChain(); return; }
        else if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR)
            throw mx::Exception("Failed to acquire swap chain image!");
        VK_CHECK_RESULT(vkResetCommandBuffer(commandBuffers[imageIndex], 0));
        VkCommandBufferBeginInfo beginInfo{};
        beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
        if (vkBeginCommandBuffer(commandBuffers[imageIndex], &beginInfo) != VK_SUCCESS)
            throw mx::Exception("Failed to begin recording command buffer!");
        VkRenderPassBeginInfo rpInfo{};
        rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
        rpInfo.renderPass = renderPass;
        rpInfo.framebuffer = swapChainFramebuffers[imageIndex];
        rpInfo.renderArea.offset = {0, 0};
        rpInfo.renderArea.extent = swapChainExtent;
        std::array<VkClearValue, 2> cv{};
        cv[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
        cv[1].depthStencil = {1.0f, 0};
        rpInfo.clearValueCount = static_cast<uint32_t>(cv.size());
        rpInfo.pClearValues = cv.data();
        VkCommandBuffer cmd = commandBuffers[imageIndex];
        vkCmdBeginRenderPass(cmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE);
        VkViewport vp{}; vp.width = (float)swapChainExtent.width;
        vp.height = (float)swapChainExtent.height; vp.maxDepth = 1.0f;
        VkRect2D sc{}; sc.extent = swapChainExtent;
        vkCmdSetViewport(cmd, 0, 1, &vp);
        vkCmdSetScissor(cmd, 0, 1, &sc);
        if (spritePipeline != VK_NULL_HANDLE && backgroundSprite != nullptr) {
            vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, spritePipeline);
            backgroundSprite->setShaderParams(1.0f, 1.0f, 1.0f, 1.0f);
            backgroundSprite->drawSpriteRect(0, 0,
                static_cast<int>(swapChainExtent.width),
                static_cast<int>(swapChainExtent.height));
            backgroundSprite->renderSprites(cmd, spritePipelineLayout,
                swapChainExtent.width, swapChainExtent.height);
            backgroundSprite->clearQueue();
        }
        float aspect = vp.width / vp.height;
        float time = SDL_GetTicks() / 1000.0f;
        float camDist = camZoom;
        float camHeight = camZoom * 0.92f;
        glm::vec3 camPos = glm::vec3(sinf(camAngle) * camDist, camHeight, cosf(camAngle) * camDist);
        glm::vec3 camTarget = glm::vec3(0.0f, 0.0f, 0.0f);
        glm::mat4 viewMat = glm::lookAt(camPos, camTarget, glm::vec3(0.0f, 1.0f, 0.0f));
        glm::mat4 projMat = glm::perspective(glm::radians(45.0f), aspect, 0.1f, 100.0f);
        projMat[1][1] *= -1;
        VkPipeline pipe = useWireFrame ? graphicsPipelineMatrix : graphicsPipeline;
        vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipe);
        objDrawIndex = 0;
        drawModelTextured(cmd, imageIndex, tableModel.get(), viewMat, projMat, time,
                  glm::vec3(0.0f, 0.0f, 0.0f),
                  glm::vec3(1.0f, 1.0f, 1.0f),
                  glm::vec4(1.0f, 1.0f, 1.0f, 1.0f));
        drawModel(cmd, imageIndex, tableWoodModel.get(), viewMat, projMat, time,
                  glm::vec3(0.0f, 0.0f, 0.0f),
                  glm::vec3(1.0f, 1.0f, 1.0f),
                  glm::vec4(0.4f, 0.22f, 0.05f, 1.0f));
        drawModel(cmd, imageIndex, tablePocketModel.get(), viewMat, projMat, time,
                  glm::vec3(0.0f, 0.0f, 0.0f),
                  glm::vec3(1.0f, 1.0f, 1.0f),
                  glm::vec4(0.08f, 0.08f, 0.08f, 1.0f));
        for (int i = 0; i < NUM_BALLS; i++) {
            if (!balls[i].active || balls[i].pocketed) continue;
            glm::mat4 m(1.0f);
            m = glm::translate(m, glm::vec3(balls[i].pos.x, BALL_RADIUS, balls[i].pos.y));
            m = glm::rotate(m, balls[i].spinAngle, glm::vec3(0.0f, 1.0f, 0.0f));
            m = glm::scale(m, glm::vec3(BALL_RADIUS));
            drawModelMat(cmd, imageIndex, sphereModel.get(), viewMat, projMat, time,
                         m, glm::vec4(BALL_COLORS[i], 1.0f));
        }
        for (auto &anim : sinkAnims) {
            float t = anim.timer / SINK_DURATION;
            float y = glm::mix(BALL_RADIUS, -BALL_RADIUS * 3.5f, t);
            float sc = BALL_RADIUS * (1.0f - t * 0.75f);
            glm::mat4 m(1.0f);
            m = glm::translate(m, glm::vec3(anim.pocketPos.x, y, anim.pocketPos.y));
            m = glm::rotate(m, anim.spinAngle + t * 6.0f, glm::vec3(0.0f, 1.0f, 0.0f));
            m = glm::scale(m, glm::vec3(sc));
            drawModelMat(cmd, imageIndex, sphereModel.get(), viewMat, projMat, time,
                         m, glm::vec4(anim.color, 1.0f));
        }
        if ((phase == GamePhase::Aiming || phase == GamePhase::Charging) && balls[0].active) {
            float offset = (phase == GamePhase::Charging)
                             ? (chargeAmount / MAX_POWER * 1.0f) : 0.0f;
            float stickDist = BALL_RADIUS + 0.3f + offset;
            float worldCueAngle = cueAngle + camAngle;
            glm::vec2 dir(cosf(worldCueAngle), sinf(worldCueAngle));
            glm::vec2 sc2 = balls[0].pos - dir * (stickDist + CUE_LENGTH * 0.5f);
            glm::mat4 m(1.0f);
            m = glm::translate(m, glm::vec3(sc2.x, BALL_RADIUS + 0.05f, sc2.y));
            m = glm::rotate(m, -worldCueAngle, glm::vec3(0.0f, 1.0f, 0.0f));
            m = glm::scale(m, glm::vec3(CUE_LENGTH * 0.5f, 0.03f, 0.03f));
            float pct = chargeAmount / MAX_POWER;
            glm::vec4 cueCol = (phase == GamePhase::Charging)
                ? glm::vec4(0.55f + pct*0.45f, 0.27f*(1-pct), 0.07f*(1-pct), 1)
                : glm::vec4(0.55f, 0.27f, 0.07f, 1.0f);
            drawModelMat(cmd, imageIndex, cubeModel.get(), viewMat, projMat, time, m, cueCol);
            float aimLen = 2.0f;
            glm::vec2 ac = balls[0].pos + dir * (BALL_RADIUS + aimLen * 0.5f);
            glm::mat4 am(1.0f);
            am = glm::translate(am, glm::vec3(ac.x, BALL_RADIUS + 0.06f, ac.y));
            am = glm::rotate(am, -worldCueAngle, glm::vec3(0.0f, 1.0f, 0.0f));
            am = glm::scale(am, glm::vec3(aimLen * 0.5f, 0.008f, 0.015f));
            drawModelMat(cmd, imageIndex, cubeModel.get(), viewMat, projMat, time,
                         am, glm::vec4(1, 1, 0, 1.0f));
        }
        if (spritePipeline != VK_NULL_HANDLE && !sprites.empty()) {
            vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, spritePipeline);
            for (auto &sp : sprites) {
                if (!sp) continue;
                sp->renderSprites(cmd, spritePipelineLayout,
                                  swapChainExtent.width, swapChainExtent.height);
            }
        }
        if (textRenderer && textPipeline != VK_NULL_HANDLE) {
            vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, textPipeline);
            textRenderer->renderText(cmd, textPipelineLayout,
                                     swapChainExtent.width, swapChainExtent.height);
        }
        vkCmdEndRenderPass(cmd);
        if (vkEndCommandBuffer(cmd) != VK_SUCCESS)
            throw mx::Exception("Failed to record command buffer!");
        VkSubmitInfo si{}; si.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
        VkSemaphore wait[] = { imageAvailableSemaphore };
        VkPipelineStageFlags ws[] = { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };
        si.waitSemaphoreCount = 1; si.pWaitSemaphores = wait; si.pWaitDstStageMask = ws;
        si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
        VkSemaphore sig[] = { renderFinishedSemaphore };
        si.signalSemaphoreCount = 1; si.pSignalSemaphores = sig;
        if (vkQueueSubmit(graphicsQueue, 1, &si, VK_NULL_HANDLE) != VK_SUCCESS)
            throw mx::Exception("Failed to submit draw command buffer!");
        VkPresentInfoKHR pi{}; pi.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
        pi.waitSemaphoreCount = 1; pi.pWaitSemaphores = sig;
        pi.swapchainCount = 1; pi.pSwapchains = &swapChain; pi.pImageIndices = &imageIndex;
        result = vkQueuePresentKHR(presentQueue, &pi);
        if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR)
            recreateSwapChain();
        else if (result != VK_SUCCESS)
            throw mx::Exception("Failed to present swap chain image!");
        vkQueueWaitIdle(graphicsQueue);
        clearTextQueue();
        for (auto &sp : sprites) { if (sp) sp->clearQueue(); }
    }
    void draw() override {
        switch(screen) {
            case GameScreen::Intro:
                drawIntro();
                break;
            case GameScreen::Game:
                drawGame();
                break;
            case GameScreen::Scores:
                drawScores();
                break;
            case GameScreen::Start:
                drawStart();
                break;
        }
    }
    void event(SDL_Event &e) override {
        if (e.type == SDL_QUIT) { quit(); return; }
        if (e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE) {
            if (screen == GameScreen::Scores) {
                setScreen(GameScreen::Intro);
            } else {
                quit();
            }
            return;
        }
        if (e.type == SDL_TEXTINPUT && screen == GameScreen::Scores && enteringName) {
            if (playerName.length() < 15) playerName += e.text.text;
            return;
        }
        if (e.type == SDL_KEYDOWN && screen == GameScreen::Scores) {
            if (enteringName) {
                if (e.key.keysym.sym == SDLK_RETURN && !playerName.empty()) {
                    highScores.addScore(playerName, finalScore);
                    highScores.write();
                    enteringName = false;
                } else if (e.key.keysym.sym == SDLK_BACKSPACE && !playerName.empty()) {
                    playerName.pop_back();
                }
            } else {
                if (e.key.keysym.sym == SDLK_RETURN)
                    setScreen(GameScreen::Intro);
            }
            return;
        }
        if (e.type == SDL_KEYDOWN) {
            switch (e.key.keysym.sym) {
            case SDLK_r: if (screen == GameScreen::Game) resetGame(); break;
            case SDLK_p: setWireFrame(!getWireFrame()); break;
            case SDLK_SPACE:
                if (screen == GameScreen::Game && phase == GamePhase::Aiming) {
                    phase = GamePhase::Charging;
                    chargeAmount = 0.0f;
                }
                break;
            case SDLK_RETURN:
                if (screen == GameScreen::Game && phase == GamePhase::Placing) {
                    bool ok = true;
                    for (int i = 1; i < NUM_BALLS; i++) {
                        if (!balls[i].active || balls[i].pocketed) continue;
                        if (glm::length(balls[0].pos - balls[i].pos) < BALL_RADIUS * 2.5f)
                        { ok = false; break; }
                    }
                    if (ok) phase = GamePhase::Aiming;
                }
                break;
            default: break;
            }
        }
        if (e.type == SDL_KEYUP && e.key.keysym.sym == SDLK_SPACE
            && screen == GameScreen::Game && phase == GamePhase::Charging) {
            float worldCueAngle = cueAngle + camAngle;
            glm::vec2 dir(cosf(worldCueAngle), sinf(worldCueAngle));
            balls[0].vel = dir * chargeAmount;
            phase = GamePhase::Rolling;
            shotCount++;
            chargeAmount = 0.0f;
        }
        if (e.type == SDL_CONTROLLERDEVICEADDED && !gameController) {
            gameController = SDL_GameControllerOpen(e.cdevice.which);
            if (gameController)
                SDL_Log("Controller connected: %s", SDL_GameControllerName(gameController));
        }
        if (e.type == SDL_CONTROLLERDEVICEREMOVED && gameController) {
            SDL_Joystick *j = SDL_GameControllerGetJoystick(gameController);
            if (j && SDL_JoystickInstanceID(j) == e.cdevice.which) {
                SDL_GameControllerClose(gameController);
                gameController = nullptr;
                SDL_Log("Controller disconnected");
            }
        }
        if (e.type == SDL_CONTROLLERBUTTONDOWN) {
            if (screen == GameScreen::Intro) {
                if (e.cbutton.button == SDL_CONTROLLER_BUTTON_A ||
                    e.cbutton.button == SDL_CONTROLLER_BUTTON_START)
                    setScreen(GameScreen::Start);
                return;
            }
            if (screen == GameScreen::Scores) {
                if (!enteringName) {
                    if (e.cbutton.button == SDL_CONTROLLER_BUTTON_A ||
                        e.cbutton.button == SDL_CONTROLLER_BUTTON_B ||
                        e.cbutton.button == SDL_CONTROLLER_BUTTON_START)
                        setScreen(GameScreen::Intro);
                }
                return;
            }
            if (screen == GameScreen::Start) {
                if (e.cbutton.button == SDL_CONTROLLER_BUTTON_A ||
                    e.cbutton.button == SDL_CONTROLLER_BUTTON_START) {
                    resetGame();
                    setScreen(GameScreen::Game);
                } else if (e.cbutton.button == SDL_CONTROLLER_BUTTON_Y) {
                    setScreen(GameScreen::Scores);
                } else if (e.cbutton.button == SDL_CONTROLLER_BUTTON_BACK) {
                    quit();
                }
                return;
            }
            switch (e.cbutton.button) {
            case SDL_CONTROLLER_BUTTON_BACK:
                quit();
                return;
            case SDL_CONTROLLER_BUTTON_A:
            case SDL_CONTROLLER_BUTTON_B:
                if (screen == GameScreen::Game) {
                    if (phase == GamePhase::Aiming) {
                        phase = GamePhase::Charging;
                        chargeAmount = 0.0f;
                        ctrlChargeButton = e.cbutton.button;
                    } else if (phase == GamePhase::Placing) {
                        bool ok = true;
                        for (int i = 1; i < NUM_BALLS; i++) {
                            if (!balls[i].active || balls[i].pocketed) continue;
                            if (glm::length(balls[0].pos - balls[i].pos) < BALL_RADIUS * 2.5f)
                            { ok = false; break; }
                        }
                        if (ok) phase = GamePhase::Aiming;
                    }
                }
                break;
            default: break;
            }
        }
        if (e.type == SDL_CONTROLLERBUTTONUP) {
            if ((e.cbutton.button == SDL_CONTROLLER_BUTTON_A ||
                 e.cbutton.button == SDL_CONTROLLER_BUTTON_B) &&
                 e.cbutton.button == ctrlChargeButton &&
                 screen == GameScreen::Game &&
                 phase == GamePhase::Charging) {
                float worldCueAngle = cueAngle + camAngle;
                glm::vec2 dir(cosf(worldCueAngle), sinf(worldCueAngle));
                balls[0].vel = dir * chargeAmount;
                phase = GamePhase::Rolling;
                shotCount++;
                chargeAmount = 0.0f;
                ctrlChargeButton = -1;
            }
        }
    }
    void cleanup() override {
        if (gameController) {
            SDL_GameControllerClose(gameController);
            gameController = nullptr;
        }
        if (sphereModel) sphereModel->cleanup(device);
        if (cylinderModel) cylinderModel->cleanup(device);
        if (cubeModel) cubeModel->cleanup(device);
        if (tableModel) tableModel->cleanup(device);
        if (tableWoodModel) tableWoodModel->cleanup(device);
        if (tablePocketModel) tablePocketModel->cleanup(device);
        cleanupTableTexture();
        cleanupObjectPool();
        mx::VKWindow::cleanup();
    }
private:
    std::unique_ptr<mx::MXModel> sphereModel;
    std::unique_ptr<mx::MXModel> cylinderModel;
    std::unique_ptr<mx::MXModel> cubeModel;
    std::unique_ptr<mx::MXModel> tableModel;
    std::unique_ptr<mx::MXModel> tableWoodModel;
    std::unique_ptr<mx::MXModel> tablePocketModel;
    PoolBall balls[NUM_BALLS];
    std::vector<SinkAnim> sinkAnims;
    GamePhase phase = GamePhase::Aiming;
    float cueAngle = 0.0f;
    float chargeAmount = 0.0f;
    int shotCount = 0;
    float lastTime = 0.0f;
    float camAngle = 0.4f;
    float camZoom = 13.0f;
    SDL_GameController *gameController = nullptr;
    int ctrlChargeButton = -1;
    VkImage tableTexImage = VK_NULL_HANDLE;
    VkDeviceMemory tableTexMemory = VK_NULL_HANDLE;
    VkImageView tableTexImageView = VK_NULL_HANDLE;
    mx::VKSprite *backgroundSprite = nullptr;
    mx::VKSprite *startscreenSprite = nullptr;
    mx::VKSprite *introSprite = nullptr;
    mx::VKSprite *scoresBackground = nullptr;
    HighScores highScores;
    bool enteringName = false;
    std::string playerName;
    int finalScore = 0;
    int lastScoreFontSize = 0;
    void loadTableTexture() {
        std::string path = util.path + "/data/table.png";
        SDL_Surface *surface = png::LoadPNG(path.c_str());
        if (!surface) {
            SDL_Log("Failed to load table.png");
            return;
        }
        SDL_Surface *rgba = SDL_ConvertSurfaceFormat(surface, SDL_PIXELFORMAT_ABGR8888, 0);
        SDL_FreeSurface(surface);
        if (!rgba) {
            SDL_Log("Failed to convert table.png to RGBA");
            return;
        }
        VkDeviceSize imgSize = rgba->w * rgba->h * 4;
        VkBuffer stg; VkDeviceMemory stgMem;
        createBuffer(imgSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
                     VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
                     VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
                     stg, stgMem);
        void *d;
        vkMapMemory(device, stgMem, 0, imgSize, 0, &d);
        memcpy(d, rgba->pixels, imgSize);
        vkUnmapMemory(device, stgMem);
        createImage(rgba->w, rgba->h, VK_FORMAT_R8G8B8A8_UNORM,
                    VK_IMAGE_TILING_OPTIMAL,
                    VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
                    VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
                    tableTexImage, tableTexMemory);
        transitionImageLayout(tableTexImage, VK_FORMAT_R8G8B8A8_UNORM,
                              VK_IMAGE_LAYOUT_UNDEFINED,
                              VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL);
        copyBufferToImage(stg, tableTexImage, rgba->w, rgba->h);
        transitionImageLayout(tableTexImage, VK_FORMAT_R8G8B8A8_UNORM,
                              VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
                              VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
        vkDestroyBuffer(device, stg, nullptr);
        vkFreeMemory(device, stgMem, nullptr);
        SDL_FreeSurface(rgba);
        tableTexImageView = createImageView(tableTexImage, VK_FORMAT_R8G8B8A8_UNORM,
                                            VK_IMAGE_ASPECT_COLOR_BIT);
    }
    void cleanupTableTexture() {
        if (tableTexImageView != VK_NULL_HANDLE)
            vkDestroyImageView(device, tableTexImageView, nullptr);
        if (tableTexImage != VK_NULL_HANDLE)
            vkDestroyImage(device, tableTexImage, nullptr);
        if (tableTexMemory != VK_NULL_HANDLE)
            vkFreeMemory(device, tableTexMemory, nullptr);
    }
    size_t swapCount = 0;
    VkDescriptorPool objPool = VK_NULL_HANDLE;
    std::vector<VkBuffer> objUBOs;
    std::vector<VkDeviceMemory> objUBOMem;
    std::vector<void*> objUBOMapped;
    std::vector<VkDescriptorSet> objDescSets;
    int objDrawIndex = 0;
    void createObjectPool() {
        swapCount = swapChainImages.size();
        size_t total = MAX_DRAWS * swapCount;
        std::array<VkDescriptorPoolSize, 2> poolSizes{};
        poolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
        poolSizes[0].descriptorCount = static_cast<uint32_t>(total);
        poolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
        poolSizes[1].descriptorCount = static_cast<uint32_t>(total);
        VkDescriptorPoolCreateInfo pi{};
        pi.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
        pi.poolSizeCount = static_cast<uint32_t>(poolSizes.size());
        pi.pPoolSizes = poolSizes.data();
        pi.maxSets = static_cast<uint32_t>(total);
        VK_CHECK_RESULT(vkCreateDescriptorPool(device, &pi, nullptr, &objPool));
        VkDeviceSize uboSize = sizeof(mx::UniformBufferObject);
        objUBOs.resize(total);
        objUBOMem.resize(total);
        objUBOMapped.resize(total);
        for (size_t i = 0; i < total; i++) {
            createBuffer(uboSize, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT,
                         VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
                         VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
                         objUBOs[i], objUBOMem[i]);
            VK_CHECK_RESULT(vkMapMemory(device, objUBOMem[i], 0, uboSize, 0, &objUBOMapped[i]));
        }
        std::vector<VkDescriptorSetLayout> layouts(total, descriptorSetLayout);
        VkDescriptorSetAllocateInfo ai{};
        ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
        ai.descriptorPool = objPool;
        ai.descriptorSetCount = static_cast<uint32_t>(total);
        ai.pSetLayouts = layouts.data();
        objDescSets.resize(total);
        VK_CHECK_RESULT(vkAllocateDescriptorSets(device, &ai, objDescSets.data()));
        for (size_t i = 0; i < total; i++) {
            VkDescriptorImageInfo imgInfo{};
            imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
            imgInfo.imageView = textureImageView;
            imgInfo.sampler = textureSampler;
            VkDescriptorBufferInfo bufInfo{};
            bufInfo.buffer = objUBOs[i];
            bufInfo.offset = 0;
            bufInfo.range = uboSize;
            std::array<VkWriteDescriptorSet, 2> writes{};
            writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
            writes[0].dstSet = objDescSets[i];
            writes[0].dstBinding = 0;
            writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
            writes[0].descriptorCount = 1;
            writes[0].pImageInfo = &imgInfo;
            writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
            writes[1].dstSet = objDescSets[i];
            writes[1].dstBinding = 1;
            writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
            writes[1].descriptorCount = 1;
            writes[1].pBufferInfo = &bufInfo;
            vkUpdateDescriptorSets(device, static_cast<uint32_t>(writes.size()),
                                   writes.data(), 0, nullptr);
        }
    }
    void cleanupObjectPool() {
        for (size_t i = 0; i < objUBOs.size(); i++) {
            if (objUBOMem[i] != VK_NULL_HANDLE)
                vkUnmapMemory(device, objUBOMem[i]);
            if (objUBOs[i] != VK_NULL_HANDLE)
                vkDestroyBuffer(device, objUBOs[i], nullptr);
            if (objUBOMem[i] != VK_NULL_HANDLE)
                vkFreeMemory(device, objUBOMem[i], nullptr);
        }
        objUBOs.clear(); objUBOMem.clear(); objUBOMapped.clear();
        if (objPool != VK_NULL_HANDLE) {
            vkDestroyDescriptorPool(device, objPool, nullptr);
            objPool = VK_NULL_HANDLE;
        }
        objDescSets.clear();
    }
    void drawModel(VkCommandBuffer cmd, uint32_t imgIdx, mx::MXModel *mdl,
                   const glm::mat4 &v, const glm::mat4 &p, float t,
                   const glm::vec3 &pos, const glm::vec3 &scl,
                   const glm::vec4 &col) {
        glm::mat4 m(1.0f);
        m = glm::translate(m, pos);
        m = glm::scale(m, scl);
        drawModelMat(cmd, imgIdx, mdl, v, p, t, m, col);
    }
    void drawModelTextured(VkCommandBuffer cmd, uint32_t imgIdx, mx::MXModel *mdl,
                           const glm::mat4 &v, const glm::mat4 &p, float t,
                           const glm::vec3 &pos, const glm::vec3 &scl,
                           const glm::vec4 &col) {
        if (objDrawIndex >= MAX_DRAWS) return;
        if (tableTexImageView == VK_NULL_HANDLE) {
            drawModel(cmd, imgIdx, mdl, v, p, t, pos, scl, col);
            return;
        }
        size_t slot = imgIdx * MAX_DRAWS + objDrawIndex;
        objDrawIndex++;
        glm::mat4 m(1.0f);
        m = glm::translate(m, pos);
        m = glm::scale(m, scl);
        mx::UniformBufferObject ubo{};
        ubo.model = m;
        ubo.view = v;
        ubo.proj = p;
        ubo.params = glm::vec4(t, (float)swapChainExtent.width,
                               (float)swapChainExtent.height, 0.0f);
        ubo.color = col;
        memcpy(objUBOMapped[slot], &ubo, sizeof(ubo));
        VkDescriptorImageInfo imgInfo{};
        imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
        imgInfo.imageView = tableTexImageView;
        imgInfo.sampler = textureSampler;
        VkWriteDescriptorSet write{};
        write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
        write.dstSet = objDescSets[slot];
        write.dstBinding = 0;
        write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
        write.descriptorCount = 1;
        write.pImageInfo = &imgInfo;
        vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
        vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
                                pipelineLayout, 0, 1, &objDescSets[slot], 0, nullptr);
        mdl->draw(cmd);
    }
    void drawModelMat(VkCommandBuffer cmd, uint32_t imgIdx, mx::MXModel *mdl,
                      const glm::mat4 &v, const glm::mat4 &p, float t,
                      const glm::mat4 &m, const glm::vec4 &col) {
        if (objDrawIndex >= MAX_DRAWS) return;
        size_t slot = imgIdx * MAX_DRAWS + objDrawIndex;
        objDrawIndex++;
        mx::UniformBufferObject ubo{};
        ubo.model = m;
        ubo.view = v;
        ubo.proj = p;
        ubo.params = glm::vec4(t, (float)swapChainExtent.width,
                               (float)swapChainExtent.height, 0.0f);
        ubo.color = col;
        memcpy(objUBOMapped[slot], &ubo, sizeof(ubo));
        vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
                                pipelineLayout, 0, 1, &objDescSets[slot], 0, nullptr);
        mdl->draw(cmd);
    }
    void rackBalls() {
        balls[0] = {};
        balls[0].pos = glm::vec2(-TABLE_HALF_W * 0.5f, 0.0f);
        balls[0].active = true;
        balls[0].number = 0;
        int order[15] = {1,2,3,8,4,5,6,7,9,10,11,12,13,14,15};
        float sx = TABLE_HALF_W * 0.3f;
        float sp = BALL_RADIUS * 2.1f;
        int idx = 0;
        for (int row = 0; row < 5; row++) {
            for (int col = 0; col <= row; col++) {
                if (idx >= 15) break;
                int bn = order[idx];
                balls[bn] = {};
                balls[bn].pos = glm::vec2(sx + row * sp * 0.866f,
                                             (col - row * 0.5f) * sp);
                balls[bn].active = true;
                balls[bn].number = bn;
                balls[bn].spinAngle = randFloat(0, 2*M_PI);
                idx++;
            }
        }
    }
    void resetGame() {
        rackBalls();
        sinkAnims.clear();
        phase = GamePhase::Aiming;
        cueAngle = 0.0f;
        chargeAmount = 0.0f;
        shotCount = 0;
        lastTime = SDL_GetTicks() / 1000.0f;
    }
    void handleAiming(float dt) {
        const Uint8 *k = SDL_GetKeyboardState(nullptr);
        if (k[SDL_SCANCODE_LEFT]) cueAngle -= 1.5f * dt;
        if (k[SDL_SCANCODE_RIGHT]) cueAngle += 1.5f * dt;
    }
    void handleCharging(float dt) {
        const Uint8 *k = SDL_GetKeyboardState(nullptr);
        if (k[SDL_SCANCODE_LEFT]) cueAngle -= 1.5f * dt;
        if (k[SDL_SCANCODE_RIGHT]) cueAngle += 1.5f * dt;
        chargeAmount += MAX_POWER * 0.8f * dt;
        if (chargeAmount > MAX_POWER) chargeAmount = MAX_POWER;
    }
    void handlePlacing(float dt) {
        const Uint8 *k = SDL_GetKeyboardState(nullptr);
        float s = 3.0f * dt;
        glm::vec2 camRight( cosf(camAngle), -sinf(camAngle));
        glm::vec2 camFwd (-sinf(camAngle), -cosf(camAngle));
        if (k[SDL_SCANCODE_LEFT]) balls[0].pos -= camRight * s;
        if (k[SDL_SCANCODE_RIGHT]) balls[0].pos += camRight * s;
        if (k[SDL_SCANCODE_UP]) balls[0].pos += camFwd * s;
        if (k[SDL_SCANCODE_DOWN]) balls[0].pos -= camFwd * s;
        float m = BALL_RADIUS + 0.1f;
        balls[0].pos.x = glm::clamp(balls[0].pos.x, -TABLE_HALF_W + m, TABLE_HALF_W - m);
        balls[0].pos.y = glm::clamp(balls[0].pos.y, -TABLE_HALF_H + m, TABLE_HALF_H - m);
    }
    void updatePhysics(float dt) {
        float maxSpeed = 0.0f;
        for (auto &b : balls) {
            if (b.active && !b.pocketed) {
                float spd = glm::length(b.vel);
                if (spd > maxSpeed) maxSpeed = spd;
            }
        }
        float maxMovePerStep = BALL_RADIUS * 0.75f;
        int steps = std::max(8, (int)std::ceil(maxSpeed * dt * 60.0f / maxMovePerStep));
        float sub = dt / (float)steps;
        float stepFriction = std::pow(FRICTION, 4.0f / (float)steps);
        for (int s = 0; s < steps; s++) {
            for (auto &b : balls) {
                if (!b.active || b.pocketed) continue;
                b.pos += b.vel * sub * 60.0f;
            }
            for (int i = 0; i < NUM_BALLS; i++) {
                if (!balls[i].active || balls[i].pocketed) continue;
                for (int j = i+1; j < NUM_BALLS; j++) {
                    if (!balls[j].active || balls[j].pocketed) continue;
                    resolveBall(balls[i], balls[j]);
                }
            }
            for (auto &b : balls) {
                if (!b.active || b.pocketed) continue;
                resolveWall(b);
            }
            for (auto &b : balls) {
                if (!b.active || b.pocketed) continue;
                checkPocket(b);
            }
            for (auto &b : balls) {
                if (!b.active || b.pocketed) continue;
                b.vel *= stepFriction;
                if (glm::length(b.vel) < MIN_SPEED) b.vel = glm::vec2(0);
            }
        }
    }
    void resolveBall(PoolBall &a, PoolBall &b) {
        glm::vec2 d = b.pos - a.pos;
        float dist = glm::length(d);
        float minD = BALL_RADIUS * 2.0f;
        if (dist < minD && dist > 0.0001f) {
            glm::vec2 n = d / dist;
            float ov = minD - dist;
            a.pos -= n * (ov * 0.5f);
            b.pos += n * (ov * 0.5f);
            float rv = glm::dot(a.vel - b.vel, n);
            if (rv > 0) { a.vel -= n * rv; b.vel += n * rv; }
        }
    }
    void resolveWall(PoolBall &b) {
        float l = -TABLE_HALF_W + BALL_RADIUS, r = TABLE_HALF_W - BALL_RADIUS;
        float t = -TABLE_HALF_H + BALL_RADIUS, bt = TABLE_HALF_H - BALL_RADIUS;
        if (b.pos.x < l) { b.pos.x = l; b.vel.x = -b.vel.x * 0.8f; }
        if (b.pos.x > r) { b.pos.x = r; b.vel.x = -b.vel.x * 0.8f; }
        if (b.pos.y < t) { b.pos.y = t; b.vel.y = -b.vel.y * 0.8f; }
        if (b.pos.y > bt){ b.pos.y = bt; b.vel.y = -b.vel.y * 0.8f; }
    }
    void checkPocket(PoolBall &b) {
        for (int i = 0; i < 6; i++) {
            if (glm::length(b.pos - POCKETS[i]) < POCKET_R) {
                int idx = static_cast<int>(&b - &balls[0]);
                SinkAnim anim;
                anim.pocketPos = POCKETS[i];
                anim.color = BALL_COLORS[idx];
                anim.spinAngle = b.spinAngle;
                anim.timer = 0.0f;
                sinkAnims.push_back(anim);
                b.pocketed = true;
                b.vel = glm::vec2(0);
                return;
            }
        }
    }
    bool anyBallMoving() const {
        for (auto &b : balls)
            if (b.active && !b.pocketed && b.isMoving()) return true;
        if (!sinkAnims.empty()) return true;
        return false;
    }
    bool allObjectBallsPocketed() const {
        for (int i = 1; i < NUM_BALLS; i++)
            if (balls[i].active && !balls[i].pocketed) return false;
        return true;
    }
    void checkGameOver() {
        if (allObjectBallsPocketed()) goToScoresScreen();
    }
};
int main(int argc, char **argv) {
    Arguments args = proc_args(argc, argv);
    try {
        PoolWindow window(args.path, args.width, args.height, args.fullscreen);
        window.initVulkan();
        window.loop();
        window.cleanup();
    } catch (mx::Exception &e) {
        SDL_Log("mx: Exception: %s\n", e.text().c_str());
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}