
기능을 만든 초창기부터 지금까지 가장 많은 사랑을 받고 있는 퀘스트 로드맵 기능입니다.
기능이 완성되기 전에 많은 사람들이 사용할 것이라고 생각하면서 들떴던 기억이 납니다.
기존에 비슷한 로드맵이 있는 사이트가 있었지만 아쉬움이 많았습니다.
그래서 우리가 직접 만들고 무료로 제공하자는 기획이 시작되었습니다.
NextJS 라이브러리는 react-xyflow 를 사용하였습니다. 이걸 사용하여 퀘스트간의 연결관계를 보여주고 깔끔하게 처리했습니다.
Node와 Edge로 나누어 설계했습니다.
Node는 하나의 퀘스트를 의미하고, Edge는 연결관계를 나타냅니다.
스키마 중 Single coordinate는 화면에서 필터를 적용할 때 위치입니다.
그래서 총 저장 해야 하는 정보는 다음과 같습니다.
xyflow에서 제공하는 자동 위치 기능을 사용했는데, 디자인이 매우 참혹한 수준이었습니다...
기획자가 고생좀 했습니다;;;
-- Roadmap Node
CREATE TABLE IF NOT EXISTS ROADMAP_NODE
(
ID TEXT primary key,
TOTAL_X_COORDINATE NUMERIC,
TOTAL_Y_COORDINATE NUMERIC,
SINGLE_X_COORDINATE NUMERIC,
SINGLE_Y_COORDINATE NUMERIC,
TOTAL_KAPPA_X_COORDINATE NUMERIC,
TOTAL_KAPPA_Y_COORDINATE NUMERIC,
SINGLE_KAPPA_X_COORDINATE NUMERIC,
SINGLE_KAPPA_Y_COORDINATE NUMERIC,
NODE_COLOR TEXT,
UPDATE_TIME timestamp with time zone default now()
);
COMMENT ON COLUMN ROADMAP_NODE.ID IS '로드맵 퀘스트 아이디';
COMMENT ON COLUMN ROADMAP_NODE.TOTAL_X_COORDINATE IS '로드맵 전체 X 좌표';
COMMENT ON COLUMN ROADMAP_NODE.TOTAL_Y_COORDINATE IS '로드맵 전체 Y 좌표';
COMMENT ON COLUMN ROADMAP_NODE.SINGLE_X_COORDINATE IS '로드맵 싱글 X 좌표';
COMMENT ON COLUMN ROADMAP_NODE.SINGLE_Y_COORDINATE IS '로드맵 싱글 Y 좌표';
COMMENT ON COLUMN ROADMAP_NODE.TOTAL_X_COORDINATE IS '로드맵 카파 전체 X 좌표';
COMMENT ON COLUMN ROADMAP_NODE.TOTAL_Y_COORDINATE IS '로드맵 카파 전체 Y 좌표';
COMMENT ON COLUMN ROADMAP_NODE.SINGLE_X_COORDINATE IS '로드맵 카파 싱글 X 좌표';
COMMENT ON COLUMN ROADMAP_NODE.SINGLE_Y_COORDINATE IS '로드맵 카파 싱글 Y 좌표';
COMMENT ON COLUMN ROADMAP_NODE.NODE_COLOR IS '로드맵 노드 색상';
COMMENT ON COLUMN ROADMAP_NODE.UPDATE_TIME IS '로드맵 업데이트 날짜';
-- Roadmap Edge
CREATE TABLE IF NOT EXISTS ROADMAP_EDGE
(
ID TEXT primary key,
SOURCE_ID TEXT,
TARGET_ID TEXT,
UPDATE_TIME timestamp with time zone default now()
);
COMMENT ON COLUMN ROADMAP_EDGE.ID IS '로드맵 엣지 아이디';
COMMENT ON COLUMN ROADMAP_EDGE.SOURCE_ID IS '로드맵 엣지 연결 시작 노드 아이디';
COMMENT ON COLUMN ROADMAP_EDGE.TARGET_ID IS '로드맵 엣지 연결 끝 노드 아이디';
COMMENT ON COLUMN ROADMAP_EDGE.UPDATE_TIME IS '로드맵 엣지 업데이트 날짜';
퀘스트 노드 저장 값

퀘스트 노드 엣지 저장 값

사용자가 Node의 체크박스를 클릭할 때 처리입니다.
역순 체크 허용
중간 해제 시 뒷 단계 해제
그냥 구현하면 쉬울 수 있지만, 사용 중인 라이브러리(react-xyflow)에 맞추다 보니 난이도가 있었습니다.
활성화 상태

비활성화 상태

상태 변환

필터가 있고, 커스텀을 많이해야 해서 코드가 많이 복잠해졌는데 그냥 쓰기로 했습니다.
커스텀을 하다보니 기본 정보 외에도 퀘스트 노드에 보여줘야 할 항목들, 필터가 적용시에 바뀌어야 하는 디자인 등을 적용해주었습니다.
const generateNodes = useCallback(() => {
return roadmapInfo.node_info.flatMap((npc) => {
if (tabState !== "all" && npc.id !== tabState) return [];
return npc.quests.map((quest) => ({
id: quest.id,
type: npcIdList.includes(quest.id) ? "npcNode" : "questNode",
sourcePosition: Position.Right,
targetPosition: Position.Left,
data: {
name: quest.name,
id: quest.id,
type: npcIdList.includes(quest.id) ? "npc" : "quest",
image: npcIdList.includes(quest.id) ? npc.image : "none",
kappa_required: quest.kappa_required,
url_mapping: quest.url_mapping,
isCheck: questIdSet.has(quest.id),
task_requirements: quest.task_requirements,
task_next: quest.task_next,
npc_id: quest.npc_id,
node_color: quest.node_color,
view_only_kappa: onlyKappa,
},
position: {
x: onlyKappa
? tabState === "all"
? quest.total_kappa_x_coordinate
: quest.single_kappa_x_coordinate
: tabState === "all"
? quest.total_x_coordinate
: quest.single_x_coordinate,
y: onlyKappa
? tabState === "all"
? quest.total_kappa_y_coordinate
: quest.single_kappa_y_coordinate
: tabState === "all"
? quest.total_y_coordinate
: quest.single_y_coordinate,
},
draggable: false,
}));
});
}, [roadmapInfo.node_info, tabState, onlyKappa, npcIdList, questIdSet]);
여기가 너무 어려웠는데 위에서 설명한 체크 동작 규칙을 구현해야 해서 험난했습니다.
const onNodeChange = useCallback(
(data: any, isCheck: boolean) => {
const updateNodeCheckStatus = (
nodeId: string,
checkStatus: boolean,
nodes: any[],
): any[] => {
const updatedNodes = new Map(nodes.map((node) => [node.id, node]));
const updateNode = (nodeId: string) => {
const node = updatedNodes.get(nodeId);
if (!node) return;
updatedNodes.set(nodeId, {
...node,
data: { ...node.data, isCheck: checkStatus },
});
if (checkStatus && node.data.task_requirements) {
node.data.task_requirements.forEach(updateNode);
} else if (!checkStatus && node.data.task_next) {
node.data.task_next.forEach(updateNode);
}
};
updateNode(nodeId);
const nodesCheckList = Array.from(updatedNodes.values())
.filter((node) => node.data.isCheck)
.map((node) => node.data.id);
setQuestList((prevQuestList) => {
const filteredQuestList = prevQuestList.filter(
(id) => !nodes.some((node) => node.data.id === id),
);
return [...new Set([...filteredQuestList, ...nodesCheckList])];
});
return Array.from(updatedNodes.values());
};
setNodes((nds) => updateNodeCheckStatus(data.id, isCheck, nds));
},
[setNodes, setQuestList],
);
퀘스트가 500개 정도이다 보니 검색의 필요성이 느껴져 추가했습니다.
검색하면 해당 위치로 화면이 이동합니다. 중복이 있을경우 계속 엔터를 하면 다음 노드로 이동합니다.
const handleSearch = () => {
if (searchQuery.length < 1) {
setAlertDesc(alertMessageI18N.inputWord[localeKey]);
requestAnimationFrame(() => {
setAlertStatus(true);
});
return;
}
const matchingNodes = nodes.filter(
(node) =>
node.data.name.ko.toLowerCase().includes(searchQuery.toLowerCase()) ||
node.data.name.ja.toLowerCase().includes(searchQuery.toLowerCase()) ||
node.data.name.en.toLowerCase().includes(searchQuery.toLowerCase()),
);
if (matchingNodes.length > 0) {
const nextIndex = searchIndex % matchingNodes.length;
const targetNode = matchingNodes[nextIndex];
setSearchIndex(nextIndex + 1);
const { x, y } = targetNode.position;
const bounds = {
x: x,
y: y,
width: 300,
height: 300,
};
reactFlowInstance.fitBounds(bounds);
} else {
setAlertDesc(alertMessageI18N.notFound[localeKey]);
requestAnimationFrame(() => {
setAlertStatus(true);
});
}
};
꽤나 개발이 어렵기도 했고, 노드를 커스텀 하는데 좀 힘들었습니다.
개발 당시에 가장 인기가 많은 기능이 되지 않을까 했는데 실제로도 그렇게 되어서 상당히 좋은 기억으로 남아 있습니다.
퀘스트 로드맵은 현재도 많은 유저들에게 핵심 기능으로 사랑받고 있습니다.
초기 퀘스트 로드맵입니다. 지금 디자인이랑은 많이 비교 됩니다 ㅎㅎ
