- 将翻转逻辑集中到Animation类中处理 - 添加spriteFrameOffsets_存储帧偏移量 - 改进角色动画方向切换时的表现 - 移除CharacterAnimation中的ApplyFlipRecursive方法 - 优化动画帧位置和旋转的计算方式
438 lines
14 KiB
C++
438 lines
14 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 <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();
|
|
// 先填一套最小可运行的默认动作,保证资源或 PVF 缺失时角色仍能工作。
|
|
BuildFallbackActions(config);
|
|
// 再尝试用 PVF 动作逻辑覆盖默认值。
|
|
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");
|
|
}
|
|
|
|
void CharacterActionLibrary::BuildFallbackActions(
|
|
const character::CharacterConfig& config) {
|
|
auto animationTagOrDefault = [&config](const std::string& actionId,
|
|
const std::string& fallback) {
|
|
if (config.animationPath.count(actionId) > 0) {
|
|
return actionId;
|
|
}
|
|
if (config.animationPath.count(fallback) > 0) {
|
|
return fallback;
|
|
}
|
|
if (!config.animationPath.empty()) {
|
|
return config.animationPath.begin()->first;
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
CharacterActionDefinition idle;
|
|
idle.actionId = "idle";
|
|
idle.animationTag = animationTagOrDefault("rest", "rest");
|
|
idle.totalFrames = 1;
|
|
idle.loop = true;
|
|
idle.canMove = true;
|
|
addOrReplaceAction(actions_, idle);
|
|
|
|
CharacterActionDefinition move;
|
|
move.actionId = "move";
|
|
move.animationTag = animationTagOrDefault("run", animationTagOrDefault("walk", "rest"));
|
|
move.totalFrames = 1;
|
|
move.loop = true;
|
|
move.canMove = true;
|
|
addOrReplaceAction(actions_, move);
|
|
|
|
CharacterActionDefinition jump;
|
|
jump.actionId = "jump";
|
|
jump.animationTag = animationTagOrDefault("jump", "rest");
|
|
jump.totalFrames = 18;
|
|
jump.canTurn = true;
|
|
addOrReplaceAction(actions_, jump);
|
|
|
|
CharacterActionDefinition fall;
|
|
fall.actionId = "fall";
|
|
fall.animationTag = animationTagOrDefault("jump", "rest");
|
|
fall.totalFrames = 1;
|
|
fall.loop = true;
|
|
fall.canTurn = true;
|
|
addOrReplaceAction(actions_, fall);
|
|
|
|
CharacterActionDefinition landing;
|
|
landing.actionId = "landing";
|
|
landing.animationTag = animationTagOrDefault("rest", "rest");
|
|
landing.totalFrames = 6;
|
|
addOrReplaceAction(actions_, landing);
|
|
|
|
CharacterActionDefinition attack1;
|
|
attack1.actionId = "attack_1";
|
|
attack1.animationTag = animationTagOrDefault("attack1", animationTagOrDefault("attack", "rest"));
|
|
attack1.totalFrames = 24;
|
|
attack1.canMove = false;
|
|
attack1.canTurn = false;
|
|
attack1.canBeInterrupted = true;
|
|
attack1.cancelRules.push_back({"attack_2", 12, 22, true, false});
|
|
addOrReplaceAction(actions_, attack1);
|
|
|
|
CharacterActionDefinition attack2;
|
|
attack2.actionId = "attack_2";
|
|
attack2.animationTag = animationTagOrDefault("attack2", attack1.animationTag);
|
|
attack2.totalFrames = 28;
|
|
attack2.canMove = false;
|
|
attack2.canTurn = false;
|
|
attack2.canBeInterrupted = true;
|
|
addOrReplaceAction(actions_, attack2);
|
|
|
|
CharacterActionDefinition skill1;
|
|
skill1.actionId = "skill_1";
|
|
skill1.animationTag = animationTagOrDefault("skill1", attack1.animationTag);
|
|
skill1.totalFrames = 36;
|
|
skill1.canMove = false;
|
|
skill1.canTurn = false;
|
|
skill1.canBeInterrupted = false;
|
|
addOrReplaceAction(actions_, skill1);
|
|
|
|
CharacterActionDefinition hurt;
|
|
hurt.actionId = "hurt_light";
|
|
hurt.animationTag = animationTagOrDefault("damage", animationTagOrDefault("hurt", "rest"));
|
|
hurt.totalFrames = 18;
|
|
hurt.canMove = false;
|
|
hurt.canTurn = false;
|
|
hurt.canBeInterrupted = true;
|
|
addOrReplaceAction(actions_, hurt);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (definition.animationTag.empty()) {
|
|
// 如果动作脚本没有显式指定动画标签,则尝试用同名动作映射。
|
|
if (config.animationPath.count(actionId) > 0) {
|
|
definition.animationTag = actionId;
|
|
}
|
|
}
|
|
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
|