Files
Frostbite2D/Game/src/character/CharacterActionLibrary.cpp
Lenheart 1200cf0181 refactor(character): 重构角色动作与动画系统
- 移除自动回退动作生成逻辑,改为严格检查动作定义
- 增加动作资源缺失时的详细错误报告机制
- 统一输入事件处理接口,优化角色对象生命周期管理
- 改进动画标签管理,移除隐式回退逻辑
- 增强状态机对无效动作的处理能力
2026-04-04 05:53:07 +08:00

357 lines
11 KiB
C++

#include "character/CharacterActionLibrary.h"
#include <SDL2/SDL.h>
#include <frostbite2D/resource/pvf_archive.h>
#include <frostbite2D/resource/script_parser.h>
#include <algorithm>
#include <cctype>
#include <sstream>
#include <vector>
namespace frostbite2D {
namespace {
std::string toLower(const std::string& value) {
std::string result = value;
std::transform(result.begin(), result.end(), result.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return result;
}
int toInt(const std::string& value, int fallback = 0) {
try {
return std::stoi(value);
} catch (...) {
return fallback;
}
}
float toFloat(const std::string& value, float fallback = 0.0f) {
try {
return std::stof(value);
} catch (...) {
return fallback;
}
}
bool isTrueToken(const std::string& value) {
std::string token = toLower(value);
return token == "1" || token == "true" || token == "[true]" || token == "yes";
}
std::string trim(const std::string& value) {
size_t begin = 0;
size_t end = value.size();
while (begin < end && std::isspace(static_cast<unsigned char>(value[begin]))) {
++begin;
}
while (end > begin
&& std::isspace(static_cast<unsigned char>(value[end - 1]))) {
--end;
}
return value.substr(begin, end - begin);
}
std::string fileStem(const std::string& path) {
size_t slashPos = path.find_last_of("/\\");
size_t begin = slashPos == std::string::npos ? 0 : slashPos + 1;
size_t dotPos = path.find_last_of('.');
if (dotPos == std::string::npos || dotPos < begin) {
return path.substr(begin);
}
return path.substr(begin, dotPos - begin);
}
class ScriptTokenStream {
public:
explicit ScriptTokenStream(const std::string& path)
: path_(PvfArchive::get().normalizePath(path)) {
auto rawData = PvfArchive::get().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 Next() {
if (IsEnd()) {
return {};
}
return tokens_[index_++];
}
private:
std::string path_;
std::vector<std::string> tokens_;
size_t index_ = 0;
bool valid_ = false;
};
// action_list.lst 同时兼容两种格式:
// 1. 标准 PVF #PVF_File + 索引 + `path`
// 2. 早期临时版的 actionId + path 成对写法
bool isNewActionListPath(const std::string& path) {
return path.find("action_logic") != std::string::npos
&& path.size() >= 4
&& path.substr(path.size() - 4) == ".act";
}
// action_list.lst only accepts #PVF_File entries like `swordman/action_logic/idle.act`.
std::vector<std::pair<std::string, std::string>> parseActionListEntries(
ScriptTokenStream& listStream) {
std::vector<std::pair<std::string, std::string>> entries;
std::vector<std::string> tokens;
auto& pvf = PvfArchive::get();
while (!listStream.IsEnd()) {
std::string token = trim(listStream.Next());
if (!token.empty()) {
tokens.push_back(token);
}
}
for (size_t i = 0; i + 1 < tokens.size(); i += 2) {
std::string rawActionPath = trim(tokens[i + 1]);
std::string actionPath = pvf.normalizePath(rawActionPath);
if (actionPath.empty() || !isNewActionListPath(actionPath)) {
continue;
}
entries.push_back({toLower(fileStem(actionPath)), actionPath});
}
return entries;
}
CharacterFrameEventType parseFrameEventType(const std::string& token) {
std::string lower = toLower(token);
if (lower == "open_hit_window") {
return CharacterFrameEventType::OpenHitWindow;
}
if (lower == "close_hit_window") {
return CharacterFrameEventType::CloseHitWindow;
}
if (lower == "open_input_buffer") {
return CharacterFrameEventType::OpenInputBuffer;
}
if (lower == "close_input_buffer") {
return CharacterFrameEventType::CloseInputBuffer;
}
if (lower == "set_velocity_xy") {
return CharacterFrameEventType::SetVelocityXY;
}
if (lower == "set_velocity_z") {
return CharacterFrameEventType::SetVelocityZ;
}
if (lower == "finish_action") {
return CharacterFrameEventType::FinishAction;
}
return CharacterFrameEventType::AnimationEvent;
}
void addOrReplaceAction(std::map<std::string, CharacterActionDefinition>& actions,
CharacterActionDefinition definition) {
if (definition.actionId.empty()) {
return;
}
actions[definition.actionId] = std::move(definition);
}
} // namespace
void CharacterCommandBuffer::Advance(float deltaTime) {
for (auto& button : buttons_) {
button.age += deltaTime;
}
buttons_.erase(
std::remove_if(buttons_.begin(), buttons_.end(),
[this](const BufferedButton& button) {
return button.age > bufferWindowSeconds_;
}),
buttons_.end());
}
void CharacterCommandBuffer::Submit(const CharacterCommand& command) {
if (command.type == CharacterCommandType::Move) {
moveAxis_ = command.moveAxis;
if (moveAxis_.lengthSquared() > 1.0f) {
moveAxis_ = moveAxis_.normalized();
}
return;
}
buttons_.push_back({command.type, 0.0f});
}
CharacterIntent CharacterCommandBuffer::BuildIntent() const {
CharacterIntent intent;
intent.moveX = moveAxis_.x;
intent.moveY = moveAxis_.y;
intent.wantJump = HasBuffered(CharacterCommandType::JumpPressed);
intent.wantAttack = HasBuffered(CharacterCommandType::AttackPressed);
intent.wantSkill1 = HasBuffered(CharacterCommandType::Skill1Pressed);
intent.wantDash = HasBuffered(CharacterCommandType::DashPressed);
return intent;
}
bool CharacterCommandBuffer::HasBuffered(CharacterCommandType type) const {
for (const auto& button : buttons_) {
if (button.type == type) {
return true;
}
}
return false;
}
void CharacterCommandBuffer::Consume(CharacterCommandType type) {
auto it = std::find_if(buttons_.begin(), buttons_.end(),
[type](const BufferedButton& button) {
return button.type == type;
});
if (it != buttons_.end()) {
buttons_.erase(it);
}
}
void CharacterCommandBuffer::Clear() {
moveAxis_ = Vec2::Zero();
buttons_.clear();
}
bool CharacterActionLibrary::LoadForConfig(const character::CharacterConfig& config) {
actions_.clear();
TryLoadPvfActionScripts(config);
return !actions_.empty();
}
const CharacterActionDefinition* CharacterActionLibrary::FindAction(
const std::string& actionId) const {
auto it = actions_.find(actionId);
if (it != actions_.end()) {
return &it->second;
}
return nullptr;
}
const CharacterActionDefinition* CharacterActionLibrary::GetDefaultAction() const {
return FindAction("idle");
}
std::string CharacterActionLibrary::DescribeActionIds() const {
if (actions_.empty()) {
return "<none>";
}
std::ostringstream stream;
bool first = true;
for (const auto& [actionId, definition] : actions_) {
(void)definition;
if (!first) {
stream << ", ";
}
stream << actionId;
first = false;
}
return stream.str();
}
bool CharacterActionLibrary::TryLoadPvfActionScripts(
const character::CharacterConfig& config) {
std::string listPath = "character/" + config.jobTag + "/action_logic/action_list.lst";
ScriptTokenStream listStream(listPath);
if (!listStream.IsValid()) {
return false;
}
auto entries = parseActionListEntries(listStream);
if (entries.empty()) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"CharacterActionLibrary: action_list parsed 0 entries from %s",
listPath.c_str());
}
for (const auto& [actionId, relativePath] : entries) {
if (actionId.empty() || relativePath.empty()) {
continue;
}
std::string fullActPath = relativePath.rfind("character/", 0) == 0
? relativePath
: "character/" + relativePath;
ScriptTokenStream actionStream(fullActPath);
if (!actionStream.IsValid()) {
continue;
}
CharacterActionDefinition definition;
definition.actionId = actionId;
while (!actionStream.IsEnd()) {
std::string token = toLower(actionStream.Next());
if (token.empty()) {
break;
}
if (token == "[action id]") {
definition.actionId = toLower(trim(actionStream.Next()));
} else if (token == "[animation tag]") {
definition.animationTag = trim(actionStream.Next());
} else if (token == "[total frames]") {
definition.totalFrames = toInt(actionStream.Next(), definition.totalFrames);
} else if (token == "[loop]") {
definition.loop = isTrueToken(actionStream.Next());
} else if (token == "[can move]") {
definition.canMove = isTrueToken(actionStream.Next());
} else if (token == "[can turn]") {
definition.canTurn = isTrueToken(actionStream.Next());
} else if (token == "[can be interrupted]") {
definition.canBeInterrupted = isTrueToken(actionStream.Next());
} else if (token == "[move speed scale]") {
definition.moveSpeedScale = toFloat(actionStream.Next(), definition.moveSpeedScale);
} else if (token == "[cancel rule]") {
CharacterCancelRuleDefinition rule;
rule.targetAction = toLower(trim(actionStream.Next()));
rule.beginFrame = toInt(actionStream.Next(), 0);
rule.endFrame = toInt(actionStream.Next(), rule.beginFrame);
rule.requireGrounded = isTrueToken(actionStream.Next());
rule.requireAirborne = isTrueToken(actionStream.Next());
definition.cancelRules.push_back(rule);
} else if (token == "[frame event]") {
CharacterFrameEventDefinition frameEvent;
frameEvent.frame = toInt(actionStream.Next(), 0);
frameEvent.type = parseFrameEventType(trim(actionStream.Next()));
if (frameEvent.type == CharacterFrameEventType::SetVelocityXY) {
frameEvent.velocityXY.x = toFloat(actionStream.Next(), 0.0f);
frameEvent.velocityXY.y = toFloat(actionStream.Next(), 0.0f);
} else if (frameEvent.type == CharacterFrameEventType::SetVelocityZ) {
frameEvent.velocityZ = toFloat(actionStream.Next(), 0.0f);
} else {
frameEvent.stringValue = trim(actionStream.Next());
}
definition.frameEvents.push_back(frameEvent);
}
}
addOrReplaceAction(actions_, std::move(definition));
}
return true;
}
std::optional<CharacterActionLibrary> loadCharacterActionLibrary(
const character::CharacterConfig& config) {
CharacterActionLibrary library;
if (!library.LoadForConfig(config)) {
return std::nullopt;
}
return library;
}
} // namespace frostbite2D