팰월드 서버 직접 만들기 5탄 - 지도에 상인·수배 NPC 마커 올리기
작성일 2026-07-15 · SameOS Tools
4탄에서 만든 실시간 지도에는 빠른 이동 지점과 보스 타워만 찍혀 있었습니다. 이번에는 상인(팰 상인·방랑 상인·암거래상)과 수배 NPC 164곳을 얹었습니다. 마커 추가 자체는 30분 거리였는데, 정작 시간을 잡아먹은 건 "다 만들었는데 화면에 안 보인다"였습니다. 원인이 하나가 아니라 셋이 겹쳐 있었거든요. 그 순서 그대로 씁니다.
마커 데이터 - JSON 하나면 됩니다
마커 위치는 poi.json 파일 하나에 종류별로 모았습니다. 빠른 이동·보스 타워처럼 이름이 필요 없는 것은 좌표 배열만, 상인·NPC처럼 이름을 보여줄 것은 이름(n)과 좌표(p)를 같이 둡니다. 상인은 종류(k)도 둬서 아이콘을 다르게 그립니다. 좌표 숫자는 게임에서 직접 얻을 수 있습니다 - 게임 안에서 그 장소에 서서 지도(M)를 열면 커서 위치의 월드 좌표가 표시되는데, 그 두 숫자를 그대로 적으면 됩니다. 저는 상인 15곳은 직접 돌며 적었고, 수배 NPC는 게임 데이터에서 좌표를 뽑아 정리했습니다. 처음이라면 상인 두세 곳으로 시작해 보세요 - 형식만 맞으면 몇 개든 똑같이 동작합니다.
// map/poi.json - 종류별로 묶은 마커 데이터 (좌표는 게임 월드 좌표 그대로)
{
"fast_travel": [[-327532, -70411], [-50301, 287392], ...],
"boss_tower": [[-121325, 262935], ...],
"vendors": [
{"n": "팰상인", "k": "pal", "p": [-344208, 193802]},
{"n": "방랑상인", "k": "merchant", "p": [-181263, 369140]},
{"n": "암거래상", "k": "black", "p": [-38295, -419295]}
],
"npcs": [
{"n": "Attention-Seeking Monster Dazzle", "p": [-711867, -229855]},
{"n": "Serial Borrower Pinch", "p": [-686622, -380407]}
]
}
레이어 렌더 - 종류마다 div 하나씩
렌더는 단순합니다. 종류별로 div를 하나씩 두고, poi.json을 받아 각 좌표를 이미지 태그로 바꿔 채웁니다. 이름 있는 마커(상인·NPC)는 title 속성을 줘서 마우스를 올리면 이름이 뜨게 했습니다. 상인은 여기에 종류별 판매 품목 설명을 붙였습니다 - 개별 상인의 정확한 재고는 위치·패치마다 달라서 지어내지 않고, 종류별 공통 품목만 정직하게 적었습니다. 아래 코드는 4탄 없이도 돌아가도록 필요한 도우미 함수까지 전부 담았습니다 - public/app.js 끝에 그대로 붙여 넣으면 됩니다.
// public/app.js 끝에 통째로 붙여 넣기 (필요한 도우미 포함 - 이 코드만으로 동작)
// 도우미 1) id로 요소 찾기
const $ = (id) => document.getElementById(id);
// 도우미 2) 이름에 특수문자가 있어도 안전하게 (따옴표 등 이스케이프)
function escapeHtml(t) {
return String(t).replace(/[&<>"']/g, (c) =>
({ "&":"&", "<":"<", ">":">", '"':""", "'":"'" }[c]));
}
// 도우미 3) 게임 월드 좌표 → 지도 이미지의 % 위치
// (팰월드 지도의 월드 범위 - 이 숫자는 그대로 쓰면 됩니다)
const WORLD = { minX: -999940, minY: -738920, span: 1447840 };
const worldToPct = (x, y) => ({
left: ((y - WORLD.minY) / WORLD.span) * 100,
top: (1 - (x - WORLD.minX) / WORLD.span) * 100,
});
// 본체) poi.json을 읽어 레이어별로 마커를 그린다
fetch("map/poi.json?t=" + Date.now(), { cache: "no-store" })
.then((r) => r.json()).then((poi) => {
// 이름 없는 마커 (빠른 이동, 보스 타워)
const marker = (src) => ([x, y]) => {
const p = worldToPct(x, y);
return `<img class="poi" src="${src}"
style="left:${p.left.toFixed(2)}%;top:${p.top.toFixed(2)}%">`;
};
// 이름 있는 마커 - title이 마우스 오버 툴팁이 된다
const named = (src, title) => (pt) => {
const p = worldToPct(pt.p[0], pt.p[1]);
return `<img class="poi poi-named" src="${src}" title="${escapeHtml(title(pt))}"
style="left:${p.left.toFixed(2)}%;top:${p.top.toFixed(2)}%">`;
};
// 상인 종류별 판매 안내 (개별 재고는 위치마다 달라 종류 공통만)
const vendorSells = {
pal: "팰 상인 · 이 지역에서 나오는 팰을 판매 (위치마다 다름)",
merchant: "방랑 상인 · 화살, 화약, 가죽, 케이크 재료 등 잡화와 재료",
black: "암거래상 · 희귀·특수 팰과 인간형 팰",
};
$("map-vd").innerHTML = (poi.vendors || []).map((v) =>
named(`map/icon-${v.k}.svg`, (x) => vendorSells[x.k] || x.n)(v)).join("");
$("map-npc").innerHTML = (poi.npcs || []).map(named("map/icon-npc.svg", (x) => x.n)).join("");
});
// 체크박스로 레이어 켜고 끄기
$("toggle-vd").addEventListener("change", (e) => { $("map-vd").hidden = !e.target.checked; });
$("toggle-npc").addEventListener("change", (e) => { $("map-npc").hidden = !e.target.checked; });
지도 HTML 쪽에는 레이어 div 두 개와 체크박스 두 개만 추가하면 됩니다. 아이콘은 그림판이 필요 없습니다 - 아래 SVG 텍스트를 그대로 파일로 저장하면 그게 아이콘입니다 (빨간 과녁 모양). 상인 아이콘도 색만 바꿔 같은 방식으로 만들면 됩니다.
<!-- 지도 HTML(팰월드 지도가 있는 페이지)의 레이어 영역에 추가 -->
<label><input type="checkbox" id="toggle-vd" checked> 상인</label>
<label><input type="checkbox" id="toggle-npc" checked> 수배 NPC</label>
...
<div id="map-vd"></div>
<div id="map-npc"></div>
<!-- map/icon-npc.svg 로 저장 - 이 텍스트가 곧 아이콘입니다 -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="#7f1d1d" stroke="#fca5a5" stroke-width="2"/>
<circle cx="12" cy="12" r="4.5" fill="none" stroke="#fca5a5" stroke-width="1.6"/>
<circle cx="12" cy="12" r="1.4" fill="#fca5a5"/>
</svg>
그런데 안 보인다 - 원인 셋
여기까지 만들고 지도를 열었는데 NPC가 하나도 안 보였습니다. 데이터도 코드도 아이콘도 확인했는데 전부 정상이라 한참 헤맸습니다. 원인은 셋이 겹쳐 있었습니다. 첫째, 마커가 164개나 돼서 NPC 레이어만 기본 꺼짐(hidden)으로 만들어 두고는 그 사실을 잊었습니다. 둘째, poi.json을 지도 이미지와 같은 폴더에 두는 바람에 "지도 타일은 오래 캐시"하는 규칙에 데이터까지 걸려서, 브라우저가 NPC가 없던 어제의 poi.json을 24시간 동안 재사용하고 있었습니다. 셋째, 관리 페이지가 도커 이미지 빌드 시점에 파일을 복사(COPY)하는 구조라, 서버 쪽 파일을 고쳐도 재빌드 전에는 실행 중인 컨테이너에 반영되지 않았습니다.
# 원인별 해결 - 셋 다 고쳐야 화면에 나온다
# 1) 레이어 기본값: hidden 제거 + 체크박스 checked
# <div id="map-npc" hidden> → <div id="map-npc">
# 2) 캐시 분리: 이미지(webp/svg)만 오래 캐시, 데이터(json)는 항상 새로
"Cache-Control": /\.(webp|png|svg)$/.test(rel)
? "public, max-age=86400" # 지도 타일·아이콘
: "no-cache", # poi.json 같은 데이터
# 3) 도커 재빌드: COPY 방식이라 파일 수정 후엔 빌드가 필요
docker compose build palworld-admin
docker compose up -d palworld-admin
교훈은 단순합니다. "화면에 안 보인다"는 문제는 데이터 → 렌더 → 표시 상태 → 캐시 → 배포 경로 순서로 하나씩 눈으로 확인하면 반드시 어딘가에서 걸립니다. 이번엔 그중 셋이 동시에 걸려 있었을 뿐입니다. 특히 데이터 파일을 이미지와 같은 캐시 규칙에 두는 실수는 다른 프로젝트에서도 저지르기 쉬우니, 처음부터 "이미지는 오래, 데이터는 항상 새로"를 규칙으로 두는 것을 권합니다.