feat(rogue-map): 절차 생성 알고리즘 JS 미러 + 테스트 9건
- StS식 경로 걷기 4개 (시작열 셔플 앞2 상이 보장) - 행별 가중 타입 배정 + elite 부모 연속 금지 - 연결성·타입 규칙·간선 제약·결정성 테스트 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
tools/map/rogue-map.mjs
Normal file
84
tools/map/rogue-map.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
// 로그라이크 맵 절차 생성 — StS식 경로 걷기.
|
||||
// ⚠️ 전투 규칙과 마찬가지로 tools/deck/gen-slaydeck.mjs 의 Lua(GenerateMap)와 로직 동기화 유지할 것.
|
||||
// (Lua는 math.random, 여기는 주입 rng — 수치 동일성이 아니라 구조 규칙 동일성이 대상)
|
||||
|
||||
export const ROWS = 7; // 걷는 행 1..7, 보스는 row 8
|
||||
export const COLS = 4;
|
||||
export const PATHS = 4;
|
||||
|
||||
// 행별 타입 가중치 (설계 문서 표와 동일)
|
||||
function rowWeights(row) {
|
||||
if (row === ROWS) {
|
||||
return [['rest', 50], ['combat', 25], ['shop', 10], ['elite', 8], ['treasure', 7]];
|
||||
}
|
||||
if (row >= 4) {
|
||||
return [['combat', 45], ['elite', 16], ['shop', 12], ['rest', 12], ['treasure', 15]];
|
||||
}
|
||||
return [['combat', 45], ['shop', 12], ['rest', 12]];
|
||||
}
|
||||
|
||||
// rng: () => [0,1) 균등 난수 (mulberry32 등)
|
||||
export function generateMap(rng) {
|
||||
const randInt = (lo, hi) => lo + Math.floor(rng() * (hi - lo + 1)); // [lo,hi]
|
||||
const nodes = {};
|
||||
const start = [];
|
||||
const nodeId = (r, c) => `r${r}c${c}`;
|
||||
|
||||
const ensureNode = (r, c) => {
|
||||
const id = nodeId(r, c);
|
||||
if (!nodes[id]) nodes[id] = { type: 'combat', row: r, col: c, next: [] };
|
||||
return id;
|
||||
};
|
||||
const addNext = (id, nid) => {
|
||||
if (!nodes[id].next.includes(nid)) nodes[id].next.push(nid);
|
||||
};
|
||||
|
||||
nodes.boss = { type: 'boss', row: ROWS + 1, col: 0, next: [] };
|
||||
|
||||
// 시작열: 셔플 앞 2개(상이 보장) + 랜덤 2개
|
||||
const cols = [1, 2, 3, 4];
|
||||
for (let i = cols.length - 1; i >= 1; i--) {
|
||||
const j = randInt(0, i);
|
||||
[cols[i], cols[j]] = [cols[j], cols[i]];
|
||||
}
|
||||
const starts = [cols[0], cols[1], randInt(1, COLS), randInt(1, COLS)];
|
||||
|
||||
for (let p = 0; p < PATHS; p++) {
|
||||
let c = starts[p];
|
||||
const sid = ensureNode(1, c);
|
||||
if (!start.includes(sid)) start.push(sid);
|
||||
for (let r = 1; r < ROWS; r++) {
|
||||
let nc = c + randInt(-1, 1);
|
||||
if (nc < 1) nc = 1;
|
||||
if (nc > COLS) nc = COLS;
|
||||
ensureNode(r + 1, nc);
|
||||
addNext(nodeId(r, c), nodeId(r + 1, nc));
|
||||
c = nc;
|
||||
}
|
||||
addNext(nodeId(ROWS, c), 'boss');
|
||||
}
|
||||
|
||||
// 타입 배정 — 행 오름차순 (1~2행은 combat 고정)
|
||||
for (let r = 3; r <= ROWS; r++) {
|
||||
for (let c = 1; c <= COLS; c++) {
|
||||
const id = nodeId(r, c);
|
||||
const node = nodes[id];
|
||||
if (!node) continue;
|
||||
// elite 부모 검사 (연속 엘리트 방지)
|
||||
let eliteParent = false;
|
||||
for (const pn of Object.values(nodes)) {
|
||||
if (pn.row === r - 1 && pn.type === 'elite' && pn.next.includes(id)) eliteParent = true;
|
||||
}
|
||||
const w = rowWeights(r).map(([t, wt]) => [t, t === 'elite' && eliteParent ? 0 : wt]);
|
||||
const total = w.reduce((s, [, wt]) => s + wt, 0);
|
||||
const roll = rng() * total;
|
||||
let acc = 0;
|
||||
for (const [t, wt] of w) {
|
||||
acc += wt;
|
||||
if (roll <= acc) { node.type = t; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, start };
|
||||
}
|
||||
Reference in New Issue
Block a user