diff --git a/include/game/character.hpp b/include/game/character.hpp index 705f81a2..ca2fe671 100644 --- a/include/game/character.hpp +++ b/include/game/character.hpp @@ -133,5 +133,10 @@ const char* getClassName(Class characterClass); */ const char* getGenderName(Gender gender); +/** + * Get M2 model path for a given race and gender + */ +std::string getPlayerModelPath(Race race, Gender gender); + } // namespace game } // namespace wowee diff --git a/include/rendering/character_preview.hpp b/include/rendering/character_preview.hpp new file mode 100644 index 00000000..2ad1814b --- /dev/null +++ b/include/rendering/character_preview.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "game/character.hpp" +#include +#include +#include + +namespace wowee { +namespace pipeline { class AssetManager; } +namespace rendering { + +class CharacterRenderer; +class Camera; + +class CharacterPreview { +public: + CharacterPreview(); + ~CharacterPreview(); + + bool initialize(pipeline::AssetManager* am); + void shutdown(); + + bool loadCharacter(game::Race race, game::Gender gender, + uint8_t skin, uint8_t face, + uint8_t hairStyle, uint8_t hairColor, + uint8_t facialHair); + + void update(float deltaTime); + void render(); + void rotate(float yawDelta); + + GLuint getTextureId() const { return colorTexture_; } + int getWidth() const { return fboWidth_; } + int getHeight() const { return fboHeight_; } + +private: + void createFBO(); + void destroyFBO(); + + pipeline::AssetManager* assetManager_ = nullptr; + std::unique_ptr charRenderer_; + std::unique_ptr camera_; + + GLuint fbo_ = 0; + GLuint colorTexture_ = 0; + GLuint depthRenderbuffer_ = 0; + static constexpr int fboWidth_ = 400; + static constexpr int fboHeight_ = 500; + + static constexpr uint32_t PREVIEW_MODEL_ID = 9999; + uint32_t instanceId_ = 0; + bool modelLoaded_ = false; + float modelYaw_ = 180.0f; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/ui/character_create_screen.hpp b/include/ui/character_create_screen.hpp index 2a4668fd..490f1723 100644 --- a/include/ui/character_create_screen.hpp +++ b/include/ui/character_create_screen.hpp @@ -6,21 +6,27 @@ #include #include #include +#include namespace wowee { namespace game { class GameHandler; } +namespace pipeline { class AssetManager; } +namespace rendering { class CharacterPreview; } namespace ui { class CharacterCreateScreen { public: CharacterCreateScreen(); + ~CharacterCreateScreen(); void render(game::GameHandler& gameHandler); + void update(float deltaTime); void setOnCreate(std::function cb) { onCreate = std::move(cb); } void setOnCancel(std::function cb) { onCancel = std::move(cb); } void setStatus(const std::string& msg, bool isError = false); void reset(); + void initializePreview(pipeline::AssetManager* am); private: char nameBuffer[13] = {}; // WoW max name = 12 chars + null @@ -36,6 +42,20 @@ private: std::function onCreate; std::function onCancel; + + // 3D model preview + std::unique_ptr preview_; + int prevRaceIndex_ = -1; + int prevGenderIndex_ = -1; + int prevSkin_ = -1; + int prevFace_ = -1; + int prevHairStyle_ = -1; + int prevHairColor_ = -1; + int prevFacialHair_ = -1; + bool draggingPreview_ = false; + float dragStartX_ = 0.0f; + + void updatePreviewIfNeeded(); }; } // namespace ui diff --git a/src/game/character.cpp b/src/game/character.cpp index dc9908b4..fd786e55 100644 --- a/src/game/character.cpp +++ b/src/game/character.cpp @@ -92,5 +92,52 @@ const char* getGenderName(Gender gender) { } } +std::string getPlayerModelPath(Race race, Gender gender) { + switch (race) { + case Race::HUMAN: + return gender == Gender::FEMALE + ? "Character\\Human\\Female\\HumanFemale.m2" + : "Character\\Human\\Male\\HumanMale.m2"; + case Race::ORC: + return gender == Gender::FEMALE + ? "Character\\Orc\\Female\\OrcFemale.m2" + : "Character\\Orc\\Male\\OrcMale.m2"; + case Race::DWARF: + return gender == Gender::FEMALE + ? "Character\\Dwarf\\Female\\DwarfFemale.m2" + : "Character\\Dwarf\\Male\\DwarfMale.m2"; + case Race::NIGHT_ELF: + return gender == Gender::FEMALE + ? "Character\\NightElf\\Female\\NightElfFemale.m2" + : "Character\\NightElf\\Male\\NightElfMale.m2"; + case Race::UNDEAD: + return gender == Gender::FEMALE + ? "Character\\Scourge\\Female\\ScourgeFemale.m2" + : "Character\\Scourge\\Male\\ScourgeMale.m2"; + case Race::TAUREN: + return gender == Gender::FEMALE + ? "Character\\Tauren\\Female\\TaurenFemale.m2" + : "Character\\Tauren\\Male\\TaurenMale.m2"; + case Race::GNOME: + return gender == Gender::FEMALE + ? "Character\\Gnome\\Female\\GnomeFemale.m2" + : "Character\\Gnome\\Male\\GnomeMale.m2"; + case Race::TROLL: + return gender == Gender::FEMALE + ? "Character\\Troll\\Female\\TrollFemale.m2" + : "Character\\Troll\\Male\\TrollMale.m2"; + case Race::BLOOD_ELF: + return gender == Gender::FEMALE + ? "Character\\BloodElf\\Female\\BloodElfFemale.m2" + : "Character\\BloodElf\\Male\\BloodElfMale.m2"; + case Race::DRAENEI: + return gender == Gender::FEMALE + ? "Character\\Draenei\\Female\\DraeneiFemale.m2" + : "Character\\Draenei\\Male\\DraeneiMale.m2"; + default: + return "Character\\Human\\Male\\HumanMale.m2"; + } +} + } // namespace game } // namespace wowee diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp new file mode 100644 index 00000000..a409b8e8 --- /dev/null +++ b/src/rendering/character_preview.cpp @@ -0,0 +1,369 @@ +#include "rendering/character_preview.hpp" +#include "rendering/character_renderer.hpp" +#include "rendering/camera.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/m2_loader.hpp" +#include "pipeline/dbc_loader.hpp" +#include "core/logger.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { + +CharacterPreview::CharacterPreview() = default; + +CharacterPreview::~CharacterPreview() { + shutdown(); +} + +bool CharacterPreview::initialize(pipeline::AssetManager* am) { + assetManager_ = am; + + charRenderer_ = std::make_unique(); + if (!charRenderer_->initialize()) { + LOG_ERROR("CharacterPreview: failed to initialize CharacterRenderer"); + return false; + } + charRenderer_->setAssetManager(am); + + // Disable fog and shadows for the preview + charRenderer_->setFog(glm::vec3(0.05f, 0.05f, 0.1f), 9999.0f, 10000.0f); + charRenderer_->clearShadowMap(); + + camera_ = std::make_unique(); + // Portrait-style camera: WoW Z-up coordinate system + // Model at origin, camera positioned along +Y looking toward -Y + camera_->setFov(30.0f); + camera_->setAspectRatio(static_cast(fboWidth_) / static_cast(fboHeight_)); + // Pull camera back far enough to see full body + head with margin + // Human ~2 units tall, Tauren ~2.5. At distance 4.5 with FOV 30: + // vertical visible = 2 * 4.5 * tan(15°) ≈ 2.41 units + camera_->setPosition(glm::vec3(0.0f, 4.5f, 0.9f)); + camera_->setRotation(270.0f, 0.0f); + + createFBO(); + + LOG_INFO("CharacterPreview initialized (", fboWidth_, "x", fboHeight_, ")"); + return true; +} + +void CharacterPreview::shutdown() { + destroyFBO(); + if (charRenderer_) { + charRenderer_->shutdown(); + charRenderer_.reset(); + } + camera_.reset(); + modelLoaded_ = false; + instanceId_ = 0; +} + +void CharacterPreview::createFBO() { + // Create color texture + glGenTextures(1, &colorTexture_); + glBindTexture(GL_TEXTURE_2D, colorTexture_); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, fboWidth_, fboHeight_, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glBindTexture(GL_TEXTURE_2D, 0); + + // Create depth renderbuffer + glGenRenderbuffers(1, &depthRenderbuffer_); + glBindRenderbuffer(GL_RENDERBUFFER, depthRenderbuffer_); + glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, fboWidth_, fboHeight_); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + + // Create FBO + glGenFramebuffers(1, &fbo_); + glBindFramebuffer(GL_FRAMEBUFFER, fbo_); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexture_, 0); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthRenderbuffer_); + + GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) { + LOG_ERROR("CharacterPreview: FBO incomplete, status=", status); + } + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + +void CharacterPreview::destroyFBO() { + if (fbo_) { glDeleteFramebuffers(1, &fbo_); fbo_ = 0; } + if (colorTexture_) { glDeleteTextures(1, &colorTexture_); colorTexture_ = 0; } + if (depthRenderbuffer_) { glDeleteRenderbuffers(1, &depthRenderbuffer_); depthRenderbuffer_ = 0; } +} + +bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, + uint8_t skin, uint8_t face, + uint8_t hairStyle, uint8_t hairColor, + uint8_t facialHair) { + if (!charRenderer_ || !assetManager_ || !assetManager_->isInitialized()) { + return false; + } + + // Remove existing instance + if (instanceId_ > 0) { + charRenderer_->removeInstance(instanceId_); + instanceId_ = 0; + modelLoaded_ = false; + } + + std::string m2Path = game::getPlayerModelPath(race, gender); + std::string modelDir; + std::string baseName; + { + size_t slash = m2Path.rfind('\\'); + if (slash != std::string::npos) { + modelDir = m2Path.substr(0, slash + 1); + baseName = m2Path.substr(slash + 1); + } else { + baseName = m2Path; + } + size_t dot = baseName.rfind('.'); + if (dot != std::string::npos) { + baseName = baseName.substr(0, dot); + } + } + + auto m2Data = assetManager_->readFile(m2Path); + if (m2Data.empty()) { + LOG_WARNING("CharacterPreview: failed to read M2: ", m2Path); + return false; + } + + auto model = pipeline::M2Loader::load(m2Data); + + // Load skin file + std::string skinPath = modelDir + baseName + "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, model); + } + + if (!model.isValid()) { + LOG_WARNING("CharacterPreview: invalid model: ", m2Path); + return false; + } + + // Look up CharSections.dbc for all appearance textures + uint32_t targetRaceId = static_cast(race); + uint32_t targetSexId = (gender == game::Gender::FEMALE) ? 1u : 0u; + + std::string bodySkinPath; + std::string faceLowerPath; + std::string faceUpperPath; + std::string hairScalpPath; + std::vector underwearPaths; + + auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc"); + if (charSectionsDbc) { + bool foundSkin = false; + bool foundFace = false; + bool foundHair = false; + bool foundUnderwear = false; + + for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) { + uint32_t raceId = charSectionsDbc->getUInt32(r, 1); + uint32_t sexId = charSectionsDbc->getUInt32(r, 2); + uint32_t baseSection = charSectionsDbc->getUInt32(r, 3); + uint32_t variationIndex = charSectionsDbc->getUInt32(r, 8); + uint32_t colorIndex = charSectionsDbc->getUInt32(r, 9); + + if (raceId != targetRaceId || sexId != targetSexId) continue; + + // Section 0: Body skin (variation = skin color, colorIndex = 0) + if (baseSection == 0 && !foundSkin && + variationIndex == static_cast(skin) && colorIndex == 0) { + std::string tex1 = charSectionsDbc->getString(r, 4); + if (!tex1.empty()) { + bodySkinPath = tex1; + foundSkin = true; + } + } + // Section 1: Face (variation = face index, colorIndex = skin color) + else if (baseSection == 1 && !foundFace && + variationIndex == static_cast(face) && + colorIndex == static_cast(skin)) { + std::string tex1 = charSectionsDbc->getString(r, 4); + std::string tex2 = charSectionsDbc->getString(r, 5); + if (!tex1.empty()) faceLowerPath = tex1; + if (!tex2.empty()) faceUpperPath = tex2; + foundFace = true; + } + // Section 3: Hair (variation = hair style, colorIndex = hair color) + else if (baseSection == 3 && !foundHair && + variationIndex == static_cast(hairStyle) && + colorIndex == static_cast(hairColor)) { + std::string tex1 = charSectionsDbc->getString(r, 4); + if (!tex1.empty()) { + hairScalpPath = tex1; + foundHair = true; + } + } + // Section 4: Underwear (variation = skin color, colorIndex = 0) + else if (baseSection == 4 && !foundUnderwear && + variationIndex == static_cast(skin) && colorIndex == 0) { + for (int f = 4; f <= 6; f++) { + std::string tex = charSectionsDbc->getString(r, f); + if (!tex.empty()) { + underwearPaths.push_back(tex); + } + } + foundUnderwear = true; + } + } + } + + // Assign texture filenames on model before GPU upload + for (auto& tex : model.textures) { + if (tex.type == 1 && tex.filename.empty() && !bodySkinPath.empty()) { + tex.filename = bodySkinPath; + } else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) { + tex.filename = hairScalpPath; + } + } + + // Load external .anim files + for (uint32_t si = 0; si < model.sequences.size(); si++) { + if (!(model.sequences[si].flags & 0x20)) { + char animFileName[256]; + snprintf(animFileName, sizeof(animFileName), + "%s%s%04u-%02u.anim", + modelDir.c_str(), + baseName.c_str(), + model.sequences[si].id, + model.sequences[si].variationIndex); + auto animFileData = assetManager_->readFile(animFileName); + if (!animFileData.empty()) { + pipeline::M2Loader::loadAnimFile(m2Data, animFileData, si, model); + } + } + } + + charRenderer_->loadModel(model, PREVIEW_MODEL_ID); + + // Composite body skin + face + underwear overlays + if (!bodySkinPath.empty()) { + std::vector layers; + layers.push_back(bodySkinPath); + // Face lower texture composited onto body at the face region + if (!faceLowerPath.empty()) { + layers.push_back(faceLowerPath); + } + if (!faceUpperPath.empty()) { + layers.push_back(faceUpperPath); + } + for (const auto& up : underwearPaths) { + layers.push_back(up); + } + + if (layers.size() > 1) { + GLuint compositeTex = charRenderer_->compositeTextures(layers); + if (compositeTex != 0) { + for (size_t ti = 0; ti < model.textures.size(); ti++) { + if (model.textures[ti].type == 1) { + charRenderer_->setModelTexture(PREVIEW_MODEL_ID, static_cast(ti), compositeTex); + break; + } + } + } + } + } + + // If hair scalp texture was found, ensure it's loaded for type-6 slot + if (!hairScalpPath.empty()) { + GLuint hairTex = charRenderer_->loadTexture(hairScalpPath); + if (hairTex != 0) { + for (size_t ti = 0; ti < model.textures.size(); ti++) { + if (model.textures[ti].type == 6) { + charRenderer_->setModelTexture(PREVIEW_MODEL_ID, static_cast(ti), hairTex); + break; + } + } + } + } + + // Create instance at origin with current yaw + instanceId_ = charRenderer_->createInstance(PREVIEW_MODEL_ID, + glm::vec3(0.0f, 0.0f, 0.0f), + glm::vec3(0.0f, 0.0f, modelYaw_), + 1.0f); + + if (instanceId_ == 0) { + LOG_WARNING("CharacterPreview: failed to create instance"); + return false; + } + + // Set default geosets (naked character) + std::unordered_set activeGeosets; + // Body parts (group 0: IDs 0-18) + for (uint16_t i = 0; i <= 18; i++) { + activeGeosets.insert(i); + } + // Hair style geoset: group 1 = 100 + variation + 1 + activeGeosets.insert(static_cast(100 + hairStyle + 1)); + // Facial hair geoset: group 2 = 200 + variation + 1 + activeGeosets.insert(static_cast(200 + facialHair + 1)); + activeGeosets.insert(301); // Gloves: bare hands + activeGeosets.insert(401); // Boots: bare feet + activeGeosets.insert(501); // Chest: bare + activeGeosets.insert(701); // Ears: default + activeGeosets.insert(1301); // Trousers: bare legs + activeGeosets.insert(1501); // Back body (cloak=none) + charRenderer_->setActiveGeosets(instanceId_, activeGeosets); + + // Play idle animation (Stand = animation ID 0) + charRenderer_->playAnimation(instanceId_, 0, true); + + modelLoaded_ = true; + LOG_INFO("CharacterPreview: loaded ", m2Path, + " skin=", (int)skin, " face=", (int)face, + " hair=", (int)hairStyle, " hairColor=", (int)hairColor, + " facial=", (int)facialHair); + return true; +} + +void CharacterPreview::update(float deltaTime) { + if (charRenderer_ && modelLoaded_) { + charRenderer_->update(deltaTime); + } +} + +void CharacterPreview::render() { + if (!fbo_ || !charRenderer_ || !camera_ || !modelLoaded_) return; + + // Save current viewport + GLint prevViewport[4]; + glGetIntegerv(GL_VIEWPORT, prevViewport); + + // Save current FBO binding + GLint prevFbo; + glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo); + + // Bind our FBO + glBindFramebuffer(GL_FRAMEBUFFER, fbo_); + glViewport(0, 0, fboWidth_, fboHeight_); + + // Clear with dark blue background + glClearColor(0.05f, 0.05f, 0.1f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + glEnable(GL_DEPTH_TEST); + + // Render the character model + charRenderer_->render(*camera_, camera_->getViewMatrix(), camera_->getProjectionMatrix()); + + // Restore previous FBO and viewport + glBindFramebuffer(GL_FRAMEBUFFER, static_cast(prevFbo)); + glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]); +} + +void CharacterPreview::rotate(float yawDelta) { + modelYaw_ += yawDelta; + if (instanceId_ > 0 && charRenderer_) { + charRenderer_->setInstanceRotation(instanceId_, glm::vec3(0.0f, 0.0f, modelYaw_)); + } +} + +} // namespace rendering +} // namespace wowee diff --git a/src/ui/character_create_screen.cpp b/src/ui/character_create_screen.cpp index 2cb349ba..aa9eac29 100644 --- a/src/ui/character_create_screen.cpp +++ b/src/ui/character_create_screen.cpp @@ -1,4 +1,5 @@ #include "ui/character_create_screen.hpp" +#include "rendering/character_preview.hpp" #include "game/game_handler.hpp" #include #include @@ -28,6 +29,8 @@ CharacterCreateScreen::CharacterCreateScreen() { reset(); } +CharacterCreateScreen::~CharacterCreateScreen() = default; + void CharacterCreateScreen::reset() { std::memset(nameBuffer, 0, sizeof(nameBuffer)); raceIndex = 0; @@ -41,6 +44,30 @@ void CharacterCreateScreen::reset() { statusMessage.clear(); statusIsError = false; updateAvailableClasses(); + + // Reset preview tracking to force model reload on next render + prevRaceIndex_ = -1; + prevGenderIndex_ = -1; + prevSkin_ = -1; + prevFace_ = -1; + prevHairStyle_ = -1; + prevHairColor_ = -1; + prevFacialHair_ = -1; +} + +void CharacterCreateScreen::initializePreview(pipeline::AssetManager* am) { + if (!preview_) { + preview_ = std::make_unique(); + preview_->initialize(am); + } + // Force model reload + prevRaceIndex_ = -1; +} + +void CharacterCreateScreen::update(float deltaTime) { + if (preview_) { + preview_->update(deltaTime); + } } void CharacterCreateScreen::setStatus(const std::string& msg, bool isError) { @@ -62,23 +89,98 @@ void CharacterCreateScreen::updateAvailableClasses() { } } +void CharacterCreateScreen::updatePreviewIfNeeded() { + if (!preview_) return; + + bool changed = (raceIndex != prevRaceIndex_ || + genderIndex != prevGenderIndex_ || + skin != prevSkin_ || + face != prevFace_ || + hairStyle != prevHairStyle_ || + hairColor != prevHairColor_ || + facialHair != prevFacialHair_); + + if (changed) { + preview_->loadCharacter( + allRaces[raceIndex], + static_cast(genderIndex), + static_cast(skin), + static_cast(face), + static_cast(hairStyle), + static_cast(hairColor), + static_cast(facialHair)); + + prevRaceIndex_ = raceIndex; + prevGenderIndex_ = genderIndex; + prevSkin_ = skin; + prevFace_ = face; + prevHairStyle_ = hairStyle; + prevHairColor_ = hairColor; + prevFacialHair_ = facialHair; + } +} + void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) { + // Render the preview to FBO before the ImGui frame + if (preview_) { + updatePreviewIfNeeded(); + preview_->render(); + } + ImVec2 displaySize = ImGui::GetIO().DisplaySize; - ImVec2 winSize(600, 520); - ImGui::SetNextWindowSize(winSize, ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2((displaySize.x - winSize.x) * 0.5f, - (displaySize.y - winSize.y) * 0.5f), - ImGuiCond_FirstUseEver); + bool hasPreview = (preview_ && preview_->getTextureId() != 0); + float previewWidth = hasPreview ? 320.0f : 0.0f; + float controlsWidth = 540.0f; + float totalWidth = hasPreview ? (previewWidth + controlsWidth + 16.0f) : 600.0f; + float totalHeight = 580.0f; - ImGui::Begin("Create Character", nullptr, ImGuiWindowFlags_NoCollapse); + ImGui::SetNextWindowSize(ImVec2(totalWidth, totalHeight), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2((displaySize.x - totalWidth) * 0.5f, + (displaySize.y - totalHeight) * 0.5f), + ImGuiCond_Always); - ImGui::Text("Create Character"); - ImGui::Separator(); - ImGui::Spacing(); + ImGui::Begin("Create Character", nullptr, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize); + + if (hasPreview) { + // Left panel: 3D preview + ImGui::BeginChild("##preview_panel", ImVec2(previewWidth, -40.0f), false); + { + // Display the FBO texture (flip Y for OpenGL) + float imgW = previewWidth - 8.0f; + float imgH = imgW * (static_cast(preview_->getHeight()) / + static_cast(preview_->getWidth())); + if (imgH > totalHeight - 80.0f) { + imgH = totalHeight - 80.0f; + imgW = imgH * (static_cast(preview_->getWidth()) / + static_cast(preview_->getHeight())); + } + + ImGui::Image( + static_cast(preview_->getTextureId()), + ImVec2(imgW, imgH), + ImVec2(0.0f, 1.0f), // UV top-left (flipped Y) + ImVec2(1.0f, 0.0f)); // UV bottom-right (flipped Y) + + // Mouse drag rotation on the preview image + if (ImGui::IsItemHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { + float deltaX = ImGui::GetIO().MouseDelta.x; + preview_->rotate(deltaX * 0.5f); + } + + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Drag to rotate"); + } + ImGui::EndChild(); + + ImGui::SameLine(); + + // Right panel: controls + ImGui::BeginChild("##controls_panel", ImVec2(0, -40.0f), false); + } // Name input ImGui::Text("Name:"); - ImGui::SameLine(100); + ImGui::SameLine(80); ImGui::SetNextItemWidth(200); ImGui::InputText("##name", nameBuffer, sizeof(nameBuffer)); @@ -86,8 +188,8 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) { // Race selection ImGui::Text("Race:"); - ImGui::SameLine(100); - ImGui::BeginGroup(); + ImGui::Spacing(); + ImGui::Indent(10.0f); ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "Alliance:"); ImGui::SameLine(); for (int i = 0; i < ALLIANCE_COUNT; ++i) { @@ -120,13 +222,13 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) { } if (selected) ImGui::PopStyleColor(); } - ImGui::EndGroup(); + ImGui::Unindent(10.0f); ImGui::Spacing(); // Class selection ImGui::Text("Class:"); - ImGui::SameLine(100); + ImGui::SameLine(80); if (!availableClasses.empty()) { ImGui::BeginGroup(); for (int i = 0; i < static_cast(availableClasses.size()); ++i) { @@ -145,7 +247,7 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) { // Gender ImGui::Text("Gender:"); - ImGui::SameLine(100); + ImGui::SameLine(80); ImGui::RadioButton("Male", &genderIndex, 0); ImGui::SameLine(); ImGui::RadioButton("Female", &genderIndex, 1); @@ -161,10 +263,13 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) { ImGui::Text("Appearance"); ImGui::Spacing(); - auto slider = [](const char* label, int* val, int maxVal) { + float sliderWidth = hasPreview ? 180.0f : 200.0f; + float labelCol = hasPreview ? 100.0f : 120.0f; + + auto slider = [&](const char* label, int* val, int maxVal) { ImGui::Text("%s", label); - ImGui::SameLine(120); - ImGui::SetNextItemWidth(200); + ImGui::SameLine(labelCol); + ImGui::SetNextItemWidth(sliderWidth); char id[32]; snprintf(id, sizeof(id), "##%s", label); ImGui::SliderInt(id, val, 0, maxVal); @@ -176,18 +281,24 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) { slider("Hair Color", &hairColor, game::getMaxHairColor(currentRace, currentGender)); slider("Facial Feature", &facialHair, game::getMaxFacialFeature(currentRace, currentGender)); - ImGui::Spacing(); - ImGui::Separator(); ImGui::Spacing(); // Status message if (!statusMessage.empty()) { + ImGui::Separator(); + ImGui::Spacing(); ImVec4 color = statusIsError ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f) : ImVec4(0.3f, 1.0f, 0.3f, 1.0f); ImGui::TextColored(color, "%s", statusMessage.c_str()); - ImGui::Spacing(); } - // Buttons + if (hasPreview) { + ImGui::EndChild(); // controls_panel + } + + // Bottom buttons (outside children) + ImGui::Separator(); + ImGui::Spacing(); + if (ImGui::Button("Create", ImVec2(150, 35))) { std::string name(nameBuffer); if (name.empty()) {