diff --git a/resources/w/spark.png b/resources/w/spark.png new file mode 100644 index 0000000..76cc119 --- /dev/null +++ b/resources/w/spark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36ea9bebf70ce832bcb3a83435b9881116c629dccf3b97859beb7db8ed2224bc +size 2732 diff --git a/src/Character.cpp b/src/Character.cpp index 9a60492..b5a2bc7 100644 --- a/src/Character.cpp +++ b/src/Character.cpp @@ -1,6 +1,7 @@ #include "Character.h" #include #include +#include #include "GameConstants.h" #include "Environment.h" @@ -268,6 +269,10 @@ void Character::update(int64_t deltaMs) { + if (hitSparkEmitter.isConfigured()) { + hitSparkEmitter.update(static_cast(deltaMs)); + } + auto it = animations.find(currentState); if (it == animations.end()) return; @@ -304,13 +309,22 @@ void Character::update(int64_t deltaMs) { showWeapon = false; } + auto attackDirectionToTarget = [this]() { + Eigen::Vector3f dir = attackTarget->position - position; + dir.y() = 0.f; + float n = dir.norm(); + if (n > 1e-3f) dir /= n; + else dir = Eigen::Vector3f::Zero(); + return dir; + }; + if (isPlayer) { if (prevFrame == 18 && static_cast(anim.currentFrame) != 18 && (currentState == AnimationState::ACTION_ATTACK || currentState == AnimationState::ACTION_ATTACK_2)) { if (attackTarget != nullptr) { - attackTarget->applyDamage(10.f); + attackTarget->applyDamage(10.f, attackDirectionToTarget()); } } } @@ -320,7 +334,7 @@ void Character::update(int64_t deltaMs) { { if (attackTarget != nullptr) { - attackTarget->applyDamage(10.f); + attackTarget->applyDamage(10.f, attackDirectionToTarget()); } } } @@ -365,6 +379,11 @@ void Character::update(int64_t deltaMs) { } void Character::draw(Renderer& renderer) { + // Sparks are world-space billboards — render under the caller's camera matrices. + if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) { + hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false); + } + if (!isPlayer && hp <= 0) { return; @@ -624,6 +643,11 @@ void Character::drawShadowDepthGpuSkinning(Renderer& renderer) { // ==================== Main pass with shadows ==================== void Character::drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera) { + // Sparks are world-space billboards — render under the caller's camera matrices. + if (hitSparkEmitter.isConfigured() && hitSparkEmitter.getActiveParticleCount() > 0) { + hitSparkEmitter.draw(renderer, 1.0f, Environment::width, Environment::height, false); + } + if (!isPlayer && hp <= 0) { return; @@ -755,10 +779,45 @@ void Character::drawGpuSkinningWithShadow(Renderer& renderer, const Eigen::Matri CheckGlError(__FILE__, __LINE__); } -void Character::applyDamage(float damageAmount) +void Character::setupHitSparks(std::shared_ptr sparkTexture) +{ + hitSparkEmitter.setTexture(sparkTexture); + hitSparkEmitter.setShaderProgramName("spark"); + hitSparkEmitter.setMaxParticles(64); + hitSparkEmitter.setParticleSize(0.08f); + hitSparkEmitter.setBiasX(0.0f); + hitSparkEmitter.setEmissionRate(1.0e9f); // effectively disables auto-emission + hitSparkEmitter.setSpeedRange(2.0f, 4.0f); + hitSparkEmitter.setZSpeedRange(0.5f, 2.0f); + hitSparkEmitter.setScaleRange(0.8f, 1.2f); + hitSparkEmitter.setLifeTimeRange(300.0f, 600.0f); + hitSparkEmitter.setIsActive(false); + hitSparkEmitter.markConfigured(); +} + +void Character::applyDamage(float damageAmount, const Eigen::Vector3f& attackDirection) { hp = hp - damageAmount; if (hp < 0) hp = 0; + + if (!hitSparkEmitter.isConfigured()) return; + + Eigen::Vector3f emitPos = position + Eigen::Vector3f(0.f, 1.f, 0.f); + hitSparkEmitter.resetEmissionPoints({ emitPos }); + hitSparkEmitter.setEmissionDirection(attackDirection); + + static std::mt19937 gen(std::random_device{}()); + std::uniform_int_distribution countDist(10, 15); + int count = countDist(gen); + for (int i = 0; i < count; ++i) { + hitSparkEmitter.emit(); + } +} + +void Character::prepareHitSparksForDraw(const Eigen::Matrix4f& cameraViewMatrix) +{ + if (!hitSparkEmitter.isConfigured()) return; + hitSparkEmitter.prepareForDraw(cameraViewMatrix); } } // namespace ZL diff --git a/src/Character.h b/src/Character.h index c2c032e..bb4b440 100644 --- a/src/Character.h +++ b/src/Character.h @@ -3,6 +3,7 @@ #include "render/Renderer.h" #include "render/TextureManager.h" #include "items/Item.h" +#include "SparkEmitter.h" #include #include #include @@ -51,7 +52,19 @@ public: void drawWithShadow(Renderer& renderer, const Eigen::Matrix4f& lightFromCamera, GLuint shadowMapTex, const Eigen::Vector3f& lightDirCamera); - void applyDamage(float damageAmount); + // attackDirection is a world-space horizontal vector pointing from the + // attacker toward this character — i.e. the direction the hit pushes + // them. A zero vector disables directional sparks (isotropic burst). + void applyDamage(float damageAmount, const Eigen::Vector3f& attackDirection = Eigen::Vector3f::Zero()); + + // Configures the per-character hit-spark emitter with the shared spark + // texture. Safe to call once per character after construction. + void setupHitSparks(std::shared_ptr sparkTexture); + + // Call per-frame between update() and draw(), with the active camera + // view matrix (world → eye). The emitter billboards and sorts its + // particles against that camera before its VBO is uploaded. + void prepareHitSparksForDraw(const Eigen::Matrix4f& cameraViewMatrix); // Public: read by Game for camera tracking and ray-cast origin Eigen::Vector3f position = Eigen::Vector3f(0.f, 0.f, 0.f); @@ -92,6 +105,8 @@ public: std::string weaponAttachBoneName = "RightHand"; bool showWeapon = true; + SparkEmitter hitSparkEmitter; + private: diff --git a/src/Location.cpp b/src/Location.cpp index 85f0858..718e7fd 100644 --- a/src/Location.cpp +++ b/src/Location.cpp @@ -44,6 +44,8 @@ namespace ZL //auto playerTexture = std::make_shared(CreateTextureDataFromPng("resources/w/gg/IMG_20260413_182354_992.png", CONST_ZIP_FILE)); auto playerTexture = std::make_shared(CreateTextureDataFromPng("resources/w/gg/UniV_Grid_2K_Base_color.png", CONST_ZIP_FILE)); + auto sparkTexture = std::make_shared(CreateTextureDataFromPng("resources/w/spark.png", CONST_ZIP_FILE)); + player = std::make_unique(); /* player->loadBinaryAnimation(AnimationState::STAND, "resources/w/gg/gg_stand_idle001.anim", CONST_ZIP_FILE); @@ -93,10 +95,14 @@ namespace ZL player->canAttack = true; player->isPlayer = true; + player->setupHitSparks(sparkTexture); std::cout << "Load resurces step 9" << std::endl; // Load NPCs from JSON npcs = GameObjectLoader::loadAndCreate_Npcs("resources/config2/npcs.json", CONST_ZIP_FILE); + for (auto& npc : npcs) { + if (npc) npc->setupHitSparks(sparkTexture); + } auto ghostTexture = std::make_shared(CreateTextureDataFromPng("resources/w/ghost_skin001.png", CONST_ZIP_FILE)); @@ -125,6 +131,7 @@ namespace ZL npc02->setTarget(npc02->position); npc02->canAttack = true; npc02->attackTarget = player.get(); + npc02->setupHitSparks(sparkTexture); npcs.push_back(std::move(npc02)); @@ -338,6 +345,10 @@ namespace ZL } } + const Eigen::Matrix4f currentView = renderer.GetCurrentModelViewMatrix(); + if (player) player->prepareHitSparksForDraw(currentView); + for (auto& npc : npcs) npc->prepareHitSparksForDraw(currentView); + if (player) player->draw(renderer); for (auto& npc : npcs) npc->draw(renderer); @@ -499,12 +510,20 @@ namespace ZL #ifdef DEBUG_LIGHT // In debug-light mode characters use the plain shaders (draw normally // but from the light's viewpoint — projection/view already on stack). + { + const Eigen::Matrix4f currentView = renderer.GetCurrentModelViewMatrix(); + if (player) player->prepareHitSparksForDraw(currentView); + for (auto& npc : npcs) npc->prepareHitSparksForDraw(currentView); + } if (player) player->draw(renderer); for (auto& npc : npcs) npc->draw(renderer); #else // Characters use their own shadow-aware shaders CheckGlError(__FILE__, __LINE__); + if (player) player->prepareHitSparksForDraw(cameraViewMatrix); + for (auto& npc : npcs) npc->prepareHitSparksForDraw(cameraViewMatrix); + if (player) player->drawWithShadow(renderer, lightFromCamera, shadowMap->getDepthTexture(), lightDirCamera); CheckGlError(__FILE__, __LINE__); diff --git a/src/SparkEmitter.cpp b/src/SparkEmitter.cpp index d65de51..1e3e522 100644 --- a/src/SparkEmitter.cpp +++ b/src/SparkEmitter.cpp @@ -38,7 +38,8 @@ namespace ZL { scaleRange(copyFrom.scaleRange), lifeTimeRange(copyFrom.lifeTimeRange), shaderProgramName(copyFrom.shaderProgramName), - configured(copyFrom.configured), useWorldSpace(copyFrom.useWorldSpace) + configured(copyFrom.configured), useWorldSpace(copyFrom.useWorldSpace), + emissionDirection(copyFrom.emissionDirection) { // Each copy gets its own GPU buffers; only copy CPU-side data sparkQuad.data = copyFrom.sparkQuad.data; @@ -77,8 +78,12 @@ namespace ZL { texture = tex; } - void SparkEmitter::prepareDrawData(bool withRotation) { - if (!drawDataDirty) return; + void SparkEmitter::prepareDrawData(const Eigen::Matrix4f& cameraViewMatrix) { + const bool billboard = !cameraViewMatrix.isApprox(Eigen::Matrix4f::Identity()); + + // Billboarding depends on camera orientation every frame, so we must + // rebuild even when particle state hasn't changed. + if (!drawDataDirty && !billboard) return; drawPositions.clear(); drawTexCoords.clear(); @@ -88,17 +93,32 @@ namespace ZL { return; } + // When billboarding, the camera's world-space right/up axes are the + // first two rows of the view matrix's rotation part. Sort key is + // eye-space Z (camera looks down -Z, so smaller z = farther; sorting + // ascending puts far particles first = back-to-front). + Vector3f camRight(1.f, 0.f, 0.f); + Vector3f camUp(0.f, 1.f, 0.f); + if (billboard) { + camRight = Vector3f(cameraViewMatrix(0, 0), cameraViewMatrix(0, 1), cameraViewMatrix(0, 2)); + camUp = Vector3f(cameraViewMatrix(1, 0), cameraViewMatrix(1, 1), cameraViewMatrix(1, 2)); + } + std::vector> sortedParticles; sortedParticles.reserve(getActiveParticleCount()); for (const auto& particle : particles) { - if (particle.active) { - Vector3f posCam; - - posCam = particle.position; - - sortedParticles.push_back({ &particle, posCam(2) }); + if (!particle.active) continue; + float depthKey; + if (billboard) { + depthKey = cameraViewMatrix(2, 0) * particle.position(0) + + cameraViewMatrix(2, 1) * particle.position(1) + + cameraViewMatrix(2, 2) * particle.position(2) + + cameraViewMatrix(2, 3); + } else { + depthKey = particle.position(2); } + sortedParticles.push_back({ &particle, depthKey }); } std::sort(sortedParticles.begin(), sortedParticles.end(), @@ -108,13 +128,38 @@ namespace ZL { for (const auto& [particlePtr, depth] : sortedParticles) { const auto& particle = *particlePtr; - Vector3f posCam; - posCam = particle.position; - - float size = particleSize * particle.scale; - + if (billboard) { + Vector3f r = camRight * size; + Vector3f u = camUp * size; + const Vector3f& c = particle.position; + + Vector3f bl = c - r - u; + Vector3f tl = c - r + u; + Vector3f tr = c + r + u; + Vector3f br = c + r - u; + + drawPositions.push_back({ bl(0), bl(1), bl(2) }); + drawTexCoords.push_back({ 0.0f, 0.0f }); + + drawPositions.push_back({ tl(0), tl(1), tl(2) }); + drawTexCoords.push_back({ 0.0f, 1.0f }); + + drawPositions.push_back({ tr(0), tr(1), tr(2) }); + drawTexCoords.push_back({ 1.0f, 1.0f }); + + drawPositions.push_back({ bl(0), bl(1), bl(2) }); + drawTexCoords.push_back({ 0.0f, 0.0f }); + + drawPositions.push_back({ tr(0), tr(1), tr(2) }); + drawTexCoords.push_back({ 1.0f, 1.0f }); + + drawPositions.push_back({ br(0), br(1), br(2) }); + drawTexCoords.push_back({ 1.0f, 0.0f }); + } else { + const Vector3f& posCam = particle.position; + drawPositions.push_back({ posCam(0) - size, posCam(1) - size, posCam(2) }); drawTexCoords.push_back({ 0.0f, 0.0f }); @@ -132,19 +177,18 @@ namespace ZL { drawPositions.push_back({ posCam(0) + size, posCam(1) - size, posCam(2) }); drawTexCoords.push_back({ 1.0f, 0.0f }); - - + } } drawDataDirty = false; } - void SparkEmitter::prepareForDraw(bool withRotation) { + void SparkEmitter::prepareForDraw(const Eigen::Matrix4f& cameraViewMatrix) { if (!configured) return; - - prepareDrawData(withRotation); - - if (!drawPositions.empty()) { + + prepareDrawData(cameraViewMatrix); + + if (!drawPositions.empty()) { sparkQuad.data.PositionData = drawPositions; sparkQuad.data.TexCoordData = drawTexCoords; sparkQuad.RefreshVBO(); @@ -368,6 +412,19 @@ namespace ZL { float speed = speedDist(gen); float zSpeed = zSpeedDist(gen); + if (emissionDirection.squaredNorm() > 1e-6f) { + // Directional mode: spray in a cone around emissionDirection. + Vector3f dir = emissionDirection.normalized(); + Vector3f up(0.f, 1.f, 0.f); + if (std::fabs(dir.dot(up)) > 0.95f) up = Vector3f(1.f, 0.f, 0.f); + Vector3f right = dir.cross(up).normalized(); + Vector3f forward = right.cross(dir).normalized(); + std::uniform_real_distribution spreadDist(-0.6f, 0.6f); + Vector3f v = (dir + right * spreadDist(gen) + forward * spreadDist(gen)).normalized() * speed; + v(1) += zSpeed; + return v; + } + // Теперь biasX берется из JSON if (emitterIndex == 0) { return Vector3f{ diff --git a/src/SparkEmitter.h b/src/SparkEmitter.h index b41ee4b..8a5d11a 100644 --- a/src/SparkEmitter.h +++ b/src/SparkEmitter.h @@ -51,9 +51,18 @@ namespace ZL { std::string shaderProgramName; bool configured; - void prepareDrawData(bool withRotation); + // Rebuilds the CPU-side quad/UV arrays. If cameraViewMatrix is identity, + // particles are rendered as axis-aligned quads in world XY and sorted by + // world Z (legacy behavior). Otherwise quads are billboarded toward the + // camera using its world-space right/up axes, and particles are sorted + // back-to-front in eye space for correct alpha blending. + void prepareDrawData(const Eigen::Matrix4f& cameraViewMatrix); bool useWorldSpace; + // When non-zero, velocities spray in a cone around this direction + // instead of the default planar XY + Z spread used by JSON emitters. + Vector3f emissionDirection = Vector3f{ 0.f, 0.f, 0.f }; + public: SparkEmitter(); SparkEmitter(const SparkEmitter& copyFrom); @@ -74,11 +83,24 @@ namespace ZL { void setUseWorldSpace(bool use) { useWorldSpace = use; } bool loadFromJsonFile(const std::string& path, Renderer& renderer, const std::string& zipFile = ""); + // Programmatic configuration (alternative to loadFromJsonFile). + // Ranges follow the same semantics as the JSON fields. + void setSpeedRange(float minVal, float maxVal) { speedRange.min = minVal; speedRange.max = maxVal; } + void setZSpeedRange(float minVal, float maxVal) { zSpeedRange.min = minVal; zSpeedRange.max = maxVal; } + void setScaleRange(float minVal, float maxVal) { scaleRange.min = minVal; scaleRange.max = maxVal; } + void setLifeTimeRange(float minVal, float maxVal) { lifeTimeRange.min = minVal; lifeTimeRange.max = maxVal; } + void setEmissionDirection(const Vector3f& dir) { emissionDirection = dir; } + void markConfigured() { configured = true; } + bool isConfigured() const { return configured; } + void update(float deltaTimeMs); void emit(float ageMs = 0.0f, float lerpT = 0.0f); // Вызывать ДО draw() в начале кадра: готовит данные и загружает в VBO. - void prepareForDraw(bool withRotation = true); + // Pass the current camera view matrix (world → eye) to billboard sparks + // toward the camera and sort them back-to-front. Identity (default) + // keeps the legacy non-billboarded behavior. + void prepareForDraw(const Eigen::Matrix4f& cameraViewMatrix = Eigen::Matrix4f::Identity()); void draw(Renderer& renderer, float zoom, int screenWidth, int screenHeight); void draw(Renderer& renderer, float zoom, int screenWidth, int screenHeight, bool withRotation);