SFML에서는 공식적으로 제공하는 GUI가 없습니다.
SFML은 멀티미디어 라이브러리지, GUI 라이브러리가 아닙니다.
물론 구글링을 조금만 해도 구현하는 예시들을 많이 찾아볼 수 있지만,
정해진 길이 없다는 것이 난처합니다.
게임을 만들다보면 으레 필수가 되는 GUI들은 여기서도 미리 구현을 해두려고 합니다.
Button이나 TextInput 같은 게 있겠네요.
지난 포스트에서 긁어와서 쓴 RichText 또한 이런 부류에 해당됩니다.
이번에는 Button을 만들겠습니다.
제목에서 죽이고픈 마우스 이벤트라고 했듯이, 구현할 게 많아서 시간을 좀 오래 썼고 포스팅을 오래 안 한 이유이기도 합니다. 구현이 어려운 건 아니지만, 너무 귀찮거든요.
* 글을 다 쓰고 느꼈습니다. 구현도 어렵네요. *
Button (그리고 TextInput)은 옛날에 학교에서 기말 프로젝트로 구현해본 적이 있습니다.
음악 플레이어를 만드는 프로젝트였는데 GUI를 구현하겠다고 까부는 바람에 고생을 많이 했었습니다.
해당 프로젝트는 제 github에 업로드가 되어 있습니다...만 뭐 그게 중요한 건 아니고
여기서 구현할 Button은 그 때 구현한 것과는 다릅니다. 시스템 구조가 다를 뿐더러, 예전에 구현했던 게 하드코딩에 가까운 것이 이유이기도 합니다.
마우스 입력을 받아 상호작용을 해야하기 때문에, Event System과 연결이 되어있어야 합니다.
그래서 처음에는 Entity 쪽을 상속받아서 만들어보려고 했는데, 문제가 있었습니다.
일단 그래픽을 그리다보면 겹칠 수가 있고, 버튼 위를 다른 그래픽이 가리는 상황이 발생할 수 있습니다.
그런 걸 무시하고 클릭할 수 있게끔 하고 싶다면, 그냥 무시하면 됩니다.
마우스 좌표가 버튼 그래픽 영역에 포함되는지만 판단하면 되니까요.
문제는 그런 경우에는 이벤트가 발생하지 않게끔 처리하는 것을 어떻게 구현하느냐입니다.
아직 전체적으로 미완성인 것 같아서 포스팅을 보류하고 있었는데,
렌더링 시스템은 위와 같은 Scene의 구조로 돌아갑니다.
Scene 하나에 여러 개의 Layer가 있고 각각에 그래픽 (sf::Drawable)을 저장해놓습니다.
렌더링을 할 때는 Layer 순서대로 각 그래픽을 그려주면 됩니다.
하단 그림은 예시입니다. Scene 1이 인게임 화면을 그리는 Scene이라면, Scene 2는 미니맵 등을 그리는 Scene이라고 볼 수 있습니다. 점선 사각형 영역이 Scene을 그리는 영역입니다.
찾아보니 이러한 구조를 Scene graph라고 하더군요.
버튼 그래픽이 가려지는 지의 여부는, 맨 위 Layer 부터 순서대로 검사해서 그 좌표에서 가장 먼저 검출되는 그래픽이 해당 버튼인지를 판단하면 됩니다.
문제는
1] Scene 및 Layer에 저장되는 개체는 Entity가 아니라는 점 (Entity가 그래픽 요소가 없는 객체일 수도 있습니다),
2] Component를 통해서 그래픽 개체를 가져오더라도, Scene에서 이를 검사하려면 굉장히 멀리 돌아가야 한다는 점
(Entity와 Scene의 직접적인 연결점이 없습니다. Entity가 포함된 EntityManager가 포함된 Scene으로 거슬러 올라가야 되는데, Entity에는 EntityManager에 관한 정보가 없고 EntityManager에도 Scene에 관한 정보가 없기 때문에 비용이 꽤 큽니다. 되려 역순으로는 property가 있습니다. Scene -> EntityManager -> Entity) 입니다.
그래서 Entity로는 구현이 어려웠고, 마찬가지의 이유로 Component로 구현하기도 어려웠습니다.
결국, 그래픽 요소에다 직접 구현하기로 했습니다. 그러면 Scene에 추가할 수도 있고, Component를 통해서 빈 Entity에 추가해놓으면 되니까요.
Button은 실제 그래픽 요소는 포인터로 안에다 연결해놓고, Button 자체는 그 그래픽에 대한 마우스 이벤트을 매개로 연결해주는 역할로 구현하기로 했습니다.
Button Event는 다음과 같습니다.
1] MousePressed : 버튼 위에서 마우스 버튼을 눌렀을 때
2] MouseReleased : 버튼 위에서 마우스 버튼을 뗐을 때
3] MouseClick : 버튼 위에서 마우스 버튼을 눌렀다 뗐을 때 (1번과 2번이 모두 발생한 경우)
4] MouseOver : 버튼 영역에 마우스가 들어왔을 때
5] MouseLeave : 버튼 영역에서 마우스가 밖으로 이동했을 때
여기서 또 문제가 발생합니다.
그 그래픽이 포함하는 영역, 다시 말해 Boundary를 구하는 메서드가 sf::Drawable 수준에서 구현된 게 아니었습니다.
SFML에서 위 기능을 하는 메서드가 두 개, getLocalBounds와 getGlobalBounds인데 이 함수가 구현된 클래스가
sf::Shape, sf::Sprite, sf::Text 입니다. 이 세 클래스는 sf::Drawable과 sf::Transformable을 상속받는데
그럼 sf::Transformable에는 이 메서드가 정의되어있냐 하면 그것도 아닙니다.
sf::Drawable만을 상속받는 sf::VertexArray나 sf::VertexBuffer에는 관련 함수가 정의되어 있지 않았습니다.
그래서 일일이 casting을 해보면서 Boundary를 구해야 합니다.
그리고 저는 이것 이외에도 SpriteSheet나 TileMap 같은 커스텀 요소도 정의를 해놓은 까닭에, 다 고려를 해줘야했고 만약 유저가 커스텀을 추가하면 지원해주지 못한다는 단점도 있었습니다.
이걸 해결하기 위해서 그래픽 커스텀용 인터페이스로 GraphicInterface라는 가상 클래스를 정의해줬습니다.
#include <SFML/Graphics/Drawable.hpp>
#include <SFML/Graphics/Transformable.hpp>
#include <SFML/Graphics/RenderTarget.hpp>
class GraphicInterface : public sf::Drawable, public sf::Transformable
{
public:
virtual sf::FloatRect getLocalBounds() const = 0;
virtual sf::FloatRect getGlobalBounds() const = 0;
};
기존에 있던 그래픽들은 일일이 casting으로 비교해줘야 하지만, 적어도 커스텀 요소들은 GraphicInterface를 상속받게만 해주면 모두 대응할 수 있습니다. 그렇지 않은 경우에는... 경고를 로그로 찍어줘야겠죠.
Button 역시 GraphicInterface를 상속받습니다.
#include <any>
#include <functional>
#include <unordered_map>
#include <SFML/Window/Mouse.hpp>
#include <SFML/Window/Event.hpp>
#include "GraphicInterface.h"
class Button : public GraphicInterface
{
private:
sf::Drawable* graphic;
std::unordered_map<sf::Mouse::Button, std::unordered_map<unsigned int, std::function<void(sf::Vector2f)>>> mousedown, mouseup, click;
std::unordered_map<unsigned int, std::function<void(sf::Vector2f)>> mouseover, mouseleave;
unsigned int indicator_down, indicator_up, indicator_hover;
bool pressed_here;
class EventManager* manager;
bool hover;
void checkHover(const std::vector<std::any>& v);
void checkDown(const std::vector<std::any>& v);
void checkUp(const std::vector<std::any>& v);
protected:
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
public:
Button(sf::Drawable* graphic);
~Button();
void setGraphic(sf::Drawable* graphic, bool release = true);
void setManager(EventManager* manager);
unsigned int addMouseDown(const std::function<void(sf::Vector2f)>& mousedown, sf::Mouse::Button button = sf::Mouse::Left);
unsigned int addMouseUp(const std::function<void(sf::Vector2f)>& mouseup, sf::Mouse::Button button = sf::Mouse::Left);
unsigned int addMouseOver(const std::function<void(sf::Vector2f)>& mouseover);
unsigned int addMouseLeave(const std::function<void(sf::Vector2f)>& mouseleave);
unsigned int addClick(const std::function<void(sf::Vector2f)>& click, sf::Mouse::Button button = sf::Mouse::Left);
void onMouseDown(sf::Vector2f position, sf::Mouse::Button button = sf::Mouse::Left);
void onMouseUp(sf::Vector2f position, sf::Mouse::Button button = sf::Mouse::Left);
void onMouseOver(sf::Vector2f position);
void onMouseLeave(sf::Vector2f position);
void onClick(sf::Vector2f position, sf::Mouse::Button button = sf::Mouse::Left);
void removeAllMouseDown();
void removeAllMouseUp();
void removeAllMouseOver();
void removeAllMouseLeave();
void removeAllMouseClick();
void removeAllEvents();
sf::FloatRect getLocalBounds() const override;
sf::FloatRect getGlobalBounds() const override;
};
실제 구현보다는 함수를 조금 줄였습니다. 그리고 최종 버전과 다른 부분이 많습니다. 글을 쓰는 도중에도 많이 바꿨거든요. 이벤트 타입이나 indicator를 지정해서 함수를 호출하거나, 같은 parameter를 받고 특정 함수를 리스트에서 제거하는 함수들입니다. 조건에 해당되는 함수 객체를 찾기만 하면 되므로 생략했습니다.
Button을 구현하는 과정에서 EventManager의 동작을 조금 수정했습니다.
원래는 indicator를 사용자가 지정해서 해당 indicator로 접근하는 함수를 추가하는 방식이었는데,
이렇게 하니 Button에서 이벤트 리스닝을 위해 추가하는 함수의 indicator를 미리 지정해줘야 하고, 기존 이벤트가 등록되어 있는 경우에는 충돌할 수가 있어서 EventManager 내부에서 indicator를 할당해서 return해주는 방식으로 변경하였습니다.
사용자가 indicator를 찾기만 하면 그 함수를 지워버릴 수 있다는 단점이 있기는 한데, 뭐 그러려면 굳이 찾아야되는 거니까요. 실수로 지워질 일은 없다고 생각합니다. 마냥 지울 수 없게끔만 처리해버리면 그것대로 문제가 되기도 하고...
개략적인 작동 방식은 이렇습니다.
우선 Button으로 사용할 Graphic과 이벤트 수신을 위한 EventManager가 등록되어 있어야 합니다.
각각이 setGraphic, setManager 함수이고, setGraphic은 특히 그냥 property에 할당만 하면 되니 생략하겠습니다.
setManager에서도 property에 할당은 하지만, 기존에 manager가 존재하는 경우에는 이벤트 수신을 중지해야 하고, 새로 연결되는 manager에는 연결해야 하므로 추가 작업이 필요합니다.
void Button::setManager(EventManager* manager)
{
if (this->manager)
{
manager->disconnect(sf::Event::MouseButtonPressed, indicator_down);
manager->disconnect(sf::Event::MouseButtonReleased, indicator_up);
manager->disconnect(sf::Event::MouseMoved, indicator_hover);
}
this->manager = manager;
if (manager)
{
indicator_down = manager->connect(sf::Event::MouseButtonPressed, Event([this](const std::vector<std::any>& v) { checkDown(v); }));
indicator_up = manager->connect(sf::Event::MouseButtonReleased, Event([this](const std::vector<std::any>& v) { checkUp(v); }));
indicator_hover = manager->connect(sf::Event::MouseMoved, Event([this](const std::vector<std::any>& v) { checkHover(v); }));
}
}
람다식을 그대로 Event 객체로 만들기 위해서 시간을 좀 많이 썼는데,
결과는 Event 객체로 만들어서 넣자 가 되었습니다. 이건 다음 포스팅에서 다뤄볼게요.
check 함수는 밑에 중요한 함수를 설명할 때 다시 설명하겠습니다.
add~ 함수들은 지정된 이벤트 타입, 버튼 종류에 따라 함수를 리스트에 추가합니다. addMouseDown만 간략하게 보이겠습니다.
unsigned int Button::addMouseDown(const std::function<void(sf::Vector2f)>& mousedown, sf::Mouse::Button button)
{
unsigned int indicator = getNextIndicator(this->mousedown[button]);
this->mousedown[button].emplace(indicator, mousedown);
return indicator;
}
getNextIndicator는 해당 map에서 다음으로 할당 가능한 (비어 있는) hash ID를 찾는 함수입니다.
단순하게, 0부터 시작해서 find 결과가 end인 id를 찾으면 그대로 반환하고, 그렇지 않으면 1 증가하게 했습니다.
그 후에는 리스트에 함수를 추가하고, indicator를 반환해주면 됩니다.
여기서도 그렇고, 이벤트 타입에 따라 함수를 구현할 때는 mouseDown, mouseUp, mouseClick의 경우에는 버튼의 영향을 받고 (좌클릭, 우클릭 등) mouseOver과 mouseLeft는 그런 것에 영향을 받지 않는다는 것에 주의해야 합니다.
후자의 경우에 button type까지 구별하여 리스트에 함수를 추가할 필요가 없습니다.
on~ 함수들은 해당 이벤트를 발생시키는 함수입니다. 무조건 발생시키므로, 마우스 위치 같은 조건이 맞지 않더라도 실행되는 경우를 주의해야 합니다. 따라서 그냥 함수를 실행시키고, 관련 데이터를 넘겨주면 됩니다.
Button에서 이벤트가 받는 데이터는 (버튼 종류는 이미 호출하기 전에 구분되므로) 마우스 좌표뿐입니다.
void Button::onMouseDown(sf::Vector2f position, sf::Mouse::Button button)
{
for (auto& func : mousedown[button])
func.second(position);
}
variation으로 indicator까지 지정하는 경우, button만 받는 경우, 좌표만 받는 경우, parameter를 받지 않는 경우 등등을 만들 수 있지만 여기서는 굳이 그런 것까지 서술하지 않아도 괜찮을 것 같습니다. 제 구현에서는 전자의 두 개만 추가로 구현했습니다.
다만 좌표가 없는 경우에는 자동으로 버튼 기준 중심점을 데이터로 넣게끔 설계했습니다.
remove~ 계열 함수 또한 마찬가지입니다. 해당 이벤트 타입에 indicator로 지정된 함수가 존재한다면 지워버리기만 하면 (혹은 clear 해버리면) 되므로, 이 함수에 대해서는 굳이 코드를 서술하지 않겠습니다.
이제 중요한 함수들입니다.
checkDown, checkUp, checkHover은 각각 마우스를 누를 때, 뗄 때, 움직일 때 체크하게끔 EventManager와 연결시켜 놓았습니다. 따라서 각각의 함수 안에서 마우스 좌표가 버튼 영역 내부인지를 판별하여 알맞게 on~ 함수를 호출하면 됩니다.
void Button::checkHover(const std::vector<std::any>& v)
{
sf::Event e = std::any_cast<sf::Event>(v[0]);
if (getGlobalBounds().contains(e.mouseMove.x, e.mouseMove.y))
{
if (!hover)
{
onMouseOver(sf::Vector2f(e.mouseMove.x, e.mouseMove.y));
hover = 1;
}
}
else if (hover)
{
onMouseLeave(sf::Vector2f(e.mouseMove.x, e.mouseMove.y));
hover = 0;
}
}
void Button::checkDown(const std::vector<std::any>& v)
{
sf::Event e = std::any_cast<sf::Event>(v[0]);
if (getGlobalBounds().contains(e.mouseButton.x, e.mouseButton.y))
{
pressed_here = 1;
onMouseDown(sf::Vector2f(e.mouseButton.x, e.mouseButton.y), e.mouseButton.button);
}
}
void Button::checkUp(const std::vector<std::any>& v)
{
sf::Event e = std::any_cast<sf::Event>(v[0]);
if (getGlobalBounds().contains(e.mouseButton.x, e.mouseButton.y))
{
onMouseUp(sf::Vector2f(e.mouseButton.x, e.mouseButton.y), e.mouseButton.button);
if (pressed_here) onClick(sf::Vector2f(e.mouseButton.x, e.mouseButton.y), e.mouseButton.button);
}
pressed_here = 0;
}
먼저, EventListener에서 이벤트를 호출할 때는 그 이벤트의 파라미터로 무엇이 얼마나 들어갈지 (필요할지) 모르기 때문에 std::vector<std::any> 형태로 제공합니다. 사용자는 본인이 정의하는 이벤트 함수에서 any_cast로 casting하여 사용해야 합니다.
SFML Event의 경우 system event로 취급되고, 0번째 인자는 무조건 sf::Event 객체를 넣게끔 설계했습니다.
우리가 버튼 이벤트를 실행할 때 필요한 것은 마우스 좌표이므로, 각 이벤트에 맞는 sf::Event 하위 객체에서 좌표값을 뽑아와야 됩니다.
예를 들어 마우스 버튼 누르기/떼기 이벤트에서는 mouseButton의 좌표를, 마우스 움직이기 이벤트에서는 mouseMove의 좌표를 가져오면 됩니다.
버튼 영역의 포함 여부는 간단하게 Global Coordinates 기준에서의 (마우스가 Global 기준이므로) Boundary 안에 좌표가 포함되는지 판단하면 됩니다.
checkHover에서는 영역의 포함 여부에 따라 hover 변수를 바꿔주고, mouseOver, mouseLeave를 호출하면 됩니다.
checkDown은 버튼 영역에 포함될 경우에만, 그 버튼을 누른 것이 되므로 그 때만 mouseDown을 호출하면 됩니다.
해당 버튼 위에서 마우스 버튼을 눌렀음을 표현하기 위해 pressed_here 이라는 변수를 정의하여 사용합니다.
checkUp은 checkDown과 똑같습니다. 단지 mouseUp을 호출한다는 점이 다르고, 버튼을 눌렀다면 (pressed_here가 true라면) Click 이벤트도 호출하게 됩니다. 밖에서 마우스를 누르고 해당 버튼에서 떼는 경우나, 해당 버튼에서 누르고 밖에서 마우스를 떼는 경우에는 Click이 호출되지 않습니다. (각각 mouseUp, mouseDown은 호출됩니다)
이러한 여부와 상관 없이 pressed_here는 false로 초기화해줘야 합니다.
이제 Boundary를 가져오는 것이 중요합니다.
기존에 정의된 sf::Shape, sf::Sprite, sf::Text는 함수가 정의되어 있습니다.
GraphicInterface도 가상 함수로 정의되어 있고, 이를 상속 받는 클래스는 모두 이 함수를 반드시 구현해야 합니다.
문제는 그 외의 클래스인데, 커스텀 클래스의 경우는 정말 알 길이 없는 미지의 세계라서 Boundary를 구할 수 없다고 경고를 띄워야 합니다. C++에는 원리 상 함수 이름만으로는 그 클래스에 함수가 존재하는지도 알 수 없고, 호출할 수도 없습니다. 그래서 웬만하면 GraphicInterface를 상속받아서 커스텀 그래픽을 구현하게끔 권장하게 하는 겁니다.
이러한 경우을 제외하고 보면, SFML에서 정의되어 있고, sf::Drawable을 상속받고, Boundary 관련 함수를 제공하지 않는 클래스는 sf::VertexArray와 sf::VertexBuffer입니다.
sf::VertexArray의 경우에는 getBounds가 존재합니다. sf::VertexBuffer의 경우엔 애초에 생성하는 데 쓰인 Vertex 집합에 접근할 수가 없는 것 같아서, 미지원으로 남겼습니다.
Button의 좌표계 내에서 graphic의 boundary, 즉 Button 기준 LocalBounds는 그 graphic의 GlobalBounds와 같습니다.
저는 따로 Util 카테고리에 GlobalBounds를 구하는 함수를 구현하고, Button에서는 그걸 그대로 가져와서 쓰게끔 했습니다.
namespace Utils
{
template <typename T>
sf::FloatRect getGlobalBounds(T* graphic)
{
if (sf::Shape* shape = dynamic_cast<sf::Shape*>(graphic))
return shape->getGlobalBounds();
else if (sf::Sprite* sprite = dynamic_cast<sf::Sprite*>(graphic))
return sprite->getGlobalBounds();
else if (sf::Text* text = dynamic_cast<sf::Text*>(graphic))
return text->getGlobalBounds();
else if (sf::VertexArray* v_arr = dynamic_cast<sf::VertexArray*>(graphic))
return v_arr->getBounds();
else if (GraphicInterface* custom = dynamic_cast<GraphicInterface*>(graphic))
return custom->getGlobalBounds();
else
{
//Graphic does not support Boundary
return sf::FloatRect();
}
}
}
Button 기준 GlobalBounds는 다른 그래픽에서도 그랬듯이, Button의 transform을 LocalBounds에 적용시키면 됩니다.
sf::FloatRect Button::getLocalBounds() const
{
return Utils::getGlobalBounds(graphic);
}
sf::FloatRect Button::getGlobalBounds() const
{
return getTransform().transformRect(getLocalBounds());
}
이 과정에서 그래픽 여러 개를 묶어 놓을 수 있는 Group을 추가해야겠다고 생각했습니다.
#include "GraphicInterface.h"
#include <vector>
class Group : public GraphicInterface
{
std::vector<sf::Drawable*> graphics;
protected:
virtual void draw(sf::RenderTarget& target, sf::RenderStates states) const override;
public:
~Group();
void append(sf::Drawable* graphic);
sf::Drawable* at(size_t index) const;
void remove(size_t index);
void clear(bool release = true);
size_t size() const;
bool empty() const;
bool has(sf::Drawable* graphic) const;
const std::vector<sf::Drawable*>& getGraphics() const;
sf::FloatRect getLocalBounds() const override;
sf::FloatRect getGlobalBounds() const override;
};
여기서도 추후 설명을 위해 함수를 두 개 뺐습니다.
사실 Group은 Graphic 버전 array를 렌더링하기 쉽게 정의해놓은 거라서, 그리 복잡한 것은 없습니다.
append, at, remove, clear, size, empty는 vector에 있는 걸 그대로 구현하면 되므로 생략하겠습니다.
has의 경우 vector list를 돌면서 parameter의 graphic이 존재하는 지 비교해서 여부를 반환해주면 됩니다.
하위 그래픽이 존재하면, 그 그래픽에 대해서 has를 실행해야 합니다. Group은 사실상 재귀 호출이 되지만,
하위 그래픽은 Button에도 존재하므로 Button에도 has를 구현해야 합니다.
이렇게 보니, Button이 Group을 상속받으면 되겠네요? 그래서 Button의 상속을 바꿨습니다.
본래는 Button에서 여러 개의 그래픽을 포함하고 싶은 경우를 위해 Group을 정의했는데, 만들다보니 Group과 굳이 구별할 이유가 없어졌습니다. 원래는 Button과 Group에 혼선이 발생하지 않도록 상속을 분리시켜 놓을 생각이었는데, 그러지 않아도 많이 꼬이더라구요.
bool Group::has(sf::Drawable* graphic) const
{
if (this == graphic) return 1;
for (auto& g : graphics)
if (g == graphic) return 1;
else if (Group* group = dynamic_cast<Group*>(g)) return group->has(graphic);
else if (Button* button = dynamic_cast<Button*>(g)) return button->has(graphic);
return 0;
}
Button은 graphic 파트만 제외시키면 됩니다.
class Button : public Group
{
//sf::Drawable* graphic;
//...
public:
//void setGraphic(sf::Drawable* graphic);
//sf::Drawable* getGraphic() const;
}
다른 Button method도 확인하고 수정해줘야 합니다.
다시 Group으로 돌아와서, getGraphics는 vector list를 그대로 반환해주는 함수입니다.
getLocalBounds의 경우 각 graphic의 GlobalBounds를 구해서 이를 모두 포함하는 최소 크기의 사각형 영역을 구해주면 됩니다.
getGlobalBounds는 지금까지와 마찬가지로, Group 자체의 transform을 LocalBounds에 적용시키면 됩니다.
여기서 구현을 했으니, Button에서는 굳이 다시 override하지 않아도 됩니다.
sf::FloatRect Group::getLocalBounds() const
{
if (empty()) return sf::FloatRect();
float top = 0, left = 0, bottom = 0, right = 0;
bool init = 0;
for (auto& graphic : graphics)
{
sf::FloatRect rect = Utils::getGlobalBounds(graphic);
if (rect.width == 0 && rect.height == 0) continue;
if (!init)
{
top = rect.top;
left = rect.left;
bottom = rect.top + rect.height;\
right = rect.left + rect.width;
init = 1;
}
else
{
top = std::min(top, rect.top);
left = std::min(left, rect.left);
bottom = std::max(bottom, rect.top + rect.height);
right = std::max(right, rect.left + rect.width);
}
}
return sf::FloatRect(left, top, right - left, bottom - top);
}
sf::FloatRect Group::getGlobalBounds() const
{
return getTransform().transformRect(getLocalBounds());
}
왜 이런 기능은 함수로 구현을 안 시켜놨을까요. 생각보다 쓸 일이 많을 것 같은데
Group 혹은 Button에 상위 그래픽, 하위 그래픽의 개념이 발생하면서 적용되는 coordinate 또한 달라졌습니다.
상위 그래픽이 좌표 (50, 50)에 위치한다면, 하위 그래픽 기준의 마우스 좌표는 그 만큼의 좌표를 반대로 이동시켜야 합니다. 따라서 버튼 이벤트를 처리하기 전에 좌표를 변환시켜줄 필요가 있습니다.
문제는 버튼의 상위 그래픽이 존재하는지, 그게 무엇인지를 알지 못합니다.
이걸 알아야 메인에서부터 하위 그래픽까지 내려오면서 coordinate를 변환시켜줄 수 있습니다.
방법을 여러 가지 생각해봤는데, 그냥 parent를 하나 추가하고 역으로 거슬러 올라가면서 구하는 게 가장 나아보였습니다.
local coordinate에서 global로 변환할 때 getTransform().transformPoint (또는 transformRect) 를 적용했으니
역과정은 역행렬을 구해서 적용시키면 되겠죠.
sf::Vector2f Utils::convertGlobalToLocal(sf::Drawable* graphic, sf::Vector2f point)
{
if (graphic == nullptr) return point;
if (Group* g = dynamic_cast<Group*>(graphic))
return g->getInverseTransform().transformPoint(convertGlobalToLocal(g->getParent(), point));
else
return graphic->getInverseTransform().transformPoint(point);
}
parent로 거슬러 올라가야 하기 때문에 재귀 호출로 순서를 다시 뒤집었습니다.
포함 여부를 판단하는 bounary는 getGlobalBounds 함수로 가져오기 때문에 boundary에 this->getTransform()이 적용되어 있다고 볼 수 있습니다. 따라서 이러한 경우에 비교할 mouse position은 parent까지만 coordinate를 변환시켜줘야 합니다. 이를 적용한 checkDown 함수는 다음과 같습니다.
void Button::checkDown(const std::vector<std::any>& v)
{
sf::Event e = std::any_cast<sf::Event>(v[0]);
sf::Vector2f point = sf::Vector2f(e.mouseButton.x, e.mouseButton.y);
point = Utils::convertGlobalToLocal(parent, point);
if (getGlobalBounds().contains(point))
{
pressed_here = 1;
onMouseDown(point, e.mouseButton.button);
}
}
이러고 테스트를 해봤는데, 마우스 좌표가 이상하다는 걸 알게 됐습니다.
사실 잘 생각해보면, 우리가 받은 마우스 좌표는 렌더링하는 지역의 기준이 아닙니다.
sf::View에 따라서 표시되는 영역이 다를 수 있고, 마우스 좌표는 이러한 영향을 받지 않기 때문에
(다시 말해서 윈도우 또한 sf::View로 인한 transform이 적용되어 있습니다) 맞춰서 변환해줄 필요가 있습니다.
이 방법은 SFML 공식 설명서에도 나와있더군요.
sf::Vector2i mousePos = sf::Mouse::getPosition(window);
sf::Vector2f worldPos = window.mapPixelToCoords(mousePos);
window를 기준으로 변환하므로, window에서 event signal을 받을 때 처리해줘야 합니다.
여기서는 pollEvent를 하고, 등록된 event listener들에 signal을 보내기 전이 되겠네요.
while (window.pollEvent(e))
{
switch (e.type) //preprocess
{
case sf::Event::MouseButtonPressed:
case sf::Event::MouseButtonReleased:
{
sf::Vector2i mousePos = sf::Mouse::getPosition(window);
sf::Vector2f worldPos = window.mapPixelToCoords(mousePos);
e.mouseButton.x = worldPos.x;
e.mouseButton.y = worldPos.y;
break;
}
case sf::Event::MouseMoved:
{
sf::Vector2i mousePos = sf::Mouse::getPosition(window);
sf::Vector2f worldPos = window.mapPixelToCoords(mousePos);
e.mouseMove.x = worldPos.x;
e.mouseMove.y = worldPos.y;
break;
}
case sf::Event::MouseWheelMoved:
{
sf::Vector2i mousePos = sf::Mouse::getPosition(window);
sf::Vector2f worldPos = window.mapPixelToCoords(mousePos);
e.mouseWheel.x = worldPos.x;
e.mouseWheel.y = worldPos.y;
break;
}
case sf::Event::MouseWheelScrolled:
{
sf::Vector2i mousePos = sf::Mouse::getPosition(window);
sf::Vector2f worldPos = window.mapPixelToCoords(mousePos);
e.mouseWheelScroll.x = worldPos.x;
e.mouseWheelScroll.y = worldPos.y;
break;
}
}
//...
마우스 휠 이벤트는 아직 여기서 딱히 구현하거나 그런 건 없지만, 어차피 사용하려면 변환이 필요하기에 구현해줬습니다. MouseEnter이나 MouseLeave는 말그대로 마우스가 윈도우에 들어왔는지, 나갔는지 여부만 알리기 때문에 별도의 데이터가 없어서 생략했습니다.
여기서 또 마음에 걸리는 게 있었습니다. 아잇 뭐가 이리 많어
SFML에서 윈도우 렌더링은 렌더링할 윈도우에 표시할 영역 (View)을 그리는 방식으로 이루어집니다.
그런데 만약 View가 여러 개라서, 영역마다 실제로 표시되는 지역이 다르다면 마우스 좌표가 어떻게 계산되는 걸까요?
현재 적용된 View와 렌더링된 영역의 실제 View는 다를 수 있습니다.
따라서 적용하고 있는 모든 View를 체크하면서 마우스 좌표가 포함되는 View를 찾고, 그 View에 맞게 변환할 필요가 있습니다.
sf::FloatRect getViewRect(const sf::View& view) const
{
sf::Vector2f size = view.getSize();
sf::Vector2f center = view.getCenter();
return sf::FloatRect(center.x - size.x / 2, center.y - size.y / 2, size.x, size.y);
}
sf::Vector2f Window::getMousePositionWithViews() const
{
sf::Vector2i mousePos = sf::Mouse::getPosition(window);
sf::Vector2f point = window.mapPixelToCoords(mousePos);
for (auto view = views.rbegin(); view != views.rend(); view++)
if (getViewRect(*view).contains(point))
return view->getInverseTransform().transformPoint(point);
return point; //no view containing the mouse point found.
}
문제는, 이걸 아직 시험해보진 않았습니다. 그냥 대충 동작하겠거니 하고 넘어가기로 했습니다.
시간을 너무 많이 썼어요.
아무튼, 되게 긴 과정이었네요.
이제 마지막으로, 버튼 위에 그래픽이 존재하는지 여부만 판단하면 됩니다.
다시 말해서 여러 개의 그래픽을 소유하고 있다는 의미이므로 이걸 구현해야 하는 곳은 Scene과 Group이 됩니다.
엄밀히 말하면 Layer에도 구현을 해야합니다만, 아직 필요하진 않으니 나중에 구현해도 될겁니다. 아마도요
아무튼, Group이 추가되면서 여기에도 구현을 해줘야 되는군요.
Group을 정의할 때 추후에 설명한다고 말했던 함수는 이 함수들입니다.
렌더 순서 상 위에 오는 그래픽들은 뒤쪽에 위치합니다. 따라서 vector를 역순으로 돌면서 그래픽 영역에 좌표가 들어가는 지 판단하면 됩니다.
만약 해당 좌표를 포함하는 모든 그래픽을 반환한다면, 굳이 역순으로 돌지 않아도 됩니다.
sf::Drawable* Group::detectGraphicAt(sf::Vector2f position)
{
for (auto graphic = graphics.rbegin(); graphic != graphics.rend(); graphic++)
if (Utils::getGlobalBounds(*graphic).contains(position))
return *graphic;
}
std::vector<sf::Drawable*> Group::detecatAllGraphicsAt(sf::Vector2f position)
{
std::vector<sf::Drawable*> ret;
for (auto& graphic : graphics)
if (Utils::getGlobalBounds(graphic).contains(position))
ret.push_back(graphic);
return ret;
}
Scene에서도 같은 원리를 적용시켜서 구현하면 되지만, 탐색하는 Entity는 반드시 그래픽 관련 Component를 소유한 Entity여야 합니다.
Group이나 Scene에서 탐지한 그래픽이 해당 버튼의 그래픽인지 판단하려면, 그 버튼이 그래픽을 소유하고 있는지 혹은 그 그래픽이 그룹 계열이라서, 버튼을 소유하고 있는지 파악해야 합니다.
여기서, 버튼 이벤트로 들어오는 좌표는 버튼의 coordinate 기준임을 알아야 합니다.
우리는 렌더링하고 있는 Scene을 기준으로 detect해야 하기 때문에 이를 다시 main coordinate로 변환해야 합니다.
과정 참 귀찮군요. 다른 방법으로는, 렌더링 중인 window 객체를 capture해와서 거기서 바로 마우스 좌표를 구해와도 됩니다.
sf::Vector2f Utils::convertLocalToGlobal(sf::Drawable* graphic, sf::Vector2f point)
{
if (graphic == nullptr) return point;
sf::Vector2f pt = graphic->getTransform().transformPoint(point);
if (Group* g = dynamic_cast<Group*>(graphic))
return convertLocalToGlobal(g->getParent(), pt);
else
return pt;
}
이렇게 해서, 버튼에 해당하는 그래픽을 클릭했을 때만 이벤트가 처리되도록 하는 코드는 다음과 같습니다.
주의할 점은, 의도하는 게 아니라면 button이 렌더링되는 Scene에서 detect 함수를 불러야 한다는 것입니다.
이거 하나를 자동화할 수단이 없어서 먼 길을 돌아야 한다는 것이 아쉽습니다. 고생한 것에 비해 완벽한 것도 아니고 말이죠.
//currentScene is Scene object in rendering
button->addClick([=](sf::Vector2f pos)
{
auto entity = currentScene->detectEntityAt(pos);
if (!entity) return; //no entity found
auto graphic = entity->getComponent<Renderable>().getGraphic();
if (!button->has(graphic))
if (Group* g = dynamic_cast<Group*>(graphic))
{
if (!g->has(button)) return;
}
else
return;
//Button Click Event triggered!
});
그런데 매번 이렇게 판정 코드를 이벤트 함수마다 정의해주는 건 좀 비효율적인 것 같네요.
Button에 관련 옵션을 넣어줘야겠습니다. 설마 어떤 이벤트는 오버레이 될때만, 어떤 이벤트는 상관없이 발동되기를 원하는 사람은 없겠죠?
뭐 그런 경우면 저는 그냥 똑같은 transform의 투명한 버튼을 맨 뒤에다가 추가해놓겠습니다. 오버레이 여부가 상관없다면 안 보이는 버튼 하나 추가해도 작동하니까요.
아무튼 ignoreOverlay 라는 bool 변수 하나를 추가했고, 관련 setter/getter 함수도 정의했습니다.
이 값이 false일 경우에는 이벤트 처리 전에 아까 만든 판정 코드를 실행할 겁니다.
coordinate 변환 전 좌표를 알 수 있기 때문에 굳이 변환 함수를 돌리지 않아도 됩니다.
다만 어떤 Scene에서 렌더링하는지는 알아야 하기 때문에, 이에 관한 변수를 만들고 사용자가 지정해 줄 필요가 있습니다.
이에 따라 수정한 checkDown 함수는 다음과 같습니다. checkUp도 거의 동일합니다.
void Button::checkDown(const std::vector<std::any>& v)
{
sf::Event e = std::any_cast<sf::Event>(v[0]);
sf::Vector2f mousePos = sf::Vector2f(e.mouseButton.x, e.mouseButton.y);
sf::Vector2f point = Utils::transformGlobalToLocal(parent, mousePos);
if (getGlobalBounds().contains(point))
{
if (!ignoreOverlay)
{
do
{
if (renderingScene == nullptr)
{
//Force to trigger events
break;
}
auto entity = renderingScene->detectEntityAt(mousePos);
if (!entity) return;
auto graphic = entity->getComponent<Renderable>().getGraphic();
if (!has(graphic))
if (Group* g = dynamic_cast<Group*>(graphic))
{
if (!g->has(this))
return;
}
else
return;
} while (0);
}
pressed_here = 1;
onMouseDown(point, e.mouseButton.button);
}
}
GlobalBound 판정 이전에 overlay 판정을 해도 돌아가는 데 문제는 없습니다만, 그렇게 되면 매번 overlay 판정이 돌아가기 때문에 귀찮지만 조건문 안에 넣어줬습니다.
detect를 실행하는 scene이 null인 경우에는 무시하고 이벤트를 호출하게끔 했습니다.
단순 if문이기 때문에 goto문을 쓰거나 do while(false)를 써서 break를 쓸 수 있게끔 해야 합니다.
이런 경우에 쓰는 goto문이 프로그램에 악영향을 끼칠 일은 (아마도) 없지만, 혹시나해서 저는 후자를 사용했습니다.
사람들이 너무 goto문을 묻지마 금기시하는 게 아닌가 싶지만, 가끔 백신 프로그램도 오진해서 격리시키더군요.
불쌍하네요 흑흑
뭐, 다시 돌아와서, 저같은 경우에는 대신 경고 메시지를 출력하게 했는데, 이때문에 Scene을 지정해주지 않으면 마우스를 움직일 때마다 메시지가 출력됩니다. 뭐, 의도한 게 아니라서 그런 거니 이 정도는 괜찮겠죠.
checkHover 같은 경우에는 조금 다릅니다. boundary에 포함되더라도 다른 그래픽이 overlay 되어 있어서 그 쪽이 detect되면 MouseLeave 이벤트를 호출해야 할 수 있기 때문에 관련 코드를 추가로 넣었습니다.
위 코드에서 return할 때마다 체크해줘야 하는데, 그 경우가 3개나 됩니다.
그래서 버튼이 detect 되는 경우만 골라서 break 해주고, 나머지 경우에 체크하도록 변경했습니다.
void Button::checkHover(const std::vector<std::any>& v)
{
sf::Event e = std::any_cast<sf::Event>(v[0]);
sf::Vector2f mousePos = sf::Vector2f(e.mouseMove.x, e.mouseMove.y);
sf::Vector2f point = Utils::transformGlobalToLocal(parent, mousePos);
if (getGlobalBounds().contains(point))
{
if (!ignoreOverlay)
{
do
{
if (renderingScene == nullptr)
{
//Force to trigger events
break;
}
auto entity = renderingScene->detectEntityAt(mousePos);
if (entity)
{
auto graphic = entity->getComponent<Renderable>().getGraphic();
if (has(graphic)) break;
if (Group* g = dynamic_cast<Group*>(graphic))
{
if (g->has(this)) break;
}
}
if (hover)
{
onMouseLeave(point);
hover = 0;
}
return;
} while (0);
}
if (!hover)
{
onMouseOver(point);
hover = 1;
}
}
else if (hover)
{
onMouseLeave(point);
hover = 0;
}
}
이쪽이 복잡한 대신, 버튼 이벤트는 간단하게 작성할 수 있습니다.
button->addMouseOver([=](sf::Vector2f pos)
{
button->at<sf::RectangleShape>(0)->setFillColor(sf::Color(0, 200, 0));
});
button->addMouseLeave([=](sf::Vector2f pos)
{
button->at<sf::RectangleShape>(0)->setFillColor(sf::Color::Green);
});
이 결과를 위해서 여러 가지를 구현하고, 돌고 돌아서 여기까지 왔습니다.
코드도 길고, 포스트도 더 길어졌네요.
* 해결하지 못하거나 어려운 것들 *
Button을 만들다가 되게 많은 걸 추가했고, 갈아엎었습니다.
그런 와중에도 좀 찝찝하게 남겨둔 것들이 몇 가지 있는데, 다음과 같은 것들이 있습니다.
1. 여러 개의 Graphic을 포함하는 최소 사각형 영역을 Boundary로 합니다. 따라서 실제로는 그래픽이 존재하지 않는 영역을 클릭하더라도, Boundary에 포함되어 있으면 Button Event를 실행시킬 수 있습니다.
이 문제같은 경우에는 Button의 Global Boundary로 판단하지 말고, 각각의 하위 그래픽 Boundary에 포함되는지 검사하면 오차를 훨씬 줄일 수 있습니다. 다만 재귀 호출 처리도 해야하고, 결국에 이벤트는 단 한번만 호출해야 하기 때문에 복잡해서 그냥 그만뒀습니다. 이미 overlay detection을 추가한 것만으로도 복잡합니다.
이러한 경우엔 Button에 여러 개의 그래픽을 추가할 게 아니라, 각각의 Button을 만들고 이걸 Group에 넣는 게 훨씬 좋을 것 같습니다. 이벤트 추가가 좀 번거롭습니다만, 이걸 좀 편하게 해줄 함수 매핑을 유틸에 추가해줬습니다.
variadic template을 쓰고, 이건 나중에 다른 것과 한꺼번에 포스팅으로 정리하겠습니다.
2. Event와 람다식
포스팅 도중에 잠깐 이야기했는데, 이건 다음 포스트로 넘기겠습니다.
제목은 죽이고픈 람다식이 될지도 모르겠네요.
이 이후에는 방금 말한대로 Event에 관해서 정리하고,
그 다음에는 지금까지 만든 걸 정리할지, 다른 GUI인 TextInput을 만들지 고민 중입니다.
너무 길게 늘어지는 것 같아서 조금만 더 구현해보고 마무리짓고 실제 게임 개발로 넘어가려 합니다.
모드 서포트나 모바일 같은 다른 플랫폼 지원도 나름 계획에 넣어봤는데, 이런 건 적용해볼 게임이라도 하나 있어야 되지 않겠나 싶네요.
그 전에 TextInput이 Button 이상으로 막노동이 많아서 이것부터 걱정해야 할 것 같네요.
'C++ > Game' 카테고리의 다른 글
[게임 프레임워크 개발 일지] #8 편식 안 하고 뭐든 잘 먹는 Event (2) | 2022.07.24 |
---|---|
[게임 프레임워크 개발 일지] #7 Variadic Template과 Lambda (0) | 2022.07.20 |
[Physics] 2D 충돌 감지 구현하기 (2D Collision Detection) - Part 2 (0) | 2022.04.10 |
[Physics] 2D 충돌 감지 구현하기 (2D Collision Detection) - Part 1 (1) | 2022.04.09 |
[게임 프레임워크 개발 일지] #5 Multi Style이 적용된 Text (0) | 2022.02.18 |