From caad22cca76d15b1f8d3be557955deb9fce183d1 Mon Sep 17 00:00:00 2001 From: Lenheart <947330670@qq.com> Date: Tue, 7 Apr 2026 23:02:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(npc):=20=E6=B7=BB=E5=8A=A0NPC=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=8A=A0=E8=BD=BD=E3=80=81=E5=8A=A8=E7=94=BB=E5=92=8C?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现NPC系统的核心功能,包括: 1. 新增NpcDataLoader用于加载NPC索引和配置数据 2. 添加NpcAnimation处理NPC动画显示 3. 创建NpcObject实现NPC交互和显示逻辑 4. 在GameMapTestScene中集成测试NPC功能 --- Game/include/npc/NpcAnimation.h | 25 +++ Game/include/npc/NpcDataLoader.h | 20 +++ Game/include/npc/NpcObject.h | 59 +++++++ Game/include/scene/GameMapTestScene.h | 2 + Game/src/bootstrap/main.cpp | 9 +- Game/src/npc/NpcAnimation.cpp | 58 +++++++ Game/src/npc/NpcDataLoader.cpp | 217 ++++++++++++++++++++++++++ Game/src/npc/NpcObject.cpp | 214 +++++++++++++++++++++++++ Game/src/scene/GameMapTestScene.cpp | 31 +++- 9 files changed, 628 insertions(+), 7 deletions(-) create mode 100644 Game/include/npc/NpcAnimation.h create mode 100644 Game/include/npc/NpcDataLoader.h create mode 100644 Game/include/npc/NpcObject.h create mode 100644 Game/src/npc/NpcAnimation.cpp create mode 100644 Game/src/npc/NpcDataLoader.cpp create mode 100644 Game/src/npc/NpcObject.cpp diff --git a/Game/include/npc/NpcAnimation.h b/Game/include/npc/NpcAnimation.h new file mode 100644 index 0000000..319ee00 --- /dev/null +++ b/Game/include/npc/NpcAnimation.h @@ -0,0 +1,25 @@ +#pragma once + +#include "npc/NpcDataLoader.h" +#include +#include +#include + +namespace frostbite2D { + +class NpcAnimation : public Actor { +public: + bool Init(const npc::NpcConfig& config); + void SetDirection(int direction); + bool IsReady() const { return displayAnimation_ != nullptr; } + bool IsAnimationFinished() const; + const std::string& GetAnimationPath() const { return animationPath_; } + bool GetDisplayLocalBounds(Rect& outBounds) const; + +private: + RefPtr displayAnimation_ = nullptr; + std::string animationPath_; + int direction_ = 1; +}; + +} // namespace frostbite2D diff --git a/Game/include/npc/NpcDataLoader.h b/Game/include/npc/NpcDataLoader.h new file mode 100644 index 0000000..67ac29a --- /dev/null +++ b/Game/include/npc/NpcDataLoader.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +namespace frostbite2D::npc { + +struct NpcConfig { + int id = -1; + std::string path; + std::string name; + std::string fieldName; + std::string fieldAnimationPath; +}; + +bool loadNpcIndex(std::map& outIndex); +std::optional loadNpcConfig(int npcId); + +} // namespace frostbite2D::npc diff --git a/Game/include/npc/NpcObject.h b/Game/include/npc/NpcObject.h new file mode 100644 index 0000000..72b9f9a --- /dev/null +++ b/Game/include/npc/NpcObject.h @@ -0,0 +1,59 @@ +#pragma once + +#include "character/CharacterActionTypes.h" +#include "npc/NpcAnimation.h" +#include +#include +#include +#include +#include + +namespace frostbite2D { + +class NpcObject : public Actor { +public: + NpcObject() = default; + ~NpcObject() override = default; + + bool Construction(int npcId); + + void SetDirection(int direction); + void SetNpcPosition(const Vec2& pos); + void SetWorldPosition(const CharacterWorldPosition& pos); + void SetInteractable(bool interactable); + void BeginInteract(); + void EndInteract(); + + bool CanInteract() const; + bool IsInteracting() const { return interacting_; } + int GetNpcId() const { return npcId_; } + int GetDirection() const { return direction_; } + const std::string& GetName() const; + const std::string& GetFieldName() const; + const std::string& GetDisplayName() const; + const CharacterWorldPosition& GetWorldPosition() const { return worldPosition_; } + bool IsAnimationFinished() const; + + const npc::NpcConfig* GetConfig() const { + return config_ ? &config_.value() : nullptr; + } + + void Update(float deltaTime) override; + +private: + void EnsureNameLabel(); + void RefreshNameLabel(); + void SyncActorPositionFromWorld(); + + int npcId_ = -1; + int direction_ = 1; + CharacterWorldPosition worldPosition_; + std::optional config_; + RefPtr animation_ = nullptr; + std::array, 8> nameOutlineLabels_; + RefPtr nameLabel_ = nullptr; + bool interactable_ = true; + bool interacting_ = false; +}; + +} // namespace frostbite2D diff --git a/Game/include/scene/GameMapTestScene.h b/Game/include/scene/GameMapTestScene.h index 7873826..909b373 100644 --- a/Game/include/scene/GameMapTestScene.h +++ b/Game/include/scene/GameMapTestScene.h @@ -3,6 +3,7 @@ #include "camera/GameCameraController.h" #include "character/CharacterObject.h" #include "map/GameMap.h" +#include "npc/NpcObject.h" #include "scene/GameDebugUIScene.h" #include @@ -20,6 +21,7 @@ public: private: GameCameraController cameraController_; RefPtr character_; + RefPtr npc_; RefPtr debugScene_; bool initialized_ = false; RefPtr map_; diff --git a/Game/src/bootstrap/main.cpp b/Game/src/bootstrap/main.cpp index 9617901..ba87038 100644 --- a/Game/src/bootstrap/main.cpp +++ b/Game/src/bootstrap/main.cpp @@ -20,7 +20,7 @@ #include #include #include -#include "world/GameWorld.h" +#include "scene/GameMapTestScene.h" using namespace frostbite2D; @@ -154,9 +154,10 @@ int main(int argc, char **argv) { StartupTrace::mark("before SceneManager::ReplaceScene"); { - ScopedStartupTrace stageTrace("SceneManager::ReplaceScene(GameWorld)"); - auto gameWorld = MakePtr(); - SceneManager::get().ReplaceScene(gameWorld); + ScopedStartupTrace stageTrace( + "SceneManager::ReplaceScene(GameMapTestScene)"); + auto gameMapTestScene = MakePtr(); + SceneManager::get().ReplaceScene(gameMapTestScene); } StartupTrace::mark("after SceneManager::ReplaceScene"); diff --git a/Game/src/npc/NpcAnimation.cpp b/Game/src/npc/NpcAnimation.cpp new file mode 100644 index 0000000..5f39be9 --- /dev/null +++ b/Game/src/npc/NpcAnimation.cpp @@ -0,0 +1,58 @@ +#include "npc/NpcAnimation.h" +#include + +namespace frostbite2D { + +bool NpcAnimation::Init(const npc::NpcConfig& config) { + RemoveAllChildren(); + displayAnimation_ = nullptr; + animationPath_.clear(); + direction_ = 1; + + if (config.fieldAnimationPath.empty()) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "NpcAnimation: npc %d missing field animation path", config.id); + return false; + } + + auto animation = MakePtr(config.fieldAnimationPath); + if (!animation || !animation->IsUsable()) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "NpcAnimation: failed to load animation %s for npc %d", + config.fieldAnimationPath.c_str(), config.id); + return false; + } + + animationPath_ = config.fieldAnimationPath; + SDL_Log("NpcAnimation: npc %d loaded display animation %s", + config.id, animationPath_.c_str()); + + animation->SetVisible(true); + AddChild(animation); + displayAnimation_ = animation; + SetDirection(1); + displayAnimation_->Reset(); + SetDirection(direction_); + return true; +} + +void NpcAnimation::SetDirection(int direction) { + direction_ = direction >= 0 ? 1 : -1; + if (displayAnimation_) { + displayAnimation_->SetDirection(direction_); + } +} + +bool NpcAnimation::IsAnimationFinished() const { + return displayAnimation_ ? !displayAnimation_->IsUsable() : false; +} + +bool NpcAnimation::GetDisplayLocalBounds(Rect& outBounds) const { + if (!displayAnimation_) { + outBounds = Rect(); + return false; + } + return displayAnimation_->GetStaticLocalBounds(outBounds); +} + +} // namespace frostbite2D diff --git a/Game/src/npc/NpcDataLoader.cpp b/Game/src/npc/NpcDataLoader.cpp new file mode 100644 index 0000000..f4fa0f8 --- /dev/null +++ b/Game/src/npc/NpcDataLoader.cpp @@ -0,0 +1,217 @@ +#include "npc/NpcDataLoader.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace frostbite2D::npc { +namespace { + +constexpr char kNpcListPath[] = "npc/npc.lst"; +constexpr char kTagName[] = "[name]"; +constexpr char kTagFieldName[] = "[field name]"; +constexpr char kTagFieldAnimation[] = "[field animation]"; + +std::string toLowerCase(const std::string& str) { + std::string result = str; + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return result; +} + +std::string trim(const std::string& value) { + size_t start = 0; + size_t end = value.size(); + while (start < end && std::isspace(static_cast(value[start]))) { + ++start; + } + while (end > start && + std::isspace(static_cast(value[end - 1]))) { + --end; + } + return value.substr(start, end - start); +} + +int toInt(const std::string& value, int fallback = 0) { + try { + return std::stoi(value); + } catch (...) { + return fallback; + } +} + +bool isToken(const std::string& token, const char* expected) { + return trim(token) == expected; +} + +std::string normalizeNpcPath(const std::string& path) { + auto& pvf = PvfArchive::get(); + std::string normalized = pvf.normalizePath(path); + if (normalized.rfind("npc/", 0) == 0) { + return normalized; + } + return pvf.normalizePath("npc/" + normalized); +} + +void logTokenPreview(const char* label, const std::vector& tokens) { + std::string preview; + size_t previewCount = std::min(tokens.size(), 8); + for (size_t i = 0; i < previewCount; ++i) { + if (!preview.empty()) { + preview += " | "; + } + preview += tokens[i]; + } + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "%s: %s", label, preview.c_str()); +} + +class ScriptTokenStream { +public: + explicit ScriptTokenStream(const std::string& path) + : path_(toLowerCase(path)) { + auto& pvf = PvfArchive::get(); + auto rawData = pvf.getFileRawData(path_); + if (!rawData) { + return; + } + + ScriptParser parser(*rawData, path_); + if (!parser.isValid()) { + return; + } + + for (const auto& value : parser.parseAll()) { + tokens_.push_back(value.toString()); + } + valid_ = true; + } + + bool isValid() const { return valid_; } + bool isEnd() const { return index_ >= tokens_.size(); } + + std::string get() { + if (isEnd()) { + return {}; + } + return tokens_[index_++]; + } + +private: + std::string path_; + std::vector tokens_; + size_t index_ = 0; + bool valid_ = false; +}; + +std::map& npcIndexCache() { + static std::map cache; + static bool loaded = false; + if (!loaded) { + ScriptTokenStream stream(kNpcListPath); + if (stream.isValid()) { + while (!stream.isEnd()) { + std::string indexToken = stream.get(); + if (indexToken.empty()) { + break; + } + std::string pathToken = stream.get(); + if (pathToken.empty()) { + break; + } + cache[toInt(indexToken, -1)] = normalizeNpcPath(pathToken); + } + } else { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "NpcDataLoader: npc index %s not found", kNpcListPath); + } + loaded = true; + } + return cache; +} + +std::map& npcConfigCache() { + static std::map cache; + return cache; +} + +} // namespace + +bool loadNpcIndex(std::map& outIndex) { + outIndex = npcIndexCache(); + if (!outIndex.empty()) { + SDL_Log("NpcDataLoader: loaded npc index entries=%d", + static_cast(outIndex.size())); + } + return !outIndex.empty(); +} + +std::optional loadNpcConfig(int npcId) { + auto cacheIt = npcConfigCache().find(npcId); + if (cacheIt != npcConfigCache().end()) { + return cacheIt->second; + } + + auto indexIt = npcIndexCache().find(npcId); + if (indexIt == npcIndexCache().end()) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "NpcDataLoader: npc %d not found in index", npcId); + return std::nullopt; + } + + ScriptTokenStream stream(indexIt->second); + if (!stream.isValid()) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "NpcDataLoader: unable to load npc config %s", + indexIt->second.c_str()); + return std::nullopt; + } + + NpcConfig config; + config.id = npcId; + config.path = indexIt->second; + + std::vector tokenPreview; + while (!stream.isEnd()) { + std::string segment = stream.get(); + if (tokenPreview.size() < 8) { + tokenPreview.push_back(segment); + } + + if (isToken(segment, kTagName)) { + config.name = stream.get(); + } else if (isToken(segment, kTagFieldName)) { + config.fieldName = stream.get(); + } else if (isToken(segment, kTagFieldAnimation)) { + config.fieldAnimationPath = normalizeNpcPath(stream.get()); + } + } + + if (config.fieldName.empty()) { + config.fieldName = config.name; + } + if (config.name.empty()) { + config.name = config.fieldName; + } + + if (config.fieldAnimationPath.empty()) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "NpcDataLoader: npc %d config incomplete (name=%s, fieldName=%s, script=%s)", + npcId, config.name.c_str(), config.fieldName.c_str(), + indexIt->second.c_str()); + logTokenPreview("NpcDataLoader token preview", tokenPreview); + return std::nullopt; + } + + SDL_Log("NpcDataLoader: npc %d path=%s fieldAnimation=%s", + npcId, config.path.c_str(), config.fieldAnimationPath.c_str()); + + npcConfigCache()[npcId] = config; + return config; +} + +} // namespace frostbite2D::npc diff --git a/Game/src/npc/NpcObject.cpp b/Game/src/npc/NpcObject.cpp new file mode 100644 index 0000000..93a000d --- /dev/null +++ b/Game/src/npc/NpcObject.cpp @@ -0,0 +1,214 @@ +#include "npc/NpcObject.h" +#include "common/math/GameMath.h" +#include + +namespace frostbite2D { +namespace { + +constexpr float kNpcNameMarginY = 8.0f; +constexpr float kNpcNameFallbackTopY = -96.0f; +const Vec2 kNpcNameOutlineOffsets[] = { + Vec2(-1.0f, -1.0f), Vec2(-1.0f, 0.0f), Vec2(-1.0f, 1.0f), + Vec2(0.0f, -1.0f), Vec2(0.0f, 1.0f), + Vec2(1.0f, -1.0f), Vec2(1.0f, 0.0f), Vec2(1.0f, 1.0f), +}; + +void ConfigureNameTextLabel(RefPtr label, + const char* name, + const Color& color, + int zOrder) { + if (!label) { + return; + } + + label->SetName(name); + label->SetFont("default"); + label->SetAnchor(0.5f, 1.0f); + label->SetTextColor(color); + label->SetZOrder(zOrder); +} + +const std::string& EmptyString() { + static const std::string kEmpty; + return kEmpty; +} + +} // namespace + +bool NpcObject::Construction(int npcId) { + RemoveAllChildren(); + npcId_ = -1; + direction_ = 1; + worldPosition_ = CharacterWorldPosition(); + config_.reset(); + animation_ = nullptr; + nameOutlineLabels_.fill(nullptr); + nameLabel_ = nullptr; + interactable_ = true; + interacting_ = false; + + auto config = npc::loadNpcConfig(npcId); + if (!config) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "NpcObject: failed to load npc config %d", npcId); + return false; + } + + auto animation = MakePtr(); + if (!animation->Init(*config)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "NpcObject: failed to init npc animation %d", npcId); + return false; + } + + SDL_Log("NpcObject: npc %d config loaded name=%s fieldName=%s ani=%s", + npcId, config->name.c_str(), config->fieldName.c_str(), + config->fieldAnimationPath.c_str()); + + config_ = *config; + npcId_ = npcId; + animation_ = animation; + AddChild(animation_); + EnsureNameLabel(); + + SetWorldPosition({}); + SetDirection(1); + RefreshNameLabel(); + SDL_Log("NpcObject: npc %d construction complete", npcId_); + return true; +} + +void NpcObject::SetDirection(int direction) { + direction_ = direction >= 0 ? 1 : -1; + if (animation_) { + animation_->SetDirection(direction_); + } + RefreshNameLabel(); +} + +void NpcObject::SetNpcPosition(const Vec2& pos) { + worldPosition_.x = gameMath::RoundWorldCoordinate(pos.x); + worldPosition_.y = gameMath::RoundWorldCoordinate(pos.y); + SDL_Log("NpcObject: npc %d set ground position to (%d, %d, %d)", + npcId_, worldPosition_.x, worldPosition_.y, worldPosition_.z); + SyncActorPositionFromWorld(); +} + +void NpcObject::SetWorldPosition(const CharacterWorldPosition& pos) { + worldPosition_ = pos; + SDL_Log("NpcObject: npc %d set world position to (%d, %d, %d)", + npcId_, worldPosition_.x, worldPosition_.y, worldPosition_.z); + SyncActorPositionFromWorld(); +} + +void NpcObject::SetInteractable(bool interactable) { + interactable_ = interactable; + if (!interactable_) { + interacting_ = false; + } +} + +void NpcObject::BeginInteract() { + if (CanInteract()) { + interacting_ = true; + } +} + +void NpcObject::EndInteract() { + interacting_ = false; +} + +bool NpcObject::CanInteract() const { + return interactable_ && config_.has_value(); +} + +const std::string& NpcObject::GetName() const { + return config_ ? config_->name : EmptyString(); +} + +const std::string& NpcObject::GetFieldName() const { + return config_ ? config_->fieldName : EmptyString(); +} + +const std::string& NpcObject::GetDisplayName() const { + const std::string& fieldName = GetFieldName(); + if (!fieldName.empty()) { + return fieldName; + } + return GetName(); +} + +bool NpcObject::IsAnimationFinished() const { + return animation_ ? animation_->IsAnimationFinished() : false; +} + +void NpcObject::Update(float deltaTime) { + Actor::Update(deltaTime); +} + +void NpcObject::EnsureNameLabel() { + if (nameLabel_) { + return; + } + + for (size_t i = 0; i < nameOutlineLabels_.size(); ++i) { + RefPtr outlineLabel = TextSprite::create(); + ConfigureNameTextLabel(outlineLabel, "npcNameOutline", + Color(0.0f, 0.0f, 0.0f, 1.0f), 1); + nameOutlineLabels_[i] = outlineLabel; + AddChild(outlineLabel); + } + + nameLabel_ = TextSprite::create(); + ConfigureNameTextLabel(nameLabel_, "npcName", + Color(1.0f, 0.96f, 0.78f, 1.0f), 2); + AddChild(nameLabel_); +} + +void NpcObject::RefreshNameLabel() { + if (!nameLabel_) { + return; + } + + const std::string& displayName = GetDisplayName(); + bool hasDisplayName = !displayName.empty(); + nameLabel_->SetVisible(hasDisplayName); + for (auto& outlineLabel : nameOutlineLabels_) { + if (outlineLabel) { + outlineLabel->SetVisible(hasDisplayName); + } + } + if (!hasDisplayName) { + return; + } + + nameLabel_->SetText(displayName); + for (auto& outlineLabel : nameOutlineLabels_) { + if (outlineLabel) { + outlineLabel->SetText(displayName); + } + } + + Rect displayBounds; + float labelBaseY = kNpcNameFallbackTopY; + if (animation_ && animation_->GetDisplayLocalBounds(displayBounds)) { + labelBaseY = displayBounds.top() - kNpcNameMarginY; + } + + nameLabel_->SetPosition(0.0f, labelBaseY); + for (size_t i = 0; i < nameOutlineLabels_.size(); ++i) { + if (!nameOutlineLabels_[i]) { + continue; + } + + const Vec2& offset = kNpcNameOutlineOffsets[i]; + nameOutlineLabels_[i]->SetPosition(offset.x, labelBaseY + offset.y); + } +} + +void NpcObject::SyncActorPositionFromWorld() { + SetPosition(worldPosition_.ToScreenPosition()); + SetZOrder(worldPosition_.y); +} + +} // namespace frostbite2D diff --git a/Game/src/scene/GameMapTestScene.cpp b/Game/src/scene/GameMapTestScene.cpp index 4abf018..7e6442e 100644 --- a/Game/src/scene/GameMapTestScene.cpp +++ b/Game/src/scene/GameMapTestScene.cpp @@ -8,6 +8,9 @@ namespace frostbite2D { namespace { constexpr char kTestMapPath[] = "map/elvengard/d_elvengard.map"; +constexpr int kTestNpcId = 2; +constexpr float kTestNpcOffsetX = 180.0f; +constexpr float kTestNpcOffsetY = -24.0f; } // namespace @@ -43,9 +46,9 @@ void GameMapTestScene::onEnter() { { ScopedStartupTrace stageTrace("GameMapTestScene character construction"); - character_ = MakePtr(); - if (!character_->Construction(0)) { - SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + character_ = MakePtr(); + if (!character_->Construction(0)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "GameMapTestScene: failed to construct default character"); character_.Reset(); } else { @@ -58,6 +61,28 @@ void GameMapTestScene::onEnter() { } } + { + ScopedStartupTrace stageTrace("GameMapTestScene npc construction"); + npc_ = MakePtr(); + if (!npc_->Construction(kTestNpcId)) { + SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, + "GameMapTestScene: failed to construct npc %d", kTestNpcId); + npc_.Reset(); + } else { + Vec2 npcPos = map_->ClampCameraFocus(map_->GetDefaultCameraFocus(), 1.0f); + if (character_) { + npcPos = character_->GetWorldPosition().ToGroundPosition() + + Vec2(kTestNpcOffsetX, kTestNpcOffsetY); + } + npc_->SetNpcPosition(npcPos); + npc_->SetDirection(-1); + SDL_Log("GameMapTestScene: npc %d final ground position (%d, %d, %d)", + kTestNpcId, npc_->GetWorldPosition().x, npc_->GetWorldPosition().y, + npc_->GetWorldPosition().z); + map_->AddObject(npc_); + } + } + cameraController_.SetMap(map_.Get()); cameraController_.SetTarget(character_.Get()); cameraController_.SetDebugEnabled(false);