Merge pull request 'feat(rogue-map): �α���ũ ���� ���� �ʡ��� �ý��ۡ����� �� (���� ����Ƽ P8)' (#41) from feature/p8-rogue-map into main
This commit was merged in pull request #41.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"start": ["A", "B"],
|
||||
"nodes": {
|
||||
"A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] },
|
||||
"B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] },
|
||||
"C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] },
|
||||
"D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] },
|
||||
"E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] },
|
||||
"F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] },
|
||||
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] }
|
||||
}
|
||||
}
|
||||
71
docs/superpowers/plans/2026-06-12-rogue-map.md
Normal file
71
docs/superpowers/plans/2026-06-12-rogue-map.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# P8 — 로그라이크 절차 생성 맵·층 시스템·유물 방 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 막마다 8층×4열 맵을 Lua 런타임 절차 생성, 층별 타입 규칙·점선 맵 UI·유물 방(상자 연출) 추가.
|
||||
|
||||
**Architecture:** `data/map.json` 정적 주입 제거 → `GenerateMap` Lua 메서드(StS 경로-걷기 4개) + JS 미러(`tools/map/rogue-map.mjs`, node:test). MapHud는 정적 그리드(28노드+보스+도트 192)로 재작성, RenderMap이 런타임 토글. TreasureHud 신설(타이머 체인 흔들림 + RUID 교체).
|
||||
|
||||
**Tech Stack:** Node.js 생성기, MSW Lua, mulberry32(JS 테스트 전용 — Lua는 math.random).
|
||||
|
||||
설계: `docs/superpowers/specs/2026-06-12-rogue-map-design.md` (사용자 승인 완료)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: JS 미러 + 단위 테스트 (TDD)
|
||||
|
||||
**Files:** Create `tools/map/rogue-map.mjs`, Create `tools/map/rogue-map.test.mjs`
|
||||
|
||||
- [ ] **Step 1**: 테스트 먼저 작성 — `generateMap(rng)` import, 케이스: ①동일 시드 결정성 ②모든 노드가 시작점에서 BFS 도달 + 모든 노드에서 boss 도달 ③1~2행 combat만 ④elite·treasure는 4행부터 ⑤간선은 row+1·|Δcol|≤1 (boss 제외) ⑥elite 부모를 가진 노드는 elite 아님 ⑦boss는 row8 단일·7행 노드 전부 boss로 연결 ⑧MapStart ≥ 2개
|
||||
- [ ] **Step 2**: `node --test tools/map/rogue-map.test.mjs` → FAIL 확인
|
||||
- [ ] **Step 3**: `rogue-map.mjs` 구현 — 설계 알고리즘 그대로 (시작열 셔플 앞2 + 랜덤2, 경로 4개 걷기, 행 오름차순 가중 타입 배정·elite 부모 금지). 가중치 표는 설계 문서와 동일. ⚠️ 주석에 "Lua GenerateMap과 동기화 유지" 명시
|
||||
- [ ] **Step 4**: 테스트 PASS → 커밋 `feat(rogue-map): 절차 생성 알고리즘 JS 미러 + 테스트`
|
||||
|
||||
### Task 2: 생성기 — 정적 맵 제거 + GenerateMap(Lua) + 층 시스템
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`, Delete `data/map.json`
|
||||
|
||||
- [ ] **Step 1**: `MAP` 로드(16행)·`MAX_ROW`(26행)·`luaMapNodesTable`·`luaStartArray` 제거. `StartRun`의 `${luaMapNodesTable(...)}`/`${luaStartArray(...)}` → `self:GenerateMap()` 호출로 교체. `data/map.json` 삭제 (`git rm`)
|
||||
- [ ] **Step 2**: props 추가 — `prop('number', 'Depth', '0')`, `prop('any', 'VisitedNodes')`
|
||||
- [ ] **Step 3**: `GenerateMap` 메서드 신설 (설계 알고리즘의 Lua 구현 — MapNodes/MapStart/VisitedNodes/Depth 리셋, 경로 4개, 행 3~7 가중 타입 배정+elite 부모 금지, boss 노드)
|
||||
- [ ] **Step 4**: `PickNode` — `VisitedNodes` 추가·`Depth = node.row`·`RenderRun()`·`treasure → ShowTreasure` 분기·`self.CurrentEnemyId = node.enemy` → `""`
|
||||
- [ ] **Step 5**: `RenderRun`의 Floor 텍스트 → `"막 F/3 · D층"` (`self.Depth`)
|
||||
- [ ] **Step 6**: `CheckCombatEnd` 보스 클리어 분기에 `self:GenerateMap()` 추가 (Floor++ 후, TeleportToActMap 전)
|
||||
- [ ] **Step 7**: `BindButtons`의 `mapNodeIds` 정적 배열 → 그리드 28개+boss 루프 생성으로 교체
|
||||
- [ ] **Step 8**: 커밋 `feat(rogue-map): GenerateMap 런타임 절차 생성 + 층 시스템 (생성기)`
|
||||
|
||||
### Task 3: 생성기 — MapHud 그리드·점선 UI + RenderMap 재작성
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs` (MapHud 섹션 ~1449행, RenderMap ~3492행)
|
||||
|
||||
- [ ] **Step 1**: MapHud 섹션 재작성 — 기존 `MAP.nodes` 루프 삭제, 정적 생성:
|
||||
- `Node_r{r}c{c}` (r=1..7, c=1..4): 56×56 uisprite+button, pos x=-270+(c-1)*180, y=-330+(r-1)*105, 기본 enable=false, `Label` 자식(타입명, fontSize 16)
|
||||
- `Node_boss`: 72×72, pos (0, 405), `Label` "보스"
|
||||
- 도트: r=1..6, c=1..4, c'∈{c-1,c,c+1}∩[1,4] → `Dot_r{r}c{c}_{c'}_{k}` k=1..3 (8×8 uisprite, 노드 중심 보간 t=k/4, enable=false) + r=7 → `Dot_r7c{c}_b_{k}` (boss로)
|
||||
- guid('map') 인덱스는 결정적 루프 순서로 재배정 (섹션 전체 교체라 충돌 없음)
|
||||
- [ ] **Step 2**: `RenderMap` 재작성 — 타입색 헬퍼(전투/엘리트/상점/휴식/보물/보스), 상태 4단(현재=골드·방문=어둡게·도달가능=타입색+버튼 활성·잠김=45% 어둡게+비활성), 도트 토글(간선 존재)·현재 노드 발신 간선 골드
|
||||
- [ ] **Step 3**: `node tools/deck/gen-slaydeck.mjs` 성공 확인 → 커밋 `feat(rogue-map): 맵 그리드·점선 도트 UI + RenderMap 상태 4단 (생성기)`
|
||||
|
||||
### Task 4: 상자 RUID 선별 + TreasureHud + 메소 표기
|
||||
|
||||
**Files:** Modify `tools/deck/gen-slaydeck.mjs`
|
||||
|
||||
- [ ] **Step 1**: `asset_search_resources`("보물상자"/"상자", source=maplestory) → 메이커 격자 미리보기(기존 패턴) → 닫힘/열림 RUID 2종 확정. 생성기 상수 `CHEST_CLOSED_RUID`/`CHEST_OPEN_RUID`
|
||||
- [ ] **Step 2**: guid 맵에 `'trs': 0xe3` 추가, TreasureHud 섹션 신설 — root(hidden 패널)·Title("보물 상자")·`Chest`(160×160 uisprite+button, 닫힘 RUID, y=40)·`Reward` 텍스트(hidden, y=-120)·`Leave` 버튼(y=-260). `emit('TreasureHud', ...)`
|
||||
- [ ] **Step 3**: `HideGameHud`에 TreasureHud 추가, `ShowState`에 `elseif state == "treasure"` 분기
|
||||
- [ ] **Step 4**: 메서드 — `ShowTreasure`(ChestOpened 리셋·닫힘 RUID·Reward 숨김·ShowState), `OpenChest`(1회 가드 → 흔들림 타이머 체인 ±8px 0.08s×6 → 0.55s 후 열림 RUID + 메소 40+random(0..20) + `PickNewRelic` 유물/소진 시 메소+30 + Reward 표시), prop `ChestOpened`
|
||||
- [ ] **Step 5**: `BindButtons` — Chest 클릭→`OpenChest`, TreasureHud/Leave→`LeaveNode`
|
||||
- [ ] **Step 6**: 메소 표기 — 표시 문자열 전수 교체: TopBar/ShopHud "골드 N"→"메소 N", 가격 "N 골드"→"N 메소", PickNewRelic 토스트 "골드 +25"→"메소 +25" (내부 prop Gold 유지)
|
||||
- [ ] **Step 7**: 커밋 `feat(rogue-map): 유물 방 상자 연출·TreasureHud·메소 표기 (생성기)`
|
||||
|
||||
### Task 5: 재생성·검증·푸시·PR·머지
|
||||
|
||||
- [ ] **Step 1**: `node tools/deck/gen-slaydeck.mjs` + `node --test tools/map/rogue-map.test.mjs tools/balance/sim-balance.test.mjs` 전체 PASS
|
||||
- [ ] **Step 2**: 커밋 `feat(rogue-map): 산출물 재생성` → 메이커 refresh → 빌드 0에러 → 플레이테스트: 맵 생성(점선·상태색)·노드 진행(층 증가)·유물 방(흔들림→열림→보상)·보스 → 다음 막 새 맵, 스크린샷 확보
|
||||
- [ ] **Step 3**: push → Gitea API PR(종합 메시지) → 머지 → main pull → 메모리 갱신
|
||||
|
||||
## Self-Review 결과
|
||||
|
||||
- 설계 전 항목 매핑: 절차 생성(T1/T2)·층 시스템(T2)·점선 UI+상태 4단(T3)·유물 방+상자 모션(T4)·메소(T4) ✓
|
||||
- 이름 일관성: `GenerateMap`/`Depth`/`VisitedNodes`/`ShowTreasure`/`OpenChest`/`ChestOpened`/`Dot_<fid>_<c'>_<k>` 통일 ✓
|
||||
- 리스크: MapHud 섹션 전체 교체로 guid('map') 재배정 — 섹션 단위 emit이라 안전. RenderMap pairs 순회 제거(그리드 고정 루프) ✓
|
||||
92
docs/superpowers/specs/2026-06-12-rogue-map-design.md
Normal file
92
docs/superpowers/specs/2026-06-12-rogue-map-design.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# P8 — 로그라이크 절차 생성 맵·층 시스템·유물 방 설계
|
||||
|
||||
날짜: 2026-06-12 (사용자 승인 완료)
|
||||
브랜치: `feature/p8-rogue-map`
|
||||
선행: P7 (유물 19종 — 유물 방이 `PickNewRelic` 재사용)
|
||||
|
||||
## 범위
|
||||
|
||||
1. **절차 생성 맵** — 막 시작마다 8층×최대 4열 DAG를 Lua 런타임 생성 (런·막마다 다른 맵). `data/map.json` 정적 맵 제거
|
||||
2. **층(depth) 시스템** — 노드를 지날 때마다 층 +1 (층 = 행), 층별 노드 타입 등장 규칙
|
||||
3. **유물 방(보물 노드)** — 상자 열리는 모션 + 유물(`PickNewRelic`)·메소 획득, 노드 타입 `treasure` 추가
|
||||
4. **맵 UI 정비** — 노드 연결 점선(도트 3개 보간), 타입별 아이콘 색/라벨, 상태 4단(방문/현재/도달 가능/잠김)
|
||||
5. **메소 표기** — 화폐 표시 텍스트 "골드" → "메소" (메이플 IP, 표시만 — 내부 변수 Gold 유지)
|
||||
|
||||
비범위: 이벤트 노드(?), 경로 교차 방지(시각 교차 허용 — 점선이라 식별 가능), 저장.
|
||||
|
||||
## 절차 생성 알고리즘 (StS 방식)
|
||||
|
||||
그리드: 행 1~7 × 열 1~4 + 8행 보스 단일 노드. 노드 id = `"r{row}c{col}"`, 보스 = `"boss"`.
|
||||
|
||||
1. 시작 열: {1,2,3,4} 셔플 후 앞 2개 = 경로 1·2의 시작(서로 다름 보장), 경로 3·4는 랜덤
|
||||
2. 경로 4개를 각각 1행→7행으로 걸어 올림: 다음 열 = `clamp(열 + random(-1..1), 1, 4)`. 지나는 칸마다 노드 생성(중복은 병합), `(r,c) → (r+1,c')` 간선 추가(중복 간선 병합)
|
||||
3. 7행의 모든 노드 → 보스 간선
|
||||
4. `MapStart` = 1행에 생성된 노드들
|
||||
|
||||
### 타입 배정 (행 오름차순)
|
||||
|
||||
| 행 | 규칙 |
|
||||
|---|---|
|
||||
| 1~2행 | combat 고정 |
|
||||
| 3행 | combat 45 / shop 12 / rest 12 (가중 추첨, 합계로 정규화) |
|
||||
| 4~6행 | combat 45 / elite 16 / shop 12 / rest 12 / treasure 15 |
|
||||
| 7행 (보스 직전) | rest 50 / combat 25 / shop 10 / elite 8 / treasure 7 |
|
||||
| 8행 | boss 고정 |
|
||||
|
||||
추가 제약: **부모(간선으로 들어오는 이전 행 노드) 중 elite가 있으면 해당 노드는 elite 금지** (연속 엘리트 방지).
|
||||
|
||||
### 층 카운터
|
||||
|
||||
`Depth` prop: `PickNode` 시 `Depth = node.row`. 막 전환·런 시작 시 0. TopBar Floor 텍스트를 `막 F/3 · D층`으로 확장.
|
||||
|
||||
### 노드 enemy 필드 제거
|
||||
|
||||
몬스터는 P4 이후 `node.type` 그룹 필터(`BuildMonsters`)로 결정되므로 `enemy` 필드·`CurrentEnemyId` 대입은 제거(`CurrentEnemyId = ""` 유지). `data/map.json`·`luaMapNodesTable`·`luaStartArray`·`MAX_ROW` 제거.
|
||||
|
||||
## 유물 방 (TreasureHud)
|
||||
|
||||
- `PickNode` 분기에 `treasure` → `ShowTreasure` 추가 (`ShowState("treasure")`·`HideGameHud` 등록)
|
||||
- UI: 어두운 패널 + 타이틀("보물 상자") + 중앙 상자 버튼(닫힌 상자 RUID) + 보상 텍스트(초기 숨김) + 나가기 버튼
|
||||
- `OpenChest`: 1회 가드(`ChestOpened`) → **흔들림 모션**(anchoredPosition ±8px, 0.08s × 6스텝 타이머 체인) → **열린 상자 RUID로 교체** → 보상 지급·표시
|
||||
- 메소: `40 + random(0..20)`
|
||||
- 유물: `PickNewRelic()` — 미보유 추첨, 전부 보유 시 메소 +30 대체
|
||||
- 보상 텍스트 예: `유물 획득: 황소 투구 · 메소 +52`
|
||||
- 상자 닫힘/열림 스프라이트는 공식 maplestory 리소스 검색("상자"/"보물상자") 후 메이커 선별
|
||||
- 나가기 → `LeaveNode`(기존 → ShowMap)
|
||||
|
||||
## 맵 UI
|
||||
|
||||
### 정적 그리드 (생성기, MapHud 섹션 재작성)
|
||||
|
||||
- 노드 버튼 28개(`Node_r{1..7}c{1..4}`, 56×56) + `Node_boss`(72×72, 상단 중앙) + 각 노드 `Label`(타입 한글)
|
||||
- 배치: 행 y = `-330 + (row-1)*105` (보스 y=405), 열 x = `-270 + (col-1)*180`, 보스 x=0
|
||||
- 점선 도트: 모든 가능 간선(행 1~6: `c→c±1/c`, 행 7→보스)에 대해 도트 3개(8×8, t=0.25/0.5/0.75 보간 위치). 엔티티 `Dot_r{r}c{c}_{c'}_{k}` (보스행은 `Dot_r7c{c}_b_{k}`)
|
||||
- 모든 노드·도트는 기본 비활성, `RenderMap`이 토글
|
||||
|
||||
### RenderMap 재작성 (상태 4단 + 점선)
|
||||
|
||||
- 노드: 맵에 없으면 숨김. 있으면 Label = 타입명, 색:
|
||||
- 방문(`VisitedNodes`에 포함) → 어둡게 `(0.18,0.19,0.22)`
|
||||
- 현재 위치 → 골드 `(0.95,0.8,0.3)`
|
||||
- 도달 가능(IsReachable) → 타입색 밝게 + 버튼 활성
|
||||
- 잠김 → 타입색 45% 어둡게 + 버튼 비활성
|
||||
- 타입색: 전투 `(0.78,0.36,0.32)` / 엘리트 `(0.62,0.4,0.85)` / 상점 `(0.9,0.75,0.35)` / 휴식 `(0.4,0.75,0.45)` / 보물 `(0.35,0.7,0.75)` / 보스 `(0.85,0.25,0.25)`
|
||||
- 도트: 간선 존재 시 표시. 현재 노드(또는 시작 전 1행 진입선)에서 나가는 간선 = 골드, 그 외 = 회색 `(0.5,0.5,0.55)`
|
||||
- `VisitedNodes`(any prop): PickNode 시 추가
|
||||
|
||||
### 메소 표기
|
||||
|
||||
표시 문자열 "골드" → "메소": TopBar Gold·ShopHud Gold·상점 가격(`N 메소`)·유물 소진 토스트. 내부 prop `Gold` 유지.
|
||||
|
||||
## 검증
|
||||
|
||||
1. **JS 미러 + 단위 테스트**: `tools/map/rogue-map.mjs`에 `generateMap(rng)` 동일 로직(시드 PRNG 주입) + `rogue-map.test.mjs` — 모든 노드가 시작점에서 도달 가능·보스 수렴·1~2행 combat만·elite/treasure 4행부터·간선 인접 열만·elite 부모 연속 금지·결정성(동일 시드 동일 맵). ⚠️ Lua `GenerateMap`과 로직 동기화 유지(sim-balance 패턴)
|
||||
2. 기존 `sim-balance` 21건 유지 (맵과 무관)
|
||||
3. 메이커: 빌드 0에러, 플레이테스트 — 맵 생성/점선/상태색, 노드 진행(층 증가), 유물 방 상자 연출·보상, 보스 클리어 → 다음 막 새 맵
|
||||
|
||||
## 결정 사항
|
||||
|
||||
- 경로 4개·8층×4열 (사용자 승인 규모)
|
||||
- 점선 도트 방식 채택 (UI 회전 리스크 회피, StS 원작 미감)
|
||||
- 시각적 간선 교차는 허용 (점선이라 추적 가능 — YAGNI)
|
||||
- `RUN_LENGTH`/`ACT_MAPS` 막 시스템은 변경 없음 (막마다 새 맵 생성만 추가)
|
||||
@@ -13,17 +13,13 @@ if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
|
||||
throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
|
||||
}
|
||||
|
||||
const MAP = JSON.parse(readFileSync('data/map.json', 'utf8'));
|
||||
for (const id of MAP.start) {
|
||||
if (!MAP.nodes[id]) throw new Error(`[gen-slaydeck] map.start에 없는 노드 id: ${id}`);
|
||||
}
|
||||
for (const [id, n] of Object.entries(MAP.nodes)) {
|
||||
if (n.enemy && !ENEMIES.enemies[n.enemy]) throw new Error(`[gen-slaydeck] 노드 ${id}의 enemy 없음: ${n.enemy}`);
|
||||
for (const nx of n.next) {
|
||||
if (!MAP.nodes[nx]) throw new Error(`[gen-slaydeck] 노드 ${id}.next에 없는 노드 id: ${nx}`);
|
||||
}
|
||||
}
|
||||
const MAX_ROW = Math.max(...Object.values(MAP.nodes).map((n) => n.row));
|
||||
// 맵은 런타임 절차 생성(GenerateMap Lua ↔ tools/map/rogue-map.mjs 미러). 정적 data/map.json 제거됨.
|
||||
const MAP_ROWS = 7; // 걷는 행 1..7, 보스 row 8
|
||||
const MAP_COLS = 4;
|
||||
|
||||
// 보물 상자 스프라이트 (공식 maplestory 리소스, 메이커 선별)
|
||||
const CHEST_CLOSED_RUID = '43df67920c0d43298e0d93c02c6afa71';
|
||||
const CHEST_OPEN_RUID = '09c5cee56fd640bf8ae3a18ce50f4759';
|
||||
|
||||
const RELICS = JSON.parse(readFileSync('data/relics.json', 'utf8'));
|
||||
if (!RELICS.relics[RELICS.startingRelic]) throw new Error(`[gen-slaydeck] startingRelic 없음: ${RELICS.startingRelic}`);
|
||||
@@ -58,18 +54,6 @@ function luaEnemiesTable(enemies) {
|
||||
`\t${id} = { name = ${luaStr(e.name)}, maxHp = ${e.maxHp}, intents = ${luaIntentsArray(e.intents)} },`);
|
||||
return `self.Enemies = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
function luaMapNodesTable(nodes) {
|
||||
const lines = Object.entries(nodes).map(([id, n]) => {
|
||||
const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }';
|
||||
const enemyField = n.enemy ? `enemy = ${luaStr(n.enemy)}, ` : '';
|
||||
return `\t${id} = { type = ${luaStr(n.type)}, ${enemyField}row = ${n.row}, col = ${n.col}, next = ${nx} },`;
|
||||
});
|
||||
return `self.MapNodes = {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
function luaStartArray(start) {
|
||||
return 'self.MapStart = { ' + start.map(luaStr).join(', ') + ' }';
|
||||
}
|
||||
|
||||
// Lua 직렬화 헬퍼
|
||||
function luaStr(s) {
|
||||
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
|
||||
@@ -105,6 +89,7 @@ const GENERATED_UI_SECTIONS = [
|
||||
'MapHud',
|
||||
'ShopHud',
|
||||
'RestHud',
|
||||
'TreasureHud',
|
||||
'MainMenu',
|
||||
'CharacterSelectHud',
|
||||
];
|
||||
@@ -115,6 +100,7 @@ const UI_APPEND_ORDER = [
|
||||
'MapHud',
|
||||
'ShopHud',
|
||||
'RestHud',
|
||||
'TreasureHud',
|
||||
'DeckInspectHud',
|
||||
'DeckAllHud',
|
||||
'MainMenu',
|
||||
@@ -143,7 +129,7 @@ const ALIGN_BOTTOM_CENTER = 6;
|
||||
|
||||
function guid(prefix, n) {
|
||||
// 유효한 8-4-4-4-12 hex GUID 생성. prefix는 충돌 방지용 네임스페이스 바이트로 매핑.
|
||||
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : prefix === 'menu' ? 0xe0 : prefix === 'ins' ? 0xe1 : prefix === 'all' ? 0xe2 : 0xfe;
|
||||
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : prefix === 'menu' ? 0xe0 : prefix === 'ins' ? 0xe1 : prefix === 'all' ? 0xe2 : prefix === 'trs' ? 0xe3 : 0xfe;
|
||||
const v = (ns * 0x100000 + n) >>> 0;
|
||||
return `${v.toString(16).padStart(8, '0')}-0000-4000-8000-${v.toString(16).padStart(12, '0')}`;
|
||||
}
|
||||
@@ -1165,7 +1151,7 @@ function upsertUi() {
|
||||
}));
|
||||
const topTexts = [
|
||||
['Floor', -520, 160, '막 1/3', GOLD],
|
||||
['Gold', -360, 160, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }],
|
||||
['Gold', -360, 160, '메소 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }],
|
||||
];
|
||||
topTexts.forEach(([suffix, x, w, value, color], ti) => {
|
||||
combat.push(entity({
|
||||
@@ -1474,23 +1460,28 @@ function upsertUi() {
|
||||
text({ value: '다음 노드 선택', fontSize: 40, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
// 절차 생성 맵용 정적 그리드 — 노드 7행×4열 + 보스, 점선 도트. RenderMap이 런타임 토글.
|
||||
const nodeX = (c) => -270 + (c - 1) * 180;
|
||||
const nodeY = (r) => -330 + (r - 1) * 105;
|
||||
const BOSS_POS = { x: 0, y: 405 };
|
||||
let mapN = 2;
|
||||
for (const [id, node] of Object.entries(MAP.nodes)) {
|
||||
const pushMapNode = (id, pos, size, label) => {
|
||||
const nodePath = `/ui/DefaultGroup/MapHud/Node_${id}`;
|
||||
const pos = { x: node.col * 180, y: (node.row - (MAX_ROW + 1) / 2) * 140 };
|
||||
map.push(entity({
|
||||
const nodeEnt = entity({
|
||||
id: guid('map', mapN++),
|
||||
path: nodePath,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: node.row,
|
||||
displayOrder: 5,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 150, y: 80 }, pos }),
|
||||
sprite({ color: { r: 0.3, g: 0.55, b: 0.85, a: 1 }, type: 1, raycast: true }),
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }),
|
||||
sprite({ color: { r: 0.2, g: 0.22, b: 0.26, a: 1 }, type: 1, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
});
|
||||
nodeEnt.jsonString.enable = false;
|
||||
map.push(nodeEnt);
|
||||
map.push(entity({
|
||||
id: guid('map', mapN++),
|
||||
path: `${nodePath}/Label`,
|
||||
@@ -1499,11 +1490,47 @@ function upsertUi() {
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 150, parentH: 80, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 144, y: 72 }, pos: { x: 0, y: 0 } }),
|
||||
transform({ parentW: size.x, parentH: size.y, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: size.x + 20, y: 30 }, pos: { x: 0, y: 0 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: node.enemy ? `${TYPE_KO[node.type]}\n${ENEMIES.enemies[node.enemy].name}` : TYPE_KO[node.type], fontSize: 20, bold: true }),
|
||||
text({ value: label, fontSize: id === 'boss' ? 18 : 15, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
};
|
||||
for (let r = 1; r <= MAP_ROWS; r++) {
|
||||
for (let c = 1; c <= MAP_COLS; c++) {
|
||||
pushMapNode(`r${r}c${c}`, { x: nodeX(c), y: nodeY(r) }, { x: 56, y: 56 }, '');
|
||||
}
|
||||
}
|
||||
pushMapNode('boss', BOSS_POS, { x: 72, y: 72 }, '보스');
|
||||
const pushDots = (dotId, from, to) => {
|
||||
for (let k = 1; k <= 3; k++) {
|
||||
const t = k / 4;
|
||||
const dot = entity({
|
||||
id: guid('map', mapN++),
|
||||
path: `/ui/DefaultGroup/MapHud/Dot_${dotId}_${k}`,
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 8, y: 8 }, pos: { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t } }),
|
||||
sprite({ color: { r: 0.5, g: 0.5, b: 0.55, a: 0.8 }, type: 1 }),
|
||||
],
|
||||
});
|
||||
dot.jsonString.enable = false;
|
||||
map.push(dot);
|
||||
}
|
||||
};
|
||||
for (let r = 1; r < MAP_ROWS; r++) {
|
||||
for (let c = 1; c <= MAP_COLS; c++) {
|
||||
for (let c2 = c - 1; c2 <= c + 1; c2++) {
|
||||
if (c2 < 1 || c2 > MAP_COLS) continue;
|
||||
pushDots(`r${r}c${c}_${c2}`, { x: nodeX(c), y: nodeY(r) }, { x: nodeX(c2), y: nodeY(r + 1) });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let c = 1; c <= MAP_COLS; c++) {
|
||||
pushDots(`r${MAP_ROWS}c${c}_b`, { x: nodeX(c), y: nodeY(MAP_ROWS) }, BOSS_POS);
|
||||
}
|
||||
emit('MapHud', map);
|
||||
|
||||
@@ -1545,7 +1572,7 @@ function upsertUi() {
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 44 }, pos: { x: 0, y: 330 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '골드 0', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
text({ value: '메소 0', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
let shpN = 3;
|
||||
@@ -1569,7 +1596,7 @@ function upsertUi() {
|
||||
['Cost', { size: { x: 44, y: 44 }, pos: { x: -68, y: 103 }, value: '1', fontSize: 26, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }],
|
||||
['Name', { size: { x: 168, y: 30 }, pos: { x: 0, y: -8 }, value: '카드', fontSize: 20, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }],
|
||||
['Desc', { size: { x: 164, y: 56 }, pos: { x: 0, y: -58 }, value: '', fontSize: 18, bold: false, color: { r: 1, g: 1, b: 1, a: 1 } }],
|
||||
['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -105 }, value: '30 골드', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }],
|
||||
['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -105 }, value: '30 메소', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }],
|
||||
]) {
|
||||
const dOrder = suffix === 'Cost' ? 7 : suffix === 'Name' ? 6 : suffix === 'Desc' ? 8 : 9;
|
||||
shop.push(entity({
|
||||
@@ -1641,7 +1668,7 @@ function upsertUi() {
|
||||
components: [
|
||||
transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 30 }, pos: { x: 0, y: -22 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '60 골드', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
text({ value: '60 메소', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
@@ -1680,7 +1707,7 @@ function upsertUi() {
|
||||
components: [
|
||||
transform({ parentW: 560, parentH: 76, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 540, y: 30 }, pos: { x: 0, y: -22 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '20 골드', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
text({ value: '20 메소', fontSize: 20, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
shop.push(entity({
|
||||
@@ -1756,6 +1783,92 @@ function upsertUi() {
|
||||
}));
|
||||
emit('RestHud', rest);
|
||||
|
||||
// 유물 방 — 보물 상자 (P8)
|
||||
const treasure = [];
|
||||
const treasureHud = entity({
|
||||
id: guid('trs', 0),
|
||||
path: '/ui/DefaultGroup/TreasureHud',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
|
||||
displayOrder: 8,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }),
|
||||
sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }),
|
||||
],
|
||||
});
|
||||
treasureHud.jsonString.enable = false;
|
||||
treasure.push(treasureHud);
|
||||
treasure.push(entity({
|
||||
id: guid('trs', 1),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Title',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 0,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 320 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '보물 상자', fontSize: 40, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
treasure.push(entity({
|
||||
id: guid('trs', 2),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Chest',
|
||||
modelId: 'uisprite',
|
||||
entryId: 'UISprite',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
|
||||
displayOrder: 1,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 180, y: 180 }, pos: { x: 0, y: 40 } }),
|
||||
sprite({ dataId: CHEST_CLOSED_RUID, color: { r: 1, g: 1, b: 1, a: 1 }, type: 0, raycast: true }),
|
||||
button(),
|
||||
],
|
||||
}));
|
||||
treasure.push(entity({
|
||||
id: guid('trs', 3),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Hint',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 2,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 500, y: 34 }, pos: { x: 0, y: -90 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '상자를 클릭해 여세요', fontSize: 20, bold: false, color: { r: 0.85, g: 0.85, b: 0.9, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
const treasureReward = entity({
|
||||
id: guid('trs', 4),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Reward',
|
||||
modelId: 'uitext',
|
||||
entryId: 'UIText',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 3,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 800, y: 44 }, pos: { x: 0, y: -160 } }),
|
||||
sprite({ color: TRANSPARENT }),
|
||||
text({ value: '', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }),
|
||||
],
|
||||
});
|
||||
treasureReward.jsonString.enable = false;
|
||||
treasure.push(treasureReward);
|
||||
treasure.push(entity({
|
||||
id: guid('trs', 5),
|
||||
path: '/ui/DefaultGroup/TreasureHud/Leave',
|
||||
modelId: 'uibutton',
|
||||
entryId: 'UIButton',
|
||||
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
|
||||
displayOrder: 4,
|
||||
components: [
|
||||
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -280 } }),
|
||||
sprite({ color: DARK, type: 1, raycast: true }),
|
||||
button(),
|
||||
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
|
||||
],
|
||||
}));
|
||||
emit('TreasureHud', treasure);
|
||||
|
||||
const menu = [];
|
||||
menu.push(entity({
|
||||
id: guid('menu', 0),
|
||||
@@ -2121,6 +2234,9 @@ function writeCodeblocks() {
|
||||
prop('boolean', 'FirstHpLossDone', 'false'),
|
||||
prop('number', 'ClayBlockNext', '0'),
|
||||
prop('number', 'PotionMenuSlot', '0'),
|
||||
prop('number', 'Depth', '0'),
|
||||
prop('any', 'VisitedNodes'),
|
||||
prop('boolean', 'ChestOpened', 'false'),
|
||||
], [
|
||||
method('OnBeginPlay', `self:ShowMainMenu()`),
|
||||
method('HideGameHud', `self:SetEntityEnabled("/ui/DefaultGroup/Button_Attack", false)
|
||||
@@ -2133,6 +2249,7 @@ self:SetEntityEnabled("/ui/DefaultGroup/RewardHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/MapHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/ShopHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/RestHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/TreasureHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/DeckInspectHud", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/DeckAllHud", false)`),
|
||||
method('ShowState', `self:HideGameHud()
|
||||
@@ -2148,6 +2265,8 @@ elseif state == "shop" then
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/ShopHud", true)
|
||||
elseif state == "rest" then
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/RestHud", true)
|
||||
elseif state == "treasure" then
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/TreasureHud", true)
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'state' }]),
|
||||
method('ShowMainMenu', `self.SelectedClass = ""
|
||||
self:ShowState("menu")
|
||||
@@ -2225,10 +2344,9 @@ ${luaPotionsTable(POTIONS.potions)}
|
||||
${luaRelicsTable(RELICS.relics)}
|
||||
self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
|
||||
${luaEnemiesTable(ENEMIES.enemies)}
|
||||
${luaMapNodesTable(MAP.nodes)}
|
||||
${luaStartArray(MAP.start)}
|
||||
self.CurrentNodeId = ""
|
||||
self.CurrentEnemyId = ""
|
||||
self:GenerateMap()
|
||||
self:BindButtons()
|
||||
self:AddRelic("${RELICS.startingRelic}")
|
||||
self:RenderPotions()
|
||||
@@ -2400,7 +2518,13 @@ local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip")
|
||||
if skip ~= nil and skip.ButtonComponent ~= nil then
|
||||
skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end)
|
||||
end
|
||||
local mapNodeIds = { ${Object.keys(MAP.nodes).map(luaStr).join(', ')} }
|
||||
local mapNodeIds = {}
|
||||
for r = 1, ${MAP_ROWS} do
|
||||
for c = 1, ${MAP_COLS} do
|
||||
table.insert(mapNodeIds, "r" .. tostring(r) .. "c" .. tostring(c))
|
||||
end
|
||||
end
|
||||
table.insert(mapNodeIds, "boss")
|
||||
for i = 1, #mapNodeIds do
|
||||
local nid = mapNodeIds[i]
|
||||
local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid)
|
||||
@@ -2476,6 +2600,14 @@ end
|
||||
local shopPotion = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Potion")
|
||||
if shopPotion ~= nil and shopPotion.ButtonComponent ~= nil then
|
||||
shopPotion:ConnectEvent(ButtonClickEvent, function() self:BuyPotion() end)
|
||||
end
|
||||
local chest = _EntityService:GetEntityByPath("/ui/DefaultGroup/TreasureHud/Chest")
|
||||
if chest ~= nil and chest.ButtonComponent ~= nil then
|
||||
chest:ConnectEvent(ButtonClickEvent, function() self:OpenChest() end)
|
||||
end
|
||||
local treasureLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/TreasureHud/Leave")
|
||||
if treasureLeave ~= nil and treasureLeave.ButtonComponent ~= nil then
|
||||
treasureLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
|
||||
end`),
|
||||
method('StartPlayerTurn', `self.Turn = self.Turn + 1
|
||||
self.Energy = self.MaxEnergy
|
||||
@@ -3067,6 +3199,7 @@ if anyAlive == false then
|
||||
self.Floor = self.Floor + 1
|
||||
self.CurrentNodeId = ""
|
||||
self.CurrentEnemyId = ""
|
||||
self:GenerateMap()
|
||||
self:RenderRun()
|
||||
self:TeleportToActMap()
|
||||
self:ShowMap()
|
||||
@@ -3215,8 +3348,8 @@ end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], N
|
||||
self.TargetIndex = slot
|
||||
self:RenderCombat()
|
||||
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
|
||||
method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Floor", "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength))
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Gold", "골드 " .. string.format("%d", self.Gold))`),
|
||||
method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Floor", "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층")
|
||||
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
|
||||
method('OfferReward', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false)
|
||||
local pool = {}
|
||||
@@ -3314,7 +3447,7 @@ for i = 1, #self.RelicPool do
|
||||
end
|
||||
if #pool == 0 then
|
||||
self.Gold = self.Gold + 25
|
||||
self:Toast("유물을 모두 모았습니다! 골드 +25")
|
||||
self:Toast("유물을 모두 모았습니다! 메소 +25")
|
||||
return ""
|
||||
end
|
||||
return pool[math.random(1, #pool)]`, [], 0, 'string'),
|
||||
@@ -3473,6 +3606,98 @@ end`, [
|
||||
method('HideTooltip', `self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/TooltipBox", false)`),
|
||||
method('ShowMap', `self:ShowState("map")
|
||||
self:RenderMap()`),
|
||||
method('GenerateMap', `-- 절차 생성 — tools/map/rogue-map.mjs(JS 미러)와 로직 동기화 유지
|
||||
self.MapNodes = {}
|
||||
self.MapStart = {}
|
||||
self.VisitedNodes = {}
|
||||
self.Depth = 0
|
||||
self.MapNodes["boss"] = { type = "boss", row = ${MAP_ROWS} + 1, col = 0, next = {} }
|
||||
local cols = { 1, 2, 3, 4 }
|
||||
for i = #cols, 2, -1 do
|
||||
local j = math.random(1, i)
|
||||
cols[i], cols[j] = cols[j], cols[i]
|
||||
end
|
||||
local starts = { cols[1], cols[2], math.random(1, ${MAP_COLS}), math.random(1, ${MAP_COLS}) }
|
||||
for p = 1, 4 do
|
||||
local c = starts[p]
|
||||
local sid = "r1c" .. tostring(c)
|
||||
if self.MapNodes[sid] == nil then
|
||||
self.MapNodes[sid] = { type = "combat", row = 1, col = c, next = {} }
|
||||
end
|
||||
local found = false
|
||||
for i = 1, #self.MapStart do
|
||||
if self.MapStart[i] == sid then found = true end
|
||||
end
|
||||
if found == false then
|
||||
table.insert(self.MapStart, sid)
|
||||
end
|
||||
for r = 1, ${MAP_ROWS} - 1 do
|
||||
local nc = c + math.random(-1, 1)
|
||||
if nc < 1 then nc = 1 end
|
||||
if nc > ${MAP_COLS} then nc = ${MAP_COLS} end
|
||||
local nid = "r" .. tostring(r + 1) .. "c" .. tostring(nc)
|
||||
if self.MapNodes[nid] == nil then
|
||||
self.MapNodes[nid] = { type = "combat", row = r + 1, col = nc, next = {} }
|
||||
end
|
||||
local fid = "r" .. tostring(r) .. "c" .. tostring(c)
|
||||
local dup = false
|
||||
for i = 1, #self.MapNodes[fid].next do
|
||||
if self.MapNodes[fid].next[i] == nid then dup = true end
|
||||
end
|
||||
if dup == false then
|
||||
table.insert(self.MapNodes[fid].next, nid)
|
||||
end
|
||||
c = nc
|
||||
end
|
||||
local lid = "r" .. tostring(${MAP_ROWS}) .. "c" .. tostring(c)
|
||||
local bdup = false
|
||||
for i = 1, #self.MapNodes[lid].next do
|
||||
if self.MapNodes[lid].next[i] == "boss" then bdup = true end
|
||||
end
|
||||
if bdup == false then
|
||||
table.insert(self.MapNodes[lid].next, "boss")
|
||||
end
|
||||
end
|
||||
for r = 3, ${MAP_ROWS} do
|
||||
for c = 1, ${MAP_COLS} do
|
||||
local id = "r" .. tostring(r) .. "c" .. tostring(c)
|
||||
local node = self.MapNodes[id]
|
||||
if node ~= nil then
|
||||
local eliteParent = false
|
||||
for pid, pn in pairs(self.MapNodes) do
|
||||
if pn.row == r - 1 and pn.type == "elite" then
|
||||
for i = 1, #pn.next do
|
||||
if pn.next[i] == id then eliteParent = true end
|
||||
end
|
||||
end
|
||||
end
|
||||
local w
|
||||
if r == ${MAP_ROWS} then
|
||||
w = { { "rest", 50 }, { "combat", 25 }, { "shop", 10 }, { "elite", 8 }, { "treasure", 7 } }
|
||||
elseif r >= 4 then
|
||||
w = { { "combat", 45 }, { "elite", 16 }, { "shop", 12 }, { "rest", 12 }, { "treasure", 15 } }
|
||||
else
|
||||
w = { { "combat", 45 }, { "shop", 12 }, { "rest", 12 } }
|
||||
end
|
||||
local total = 0
|
||||
for i = 1, #w do
|
||||
if w[i][1] == "elite" and eliteParent == true then
|
||||
w[i][2] = 0
|
||||
end
|
||||
total = total + w[i][2]
|
||||
end
|
||||
local roll = math.random() * total
|
||||
local acc = 0
|
||||
for i = 1, #w do
|
||||
acc = acc + w[i][2]
|
||||
if roll <= acc then
|
||||
node.type = w[i][1]
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end`),
|
||||
method('IsReachable', `local list
|
||||
if self.CurrentNodeId == "" then
|
||||
list = self.MapStart
|
||||
@@ -3489,22 +3714,114 @@ for i = 1, #list do
|
||||
end
|
||||
end
|
||||
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'),
|
||||
method('RenderMap', `for id, node in pairs(self.MapNodes) do
|
||||
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. id)
|
||||
if e ~= nil then
|
||||
local reachable = self:IsReachable(id)
|
||||
if e.SpriteGUIRendererComponent ~= nil then
|
||||
if reachable then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.3, 0.55, 0.85, 1)
|
||||
method('RenderMapNode', `local base = "/ui/DefaultGroup/MapHud/Node_" .. id
|
||||
local e = _EntityService:GetEntityByPath(base)
|
||||
if e == nil then
|
||||
return
|
||||
end
|
||||
local node = self.MapNodes[id]
|
||||
if node == nil then
|
||||
e.Enable = false
|
||||
return
|
||||
end
|
||||
e.Enable = true
|
||||
local tname = "전투"
|
||||
local r0 = 0.78
|
||||
local g0 = 0.36
|
||||
local b0 = 0.32
|
||||
if node.type == "elite" then
|
||||
tname = "엘리트"
|
||||
r0 = 0.62
|
||||
g0 = 0.4
|
||||
b0 = 0.85
|
||||
elseif node.type == "shop" then
|
||||
tname = "상점"
|
||||
r0 = 0.9
|
||||
g0 = 0.75
|
||||
b0 = 0.35
|
||||
elseif node.type == "rest" then
|
||||
tname = "휴식"
|
||||
r0 = 0.4
|
||||
g0 = 0.75
|
||||
b0 = 0.45
|
||||
elseif node.type == "treasure" then
|
||||
tname = "보물"
|
||||
r0 = 0.35
|
||||
g0 = 0.7
|
||||
b0 = 0.75
|
||||
elseif node.type == "boss" then
|
||||
tname = "보스"
|
||||
r0 = 0.85
|
||||
g0 = 0.25
|
||||
b0 = 0.25
|
||||
end
|
||||
self:SetText(base .. "/Label", tname)
|
||||
local reachable = self:IsReachable(id)
|
||||
local visited = false
|
||||
if self.VisitedNodes ~= nil then
|
||||
for i = 1, #self.VisitedNodes do
|
||||
if self.VisitedNodes[i] == id then visited = true end
|
||||
end
|
||||
end
|
||||
if e.SpriteGUIRendererComponent ~= nil then
|
||||
if id == self.CurrentNodeId then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.95, 0.8, 0.3, 1)
|
||||
elseif visited == true then
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.18, 0.19, 0.22, 0.9)
|
||||
elseif reachable == true then
|
||||
e.SpriteGUIRendererComponent.Color = Color(r0, g0, b0, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(r0 * 0.45, g0 * 0.45, b0 * 0.45, 0.55)
|
||||
end
|
||||
end
|
||||
if e.ButtonComponent ~= nil then
|
||||
e.ButtonComponent.Enable = reachable
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
method('RenderMapDots', `local node = self.MapNodes[fromId]
|
||||
local has = false
|
||||
if node ~= nil then
|
||||
for i = 1, #node.next do
|
||||
if node.next[i] == toId then has = true end
|
||||
end
|
||||
end
|
||||
for k = 1, 3 do
|
||||
local d = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Dot_" .. dotId .. "_" .. tostring(k))
|
||||
if d ~= nil then
|
||||
d.Enable = has
|
||||
if has == true and d.SpriteGUIRendererComponent ~= nil then
|
||||
if fromId == self.CurrentNodeId then
|
||||
d.SpriteGUIRendererComponent.Color = Color(0.95, 0.8, 0.3, 1)
|
||||
else
|
||||
e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
|
||||
d.SpriteGUIRendererComponent.Color = Color(0.5, 0.5, 0.55, 0.8)
|
||||
end
|
||||
end
|
||||
if e.ButtonComponent ~= nil then
|
||||
e.ButtonComponent.Enable = reachable
|
||||
end
|
||||
end`, [
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'dotId' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromId' },
|
||||
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'toId' },
|
||||
]),
|
||||
method('RenderMap', `for r = 1, ${MAP_ROWS} do
|
||||
for c = 1, ${MAP_COLS} do
|
||||
self:RenderMapNode("r" .. tostring(r) .. "c" .. tostring(c))
|
||||
end
|
||||
end
|
||||
self:RenderMapNode("boss")
|
||||
for r = 1, ${MAP_ROWS} - 1 do
|
||||
for c = 1, ${MAP_COLS} do
|
||||
local fid = "r" .. tostring(r) .. "c" .. tostring(c)
|
||||
for c2 = c - 1, c + 1 do
|
||||
if c2 >= 1 and c2 <= ${MAP_COLS} then
|
||||
self:RenderMapDots(fid .. "_" .. tostring(c2), fid, "r" .. tostring(r + 1) .. "c" .. tostring(c2))
|
||||
end
|
||||
end
|
||||
end
|
||||
end`),
|
||||
end
|
||||
for c = 1, ${MAP_COLS} do
|
||||
local fid = "r" .. tostring(${MAP_ROWS}) .. "c" .. tostring(c)
|
||||
self:RenderMapDots(fid .. "_b", fid, "boss")
|
||||
end
|
||||
`),
|
||||
method('PickNode', `if self.RunActive ~= true then
|
||||
return
|
||||
end
|
||||
@@ -3512,17 +3829,25 @@ if self:IsReachable(id) ~= true then
|
||||
return
|
||||
end
|
||||
self.CurrentNodeId = id
|
||||
if self.VisitedNodes == nil then
|
||||
self.VisitedNodes = {}
|
||||
end
|
||||
table.insert(self.VisitedNodes, id)
|
||||
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
|
||||
if hud ~= nil then
|
||||
hud.Enable = false
|
||||
end
|
||||
local node = self.MapNodes[id]
|
||||
self.Depth = node.row
|
||||
self:RenderRun()
|
||||
if node.type == "shop" then
|
||||
self:ShowShop()
|
||||
elseif node.type == "rest" then
|
||||
self:ShowRest()
|
||||
elseif node.type == "treasure" then
|
||||
self:ShowTreasure()
|
||||
else
|
||||
self.CurrentEnemyId = node.enemy
|
||||
self.CurrentEnemyId = ""
|
||||
self:StartCombat()
|
||||
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
|
||||
method('ShowShop', `local pool = {}
|
||||
@@ -3545,14 +3870,14 @@ self.ShopPotion = pkeys[math.random(1, #pkeys)]
|
||||
self.ShopPotionBought = false
|
||||
self:RenderShop()
|
||||
self:ShowState("shop")`),
|
||||
method('RenderShop', `self:SetText("/ui/DefaultGroup/ShopHud/Gold", "골드 " .. string.format("%d", self.Gold))
|
||||
method('RenderShop', `self:SetText("/ui/DefaultGroup/ShopHud/Gold", "메소 " .. string.format("%d", self.Gold))
|
||||
for i = 1, 3 do
|
||||
local cid = self.ShopChoices[i]
|
||||
local c = self.Cards[cid]
|
||||
local base = "/ui/DefaultGroup/ShopHud/Card" .. tostring(i)
|
||||
if c ~= nil then
|
||||
self:ApplyCardFace(base, cid)
|
||||
self:SetText(base .. "/Price", string.format("%d", ${CARD_PRICE}) .. " 골드")
|
||||
self:SetText(base .. "/Price", string.format("%d", ${CARD_PRICE}) .. " 메소")
|
||||
local e = _EntityService:GetEntityByPath(base)
|
||||
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ShopBought[i] == true then
|
||||
@@ -3564,7 +3889,7 @@ end
|
||||
local rr = self.Relics[self.ShopRelic]
|
||||
if rr ~= nil then
|
||||
self:SetText("/ui/DefaultGroup/ShopHud/Relic/Label", rr.name .. " — " .. rr.desc)
|
||||
self:SetText("/ui/DefaultGroup/ShopHud/Relic/Price", string.format("%d", ${RELIC_PRICE}) .. " 골드")
|
||||
self:SetText("/ui/DefaultGroup/ShopHud/Relic/Price", string.format("%d", ${RELIC_PRICE}) .. " 메소")
|
||||
local re = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic")
|
||||
if re ~= nil and re.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ShopRelicBought == true then
|
||||
@@ -3577,7 +3902,7 @@ end
|
||||
local pp = self.Potions[self.ShopPotion]
|
||||
if pp ~= nil then
|
||||
self:SetText("/ui/DefaultGroup/ShopHud/Potion/Label", pp.name .. " — " .. pp.desc)
|
||||
self:SetText("/ui/DefaultGroup/ShopHud/Potion/Price", string.format("%d", ${POTIONS.shopPrice}) .. " 골드")
|
||||
self:SetText("/ui/DefaultGroup/ShopHud/Potion/Price", string.format("%d", ${POTIONS.shopPrice}) .. " 메소")
|
||||
local pe = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Potion")
|
||||
if pe ~= nil and pe.SpriteGUIRendererComponent ~= nil then
|
||||
if self.ShopPotionBought == true then
|
||||
@@ -3642,7 +3967,59 @@ local r = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud")
|
||||
if r ~= nil then
|
||||
r.Enable = false
|
||||
end
|
||||
local t = _EntityService:GetEntityByPath("/ui/DefaultGroup/TreasureHud")
|
||||
if t ~= nil then
|
||||
t.Enable = false
|
||||
end
|
||||
self:ShowMap()`),
|
||||
method('ShowTreasure', `self.ChestOpened = false
|
||||
local chest = _EntityService:GetEntityByPath("/ui/DefaultGroup/TreasureHud/Chest")
|
||||
if chest ~= nil then
|
||||
if chest.SpriteGUIRendererComponent ~= nil then
|
||||
chest.SpriteGUIRendererComponent.ImageRUID = "${CHEST_CLOSED_RUID}"
|
||||
end
|
||||
if chest.UITransformComponent ~= nil then
|
||||
chest.UITransformComponent.anchoredPosition = Vector2(0, 40)
|
||||
end
|
||||
end
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/TreasureHud/Reward", false)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/TreasureHud/Hint", true)
|
||||
self:ShowState("treasure")`),
|
||||
method('OpenChest', `if self.ChestOpened == true then
|
||||
return
|
||||
end
|
||||
self.ChestOpened = true
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/TreasureHud/Hint", false)
|
||||
local chest = _EntityService:GetEntityByPath("/ui/DefaultGroup/TreasureHud/Chest")
|
||||
local steps = { 10, -10, 8, -8, 5, 0 }
|
||||
for i = 1, #steps do
|
||||
local dx = steps[i]
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if chest ~= nil and isvalid(chest) and chest.UITransformComponent ~= nil then
|
||||
chest.UITransformComponent.anchoredPosition = Vector2(dx, 40)
|
||||
end
|
||||
end, 0.08 * i)
|
||||
end
|
||||
_TimerService:SetTimerOnce(function()
|
||||
if chest ~= nil and isvalid(chest) and chest.SpriteGUIRendererComponent ~= nil then
|
||||
chest.SpriteGUIRendererComponent.ImageRUID = "${CHEST_OPEN_RUID}"
|
||||
end
|
||||
local g = 40 + math.random(0, 20)
|
||||
local nid = self:PickNewRelic()
|
||||
local msg = ""
|
||||
if nid ~= "" then
|
||||
self:AddRelic(nid)
|
||||
local nr = self.Relics[nid]
|
||||
msg = "유물 획득: " .. nr.name .. " · 메소 +" .. tostring(g)
|
||||
else
|
||||
g = g + 30
|
||||
msg = "메소 +" .. tostring(g)
|
||||
end
|
||||
self.Gold = self.Gold + g
|
||||
self:RenderRun()
|
||||
self:SetText("/ui/DefaultGroup/TreasureHud/Reward", msg)
|
||||
self:SetEntityEnabled("/ui/DefaultGroup/TreasureHud/Reward", true)
|
||||
end, 0.55)`),
|
||||
]);
|
||||
for (const m of combat.ContentProto.Json.Methods) {
|
||||
m.ExecSpace = 6;
|
||||
|
||||
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 };
|
||||
}
|
||||
120
tools/map/rogue-map.test.mjs
Normal file
120
tools/map/rogue-map.test.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateMap, ROWS, COLS } from './rogue-map.mjs';
|
||||
import { mulberry32 } from '../balance/sim-balance.mjs';
|
||||
|
||||
function gen(seed) {
|
||||
return generateMap(mulberry32(seed));
|
||||
}
|
||||
|
||||
test('결정성: 동일 시드 동일 맵', () => {
|
||||
assert.deepEqual(gen(1), gen(1));
|
||||
assert.deepEqual(gen(42), gen(42));
|
||||
});
|
||||
|
||||
test('시작 노드 2개 이상 (경로 1·2 시작열 상이)', () => {
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const { start } = gen(s);
|
||||
assert.ok(start.length >= 2, `seed ${s}: start ${start.length}개`);
|
||||
assert.equal(new Set(start).size, start.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('연결성: 모든 노드가 시작점에서 도달 가능하고 boss에 도달함', () => {
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const { nodes, start } = gen(s);
|
||||
// 시작점에서 전방 BFS
|
||||
const fwd = new Set(start);
|
||||
const queue = [...start];
|
||||
while (queue.length) {
|
||||
const id = queue.shift();
|
||||
for (const nid of nodes[id].next) {
|
||||
if (!fwd.has(nid)) { fwd.add(nid); queue.push(nid); }
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(nodes)) {
|
||||
assert.ok(fwd.has(id), `seed ${s}: ${id} 시작점에서 도달 불가`);
|
||||
}
|
||||
// boss에서 역방향 BFS
|
||||
const back = new Set(['boss']);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
if (!back.has(id) && n.next.some((x) => back.has(x))) { back.add(id); changed = true; }
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(nodes)) {
|
||||
assert.ok(back.has(id), `seed ${s}: ${id}에서 boss 도달 불가`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('타입 규칙: 1~2행 combat만, elite·treasure는 4행부터, shop·rest는 3행부터', () => {
|
||||
for (let s = 1; s <= 50; s++) {
|
||||
const { nodes } = gen(s);
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
if (n.row <= 2) assert.equal(n.type, 'combat', `seed ${s}: ${id} (row ${n.row}) = ${n.type}`);
|
||||
if (n.type === 'elite' || n.type === 'treasure') assert.ok(n.row >= 4, `seed ${s}: ${id} ${n.type} row ${n.row}`);
|
||||
if (n.type === 'shop' || n.type === 'rest') assert.ok(n.row >= 3, `seed ${s}: ${id} ${n.type} row ${n.row}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('boss: row 8 단일 노드, 7행 노드는 전부 boss로 연결', () => {
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const { nodes } = gen(s);
|
||||
const bosses = Object.entries(nodes).filter(([, n]) => n.type === 'boss');
|
||||
assert.equal(bosses.length, 1, `seed ${s}`);
|
||||
assert.equal(bosses[0][0], 'boss');
|
||||
assert.equal(bosses[0][1].row, ROWS + 1);
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
if (n.row === ROWS) {
|
||||
assert.deepEqual(n.next, ['boss'], `seed ${s}: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('간선 제약: row+1로만, 열 차이 1 이하 (boss 간선 제외)', () => {
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
const { nodes } = gen(s);
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
for (const nid of n.next) {
|
||||
if (nid === 'boss') continue;
|
||||
const t = nodes[nid];
|
||||
assert.equal(t.row, n.row + 1, `seed ${s}: ${id}→${nid}`);
|
||||
assert.ok(Math.abs(t.col - n.col) <= 1, `seed ${s}: ${id}→${nid} 열 차이 ${Math.abs(t.col - n.col)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('elite 연속 금지: elite 부모를 가진 노드는 elite 아님', () => {
|
||||
for (let s = 1; s <= 100; s++) {
|
||||
const { nodes } = gen(s);
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
if (n.type !== 'elite') continue;
|
||||
for (const nid of n.next) {
|
||||
assert.notEqual(nodes[nid].type, 'elite', `seed ${s}: ${id}(elite) → ${nid}(elite)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('그리드 범위: 행 1..8, 열 1..4 (boss 제외)', () => {
|
||||
const { nodes } = gen(7);
|
||||
for (const [id, n] of Object.entries(nodes)) {
|
||||
if (id === 'boss') continue;
|
||||
assert.ok(n.row >= 1 && n.row <= ROWS);
|
||||
assert.ok(n.col >= 1 && n.col <= COLS);
|
||||
assert.equal(id, `r${n.row}c${n.col}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('다양성: 서로 다른 시드는 (대체로) 다른 맵', () => {
|
||||
const a = JSON.stringify(gen(1));
|
||||
let diff = 0;
|
||||
for (let s = 2; s <= 11; s++) if (JSON.stringify(gen(s)) !== a) diff++;
|
||||
assert.ok(diff >= 9, `10개 중 ${diff}개만 상이`);
|
||||
});
|
||||
36892
ui/DefaultGroup.ui
36892
ui/DefaultGroup.ui
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user