新增 CharacterShadowActor 类用于处理角色阴影的渲染 在 CharacterObject 中实现阴影的同步和渲染逻辑 移除 GameDebugActor 中不再使用的合成纹理预览代码 添加 EnsureCompositeTextureReady 方法确保纹理准备就绪
604 lines
18 KiB
C++
604 lines
18 KiB
C++
#include "character/CharacterAnimation.h"
|
|
#include "character/CharacterObject.h"
|
|
#include <frostbite2D/graphics/renderer.h>
|
|
#include <frostbite2D/resource/pvf_archive.h>
|
|
#include <SDL2/SDL.h>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <sstream>
|
|
#include <utility>
|
|
|
|
namespace frostbite2D {
|
|
namespace {
|
|
|
|
const std::array<const char*, 12> kAvatarParts = {
|
|
"weapon_avatar", "aurora_avatar", "hair_avatar", "hat_avatar",
|
|
"face_avatar", "breast_avatar", "coat_avatar", "skin_avatar",
|
|
"waist_avatar", "pants_avatar", "shoes_avatar", "weapon"};
|
|
|
|
std::string truncatePath(const std::string& path) {
|
|
size_t slashPos = path.find_last_of('/');
|
|
if (slashPos == std::string::npos) {
|
|
return {};
|
|
}
|
|
return path.substr(0, slashPos);
|
|
}
|
|
|
|
std::string actionTail(const std::string& path) {
|
|
size_t slashPos = path.find_last_of('/');
|
|
if (slashPos == std::string::npos) {
|
|
return path;
|
|
}
|
|
return path.substr(slashPos);
|
|
}
|
|
|
|
struct BoundsAccumulator {
|
|
bool valid = false;
|
|
float minX = 0.0f;
|
|
float minY = 0.0f;
|
|
float maxX = 0.0f;
|
|
float maxY = 0.0f;
|
|
|
|
void includeRect(const Rect& rect) {
|
|
if (rect.empty()) {
|
|
return;
|
|
}
|
|
|
|
if (!valid) {
|
|
minX = rect.left();
|
|
minY = rect.top();
|
|
maxX = rect.right();
|
|
maxY = rect.bottom();
|
|
valid = true;
|
|
return;
|
|
}
|
|
|
|
minX = std::min(minX, rect.left());
|
|
minY = std::min(minY, rect.top());
|
|
maxX = std::max(maxX, rect.right());
|
|
maxY = std::max(maxY, rect.bottom());
|
|
}
|
|
};
|
|
|
|
Rect transformRectBounds(const Rect& rect, const Transform2D& transform) {
|
|
Vec2 p0 = transform.transformPoint(Vec2(rect.left(), rect.top()));
|
|
Vec2 p1 = transform.transformPoint(Vec2(rect.right(), rect.top()));
|
|
Vec2 p2 = transform.transformPoint(Vec2(rect.left(), rect.bottom()));
|
|
Vec2 p3 = transform.transformPoint(Vec2(rect.right(), rect.bottom()));
|
|
|
|
float minX = std::min(std::min(p0.x, p1.x), std::min(p2.x, p3.x));
|
|
float minY = std::min(std::min(p0.y, p1.y), std::min(p2.y, p3.y));
|
|
float maxX = std::max(std::max(p0.x, p1.x), std::max(p2.x, p3.x));
|
|
float maxY = std::max(std::max(p0.y, p1.y), std::max(p2.y, p3.y));
|
|
return Rect(minX, minY, maxX - minX, maxY - minY);
|
|
}
|
|
|
|
void combineHash(uint64& seed, uint64 value) {
|
|
constexpr uint64 kOffset = 0x9e3779b97f4a7c15ULL;
|
|
seed ^= value + kOffset + (seed << 6) + (seed >> 2);
|
|
}
|
|
|
|
CharacterAnimation::CompositeFrameInfo combineCompositeFrameInfo(
|
|
const CharacterAnimation::CompositeFrameInfo& lhs,
|
|
const CharacterAnimation::CompositeFrameInfo& rhs) {
|
|
if (!lhs.valid) {
|
|
return rhs;
|
|
}
|
|
if (!rhs.valid) {
|
|
return lhs;
|
|
}
|
|
|
|
float left = std::floor(std::min(lhs.localBounds.left(), rhs.localBounds.left()));
|
|
float top = std::floor(std::min(lhs.localBounds.top(), rhs.localBounds.top()));
|
|
float right = std::ceil(std::max(lhs.localBounds.right(), rhs.localBounds.right()));
|
|
float bottom =
|
|
std::ceil(std::max(lhs.localBounds.bottom(), rhs.localBounds.bottom()));
|
|
|
|
CharacterAnimation::CompositeFrameInfo result;
|
|
result.valid = true;
|
|
result.localBounds =
|
|
Rect(left, top, std::max(right - left, 1.0f), std::max(bottom - top, 1.0f));
|
|
result.width =
|
|
std::max(static_cast<int>(std::lround(result.localBounds.width())), 1);
|
|
result.height =
|
|
std::max(static_cast<int>(std::lround(result.localBounds.height())), 1);
|
|
result.originInTexture =
|
|
Vec2(-result.localBounds.left(), -result.localBounds.top());
|
|
result.groundAnchorInTexture = result.originInTexture;
|
|
return result;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool CharacterAnimation::Init(CharacterObject* parent,
|
|
const character::CharacterConfig& config,
|
|
const CharacterEquipmentManager& equipmentManager) {
|
|
parent_ = parent;
|
|
actionAnimations_.clear();
|
|
missingAnimationPaths_.clear();
|
|
skippedMissingAnimationCount_ = 0;
|
|
currentActionTag_.clear();
|
|
direction_ = 1;
|
|
actionFrameFlagCallback_ = nullptr;
|
|
actionEndCallback_ = nullptr;
|
|
compositeCanvas_ = MakePtr<CanvasActor>();
|
|
compositeFrameInfoByAction_.clear();
|
|
compositeDirty_ = true;
|
|
compositeVersion_ = 0;
|
|
lastCompositeSignature_ = 0;
|
|
|
|
if (!compositeCanvas_->Init(1, 1)) {
|
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
|
"CharacterAnimation: failed to initialize composite canvas");
|
|
compositeCanvas_.Reset();
|
|
} else {
|
|
compositeCanvas_->SetCustomDrawCallback([this]() {
|
|
RenderCurrentActionToCompositeCanvas();
|
|
});
|
|
}
|
|
|
|
for (const auto& [actionName, actionPath] : config.animationPath) {
|
|
for (const char* slotName : kAvatarParts) {
|
|
CreateAnimationBySlot(actionName, slotName, actionPath, config, equipmentManager);
|
|
}
|
|
}
|
|
|
|
if (actionAnimations_.empty()) {
|
|
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
|
|
"CharacterAnimation: no usable action animations for job %d",
|
|
config.jobId);
|
|
return false;
|
|
}
|
|
|
|
if (skippedMissingAnimationCount_ > 0) {
|
|
SDL_Log("CharacterAnimation: skipped %zu missing animation loads across %zu unique paths for job %d",
|
|
skippedMissingAnimationCount_, missingAnimationPaths_.size(),
|
|
config.jobId);
|
|
}
|
|
|
|
BuildCompositeActionFrames();
|
|
return true;
|
|
}
|
|
|
|
void CharacterAnimation::Update(float deltaTime) {
|
|
Actor::Update(deltaTime);
|
|
|
|
uint64 renderSignature = CaptureCurrentRenderSignature();
|
|
if (renderSignature != lastCompositeSignature_) {
|
|
compositeDirty_ = true;
|
|
}
|
|
}
|
|
|
|
void CharacterAnimation::Render() {
|
|
if (compositeDirty_ && compositeCanvas_ && Renderer::get().isFrameActive()) {
|
|
RefreshCompositeTextureIfNeeded();
|
|
}
|
|
|
|
Actor::Render();
|
|
}
|
|
|
|
std::string CharacterAnimation::FormatImgPath(std::string path, Animation::ReplaceData data) {
|
|
size_t pos = path.find("%04d");
|
|
if (pos != std::string::npos) {
|
|
path.replace(pos, 4, "%02d%02d");
|
|
}
|
|
|
|
char buffer[512] = {};
|
|
std::snprintf(buffer, sizeof(buffer), path.c_str(), data.param1, data.param2);
|
|
return std::string(buffer);
|
|
}
|
|
|
|
void CharacterAnimation::CreateAnimationBySlot(
|
|
const std::string& actionName,
|
|
const std::string& slotName,
|
|
const std::string& actionPath,
|
|
const character::CharacterConfig& config,
|
|
const CharacterEquipmentManager& equipmentManager) {
|
|
if (slotName == std::string("skin_avatar")) {
|
|
if (shouldSkipMissingAnimation(actionPath)) {
|
|
return;
|
|
}
|
|
|
|
Animation::ReplaceData replaceData(0, 0);
|
|
if (const auto* equip = equipmentManager.GetEquip(slotName)) {
|
|
auto it = equip->jobAnimations.find(config.jobId);
|
|
if (it != equip->jobAnimations.end() && !it->second.empty()) {
|
|
replaceData.param1 = it->second.front().imgFormat[0];
|
|
replaceData.param2 = it->second.front().imgFormat[1];
|
|
}
|
|
}
|
|
|
|
auto animation = MakePtr<Animation>(actionPath, FormatImgPath, replaceData);
|
|
if (!animation || !animation->IsUsable()) {
|
|
return;
|
|
}
|
|
animation->SetVisible(false);
|
|
AddChild(animation);
|
|
actionAnimations_[actionName].push_back(animation);
|
|
return;
|
|
}
|
|
|
|
const auto* equip = equipmentManager.GetEquip(slotName);
|
|
if (!equip) {
|
|
return;
|
|
}
|
|
|
|
auto jobAniIt = equip->jobAnimations.find(config.jobId);
|
|
if (jobAniIt == equip->jobAnimations.end()) {
|
|
return;
|
|
}
|
|
|
|
std::string equipDir = truncatePath(equip->path);
|
|
std::string actionPathTail = actionTail(actionPath);
|
|
for (const auto& variation : jobAniIt->second) {
|
|
if (variation.animationGroup.empty()) {
|
|
continue;
|
|
}
|
|
|
|
std::string aniPath = equipDir + "/" + variation.animationGroup + actionPathTail;
|
|
if (shouldSkipMissingAnimation(aniPath)) {
|
|
continue;
|
|
}
|
|
|
|
auto animation = MakePtr<Animation>(
|
|
aniPath, FormatImgPath,
|
|
Animation::ReplaceData(variation.imgFormat[0], variation.imgFormat[1]));
|
|
if (!animation || !animation->IsUsable()) {
|
|
continue;
|
|
}
|
|
|
|
animation->SetVisible(false);
|
|
animation->SetZOrder(variation.layer);
|
|
AddChild(animation);
|
|
actionAnimations_[actionName].push_back(animation);
|
|
}
|
|
}
|
|
|
|
bool CharacterAnimation::shouldSkipMissingAnimation(const std::string& aniPath) {
|
|
PvfArchive& pvf = PvfArchive::get();
|
|
std::string normalizedPath = pvf.normalizePath(aniPath);
|
|
if (missingAnimationPaths_.find(normalizedPath) != missingAnimationPaths_.end()) {
|
|
++skippedMissingAnimationCount_;
|
|
return true;
|
|
}
|
|
|
|
if (pvf.hasFile(normalizedPath)) {
|
|
return false;
|
|
}
|
|
|
|
missingAnimationPaths_.insert(std::move(normalizedPath));
|
|
++skippedMissingAnimationCount_;
|
|
return true;
|
|
}
|
|
|
|
std::string CharacterAnimation::DescribeAvailableActions() const {
|
|
if (actionAnimations_.empty()) {
|
|
return "<none>";
|
|
}
|
|
|
|
std::ostringstream stream;
|
|
bool first = true;
|
|
for (const auto& [actionName, animations] : actionAnimations_) {
|
|
(void)animations;
|
|
if (!first) {
|
|
stream << ", ";
|
|
}
|
|
stream << actionName;
|
|
first = false;
|
|
}
|
|
return stream.str();
|
|
}
|
|
|
|
Animation* CharacterAnimation::GetPrimaryAnimation(const std::string& actionName) const {
|
|
auto it = actionAnimations_.find(actionName);
|
|
if (it == actionAnimations_.end()) {
|
|
return nullptr;
|
|
}
|
|
|
|
for (const auto& animation : it->second) {
|
|
if (animation && animation->IsUsable()) {
|
|
return animation.Get();
|
|
}
|
|
}
|
|
|
|
for (const auto& animation : it->second) {
|
|
if (animation) {
|
|
return animation.Get();
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
Animation* CharacterAnimation::GetCurrentPrimaryAnimation() const {
|
|
if (currentActionTag_.empty()) {
|
|
return nullptr;
|
|
}
|
|
return GetPrimaryAnimation(currentActionTag_);
|
|
}
|
|
|
|
void CharacterAnimation::RefreshRuntimeCallbacks() {
|
|
auto currentIt = actionAnimations_.find(currentActionTag_);
|
|
if (currentIt == actionAnimations_.end()) {
|
|
return;
|
|
}
|
|
|
|
Animation* primaryAnimation = GetPrimaryAnimation(currentActionTag_);
|
|
for (auto& animation : currentIt->second) {
|
|
if (!animation) {
|
|
continue;
|
|
}
|
|
|
|
if (animation.Get() == primaryAnimation) {
|
|
animation->changeFrameCallback_ = actionFrameFlagCallback_;
|
|
animation->endCallback_ = actionEndCallback_;
|
|
} else {
|
|
animation->changeFrameCallback_ = nullptr;
|
|
animation->endCallback_ = nullptr;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool CharacterAnimation::SetAction(const std::string& actionName) {
|
|
auto nextIt = actionAnimations_.find(actionName);
|
|
if (nextIt == actionAnimations_.end()) {
|
|
if (parent_) {
|
|
parent_->ReportFatalCharacterError("CharacterAnimation::SetAction",
|
|
"requested animation tag is not loaded",
|
|
std::string(), actionName);
|
|
} else {
|
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
|
|
"CharacterAnimation: action %s missing and parent is null",
|
|
actionName.c_str());
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (!currentActionTag_.empty()) {
|
|
auto currentIt = actionAnimations_.find(currentActionTag_);
|
|
if (currentIt != actionAnimations_.end()) {
|
|
for (auto& animation : currentIt->second) {
|
|
animation->Reset();
|
|
animation->SetVisible(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (auto& animation : nextIt->second) {
|
|
animation->Reset();
|
|
animation->SetVisible(true);
|
|
}
|
|
currentActionTag_ = actionName;
|
|
RefreshRuntimeCallbacks();
|
|
SetDirection(direction_);
|
|
MarkCompositeDirty();
|
|
return true;
|
|
}
|
|
|
|
void CharacterAnimation::SetDirection(int direction) {
|
|
direction_ = direction >= 0 ? 1 : -1;
|
|
|
|
auto currentIt = actionAnimations_.find(currentActionTag_);
|
|
if (currentIt == actionAnimations_.end()) {
|
|
return;
|
|
}
|
|
|
|
for (auto& animation : currentIt->second) {
|
|
if (animation) {
|
|
animation->SetDirection(direction_);
|
|
}
|
|
}
|
|
|
|
MarkCompositeDirty();
|
|
}
|
|
|
|
bool CharacterAnimation::IsCurrentActionFinished() const {
|
|
const Animation* primaryAnimation = GetCurrentPrimaryAnimation();
|
|
if (!primaryAnimation) {
|
|
return false;
|
|
}
|
|
return !primaryAnimation->IsUsable();
|
|
}
|
|
|
|
int CharacterAnimation::GetCurrentFrameIndex() const {
|
|
const Animation* primaryAnimation = GetCurrentPrimaryAnimation();
|
|
return primaryAnimation ? primaryAnimation->GetCurrentFrameIndex() : 0;
|
|
}
|
|
|
|
int CharacterAnimation::GetCurrentFrameCount() const {
|
|
const Animation* primaryAnimation = GetCurrentPrimaryAnimation();
|
|
return primaryAnimation ? primaryAnimation->GetTotalFrameCount() : 0;
|
|
}
|
|
|
|
float CharacterAnimation::GetCurrentNormalizedProgress() const {
|
|
int totalFrames = GetCurrentFrameCount();
|
|
if (totalFrames <= 1) {
|
|
return IsCurrentActionFinished() ? 1.0f : 0.0f;
|
|
}
|
|
|
|
float progress = static_cast<float>(GetCurrentFrameIndex())
|
|
/ static_cast<float>(totalFrames - 1);
|
|
return std::clamp(progress, 0.0f, 1.0f);
|
|
}
|
|
|
|
animation::AniFrame CharacterAnimation::GetCurrentFrameInfo() const {
|
|
const Animation* primaryAnimation = GetCurrentPrimaryAnimation();
|
|
if (!primaryAnimation) {
|
|
return animation::AniFrame();
|
|
}
|
|
return primaryAnimation->GetCurrentFrameInfo();
|
|
}
|
|
|
|
void CharacterAnimation::SetActionFrameFlagCallback(ActionFrameFlagCallback callback) {
|
|
actionFrameFlagCallback_ = std::move(callback);
|
|
RefreshRuntimeCallbacks();
|
|
}
|
|
|
|
void CharacterAnimation::SetActionEndCallback(ActionEndCallback callback) {
|
|
actionEndCallback_ = std::move(callback);
|
|
RefreshRuntimeCallbacks();
|
|
}
|
|
|
|
bool CharacterAnimation::EnsureCompositeTextureReady() {
|
|
RefreshCompositeTextureIfNeeded();
|
|
return HasCompositeTexture();
|
|
}
|
|
|
|
bool CharacterAnimation::HasCompositeTexture() const {
|
|
return compositeCanvas_ && compositeCanvas_->IsCanvasReady() &&
|
|
GetCurrentCompositeFrameInfo().valid;
|
|
}
|
|
|
|
Ptr<Texture> CharacterAnimation::GetCompositeTexture() const {
|
|
return compositeCanvas_ ? compositeCanvas_->GetOutputTexture() : nullptr;
|
|
}
|
|
|
|
Vec2 CharacterAnimation::GetCompositeTextureSize() const {
|
|
CompositeFrameInfo info = GetCurrentCompositeFrameInfo();
|
|
return Vec2(static_cast<float>(info.width), static_cast<float>(info.height));
|
|
}
|
|
|
|
Vec2 CharacterAnimation::GetCompositeOriginInTexture() const {
|
|
return GetCurrentCompositeFrameInfo().originInTexture;
|
|
}
|
|
|
|
Vec2 CharacterAnimation::GetCompositeGroundAnchorInTexture() const {
|
|
return GetCurrentCompositeFrameInfo().groundAnchorInTexture;
|
|
}
|
|
|
|
CharacterAnimation::CompositeFrameInfo CharacterAnimation::ComputeCompositeFrameInfo(
|
|
const std::vector<RefPtr<Animation>>& animations, int direction) const {
|
|
CompositeFrameInfo info;
|
|
BoundsAccumulator bounds;
|
|
|
|
for (const auto& animation : animations) {
|
|
if (!animation) {
|
|
continue;
|
|
}
|
|
|
|
Rect animationBounds;
|
|
if (!animation->GetStaticLocalBounds(direction, animationBounds)) {
|
|
continue;
|
|
}
|
|
|
|
bounds.includeRect(
|
|
transformRectBounds(animationBounds, animation->GetLocalTransform()));
|
|
}
|
|
|
|
if (!bounds.valid) {
|
|
return info;
|
|
}
|
|
|
|
float left = std::floor(bounds.minX);
|
|
float top = std::floor(bounds.minY);
|
|
float right = std::ceil(bounds.maxX);
|
|
float bottom = std::ceil(bounds.maxY);
|
|
|
|
info.valid = true;
|
|
info.localBounds = Rect(left, top, std::max(right - left, 1.0f),
|
|
std::max(bottom - top, 1.0f));
|
|
info.width = std::max(static_cast<int>(std::lround(info.localBounds.width())), 1);
|
|
info.height = std::max(static_cast<int>(std::lround(info.localBounds.height())), 1);
|
|
info.originInTexture = Vec2(-info.localBounds.left(), -info.localBounds.top());
|
|
info.groundAnchorInTexture = info.originInTexture;
|
|
return info;
|
|
}
|
|
|
|
void CharacterAnimation::BuildCompositeActionFrames() {
|
|
compositeFrameInfoByAction_.clear();
|
|
for (const auto& [actionName, animations] : actionAnimations_) {
|
|
CompositeFrameInfo rightInfo = ComputeCompositeFrameInfo(animations, 1);
|
|
CompositeFrameInfo leftInfo = ComputeCompositeFrameInfo(animations, -1);
|
|
compositeFrameInfoByAction_[actionName] =
|
|
combineCompositeFrameInfo(rightInfo, leftInfo);
|
|
}
|
|
}
|
|
|
|
CharacterAnimation::CompositeFrameInfo CharacterAnimation::GetCurrentCompositeFrameInfo() const {
|
|
auto it = compositeFrameInfoByAction_.find(currentActionTag_);
|
|
if (it == compositeFrameInfoByAction_.end()) {
|
|
return CompositeFrameInfo();
|
|
}
|
|
return it->second;
|
|
}
|
|
|
|
uint64 CharacterAnimation::CaptureCurrentRenderSignature() const {
|
|
uint64 signature = 1469598103934665603ULL;
|
|
combineHash(signature, static_cast<uint64>(direction_ >= 0 ? 1 : 0));
|
|
combineHash(signature, static_cast<uint64>(std::hash<std::string>{}(currentActionTag_)));
|
|
|
|
auto currentIt = actionAnimations_.find(currentActionTag_);
|
|
if (currentIt == actionAnimations_.end()) {
|
|
return signature;
|
|
}
|
|
|
|
for (const auto& animation : currentIt->second) {
|
|
if (!animation) {
|
|
continue;
|
|
}
|
|
combineHash(signature, animation->GetRenderSignature());
|
|
}
|
|
|
|
return signature;
|
|
}
|
|
|
|
void CharacterAnimation::MarkCompositeDirty() {
|
|
compositeDirty_ = true;
|
|
if (compositeCanvas_) {
|
|
compositeCanvas_->SetDirty();
|
|
}
|
|
}
|
|
|
|
void CharacterAnimation::UpdateCompositeCamera() {
|
|
if (!compositeCanvas_) {
|
|
return;
|
|
}
|
|
|
|
CompositeFrameInfo info = GetCurrentCompositeFrameInfo();
|
|
Camera& camera = compositeCanvas_->GetCanvasCamera();
|
|
camera.setViewport(std::max(info.width, 1), std::max(info.height, 1));
|
|
camera.setZoom(1.0f);
|
|
Vec2 cameraOrigin = GetWorldTransform().transformPoint(
|
|
Vec2(info.localBounds.left(), info.localBounds.top()));
|
|
camera.setPosition(cameraOrigin);
|
|
}
|
|
|
|
void CharacterAnimation::RefreshCompositeTextureIfNeeded() {
|
|
if (!compositeDirty_ || !compositeCanvas_) {
|
|
return;
|
|
}
|
|
|
|
CompositeFrameInfo info = GetCurrentCompositeFrameInfo();
|
|
if (!info.valid) {
|
|
compositeDirty_ = false;
|
|
lastCompositeSignature_ = CaptureCurrentRenderSignature();
|
|
return;
|
|
}
|
|
|
|
if (!compositeCanvas_->SetCanvasSize(std::max(info.width, 1),
|
|
std::max(info.height, 1))) {
|
|
return;
|
|
}
|
|
|
|
UpdateCompositeCamera();
|
|
if (!compositeCanvas_->Redraw()) {
|
|
return;
|
|
}
|
|
|
|
compositeDirty_ = false;
|
|
lastCompositeSignature_ = CaptureCurrentRenderSignature();
|
|
++compositeVersion_;
|
|
}
|
|
|
|
void CharacterAnimation::RenderCurrentActionToCompositeCanvas() {
|
|
if (actionAnimations_.find(currentActionTag_) == actionAnimations_.end()) {
|
|
return;
|
|
}
|
|
|
|
RenderChildren();
|
|
}
|
|
|
|
} // namespace frostbite2D
|