feat(npc): 添加NPC数据加载、动画和对象功能

实现NPC系统的核心功能,包括:
1. 新增NpcDataLoader用于加载NPC索引和配置数据
2. 添加NpcAnimation处理NPC动画显示
3. 创建NpcObject实现NPC交互和显示逻辑
4. 在GameMapTestScene中集成测试NPC功能
This commit is contained in:
2026-04-07 23:02:03 +08:00
parent 6855860d64
commit caad22cca7
9 changed files with 628 additions and 7 deletions

View File

@@ -0,0 +1,25 @@
#pragma once
#include "npc/NpcDataLoader.h"
#include <frostbite2D/2d/actor.h>
#include <frostbite2D/animation/animation.h>
#include <string>
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<Animation> displayAnimation_ = nullptr;
std::string animationPath_;
int direction_ = 1;
};
} // namespace frostbite2D

View File

@@ -0,0 +1,20 @@
#pragma once
#include <map>
#include <optional>
#include <string>
namespace frostbite2D::npc {
struct NpcConfig {
int id = -1;
std::string path;
std::string name;
std::string fieldName;
std::string fieldAnimationPath;
};
bool loadNpcIndex(std::map<int, std::string>& outIndex);
std::optional<NpcConfig> loadNpcConfig(int npcId);
} // namespace frostbite2D::npc

View File

@@ -0,0 +1,59 @@
#pragma once
#include "character/CharacterActionTypes.h"
#include "npc/NpcAnimation.h"
#include <frostbite2D/2d/text_sprite.h>
#include <frostbite2D/2d/actor.h>
#include <array>
#include <optional>
#include <string>
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<npc::NpcConfig> config_;
RefPtr<NpcAnimation> animation_ = nullptr;
std::array<RefPtr<TextSprite>, 8> nameOutlineLabels_;
RefPtr<TextSprite> nameLabel_ = nullptr;
bool interactable_ = true;
bool interacting_ = false;
};
} // namespace frostbite2D

View File

@@ -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 <frostbite2D/scene/scene.h>
@@ -20,6 +21,7 @@ public:
private:
GameCameraController cameraController_;
RefPtr<CharacterObject> character_;
RefPtr<NpcObject> npc_;
RefPtr<GameDebugUIScene> debugScene_;
bool initialized_ = false;
RefPtr<GameMap> map_;

View File

@@ -20,7 +20,7 @@
#include <frostbite2D/scene/scene.h>
#include <frostbite2D/scene/scene_manager.h>
#include <frostbite2D/utils/startup_trace.h>
#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<GameWorld>();
SceneManager::get().ReplaceScene(gameWorld);
ScopedStartupTrace stageTrace(
"SceneManager::ReplaceScene(GameMapTestScene)");
auto gameMapTestScene = MakePtr<GameMapTestScene>();
SceneManager::get().ReplaceScene(gameMapTestScene);
}
StartupTrace::mark("after SceneManager::ReplaceScene");

View File

@@ -0,0 +1,58 @@
#include "npc/NpcAnimation.h"
#include <SDL2/SDL.h>
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<Animation>(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

View File

@@ -0,0 +1,217 @@
#include "npc/NpcDataLoader.h"
#include <SDL2/SDL.h>
#include <frostbite2D/resource/pvf_archive.h>
#include <frostbite2D/resource/script_parser.h>
#include <algorithm>
#include <cctype>
#include <map>
#include <optional>
#include <string>
#include <vector>
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<char>(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<unsigned char>(value[start]))) {
++start;
}
while (end > start &&
std::isspace(static_cast<unsigned char>(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<std::string>& tokens) {
std::string preview;
size_t previewCount = std::min<size_t>(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<std::string> tokens_;
size_t index_ = 0;
bool valid_ = false;
};
std::map<int, std::string>& npcIndexCache() {
static std::map<int, std::string> 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<int, NpcConfig>& npcConfigCache() {
static std::map<int, NpcConfig> cache;
return cache;
}
} // namespace
bool loadNpcIndex(std::map<int, std::string>& outIndex) {
outIndex = npcIndexCache();
if (!outIndex.empty()) {
SDL_Log("NpcDataLoader: loaded npc index entries=%d",
static_cast<int>(outIndex.size()));
}
return !outIndex.empty();
}
std::optional<NpcConfig> 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<std::string> 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

214
Game/src/npc/NpcObject.cpp Normal file
View File

@@ -0,0 +1,214 @@
#include "npc/NpcObject.h"
#include "common/math/GameMath.h"
#include <SDL2/SDL.h>
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<TextSprite> 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<NpcAnimation>();
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<TextSprite> 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

View File

@@ -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<CharacterObject>();
if (!character_->Construction(0)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
character_ = MakePtr<CharacterObject>();
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<NpcObject>();
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);