278 Commits

Author SHA1 Message Date
48b43f23e6 chore: docs/superpowers 로컬 전용화(.gitignore + 추적 제거) + README 정리
개인 스킬(superpowers) 브레인스토밍/계획 산출물 71개를 git 추적 해제(로컬 파일 유지).
프로젝트 설계 문서(slaymaple_basic_framework·ui-generation-structure)는 추적 유지.
README: docs/superpowers 참조 제거 + 로컬 전용 명시.
Lua 분류 점검 결과: 게임로직 cb/*.mjs 17 기능모듈 + 보조 codeblock 12종(개념별) — 현행 양호, 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:48:38 +09:00
d1473ad9e2 docs(readme): 최신화 — 노드맵/캐릭터이미지/신규카드 + 생성기 모듈화·메이커 파일럿
- 카드 122→123장(표창 토큰), 신규 메커니즘 exhaust/dex/thorns 추가
- tools/deck 모듈 구조(lib/hud/cb) + tools/verify diffcheck 반영
- 구현 기능 PR범위 #34~#69, 노드맵 UI·캐릭터 이미지 추가
- DefaultGroup ~6.8MB, 아키텍처 메모에 생성기 모듈화·하이브리드 파일럿(PR #70~#72)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:41:08 +09:00
a5388da2cc docs(harness): RULES §1에 charselect 메이커 stock(Phase 2) + hud 15종 반영 2026-06-16 08:22:03 +09:00
8ca48eca60 feat(charselect): charselect 생성 중단 → 메이커 저작 stock화
GENERATED_UI_SECTIONS·UI_APPEND_ORDER에서 CharacterSelectHud 제거 + upsertUi emit·
hud/charselect.mjs 제거. 기존 charselect 엔티티는 stock으로 보존(메이커 편집 가능,
재생성에 안 덮임). ui 엔티티 경로집합 1442개 동일(재배치만, 손실 0). 컨트롤러는
경로+ClassPortraits 주입으로 구동 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:21:06 +09:00
eeca77df35 feat(charselect): 캐릭터 이미지 컨트롤러 런타임 주입 (ClassPortraits)
luaCharsTable() 신설(characters.json→self.ClassPortraits), boot/run 시드 +
prop, RenderCharacterSelect가 각 {key}Button/Art ImageRUID를 경로로 주입.
(메이커 저작 레이아웃이어도 컨트롤러가 이미지 채움 = 패턴 b 내용주입.)
산출물: SlayDeckController.codeblock 재생성 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:19:13 +09:00
40e351333e docs(plan): Phase 2 charselect 메이커 저작 파일럿 구현 계획 2026-06-16 08:15:24 +09:00
0a83dea2d8 docs(spec): Phase 2 캐릭터 선택 메이커 저작 파일럿 설계
charselect를 생성중단→stock화(메이커 편집), 이미지는 컨트롤러 런타임 주입
(ClassPortraits/luaCharsTable), 경로 구동 유지. 패턴 b 검증 파일럿.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:12:58 +09:00
fbf4d8d02d docs(harness): RULES §1에 cb/*.mjs(메서드)·lib/codeblock.mjs 반영 2026-06-16 08:03:14 +09:00
a141939675 refactor(cb): codeblock 메서드 161개를 cb/*.mjs 17 모듈로 분리 (codeblock 바이트 동일)
writeCodeblocks의 메서드를 연속-런 17 모듈(boot/state/soul/charselect/run/
deckturn/deckview/hand/combat/jobs/runend/render/reward/items/tooltip/map/shop)로
분리, methods 배열은 spread-concat(원본 순서 보존). prop 103개는 오케스트레이터 유지.
산출물 무변경(diffcheck: SlayDeckController.codeblock IDENTICAL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:02:22 +09:00
42eb33b579 refactor(cb): lib/codeblock.mjs로 헬퍼·상수 추출 (codeblock 바이트 동일) 2026-06-16 07:58:55 +09:00
9f7713267c docs(plan): Phase 1b codeblock 메서드 모듈화 구현 계획 (17 런 모듈, 바이트 동일) 2026-06-16 07:57:01 +09:00
bfa86f0f28 docs(spec): Phase 1b codeblock 메서드 모듈화 설계
writeCodeblocks의 메서드 161개를 연속구간별 cb/*.mjs 모듈로 분리(바이트 동일).
prop 103개는 오케스트레이터 유지. 헬퍼+공유상수는 lib/codeblock.mjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:53:22 +09:00
c5bb8c18a9 docs(harness): RULES §1에 gen-slaydeck 머지충돌 재모듈화 해결법 + diffcheck ref 추가 2026-06-16 07:44:39 +09:00
420cce561c Merge origin/main into feature/gen-modularization (생성기 모듈화)
main의 5개 PR(#62 exhaust+tooltip, #66 dex/thorns, #67 캐릭터 덱버튼 제거,
#68 스크롤바, #69 표창카드)을 모듈화 브랜치에 병합.

충돌은 tools/deck/gen-slaydeck.mjs 한 파일 — main이 그 단일체를 콘텐츠 변경
(구조/emit/top-level 상수는 불변)한 반면 본 브랜치는 모듈로 재구조화.
해결: main 버전(theirs)을 취해 **콘텐츠 마커 기반으로 재모듈화**(라인인덱스 X,
이름 자동 파생) → lib/data·lib/ui-helpers + hud/*.mjs 16종 재생성.

검증(손실 0): 재모듈화 생성기 출력이 origin/main 산출물과 **바이트 동일**
(diffcheck: ui/DefaultGroup.ui·SlayDeckController.codeblock IDENTICAL).
common.gamelogic은 origin/main 그대로 채택(유일 차이는 main의 stale `.0` 정수표기
— origin/main 원본 생성기도 정수를 만듦을 확인). 미러 테스트 sim-balance·rogue-map 통과.
RULES.md는 §1(모듈구조)+§4/§7(main) 자동 병합.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:38:54 +09:00
d265c8f918 chore(verify): diffcheck에 비교 ref 인자 추가(기본 HEAD) 2026-06-16 07:32:20 +09:00
255781d969 Merge pull request '표창 카드 손패 생성 구현' (#69) from codex/implement-shuriken-cards into main
Reviewed-on: #69
2026-06-16 07:26:11 +09:00
b904b29503 Merge pull request '덱보기 스크롤바 방향 수정' (#68) from codex/fix-lobby-deck-scrollbar into main
Reviewed-on: #68
2026-06-16 07:25:42 +09:00
0435a76fc1 Merge pull request '캐릭터 선택 덱보기 버튼 제거' (#67) from codex/remove-character-deck-buttons into main
Reviewed-on: #67
2026-06-16 07:25:03 +09:00
d82e98f832 docs(harness): RULES §1에 gen-slaydeck 모듈 구조(lib/·hud/)·diffcheck 게이트 반영 2026-06-16 02:42:20 +09:00
eafd6747a7 refactor(gen): MainMenu·CharacterSelectHud를 hud/*.mjs로 추출 (출력 바이트 동일)
emit이 묶여있던 menu/select 쌍을 buildMainMenu/buildCharSelect로 분리
(select[0].enable=false는 charselect에 포함). HUD 16종 모듈화 완료.
산출물 무변경(diffcheck IDENTICAL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:40:47 +09:00
bc266b1885 refactor(gen): HUD 14종을 hud/*.mjs로 추출 (출력 바이트 동일)
DeckHud/DeckInspect/DeckAll/Combat/Reward/Map/Shop/Rest/Treasure/JobChoice/
JobSelect/Lobby/Board/SoulShop를 각 build 함수로 분리, upsertUi는 emit 한 줄로.
전문 상수(PANEL_BG·TYPE_KO)는 해당 블록에 포함. 산출물 무변경(diffcheck IDENTICAL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:39:20 +09:00
e6a397cc55 refactor(gen): lib/ui-helpers.mjs로 UI 헬퍼·상수 추출 (출력 바이트 동일)
UI_FILE~appendUiSection(상수 30 + 헬퍼 15, 총 45)을 tools/deck/lib/ui-helpers.mjs로
이동, import로 연결. 산출물 무변경(diffcheck IDENTICAL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:31:59 +09:00
fcc103227c refactor(gen): lib/data.mjs로 데이터·lua 테이블 추출 (출력 바이트 동일)
gen-slaydeck.mjs의 데이터 로드·검증·luaXxxTable·게임상수(라인 3~188)를
tools/deck/lib/data.mjs로 이동, import로 연결. 산출물 무변경(diffcheck로 검증).
+ tools/verify/diffcheck.mjs: 워킹트리 vs HEAD 줄바꿈 정규화 비교(deny 회피) 게이트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:30:28 +09:00
44878bab9e docs(plan): 생성기 모듈화 Phase 1 구현 계획 (lib/+hud/, 바이트 동일 게이트) 2026-06-16 02:24:52 +09:00
064d81d424 docs(spec): 생성기 모듈화(Phase 1) + 하이브리드 UI 로드맵 설계
gen-slaydeck.mjs UI emit 16종을 lib/+hud/ 모듈로 분리(출력 바이트 동일·무위험).
codeblock 메서드 제외. 하이브리드 단계적: Phase2 캐릭터선택 메이커 저작 파일럿.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:20:53 +09:00
62187db5dd 표창 카드 손패 생성 구현 2026-06-16 02:19:22 +09:00
1a5953050f 덱보기 스크롤바 방향 수정 2026-06-16 02:10:34 +09:00
a902cb8bce 캐릭터 선택 덱보기 버튼 제거 2026-06-16 02:06:13 +09:00
b23dc3868e Merge pull request 'Implement dex and thorns effects' (#66) from codex/dex-thorns-combat-effects into main 2026-06-16 02:01:53 +09:00
98ca1668c8 Implement dex and thorns effects 2026-06-16 01:59:40 +09:00
654a49f3a2 Merge pull request 'Add exhaust pile and restore keyword tooltips' (#62) from codex/integer-ui-number-format into main 2026-06-16 01:33:09 +09:00
3e4619ed2f Merge origin/main into exhaust tooltip branch 2026-06-16 01:31:36 +09:00
aa872afa7b Merge pull request 'feat(charselect): 직업 선택 캐릭터 이미지 + 뒤로가기' (#65) from feature/charselect-images into main
Reviewed-on: #65
2026-06-16 01:19:09 +09:00
1eb6622cf5 chore(assets): 캐릭터 초상화 스프라이트 임포트
메이커 로컬 임포트 .sprite 디스크립터. 이번 직업 선택 화면은 기본 3종
(warrior/mage/bandit)을 사용. 나머지(hero·palladin·darkknight·archmage×2·
cleric·nightlord·shadower·bowmaster·hunter·pirate·singung)는 향후
2차 전직 선택 이미지용으로 임포트해 둠.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:16:18 +09:00
8309b25ec5 chore: 산출물 재생성 (charselect 캐릭터 이미지 + 뒤로가기)
node tools/deck/gen-slaydeck.mjs 산출물. 소스 변경(이전 커밋)의 결정적 재생성.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:16:07 +09:00
00903f2659 feat(charselect): 직업 카드 캐릭터 이미지 + 뒤로가기 (소스)
- data/characters.json 신설(전사/법사/도적 초상화 RUID 단일 소스), 생성기 로드·검증
- CharacterSelectHud: 단색 박스 → 카드 전체 캐릭터 이미지(Art 풀블리드 258×318)
  + 하단 이름 배너(NameBanner), Portrait/Desc 제거
- RenderCharacterSelect: 선택 시 카드 테두리 금색(Art 6px 인셋 뒤로)
- BackButton 추가 + BindMenuButtons 바인딩 → ShowLobby(로비 복귀), prop CharBackHandler

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:16:00 +09:00
f2c470f972 docs(plan): 직업 선택 캐릭터 이미지 + 뒤로가기 구현 계획 2026-06-16 01:08:00 +09:00
2e8a1ab869 docs(spec): 직업 선택 캐릭터 이미지 + 뒤로가기 설계
CharacterSelectHud 단색 박스 → 캐릭터 이미지 카드(이름 하단 배너·선택 금색
테두리), 뒤로가기→로비. data/characters.json 단일 소스(메이커 임포트 RUID).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:59:43 +09:00
4228f58b09 Merge pull request 'fix(monster): MonsterAttack.OnBeginPlay AnimationClip 타입가드 (LEA-3023/2007)' (#64) from fix/monsterattack-animationclip-guard into main
Reviewed-on: #64
2026-06-16 00:47:40 +09:00
5e0eca6cdf fix(monster): MonsterAttack.OnBeginPlay AnimationClip 타입가드 (LEA-3023/2007)
증상: 전투맵 진입 시 몬스터마다 [LEA-3023] TypeMismatch(AnimationClip) +
[LEA-2007] AttemptToIndex(clip nil) 서버 로그 스팸(몬스터 수만큼 반복).

원인: MonsterAttack.OnBeginPlay(chasemonster 모델 상속·메이커 저작·생성기 없음)가
정적 Sprite인 SpriteRUID를 _ResourceService:LoadAnimationClipAndWait에 넘김 →
AnimationClip이 아니라 nil 반환(LEA-3023) → clip.Frames[1] 인덱싱(LEA-2007).
이 멜리 공격 로직은 카드 기반 턴제 전투에서 호출하는 코드가 전혀 없는 죽은 코드라
크래시 외 게임 영향은 없으나 로그를 더럽힘.

수정: LoadAnimationClipAndWait 호출 전 GetTypeAndWait가 ResourceType.AnimationClip이
아니면 early-return + clip nil 가드. 정적 스프라이트 몬스터는 공격범위 설정을 건너뜀
(원래 미사용), 애니메이션 클립 몬스터는 기존대로 동작.

주의: MonsterAttack은 생성기 없는 메이커 저작 codeblock이라 디스크 직접 패치.
적용하려면 메이커에서 로컬 워크스페이스 reload 필요.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:37:36 +09:00
4da934585c Merge pull request 'docs(harness): RULES/CLAUDE/settings를 현재 저장소 상태와 동기화' (#63) from feature/harness-sync into main
Reviewed-on: #63
2026-06-16 00:13:23 +09:00
49069a16cf docs(harness): RULES/CLAUDE/settings를 현재 저장소 상태와 동기화
P14/P15/노드맵 작업으로 생긴 산출물·생성기가 하네스 문서에 미반영이던
드리프트를 정정 (개인 메모리에만 있던 내용을 공용 하네스로 승격).

- RULES §1 표: `map01~map11` → `map01~map05` + `lobby.map`(P14 5막화),
  크기 정정(ui ~7.1MB·controller ~270KB), 누락 산출물 추가
  (CombatMonster/PlayerLock/MapCamera/LobbyNpc/LobbyMobility codeblock,
  Global/SectorConfig.config)
- RULES §1: deny glob 범위 + 메이커 저작 codeblock/UI 금지 + 보조 생성기
  10종 인벤토리(생성기→산출물 매핑) 명시
- .claude/settings.json: deny를 glob화(`ui/*.ui`·`RootDesk/MyDesk/*.codeblock`)
  해 전 산출물(PopupGroup/ToastGroup.ui, codeblock 12종) 커버 + SectorConfig.config
- CLAUDE.md: 크기 정정(8.3MB→~7.1MB)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:59:28 +09:00
bda35eefc7 Add exhaust pile and restore keyword tooltips 2026-06-15 23:34:26 +09:00
44010e0fce fix(camera): 전투 진입 시 StS2 고정 카메라 재적용(KickCombatCamera)
회귀: 로비 follow(ConfineCameraArea=false)로 푼 공유 카메라가 전투맵에서 플레이어 중심으로 보임 — MapCamera의 1회성 true-set으론 재confine 안 됨. StartCombat에서 플레이어가 전투 위치(-6) 정착 후 false→true '킥'(0.2s)으로 재confine해야 StS2(플레이어 좌·몬스터 우) 복원(맵 로드 시점엔 텔레포트/낙하 중이라 바운드 오계산). data/camera.json 값 사용, 로비 follow 불변.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:26:55 +09:00
efa32d0a8f Merge pull request 'Add card keyword hover tooltips' (#60) from codex/card-keyword-tooltips into main
Reviewed-on: #60
2026-06-15 23:22:04 +09:00
7c776864e2 Add card keyword hover tooltips 2026-06-15 23:15:04 +09:00
72370aab23 Merge pull request 'Fix combat target cleanup and damage popups' (#59) from codex/fix-card-target-ui-dmgpop into main
Reviewed-on: #59
2026-06-15 23:05:46 +09:00
5377112826 Merge pull request 'feat: 노드 맵 UI 강화 — 아이콘 노드 + 다크 배경 (nodeicons.json 외부화)' (#58) from feature/node-map-ui into main
Reviewed-on: #58
2026-06-15 23:05:25 +09:00
8a5b0d4f8d Fix combat target cleanup and damage popups 2026-06-15 22:46:10 +09:00
6c35d959ac feat(node-map): 임시 scenic 배경(Critias 도시 스프라이트) + BgImage 어둡게 틴트
배경을 작동하는 map/back SPRITE RUID로 교체(메이플 BackgroundComponent 대신). BgImage 색을 0.5로 낮춰 노드 가독성 확보. 교체는 data/nodeicons.json background만 수정.
2026-06-15 15:59:43 +09:00
67d21a9619 feat(node-map): 우측 하단 노드 종류 범례 + 잠김 노드 밝기 상향(0.45→0.85)
범례: 6타입 아이콘+이름(전투/엘리트/보스/상점/휴식/보물) 패널(우하단). 잠김 노드 틴트를 밝게 해 타입 식별 가능.
2026-06-15 15:56:22 +09:00
b1d0af311a fix(node-map): 루트 불투명 다크 배경 + BgImage 자식(스프라이트 RUID만 렌더)
메이플 BackgroundComponent 리소스는 UI 스프라이트로 안 떠서 배경 이미지 미표시 → 루트를 불투명 다크로 깔아 월드 누출 방지, BgImage 자식에 background RUID(유효 스프라이트면 표시). 노드 아이콘·상태 틴트는 정상 검증.
2026-06-15 14:35:38 +09:00
5b41eb78a4 feat(node-map): MapHud 배경 이미지+오버레이, 노드 아이콘화(라벨 제거·확대), RenderMapNode ImageRUID+상태 틴트
타입별 공식 아이콘(주황버섯/골렘/발록/돈주머니/모닥불/상자) + 리스항구 배경. 절차 배치·간선·바인딩 불변.
2026-06-15 14:26:28 +09:00
3902c9b1ee feat(node-map): nodeicons.json 외부화 + 생성기 로드·검증·NodeIcons 직렬화 2026-06-15 14:22:32 +09:00
d1e51878c3 docs(node-map): 노드 맵 UI 강화 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:21:04 +09:00
cc945fce8b docs(node-map): 노드 맵 UI 강화 설계 spec (아이콘 노드+배경, nodeicons.json 외부화)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:17:01 +09:00
9966065409 docs(readme): P13~P15 + codex 반영 최신화 (로비 맵·NPC·영혼·도적·메소·카메라 추종)
P12 기준이던 README를 현재 구현 상태로 갱신:
- 디렉토리 구조: map01~05+lobby(6), data 6종(cardframes/camera 추가), tools 신규(gen-lobby-map/npc·verify), 신규 codeblock 5종
- 기능표: 로비 마을(NPC 4종·근접/클릭·로비 한정 이동/공격·카메라 추종), 도적 클래스, 카드 122장, 영혼 메타, 커스텀 프레임, 메소, 5막화, retain/sly discard/데미지 팝업(codex)
- 유용한 스크립트 호출·산출물 재생성 명령 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:17:36 +09:00
bc9bc78cef Merge pull request 'feat(lobby): 로비 카메라를 플레이어 추종(follow)으로' (#57) from feature/p15-lobby-camera-follow into main
Reviewed-on: #57
2026-06-15 08:32:02 +09:00
9cb5e1abff feat(lobby): 로비 카메라를 플레이어 추종(follow)으로 — 전투맵은 고정 유지
로비 루트에서 script.MapCamera 제거(고정 framing 억제 해제) + LobbyMobility가 진입 시
ConfineCameraArea=false·ScreenOffset(0.5,0.5)·Zoom 90으로 플레이어 추종 카메라 설정.
MSW 카메라는 기본 follow이고 ConfineCameraArea=true가 그걸 억제하므로 false가 핵심.
검증: 로비 우측 이동 시 플레이어 중앙 유지+배경 스크롤, 런 시작→map01 Confine=true 고정, 복귀→follow 복원(누설 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:05:22 +09:00
1fce0b284a Merge pull request 'feat(bandit): STS2 사일런트 카드풀 및 직업 탭 정리' (#56) from codex/class-tabbed-codex into main
Reviewed-on: #56
2026-06-15 07:27:24 +09:00
e269154d17 feat(combat): render monster damage popups with digit skins 2026-06-15 01:10:20 +09:00
b65d4af1eb fix(ui): show target marker only while dragging 2026-06-15 00:51:50 +09:00
d5318ac86b feat(ui): style monster damage numbers 2026-06-15 00:50:17 +09:00
bd91c67483 feat(ui): add prominent target marker 2026-06-15 00:47:24 +09:00
b43ee02014 feat(cards): implement retain keyword 2026-06-15 00:45:11 +09:00
6427d23f50 feat(cards): highlight drag target monster 2026-06-15 00:42:40 +09:00
b40c8d11d8 fix(combat): clear temporary curse cards after combat 2026-06-15 00:37:57 +09:00
f9e7bc3603 fix(cards): support large hand drag positions 2026-06-15 00:29:48 +09:00
256433d3f3 feat(bandit): add discard card selection 2026-06-15 00:14:08 +09:00
05a06644cf feat(bandit): implement sly discard trigger 2026-06-15 00:06:53 +09:00
709e6f8f99 fix(ui): 카드 텍스트 그림자 제거 2026-06-14 21:26:51 +09:00
a88c1d344c fix(ui): 카드 텍스트 가독성 개선 2026-06-14 21:23:52 +09:00
a24f3592c4 feat(bandit): STS2 사일런트 카드풀 반영 2026-06-14 21:14:13 +09:00
3db11f5d82 fix(ui): 전체덱 보기를 직업 탭으로 제한 2026-06-14 20:46:56 +09:00
6e1f1cf990 Merge pull request 'fix(bandit): 도적 덱을 사일런트 전용으로 정리' (#55) from codex/bandit-silent-only into main 2026-06-14 19:59:29 +09:00
304b2f3c2a fix(ui): 덱 미리보기에 직업 탭 추가 2026-06-14 19:38:43 +09:00
15bc17b351 feat(ui): 직업별 덱 미리보기 추가 2026-06-14 19:27:40 +09:00
6f436ef3eb fix(bandit): 도적 덱을 사일런트 전용으로 정리 2026-06-14 18:48:15 +09:00
cf193bf51a Merge pull request 'feat(bandit): 사일런트 도적 덱 추가' (#53) from codex/bandit-silent-deck into main 2026-06-14 17:53:02 +09:00
1e87be2cd6 merge main into bandit silent deck 2026-06-14 17:48:59 +09:00
6cc008e894 Merge pull request 'feat: P15 — 로비를 전용 맵 + 월드 NPC로 (근접·클릭 상호작용, 로비 한정 이동·공격)' (#54) from feature/p15-lobby-map-npc into main
Reviewed-on: #54
2026-06-14 13:04:17 +09:00
760b856576 fix(lobby): 로비 이동·점프를 기본값으로 — InputSpeed 5→1.4, JumpForce 5→1.23 (P15)
기존 5/5는 보행 ~9u/s·점프 상승 14u로 과함. freeze가 안 건드린 intact RigidbodyComponent.WalkSpeed(1.4)/WalkJump(1.23) 기본값에 맞춤. 실측: 보행 2.5u/s, 점프 상승 1.79u로 정상화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:02:30 +09:00
91bbe7d200 docs(p15): 계획에 메이커 정찰 실측 결과 반영
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:51:34 +09:00
989e3fe000 fix(lobby): 플레이테스트 — 이동 복원에 InputSpeed 필수 + FixedLookAt int 타입 (P15)
메이커 실측으로 확인:
- 이동에는 RigidbodyComponent.WalkAcceleration과 MovementComponent.InputSpeed가 둘 다 양수여야 함(WalkAccel만으론 안 걸림). LobbyMobility에 InputSpeed=5·JumpForce=5 추가.
- pc.FixedLookAt은 boolean이 아니라 int32 → false→0 (빌드 에러 해소).
- PlayerLock에 InputSpeed/JumpForce=0 대칭 재잠금 추가(전투맵 누설 방어).
- NPC 베이스 모델 inheritance 경고는 비치명적이라 proven-good(모델 유지) 결정 주석화.

검증: 로비 이동·점프, NpcCodex 근접(d=1.10<1.2)·↑키→카드 도감, 런 시작→map01 텔레포트+이동 잠금(InputSpeed=0), 로비 복귀→이동 재해제 전부 정상.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:50:51 +09:00
0e064cc1e9 feat(lobby): 로비 맵 흐름 통합 — 텔레포트·NPC 디스패치·StartRun map01·LobbyHud 슬림화 (P15)
- OnBeginPlay: 공격 키(Ctrl, 로비 한정) 바인딩
- ShowLobby→GoLobbyMap: 월드 시작·런 종료 시 로비 맵 텔레포트
- OnLobbyNpcInteract(id): 월드 NPC→기존 기능 패널 디스패치
- StartRun: 1막 진입 시 map01 물리 텔레포트(BuildMonsters CurrentMapName 필터 대응)
- LobbyHud: 버튼-행 제거, 투명·비레이캐스트화(맵 노출·클릭 통과), 영혼/승천 미니 정보바만 유지
- BindLobbyButtons: NPC 바인딩 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:36:13 +09:00
de1e69de7d fix(lobby): 전투맵 PlayerLock에 WalkAcceleration=0 런타임 재잠금 (P15)
로비에서 푼 이동(WalkAcceleration)이 텔레포트 후 전투맵에 누설돼도 PlayerLock이 맵 로드 시 0으로 재설정해 확실히 잠금. 정찰로 WalkAcceleration이 실제 이동 레버임을 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:31:50 +09:00
a683f186d4 feat(lobby): LobbyNpc(근접·클릭 상호작용)·LobbyMobility(이동 해제) codeblock (P15)
LobbyNpc: 거리 폴링→머리위 마크 토글, TouchEvent/UpArrow→Interact→컨트롤러 OnLobbyNpcInteract. LobbyMobility: 진입 시 pc.Enable·WalkAcceleration=0.7 복원(정찰 확정). 공격 키는 컨트롤러에서 처리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:29:44 +09:00
a309da2a99 feat(lobby): 로비 전용 맵 + NPC 4종 월드 엔티티 생성기 (P15)
map01 클론→lobby.map(헤네시스 배경), 몬스터 제거, NPC 4종(공식 메이플 NPC 스프라이트)+머리위 마크 배치. 각 NPC에 TouchReceiveComponent+script.LobbyNpc, 루트에 script.LobbyMobility(PlayerLock 제거). SectorConfig map://lobby 등록.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:27:44 +09:00
82bf22d4cc docs(p15): 로비 맵 + 월드 NPC 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:15:44 +09:00
f36bc0d14e docs(p15): 로비 맵 + 월드 NPC 설계 spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:55:54 +09:00
1f0a8099ee fix(ui): 카드 효과 드로우 애니메이션 적용
- DrawCards에 animate 인자를 추가해 새로 뽑힌 손패 슬롯만 덱 위치에서 손패 위치로 이동하도록 처리

- 카드 효과의 draw 호출은 animate=true로 실행해 즉시 생성되는 느낌을 제거

- 턴 시작 드로우와 유물 드로우는 기존 렌더 흐름을 유지

- SlayDeckController.codeblock 산출물을 생성기로 재생성

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs
2026-06-14 02:59:34 +09:00
a5f6a4509d fix(ui): 카드 드래그 중 hover 보간 중단
- hover 확대 보간 타이머 ID를 추적해 새 hover 또는 드래그 시작 시 기존 타이머를 종료

- 손패 카드 드래그 시작 시 모든 손패 카드의 scale과 위치를 기본값으로 즉시 정리

- 드래그 중 손패 hover enter/exit 처리를 무시해 드래그 위치와 hover 보간이 충돌하지 않도록 수정

- 드래그 종료 시 드래그 카드 scale을 1.0으로 복귀

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs
2026-06-14 02:55:38 +09:00
d3ae6c1c62 feat(ui): 카드 hover 보간 확대 적용
- hover된 손패/보상/상점 카드를 1.5배까지 SineEaseOut 보간으로 확대

- 같은 줄의 다른 카드는 hover 카드 기준 좌우로 110px 밀어 겹침을 줄임

- hover 해제 시 같은 보간으로 scale 1.0 및 기본 위치로 복귀

- SlayDeckController.codeblock 산출물을 생성기로 재생성

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs
2026-06-14 02:50:47 +09:00
4d3f6fc0af feat(ui): 카드 hover 확대 추가
- 손패 카드에 마우스 진입/이탈 이벤트를 연결해 hover 시 1.12배로 확대

- 보상 카드와 상점 카드에도 UITouchReceiveComponent를 추가하고 같은 hover 확대 동작 적용

- ApplyCardFace에서 카드 렌더 시 UIScale을 기본값으로 리셋해 재사용 카드가 확대 상태로 남지 않도록 처리

- 생성기 변경 후 ui/DefaultGroup.ui와 SlayDeckController.codeblock 산출물 재생성

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs

- SetCardHover/UITouchEnterEvent/UITouchReceiveComponent 산출물 카운트 확인
2026-06-14 02:42:11 +09:00
a2b8d6bfb9 feat(bandit): 사일런트 도적 덱 추가
- 도적 시작 직업을 선택 화면에서 활성화하고 bandit 스타터 덱으로 런을 시작하도록 생성기를 연결

- Slay the Spire 사일런트 카드 75장을 bandit 카드 풀에 추가하고 카드명/설명을 한글화

- 현재 전투 엔진이 지원하는 피해, 방어도, 드로우, 독, 약화, 취약, 광역, 다단히트, 회복, 파워 효과로 카드 효과를 매핑

- 도적 스타터 덱을 타격 5장, 수비 5장, 무력화, 생존자로 구성

- bandit 및 도적 전직 계열(shiv, poisoner, trickster)을 카드 프레임 매핑에 연결

- ui/DefaultGroup.ui와 SlayDeckController.codeblock을 생성기로 재생성

검증:

- node --check tools/deck/gen-slaydeck.mjs

- node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs

- 도적 카드 75장 및 한글화 잔여 영어/깨짐 없음 확인
2026-06-14 02:37:14 +09:00
8f233296af Merge pull request 'feat: P14 — 반복 런·로비·영혼·도적·몬스터 랜덤성 (요청 17항목)' (#52) from feature/p14-loop-lobby-soul into main
Reviewed-on: #52
2026-06-14 01:50:19 +09:00
5f89d61a8b fix(lobby): 로비 카드 도감용 Cards/Frames 테이블을 OnBeginPlay에 시드
- ShowCodex가 self.Cards를 순회하는데 런 시작 전(로비)엔 미설정이라 pairs(nil) 오류
  → OnBeginPlay에서 luaCardsTable/luaFramesTable을 미리 주입(StartRun과 중복 무해)
- 메이커 플레이테스트로 로비·도감·가로맵·전투(도적/메소아이콘/스킬아이콘) 전 루프 검증

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:28:56 +09:00
8879647b26 feat(soul): 영혼 메타 성장 시스템 (P14-9)
- 2차 전직 상태로 맵 보스 클리어 시 영혼 +1 적립(CheckCombatEnd 보스 분기 AwardSouls)
- UserDataStorage 영속 RPC: ReqLoadSouls/RecvSouls/SaveSouls(ExecSpace 5/6, 승천 패턴 복제)
  · key soulPoints(수치)·soulUnlocks(해금 set, 콤마 직렬화). OnBeginPlay에서 로드
- 로비 영혼 상점(SoulShopHud) 4종 해금: 두둑한 지갑(시작 메소+60)·단련된 육체(시작HP+15)
  ·덱 정제(기본카드 1장 제거)·유물 수집가(시작 유물+1). BuySoulUnlock 구매·영속 저장
- ApplySoulUnlocks가 StartRun에서 해금 효과 적용 → 덱빌딩 이점. 승천(패널티)과 분리 공존
- 산출물 재생성(id 유일성·JSON 검증 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:23:00 +09:00
7ee323ea8b feat(lobby): 로비 허브 + NPC 4종 + 반복 런 루프 (P14-8)
- LobbyHud: 메이플 로비 화면. NPC 4종 클릭 — 모험가(런 시작)·사서(카드 도감)·
  상인(영혼 상점)·안내원(게시판) + 승천 +/- + 영혼 수치 표시
- ShowState "lobby" 추가, OnBeginPlay·EndRun → ShowLobby (첫 실행/패배/클리어 모두
  로비 기점, 반복 런 루프). ShowLobby가 BindMenuButtons도 호출(전직 버튼 바인딩 보존)
- 카드 도감: DeckAllHud 재사용(CodexMode) — 저주 제외 전 카드 표시, 닫으면 로비 복귀
- 게시판(BoardHud): 게임 규칙/팁 패널. 영혼 상점(SoulShopHud): 셸(P9에서 해금 채움)
- guid 네임스페이스 lob/brd/soul 추가(id 충돌 방지). 산출물 재생성(id 유일성 검증 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:16:56 +09:00
7cc311ee91 feat(economy): 메소 표기 완성 + 메소 코인 아이콘 추가 (P14-7)
- relics.json goldIdol 설명 "골드"→"메소" (유일하게 남았던 표면 잔존 정정)
- CombatHud TopBar·ShopHud 메소 금액 옆에 금색 코인 아이콘 스프라이트 추가
  (공식 RUID 흰박스 리스크 회피 위해 색상 채움 스프라이트 사용)
- 내부 식별자 self.Gold 등은 산출물 id 안정성 위해 유지. 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:06:59 +09:00
5cde11647f feat(card-ux): 손패 최대 10장·초과 자동버림 + 마우스오버 확대/툴팁 (P14-6)
- 손패 슬롯 5→10 확장(Card6~10 신규 생성), RenderHand 장수 비례 동적 간격 배치
- DrawCards 손패 10장 상한 — 초과 드로는 버린 더미로 자동 이동(StS식, sim 미러)
- 카드 마우스오버: UITouchEnter→UIScale 1.3배 확대 + ShowTooltip(이름/설명),
  Exit→복원+숨김. 드래그 중(DragSlot) 확대 억제. RenderHand가 UIScale 리셋
- HoverCard/UnhoverCard 메서드 신설. sim 35/35 통과. 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 01:04:24 +09:00
8296775e21 feat(cards): 실제 메이플 스킬 아이콘 적용 + 피격 이펙트(fx) 분리 (P14-5)
- 카드 아트(image)를 경로 검증된 실제 스킬 아이콘 RUID로 교체(28종)
  · mapleImgFullPath의 /icon 경로 확인으로 스킬 아이콘 보장(기존엔 이펙트 프레임·맵
    오브젝트가 섞여 있었음)
- 피격 이펙트(fx) 필드 신설(18종) — 스킬 effect/hit 프레임 RUID
  · PlayCard가 PlayAttackFx/PlayAoeFx에 c.fx or c.image 전달(이펙트 분리, 없으면 폴백)
  · luaCardsTable fx 직렬화. MSW asset 메타데이터 경로 검증으로 수급(워크플로 6에이전트)
- 아이콘 미발견 카드는 기존 RUID 유지. sim 35/35 통과. 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:57:24 +09:00
d546d62755 feat(thief): 도적 클래스 + 2차 전직(어쌔신/시프) 추가 (P14-4)
- CLASSES.thief(maxHp 75), JOBS.thief = [어쌔신(CriticalThrow), 시프(SavageBlow)]
- 카드 11종: 도적1차(럭키세븐/더블스탭/다크사이트/헤이스트/드레인)
  · 어쌔신(크리티컬스로우/쉐도우스타/클로마스터리)
  · 시프(새비지블로우/스틸/메소가드), starterDecks.thief 추가
- cardframes classToFrame: thief/assassin/bandit → bandit 프레임(기존 RUID 재사용)
- CharacterSelectHud Thief 해금, BindMenuButtons ThiefButton, RenderCharacterSelect·
  StartNewGame·StartRun·JobLabel 도적 분기. 전사/법사 2차는 기존 완비 확인
- 산출물 재생성(생성기 검증 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:38:52 +09:00
8f58a90746 feat(combat): 몬스터 랜덤 구성·랜덤 행동·덱 오염(AddCard)·map01 로스터 (P14-3)
- 구성 랜덤화: BuildMonsters를 그룹별 수집 후 노드 타입별 추첨
  (일반 1~3 / 엘리트 1+일반0~2 / 보스 1, MAX_MONSTERS=4 내)
- 행동 랜덤화: EnemyActStep 순차(round-robin) → 정의된 intent 중 math.random 선택
  (스폰 시 시작 intent도 랜덤, sim-balance.mjs 미러 동기화)
- StS2식 덱 오염: AddCard intent 신규 — 저주 카드(Wound/Burn)를 버린 더미에 추가
  · Wound=사용불가 사석, Burn=사용불가+턴종료 피해2(EndPlayerTurn 처리)
  · PlayCard unplayable 가드, CardPool class 필터로 보상/상점 자동 제외
  · luaIntentsArray(card/count)·luaCardsTable(unplayable/curse/endTurnDamage) 직렬화
- map01 인카운터: 일반 5종(주황/초록/빨강달팽이/파랑/돼지) + 엘리트1 + 보스1, 우측 포메이션
  · enemies.json red_snail/stump 신규, blue_mushroom/mushmom에 AddCard intent
  · gen-map-encounters 레이아웃 맵별 분기 + 풀 인덱싱 일반화
- 막 배율 0.6→0.45(5막 기준 완화). sim 테스트 35/35 통과(신규 3 포함). 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:33:49 +09:00
8fa1079548 feat(map-ui): 노드 맵 가로 진행 레이아웃(왼→오른쪽) (P14-2)
- nodeX=행(좌→우)·nodeY=열(분기)로 좌표축 스왑, 보스는 최우측 중앙
- 호출부(노드/도트/보스 간선) 인자 스왑. Lua 무수정(좌표는 JS 빌더 전담)
- 도트 보간·간선·라벨 좌표 함수 파생이라 자동 추종. 산출물 재생성

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:14:24 +09:00
9e16465218 feat(map): 맵 5막화·노드 depth 7·rest/shop/elite 연속 금지 (P14-1)
- ACT_COUNT/RUN_LENGTH 3→5, ACT_MAPS map01~map05 (반복 런 기반 확장)
- MAP_ROWS 7→6 (걷는 행 6 + 보스 = depth 최대 7), 막 배율 0.6→0.45 완화
- 노드 타입 인접 금지를 elite 단독 → rest/shop/elite 3종으로 일반화
  (Lua GenerateMap + rogue-map.mjs JS 미러 동시 수정, 테스트 9/9 통과)
- 맵 파일 생성기 카운트 11→5, map06~map11 삭제, SectorConfig 정리(stale 제거)
- 산출물 재생성(ui/codeblock/map01~05). 검증 헬퍼 tools/verify/count.mjs 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:13:16 +09:00
f67471435e docs(p14): 반복 런·로비·영혼·도적·몬스터 랜덤성 설계 spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:03:13 +09:00
fd57e0d56d Merge pull request 'fix(card-frames): 카드 프레임 슬롯 레이아웃 정밀 보정 (픽셀 실측)' (#51) from fix/p13-card-layout into main 2026-06-13 04:01:57 +09:00
afe995a895 fix(card-frames): 프레임 슬롯 픽셀 실측 기반 레이아웃 정밀 보정 (이름→배너 중심·코스트→육각 중심·이름 폭 축소로 육각 겹침 제거)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 04:01:08 +09:00
6a6b64cbc5 Merge pull request 'feat(card-frames): 커스텀 카드 프레임 — 직업×등급 프레임·보상 가중 추첨 (P13)' (#50) from feature/p13-card-frames into main 2026-06-13 00:11:01 +09:00
b2693be111 fix(card-frames): 카드 단위 엔티티 id v2 네임스페이스 발급 — 에디터에서 소실된 자식 엔티티 신규 생성 유도
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:01:25 +09:00
675616bf51 fix(card-frames): 엔티티 id↔path 매핑 보존 (구 stride 유지·중복 검증) — 메이커 refresh in-place 병합 꼬임 수정
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:56:40 +09:00
1e48fa35b3 feat(card-frames): 산출물 재생성 (프레임 렌더링·등급·보상 가중)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:41:40 +09:00
aaa68ebe07 feat(card-frames): 보상 등급 가중 추첨 70/25/5 (Lua + JS 미러 rarityForRoll·경계 테스트)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:40:58 +09:00
35dfcbaffe feat(card-frames): 생성기 — 프레임 렌더링·cardFaceLayout 통합 (5개 카드 사이트·단색판 제거)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:39:27 +09:00
9aa4721790 feat(card-frames): 카드 등급 배정(normal10·unique17·legend5)·프레임 RUID 매핑 데이터
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:31:33 +09:00
e553ebe666 feat(card-frames): 카드 프레임 스프라이트 9종 로컬 임포트 (warior·mage·bandit × normal·unique·legend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:30:53 +09:00
a814bf2c4b docs(card-frames): P13 설계·계획 — 커스텀 카드 프레임(직업×등급)·보상 가중
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:30:05 +09:00
9e162d6e2d Merge pull request 'docs(readme): P1~P12 구현 현황·향후 개선 계획 갱신' (#48) from docs/readme-p12 into main 2026-06-12 19:31:22 +09:00
8baa97bde8 docs(readme): P1~P12 구현 현황·향후 개선 계획 갱신
- 구현 기능 표 전면 갱신 (클래스/전직·버프/디버프·절차 맵·유물/물약·승천·모션)
- 디렉토리 구조 갱신 (potions.json·rogue-map·gitea-pr·RULES.md, map.json 제거 반영)
- 스크립트 호출 예시·향후 개선 계획 10항목·RULES.md 셋업 안내

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:30:46 +09:00
66c1ac8ee1 Merge pull request 'feat(motion): 전투 모션 — 공격·피격·독뎀 (배포 퀄리티 P12)' (#47) from feature/p12-combat-motion into main 2026-06-12 18:42:41 +09:00
abd6d00052 feat(motion): 전투 모션 — 공격/피격/독뎀 (생성기+산출물)
- PlayerAttackMotion(StateComponent ATTACK→IDLE)·PlayerHitMotion(HIT+넉백 틱)
- MonsterLunge(공격 시 런지)·MonsterHitMotion(hit 클립 스왑→stand 복귀, 폴백 흔들림)
- BuildMonsters에 hit/stand 클립 pcall 캐시·motionBusy
- 훅: PlayCard·DealDamageToTarget·PlayAoeFx·독 틱·체인메일 반사·EnemyActStep

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:37:42 +09:00
2cd672b474 docs(motion): P12 설계·계획 — 전투 모션 (공격/피격/독뎀)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:32:39 +09:00
56d958fe19 Merge pull request 'feat(ascension): 승천 시스템 A1~A10 + UserDataStorage 개인 저장 (배포 퀄리티 P11)' (#46) from feature/p11-ascension into main 2026-06-12 14:25:19 +09:00
7aed1943b7 fix(ascension): 메뉴 Asc 엔티티 guid 충돌 해소 (190→195~197)
CharacterSelectHud/OpaqueBackdrop이 menu:190 선점 — AscMinus 미표시 원인

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:24:36 +09:00
9989a61675 fix(ascension): RPC ExecSpace 실측 보정 — Server=5·Client=6 (프로브 검증)
- 1은 ServerOnly(클라 호출 무시)라 저장/로드 RPC 미동작 → 5로 수정
- RecvAscension 6(Client): 서버→특정 클라 userId 라우팅 실측 확인
- 설계 문서 ExecSpace 표 갱신, 프로브 메서드 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:20:05 +09:00
2c28935d95 fix(ascension): UserId 접근을 PlayerComponent.UserId로 (빌드 경고 2건 해소)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:11:48 +09:00
b635cb3a63 feat(ascension): 승천 A1~A10·UserDataStorage 개인 저장·서버 RPC (생성기+산출물)
- ReqLoadAscension[Server]/RecvAscension[Client·특정 유저 응답]/SaveAscension[Server]
- ExecSpace 일괄 6 → 명시값 보존 (첫 서버-클라 RPC)
- 모디파이어: 적 HP/피해 ×1.1~1.2·정예 배율 +0.2/0.4·시작 HP -10/-20·메소 ×0.75/0.5
- 메인 메뉴 승천 [-]/라벨/[+], 클리어 시 해금+1·저장·'승천 N 해금!' 표시
- TopBar '· 승천N', 테스트 40건 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:10:30 +09:00
bcdf9457c8 docs(ascension): P11 설계·계획 — 승천 A1~A10·UserDataStorage 개인 저장·서버 RPC
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:04:06 +09:00
89056e903d Merge pull request 'feat(magician): 법사 클래스 — 1차 5종·2차 3계열·신규 메커니즘 4종 (배포 퀄리티 P10)' (#45) from feature/p10-magician into main 2026-06-12 14:00:42 +09:00
fc0a96fcb7 fix(magician): Jobs prop 선언 누락 (빌드 경고 6건 해소)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:57:41 +09:00
7b6e181cb0 feat(magician): 시뮬 메커니즘 동기화 + 산출물 재생성
- poison 틱(행동 시작·사망 시 행동 생략·전멸 승리 체크)·aoe(개별 취약/방어)·heal 클램프·draw
- 테스트 4건 추가 — 전체 40건 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:56:52 +09:00
e1d298f972 feat(magician): 캐릭터 선택 오픈·전직 화면 동적화 (생성기)
- Mage 버튼 활성·SelectClass(magician)·2클래스 하이라이트/가드
- JobSelectHud Job_slot1..3 범용화 + ShowJobSelect(클래스별 JOBS 채움)
- SetJob/JobLabel을 Jobs 테이블 기반으로 (luaJobsTable 주입, luaStr 개행 이스케이프)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:54:14 +09:00
811c8ec2ac feat(magician): 독 DoT·AoE·회복·드로 메커니즘 (생성기+시뮬 loadData)
- PlayCard: aoe→PlayAoeFx(전 생존 적 각자 취약/방어·슬롯별 팝업), heal/draw/poison
- EnemyActStep 행동 시작 독 틱(피해 팝업·사망 시 행동 생략·체인 계속)
- BuffsLabel 독N 표시, 클래스별 StartRun(HP 80/70·시작 덱), JOBS 상수·검증
- sim loadData: starterDecks.warrior 사용

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:51:15 +09:00
6b6037739f feat(magician): 법사 카드 14종·클래스별 시작 덱 데이터
- 1차 5종(에너지 볼트·매직 가드·매직 클로·텔레포트·슬로우)
- 2차: 위자드 불독(파이어 애로우·포이즌 브레스·엘레멘트 앰플)
  위자드 썬콜(썬더 볼트 AoE·콜드 빔·칠링 스텝) 클레릭(힐·블레스·홀리 애로우)
- starterDeck → starterDecks{warrior, magician}, 신규 필드 draw/heal/poison/aoe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:48:01 +09:00
80c5daabbf docs(magician): P10 설계·계획 — 법사 14종·신규 메커니즘 4종·전직 동적화
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:45:54 +09:00
6e8d1a88f5 Merge pull request 'feat(job): 전직 시스템 코어 + 전사 2차 (배포 퀄리티 P9)' (#44) from feature/p9-job-advancement into main 2026-06-12 13:42:36 +09:00
13d1ccb771 feat(job): 산출물 재생성 (전직·전사 2차)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:39:10 +09:00
d0b8fbe091 feat(job): 시뮬 신규 메커니즘 동기화 (hits·pierce·selfVuln·energy/blockPerTurn)
- 다단히트: 타격마다 힘 적용 합산·취약 1회 (Lua 동기화)
- pierce 방어 무시, selfVuln, 파워 루프 확장 (블록 리셋 후)
- 신규 테스트 6건 — 전체 36건 통과 (sim 27 + rogue-map 9)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:39:08 +09:00
2c9a1b351e feat(job): 클래스 풀 필터·전직 선택 흐름·전직 HUD (생성기)
- CardPool(클래스 필터) — 보상·상점 공용
- 보스 분기: 1차+비최종막 → JobChoiceHud(유물/전직), ContinueAfterBoss 추출
- JobSelectHud 3직업 패널, SetJob(대표 카드 지급·직업명 갱신)
- guid 'job'=0xe4, HideGameHud·BindButtons 등록

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:36:46 +09:00
74e3a70a19 feat(job): 다단히트·방어무시·자가취약·파워 2종 (생성기)
- PlayCard Attack: hits 합산(힘·펜닙 타격마다 적용), pierce 전달, selfVuln
- DealDamageToTarget/PlayAttackFx pierce 시그니처 (물약 화염병 false)
- StartPlayerTurn 파워 루프: energyPerTurn·blockPerTurn (블록 리셋 후)
- 카드 class 직렬화(누락 시 fail-fast)·PlayerJob prop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:34:11 +09:00
15b342972d feat(job): 전사 2차 카드 9종·클래스 필드 데이터
- 기존 9종 class=warrior, 신규: 파이터(콤보 어택·버서크·라이징 어택)
  페이지(썬더/블리자드 차지·파워 가드) 스피어맨(피어스·아이언 월·하이퍼 바디)
- 신규 필드: hits(다단)·pierce(방어무시)·selfVuln·energyPerTurn/blockPerTurn
- 이미지 RUID 메이커 선별

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:31:48 +09:00
1925144f85 docs(job): P9 설계·계획 — 전직 코어·전사 2차 9종·신규 메커니즘 4종
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:29:06 +09:00
2a0ec0ef21 Merge pull request 'chore(harness): 토큰 가드 하네스 — 산출물 접근 차단·RULES.md 공유 규칙' (#43) from chore/harness-rules into main 2026-06-12 11:01:43 +09:00
65ad2fe854 chore(harness): 토큰 가드 하네스 — 산출물 접근 차단·RULES.md·CLAUDE.md
- .claude/settings.json: ui/DefaultGroup.ui(8.3MB)·map/*.map·SlayDeckController.codeblock
  Read/Edit/Write 도구 차단 (생성 산출물 — 단일 소스는 data/*.json + tools/)
- RULES.md: 협업 공용 하네스 규칙 (카운트 검증·탐색 경로 제한·gitea-pr 절차·이중 구현 동기화)
- CLAUDE.md: RULES.md 임포트로 Claude Code 자동 적용
- .gitignore: .claude/settings.json만 커밋 예외 (local 설정은 계속 제외)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:01:13 +09:00
d7e4e88182 Merge pull request 'chore(git): Gitea PR 헬퍼 — UTF-8 안전 생성/수정/머지 도구' (#42) from chore/gitea-pr-tool into main 2026-06-12 10:48:28 +09:00
52c03b208e chore(git): Gitea PR 헬퍼 — UTF-8 안전 생성/수정/머지
Windows 셸 인라인 curl -d 본문이 CP949로 전송되어 PR #34~41 한글이
깨진 사고 재발 방지. 제목/본문은 UTF-8 spec JSON 파일로만 받고
Node fetch가 전송. git credential(GCM)에서 토큰 자동 취득·401 재시도.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:47:52 +09:00
76aaafcf1b Merge pull request 'feat(rogue-map): �α׶���ũ ���� ���� �ʡ��� �ý��ۡ����� �� (���� ����Ƽ P8)' (#41) from feature/p8-rogue-map into main 2026-06-12 10:25:39 +09:00
c69a17abe0 feat(rogue-map): 유물 방 상자 연출·TreasureHud·메소 표기 (생성기+산출물)
- TreasureHud: 보물 상자(클릭→흔들림 0.08s×6→열림 RUID 교체→유물+메소 보상)
- 상자 닫힘/열림 RUID 메이커 선별, PickNewRelic 재사용 (소진 시 메소 대체)
- ShowState/HideGameHud/LeaveNode treasure 등록, 표시 화폐 '골드'→'메소' 전환
- 테스트 30건(rogue-map 9 + sim 21) 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:22:29 +09:00
1abd9f7987 feat(rogue-map): 맵 그리드·점선 도트 UI + RenderMap 상태 4단 (생성기)
- Node_r{1..7}c{1..4}+boss 정적 그리드, Dot 간선 192개 (기본 비활성)
- RenderMapNode: 타입색 6종·현재(골드)/방문(어둡게)/도달가능/잠김
- RenderMapDots: 간선 존재 토글·현재 노드 발신 간선 골드 강조

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:18:36 +09:00
1624ef6f3b feat(rogue-map): GenerateMap 런타임 절차 생성 + 층 시스템 (생성기)
- 정적 map.json·luaMapNodesTable·luaStartArray 제거
- GenerateMap: 경로 4개 걷기·행별 가중 타입·elite 연속 금지 (JS 미러 동기화)
- Depth/VisitedNodes prop, PickNode treasure 분기·층 갱신, 보스 클리어 시 새 맵
- TopBar '막 F/3 · D층', 메소 표기 시작

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:16:55 +09:00
443aaf83d2 feat(rogue-map): 절차 생성 알고리즘 JS 미러 + 테스트 9건
- StS식 경로 걷기 4개 (시작열 셔플 앞2 상이 보장)
- 행별 가중 타입 배정 + elite 부모 연속 금지
- 연결성·타입 규칙·간선 제약·결정성 테스트

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:04:42 +09:00
67e8b4c848 docs(rogue-map): P8 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:03:34 +09:00
52d808eacd docs(rogue-map): P8 설계 — 절차 생성 맵·층 시스템·유물 방·점선 맵 UI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:01:29 +09:00
b5a648fc23 Merge pull request 'feat(potions-relics): ���� �ý��ۡ����� 19����������/���� UI (���� ����Ƽ P7)' (#40) from feature/p7-potions-relics into main 2026-06-12 07:37:20 +09:00
62b2193f2e feat(potions-relics): 유물 아이콘 행·물약 슬롯·툴팁·물약 메뉴 UI (생성기+산출물)
- TopBar: RelicSlot×10(+오버플로)·PotionSlot×5 (UITouchReceive hover 툴팁)
- TooltipBox(이름+설명)·PotionMenu(사용/버리기/닫기) 팝업
- ShopHud Potion 판매 항목, AllDeckButton 우측 이동

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:33:15 +09:00
2a42c4a372 feat(potions-relics): 물약 사용·드랍·상점 로직 (생성기)
- AddPotion(슬롯 검사)·MaybeDropPotion(승리 40% 드랍)·RenderPotions(아이콘/빈칸/잠금)
- OpenPotionMenu/ClosePotionMenu/UsePotion(전투 중 한정)/TossPotion
- 상점 ShopPotion 판매 20골드 (BuyPotion, 슬롯 검사 선행)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:30:00 +09:00
42f9143f73 feat(potions-relics): 유물 15종 효과 훅 (생성기)
- HasRelic·PickNewRelic(미보유 추첨, 소진 시 골드+25)
- ApplyRelics 확장: strength/draw/heal/healOnWin/healIfLow + combatEnd 훅
- AddRelic passive: 물약 슬롯 5칸(장인의 벨트)·최대 HP+7(건강의 반지)
- CalcPlayerAttack: 아카베코(첫 공격+8)·펜닙(10번째 2배)·부츠(5 미만→5)
- DealDamageToPlayer: 브론즈 체인메일 반사·점토 갑옷·백년의 부적
- 챔피언 벨트(취약 부여 시 약화+1), 정예·보스 유물 보상 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:29:01 +09:00
239dd6caf3 feat(potions-relics): 물약 6종·유물 15종 신규 데이터 (아이콘 RUID 메이커 선별)
- 물약: 빨간 포션·화염병·전사의 물약·수호의 물약·마나 엘릭서·저주의 병
- 유물 19종 전체 icon RUID 부여, relicPool 18종으로 확장
- 장인의 벨트(물약 슬롯 5칸) 포함 — StS 효과 × 메이플 장비 외형

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:26:55 +09:00
e4f7ff10d7 docs(potions-relics): P7 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:20:53 +09:00
6b8db0b871 docs(potions-relics): P7 설계 — 물약 시스템·유물 19종·아이콘/툴팁 UI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:41:02 +09:00
4c3be95c86 Merge pull request 'feat(buffs-power): ����/��������Power ī�塤�� ��� UI (���� ����Ƽ P6)' (#39) from feature/p6-buffs-power into main 2026-06-12 01:37:17 +09:00
88a9136398 feat(buffs-power): 산출물 재생성 (버프/디버프·Power·적 방어도 UI)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:33:20 +09:00
0a96a6955a feat(buffs-power): 밸런스 시뮬 버프/디버프·Power 동기화
- calcAttack(힘·약화·취약) 공식 export + 단위 테스트
- 적 Debuff 인텐트·플레이어/적 디버프 감소 타이밍 Lua 동기화
- Power 등록·매턴 발동·소멸 재현, chooseAction 파워 우선
- 신규 테스트 6건 (총 21건 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:32:31 +09:00
00844857ee feat(buffs-power): 적 방어도 배지·버프 라인·디버프 인텐트 UI (생성기)
- MonsterSlot에 BlockBadge(방어도)·Buffs(힘/약화/취약) 추가
- PlayerPanel Buffs 라인 (버프 + 활성 파워 표시)
- 인텐트: Debuff 보라색·공격 최종 예상치(힘·약화·취약 반영) 표시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:29:04 +09:00
6df3fcf01c feat(buffs-power): 버프/디버프·Power 전투 규칙 (생성기)
- 약화(-25%)·취약(+50%)·힘(+N) 피해 공식 양방향 적용 (CalcPlayerAttack·EnemyActStep)
- Power 카드: 사용 시 소멸, PlayerPowers 등록, 매턴 strengthPerTurn 발동
- 적 Debuff 인텐트 처리·디버프 턴 감소(플레이어 턴 종료·적 행동 후)
- 인텐트 effect 직렬화·막 배율은 Debuff 값 제외

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:28:03 +09:00
2bf5716fce feat(buffs-power): 신규 카드 4종·적 디버프 인텐트 데이터
- 차지 블로우(피해8·취약2)·위협(약화2)·인레이지(힘+2)·분노(Power, 매턴 힘+1)
- 카드 이미지 RUID 메이커 선별 (공식 리소스)
- 정예 슬라임·머쉬맘·변형된 달팽이(약화), 슬라임 킹·킹 슬라임(취약) 디버프 인텐트

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:25:02 +09:00
12f3928ab4 docs(buffs-power): P6 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:16:05 +09:00
eb06663758 docs(buffs-power): P6 설계 — 버프/디버프·Power 카드·적 방어도 UI
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:12:57 +09:00
02aa73dda3 Merge pull request 'feat(system-gaps): 경제·복합 카드·적 패턴·런 종료 복귀 (배포 퀄리티 P5)' (#38) from feature/p5-system-gaps into main 2026-06-11 09:08:55 +09:00
134353d374 feat(system-gaps): 산출물 재생성 (경제·복합카드·EndRun)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:05:17 +09:00
013f946c6b feat(system-gaps): 신규 카드 이미지 RUID (워 리프·브랜디시 — 메이커 선별)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:05:00 +09:00
8ff50b428d feat(system-gaps): 복합 카드(피해+방어)·런 종료 후 메뉴 복귀(EndRun)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:01:42 +09:00
1de8fac893 feat(system-gaps): 경제 상향(승리25·엘리트+15)·적 패턴 보강·신규 카드 2종 데이터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:57:33 +09:00
248677759c docs(system-gaps): P5 설계·계획 (경제·복합카드·적패턴·런종료 복귀)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:55:44 +09:00
2af67b3b2b Merge pull request 'feat(act-maps): 막별 맵 전환 + 맵별 인카운터 (배포 퀄리티 P4)' (#37) from feature/p4-act-maps into main 2026-06-11 08:53:20 +09:00
7890f4c029 feat(act-maps): 산출물 재생성 (맵 필터·막 텔레포트)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:50:17 +09:00
32a0b2437b feat(act-maps): 막별 맵 텔레포트 + 등록 맵 필터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:48:03 +09:00
dd2a814eeb feat(act-maps): map02~11 인카운터 자동 구성 (combat3/elite2/boss1·맵별 테마)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:45:32 +09:00
4a1c66c5fe docs(act-maps): P4 막별 맵 전환+맵별 인카운터 설계·계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:42:59 +09:00
869e4d88df Merge pull request 'feat(combat-feel): 전투 연출 — 드래그 타겟·공격 이펙트·적 개별 차례 (배포 퀄리티 P3)' (#36) from feature/p3-combat-feel into main 2026-06-11 08:39:29 +09:00
e876a8ce3a feat(combat-feel): 산출물 재생성 (드래그·이펙트·개별 차례·팝업)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:34:41 +09:00
f88e6ffeeb feat(combat-feel): 적 개별 차례 시퀀스 (ActFrame·EnemyActStep)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:31:37 +09:00
5921dfbeb8 feat(combat-feel): 데미지 팝업·사망 지연
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:28:02 +09:00
4b7ad753a4 feat(combat-feel): 공격 이펙트 후 지연 데미지 (SkillFx·FxBusy)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:25:05 +09:00
c80020a464 feat(combat-feel): 카드 드래그 타겟팅 (UITouchReceive·ResolveCardDrop)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 03:20:33 +09:00
858f9727dd docs(combat-feel): P3 전투 연출 설계+계획 (드래그 타겟·공격 이펙트·개별 차례·팝업)
probe 완료: ScreenTouchEvent/ScreenToUIPosition 실측, UITouchReceiveComponent 드래그 이벤트 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 03:18:10 +09:00
1c2062b8cc Merge pull request 'feat(card-visuals): 메이플 스킬 카드 비주얼 (배포 퀄리티 P2)' (#35) from feature/p2-card-visuals into main 2026-06-11 03:12:30 +09:00
d4ec8757ce feat(card-visuals): 산출물 재생성 2026-06-11 03:07:48 +09:00
76cb2cd65e feat(card-visuals): 보상·상점·그리드 카드 프레임 적용
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:59:38 +09:00
d2aba643fa feat(card-visuals): 손패 카드 프레임(Art·NamePlate·CostPlate)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:55:54 +09:00
d783cccea1 feat(card-visuals): ApplyCardFace 렌더 일원화 + Cards image 직렬화
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:53:07 +09:00
c4ad8fc068 feat(card-visuals): 카드를 전사 스킬로 리네임 + 공식 스킬 이미지 RUID
파워 스트라이크(a71b…)/아이언 바디(1ae9…)/슬래시 블러스트(d5bc…) — 메이커 순회 주입으로 선별(26후보 중).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:50:26 +09:00
4ea9bfe14b docs(card-visuals): P2 구현 계획 (6개 태스크)
RUID 수확(컨트롤러)→ApplyCardFace 일원화→손패 프레임→보상/상점/그리드 프레임→재생성→플레이테스트·PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:41:19 +09:00
18729a2047 docs(card-visuals): 메이플 스킬 카드 비주얼 설계 (P2)
공식 RUID 렌더 타당성 실측 완료. 카드→전사 스킬 매핑(파워 스트라이크/슬래시 블러스트/아이언 바디),
RUID 수확 워크플로, 카드 프레임(Art/NamePlate/Cost), ApplyCardFace 렌더 일원화(5표면).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:35:51 +09:00
50462f3d8a Merge pull request 'feat(combat-ui): 전투 화면 UI/HUD 전면 정비 — STS2 배치 (배포 퀄리티 P1)' (#34) from feature/p1-combat-ui into main
Reviewed-on: #34
2026-06-11 02:29:51 +09:00
2f73910e47 fix(combat-ui): 보상 화면에서 손패/덱HUD 숨김 (플레이테스트 발견)
보상 오버레이 아래로 손패가 비쳐 시각 충돌 → OfferReward 진입 시 CardHand/DeckHud off
(PickReward→ShowMap 경로가 상태 복원).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:27:13 +09:00
659ae2a60f feat(combat-ui): UI 정비 산출물 재생성 2026-06-11 02:18:38 +09:00
b5facb7763 fix(combat-ui): AABB 겹침 해소(오브·턴종료 y160, 패널 y-494)·HP바 높이 일치·덱 팝업 숨김·cmbN 제거
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:16:58 +09:00
1762112296 feat(combat-ui): ShowState 가시성 통일 + 전투 시작 시 Result 초기화 2026-06-11 02:05:53 +09:00
389e7f36cf feat(combat-ui): 타겟 프레임·몬스터 슬롯 가독성·의도 색상
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:02:20 +09:00
24e6aca305 feat(combat-ui): 플레이어 패널(HP바·방어 뱃지) + SetHpBar 폭 인자
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:58:01 +09:00
d1f894a802 feat(combat-ui): 상단 TopBar (막·골드·유물·모든덱보기 통합)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:53:36 +09:00
c5e28062eb feat(combat-ui): 에너지 오브(좌)·턴 종료 버튼(우) 재배치
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:50:00 +09:00
c7d795f839 docs(combat-ui): P1 구현 계획 (7개 태스크)
에너지 오브/턴종료 재배치 → TopBar → 플레이어 패널+SetHpBar(width) → 타겟 프레임·가독성 → ShowState → 재생성·겹침 정적검사 → 플레이테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:48:11 +09:00
2265bd7fa1 docs(combat-ui): 전투 화면 UI/HUD 전면 정비 설계 (배포 퀄리티 로드맵 P1)
STS2 배치 재구성 — 에너지 오브(좌)/턴종료(우) 분리·상단 HUD 바·플레이어 패널·
타겟 프레임·몬스터 슬롯 가독성·ShowState 가시성 상태 통일. 로드맵 P1~P5 명시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:48:11 +09:00
fb5f0d6d5b Merge pull request 'fix(ui): keep deck popups above monster hp' (#33) from fix/deck-popup-layering into main
Reviewed-on: #33
2026-06-11 01:44:48 +09:00
569c1d5eb4 Merge pull request 'feat(map01): 노드 타입별 몬스터 그룹 콘텐츠 + HP바 몬스터 추종' (#32) from feature/map01-monster-content into main
Reviewed-on: #32
2026-06-11 01:44:24 +09:00
066ad6ddca fix(ui): keep deck popups above monster hp 2026-06-11 01:41:40 +09:00
1a15225ff6 Merge pull request '덱 팝업에서 몬스터 HP UI가 앞에 뜨지 않도록 수정' (#30) from fix/deck-popup-monster-layer into main
Reviewed-on: #30
2026-06-10 23:39:12 +09:00
33bc98c015 feat(map01): HP바를 각 몬스터 위에 표시(world→screen) + 엘리트/보스 우측 배치
- PositionMonsterSlot: 고정 좌표 대신 _UILogic:WorldToScreenPosition→ScreenToUIPosition 으로
  각 몬스터 월드 위치 위(HEAD_OFFSET_Y)에 슬롯을 띄움 → 몬스터를 옮겨도 자동 추종
- 미사용 고정좌표 시스템 제거: SlotPos/ActiveSlotPos prop·StartRun 주입·SLOTS 로드·
  luaSlotGroup·upsertUi 단언·data/monster-slots.json
- map01 엘리트(머쉬맘 x3·변형된 달팽이 x5)·보스(킹 슬라임 x4) 월드 위치를 우측으로 이동

메이커 검증: combat/elite/boss 각 노드에서 해당 그룹만 등장, HP바가 각 몬스터 머리 위에 표시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 23:26:56 +09:00
895e521724 feat(map01): 노드 타입별 몬스터 그룹 콘텐츠 — 일반/엘리트/보스 배치
- map01에 6마리 배치·태그: combat(주황버섯/돼지/초록버섯), elite(머쉬맘/변형된 달팽이), boss(킹 슬라임)
- enemies.json에 적 타입 추가: pig·green_mushroom(일반), mushmom·modified_snail(엘리트), king_slime(보스)
- 컨트롤러 재생성(self.Enemies 신규 타입 포함)

메이커 검증: combat 노드→일반3, elite 노드→엘리트2, boss 노드→보스1, HP가 enemies.json 값으로 해소됨.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:54:57 +09:00
cd7ae102ca Merge pull request 'feat(combat): 노드 타입별 몬스터 그룹 (일반/엘리트/보스)' (#31) from feature/node-type-monster-groups into main
Reviewed-on: #31
2026-06-10 22:35:23 +09:00
d0353fb81f feat(node-groups): 컨트롤러 재생성 (그룹 필터·그룹 슬롯)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:53:38 +09:00
6eea4f9e17 fix(node-groups): 슬롯 좌표 단언을 MAX_MONSTERS 기준으로 (fail-fast) 2026-06-10 21:52:10 +09:00
903a06d233 feat(node-groups): RegisterMonster(group) + BuildMonsters 노드 타입 필터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:47:25 +09:00
c614b10566 feat(node-groups): 그룹별 슬롯 좌표 플러밍 (SlotPos/ActiveSlotPos)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:44:59 +09:00
6feb252674 fix(node-groups): no-clobber 가드를 == null 로 (null 값도 보존 처리) 2026-06-10 21:43:14 +09:00
428bdc8a2e feat(node-groups): CombatMonster 에 Group + 생성기 값 보존(no-clobber) 2026-06-10 21:38:39 +09:00
271a7991d1 feat(node-groups): monster-slots.json 을 그룹별 좌표 구조로 2026-06-10 21:36:42 +09:00
59c699c04b docs(node-monster-groups): 구현 계획 (5개 태스크)
slots 그룹화 → CombatMonster Group+no-clobber → 슬롯 플러밍 → RegisterMonster(group)+BuildMonsters 필터 → 재생성·플레이테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:32:46 +09:00
0d517617a3 docs(node-monster-groups): 노드 타입별 몬스터 그룹 설계
한 맵에 일반/엘리트/보스 그룹 배치 → 노드 타입으로 필터해 해당 그룹만 등장.
CombatMonster에 Group 태그(메이커 인스펙터 저작) + BuildMonsters 필터 + 그룹별 슬롯 좌표.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:29:04 +09:00
d247115ad7 Keep monster HP behind deck popups 2026-06-10 21:17:24 +09:00
26c084d951 Merge pull request '캐릭터 선택 시작 메뉴 추가' (#28) from feature/character-select-menu into main
Reviewed-on: #28
2026-06-10 21:03:44 +09:00
6349be63aa Merge pull request 'chore(model): Model_monster-43 을 Models/Monsters/ 로 재배치' (#29) from chore/relocate-monster-model into main
Reviewed-on: #29
2026-06-10 21:01:47 +09:00
7167ece6a7 Merge pull request 'fix(ui): 덱 팝업을 몬스터 HP UI 위에 렌더' (#27) from fix/deck-popup-sorting into main
Reviewed-on: #27
2026-06-10 21:00:38 +09:00
76e60d3350 Add character select start menu 2026-06-10 20:57:43 +09:00
e241382d09 chore(model): Model_monster-43 을 Models/Monsters/ 로 재배치
메이커에서 수행한 모델 재배치 반영.
- RootDesk/MyDesk/Model_monster-43.model → RootDesk/MyDesk/Models/Monsters/Model_monster-43.model (이동, 내용 동일)
- freeze-turn-monsters.mjs 의 모델 경로 참조를 새 위치로 갱신 (이동 후에도 생성기가 모델을 찾도록)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:55:19 +09:00
f4b349532d fix(ui): render deck popups above monster hp 2026-06-10 20:20:42 +09:00
683ea88271 Merge pull request 'Map monster combat' from feature/map-monster-combat
# Conflicts:
#	RootDesk/MyDesk/SlayDeckController.codeblock
2026-06-10 19:58:56 +09:00
516348c0ec Merge pull request 'Add all deck popup' from feature/deck-pile-inspector 2026-06-10 19:57:37 +09:00
f211a79c82 Merge remote-tracking branch 'origin/main' into feature/map-monster-combat
# Conflicts:
#	RootDesk/MyDesk/SlayDeckController.codeblock
2026-06-10 08:34:23 +09:00
f33a5507db fix(combat): 플레이테스트 반영 — 상태전이 실행공간 에러 제거 + 슬롯 좌표 정렬
- ReviveMonsterEntity/KillMonster의 StateComponent:ChangeState 제거
  (client 실행공간에서 LEA-3022 InvalidExecSpace 발생) → SetVisible 기반 표시/숨김으로 대체
- monster-slots.json 좌표를 맵 우측 몬스터 무리 위로 조정
- 메이커 플레이테스트로 전체 전투 루프(등록·타겟·공격·적턴·처치·승리·보상) 무에러 확인

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 02:15:40 +09:00
647516d0cd feat(combat): 맵 몬스터 카드 전투 산출물 재생성 (UI 슬롯·컨트롤러)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:31:03 +09:00
f704d0f14e refactor(combat): 죽은 단일적 코드 제거 + HP_BAR_W 상수 추출
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:29:27 +09:00
f0569d9a53 feat(combat-ui): 몬스터 슬롯 UI(HP바·의도·타겟버튼) + monster-slots.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:21:45 +09:00
a5c7d96770 feat(combat): 승리조건(전체 처치)·몬스터 슬롯 렌더·HP바·타겟 클릭
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:17:32 +09:00
423407325d feat(combat): EnemyTurn 생존 몬스터 각자 행동 2026-06-10 01:15:12 +09:00
ec45438b3c feat(combat): PlayCard 타겟 몬스터 공격 + 사망 처리/연출
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:13:33 +09:00
020be477e6 feat(combat): 컨트롤러 멀티 몬스터 상태 + 등록/BuildMonsters/부활
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:09:27 +09:00
9eef5eb66e fix(monster): gen-combat-monster 방어적 가드(componentNames/@components) + 코드블록 trailing newline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 01:06:11 +09:00
185e0f3a94 feat(monster): CombatMonster 마커(EnemyId·자기등록) + 11맵 몬스터 패치 2026-06-10 01:00:23 +09:00
de23829439 fix(sim): 빈 인카운터 즉시 승리·타겟 타이브레이크 결정성·주석·draw/empty 테스트 2026-06-10 00:57:53 +09:00
4ef3d1811d feat(sim): 전투 규칙을 멀티 몬스터로 (타겟 선택·각자 의도·전체 처치 승리) 2026-06-10 00:52:17 +09:00
b14b614d94 feat(combat-data): 맵 몬스터 적 타입(주황/파란버섯) + simEncounter 추가 2026-06-10 00:48:46 +09:00
0cbcf4c70d docs(map-monster-combat): 구현 계획 (9개 태스크)
데이터→sim(TDD)→CombatMonster 마커→컨트롤러 멀티 전투(상태/PlayCard/EnemyTurn/승리/렌더)→슬롯 UI→재생성·플레이테스트.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:45:40 +09:00
1583f7ec26 Add all deck popup 2026-06-10 00:38:34 +09:00
da5dd03183 docs(map-monster-combat): 맵 몬스터 카드 전투 설계
추상 단일 적 → 맵 실제 몬스터 멀티 전투(클릭 타겟·각자 HP/의도·전체 처치 시 승리).
컨트롤러 단일 소유 + script.CombatMonster(EnemyId) 매핑 + 월드 HP바 슬롯.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:35:49 +09:00
c0cbea42a2 Merge pull request 'Add deck pile inspector UI' (#24) from feature/deck-pile-inspector into main
Reviewed-on: #24
2026-06-10 00:24:38 +09:00
de6e12c765 Add deck pile inspector UI 2026-06-10 00:17:00 +09:00
f38a3c98b1 Merge pull request 'feat(map01): 주황버섯 추격 몬스터 배치 + 카메라 시점 미세조정' (#23) from feature/map01-monster into main
Reviewed-on: #23
2026-06-10 00:16:21 +09:00
62e76f7db2 feat(map01): 주황버섯 추격 몬스터 배치 + 카메라 시점 미세조정
- map01: StaticMonsterTemplate → 주황버섯(ChaseMonster 모델) 교체
  (애니메이션 move/stand/jump/hit/die·히트박스·위치 x 5.2 갱신, 이동은 턴전투용 정지)
- 타일맵 TemplateRUID 변경
- 카메라 cameraOffsetY -1 → -0.83 (data/camera.json + MapCamera.codeblock)
- 메이커 저장 재직렬화 포함: common.gamelogic 수치 표기(0→0.0), map02~11 컴포넌트 순서, ui/DefaultGroup.ui

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:14:43 +09:00
ff91680f18 Merge pull request '.mjs 주체별 폴더 분류 + 카메라/플레이어 제어 분리' (#22) from feature/map-camera into main
Reviewed-on: #22
2026-06-10 00:01:12 +09:00
b18c44f0a5 Merge remote-tracking branch 'origin/main' into feature/map-camera
# Conflicts:
#	README.md
2026-06-09 23:59:44 +09:00
124e49b938 refactor(tools): .mjs를 주체별 폴더로 분류 + 카메라/플레이어 제어 분리
- tools/{player,monster,camera,map,deck,balance}/ 로 8개 스크립트 분류 (git mv 이력 보존)
- gen-camera의 플레이어 입력 차단·시선 고정을 tools/player/gen-player-lock.mjs(PlayerLock 코드블록)로 분리
- MapCamera 코드블록은 카메라 속성 전용으로 정리, 11개 맵 루트에 script.PlayerLock 부착
- README 및 스크립트 주석의 도구 경로 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:52:02 +09:00
c9f82708c8 Merge pull request '맵별 고정 카메라 + 시점 조정 + 플레이어 셋업(입력차단·오른쪽) + map01 배치' (#21) from feature/map-camera into main
Reviewed-on: #21
2026-06-09 23:39:18 +09:00
f1d101f6a4 feat(map-camera): 게임 시작 시 플레이어 입력 차단·오른쪽 바라보기 + map01 몬스터 3마리 배치
MapCamera 스크립트(맵 진입 OnBeginPlay)가 카메라에 더해 플레이어도 셋업:
- PlayerControllerComponent.LookDirectionX=1 (오른쪽 — 기본은 -1 왼쪽)
- FixedLookAt=true (방향 고정)
- Enable=false (키보드 입력 차단: 이동/점프/공격)
- map01: 몬스터 3마리 배치(사용자 의도 변경 포함)
- 메이커 Play 검증: LookDirectionX=1·Enable=false 확인, 오른쪽키 입력→플레이어 미이동(입력 차단), 아바타 정상, 카메라 zoom90·offset(1.5,-1) 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:35:58 +09:00
5dfbef4f0f Merge pull request '맵별 고정 카메라 (MapCamera) — 11맵 카메라 framing 고정' (#20) from feature/map-camera into main
Reviewed-on: #20
2026-06-09 23:28:57 +09:00
fbf5cfe19f fix(map-camera): ScreenOffset→CameraOffset로 시점 조정 + zoom 90
ConfineCameraArea=true에서는 ScreenOffset이 무시됨(MSW 문서·실측 확인) → 시점 이동은 CameraOffset(월드 좌표)으로.

- data/camera.json: zoomRatio 90, cameraOffsetX 1.5, cameraOffsetY -1 추가 (x+ 오른쪽 / y- 아래)
- gen-camera: codeblock에 cam.CameraOffset = Vector2(...) 굽기 추가
- 메이커 Play 검증: 파이프라인(camera.json→gen-camera→reload)으로 zoom90·offset(1.5,-1) 적용, 시점이 우하단으로 이동 확인
- 참고: 시점 조정은 CameraOffset 사용(ScreenOffset은 confine=true에서 무효, 범위 0~1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:22:52 +09:00
9eeb12adf9 docs(map-camera): 고정 카메라 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:24:09 +09:00
68c9333b59 feat(map-camera): 맵별 고정 카메라 — MapCamera 스크립트로 11맵 카메라 framing 고정
맵 로드 시 플레이어 CameraComponent를 data/camera.json 값(현재 map01: zoom100·screenOffset0.5/0.655·confine)으로 설정.

- data/camera.json: 카메라 framing 단일 설정
- tools/gen-camera.mjs: MapCamera.codeblock 생성 + 11맵 루트에 script.MapCamera 부착(idempotent)
- 새 CameraComponent 미생성(엔진 소유) — 기존 플레이어 카메라 속성만 런타임 설정
- OnBeginPlay(client, ExecSpace 6) + LocalPlayer 카메라 재시도 타이머
- 메이커 Play 검증: zoom 60 테스트로 적용 입증, 100으로 복원. idempotent·결정적
- 참고: 맵 루트 client 스크립트는 ExecSpace 6 필요(1은 미발동)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:24:09 +09:00
8405395d84 Merge pull request 'docs: README를 전체 구현(B~E6a+메인메뉴+freeze)에 맞게 갱신' (#19) from docs/readme-full-status into main
Reviewed-on: #19
2026-06-09 13:52:38 +09:00
f310d2978e docs: README를 전체 구현(B~E6a+메인메뉴+freeze)에 맞게 갱신
디렉토리 구조(data/·tools 도구), 게임 프레임워크 현황(메인메뉴·카드전투·런영속·보상·분기맵·상점/휴식·유물·멀티act·밸런스시뮬·freeze), 스크립트 호출, 다음 단계(저장·카드제거·밸런싱·IP확장) 반영.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:51:20 +09:00
971539a72f Merge pull request 'feature/slay-deck-controller' (#14) from feature/slay-deck-controller into main
Reviewed-on: #14
2026-06-09 13:39:16 +09:00
861442e2c1 Merge main into feature/slay-deck-controller — B~E6a 카드시스템 통합 + 메인 메뉴 이식
main의 35커밋(B 전투통합~E6a 멀티act)을 브랜치에 통합. 충돌 해결:
- tools/gen-slaydeck.mjs: main(B~E6a) 생성기 채택 + 브랜치 메인 메뉴(MainMenu UI·ShowMainMenu/BindMenuButtons/StartNewGame/SetEntityEnabled·OnBeginPlay→메뉴) 이식
- ui/DefaultGroup.ui·SlayDeckController.codeblock: 통합 생성기로 재생성
- map10·모델: main 채택 후 freeze 도구(freeze-turn-monsters/player) 재적용
정적 검증: 문법·JSON 유효·생성기 결정적·메뉴/B~E6a 양쪽 유지·freeze 적용.
⚠️ Maker 연결 해제로 메뉴→게임 런타임 검증은 미수행(사용자 메이커 확인 필요).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:29:28 +09:00
cb4d72ead2 Merge pull request '다음 막 진행·적 스케일 (TODO E6a) — 멀티 act 런' (#18) from feature/floors into main
Reviewed-on: #18
2026-06-09 13:18:00 +09:00
376511dfa9 docs(E6a): 다음 막/멀티 act 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:13:56 +09:00
f42628c2e9 feat(E6a): 다음 막 진행·적 스케일 — 멀티 act 런
보스 클리어 시 즉시 종료 대신 다음 막으로, 최종 막 보스에서 런 클리어.

- Floor를 막 카운터(1..ACT_COUNT=3)로 재정의, RunLength=ACT_COUNT
- StartCombat: 적을 막 배율(mult=1+(Floor-1)*0.6)로 스케일(maxHp·의도값, 새 테이블)
- CheckCombatEnd 보스 승리: Floor<RunLength면 다음 막(같은 맵 재사용·CurrentNodeId 리셋·ShowMap), 최종 막이면 '런 클리어!'
- HP/골드/덱/유물 막 간 유지(기존 영속), combatStart 유물 전투마다 재적용
- RenderRun HUD 라벨 '층'→'막'
- 메이커 Play 검증: 보스 120→192→264 스케일, 막 1→2→3, 3막 클리어, 영속 유지
- 제외: E6b 저장/불러오기(미진행)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:13:55 +09:00
ace489ed0f Merge pull request '유물 시스템 (TODO E5) — 훅 패시브 + 3획득경로' (#17) from feature/relics into main
Reviewed-on: #17
2026-06-09 04:04:31 +09:00
4c866f3cd9 docs(E5): 유물 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:53:38 +09:00
5ebc781f81 feat(E5): 유물 시스템 — 훅 패시브 + 3획득경로
훅 기반 유물(패시브) + 시작/엘리트/상점 획득.

- data/relics.json: 유물 4종(강철심장 combatStart 방어+6, 에너지코어 turnStart 에너지+1, 흡혈 cardPlayed HP+1, 황금우상 combatReward 골드+10) + startingRelic + relicPool
- ApplyRelics(hook): RunRelics 순회·effect 적용. 4지점 연결(StartCombat/StartPlayerTurn/PlayCard Attack/CheckCombatEnd)
- 획득: AddRelic 공용 — StartRun 시작 유물(C), 엘리트 승리 무작위(A), 상점 BuyRelic 골드-60(B)
- UI: CombatHud 유물 바(RenderRelics)·ShopHud 유물 슬롯
- 생성기: relics.json 로드/검증/luaRelicsTable, RELIC_PRICE=60
- 메이커 Play 검증: 방어+6·에너지4·공격HP+1·승리골드+25·엘리트/상점 유물 획득
- 범위 밖: 부정 유물·조건부 효과·유물 제거·보스 유물

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:53:37 +09:00
14449373e0 Merge pull request 'Add main menu flow' (#7) from feature/main-menu into feature/slay-deck-controller
Reviewed-on: #7
2026-06-08 00:38:29 +09:00
maple
27818e92c7 Fix turn combat facing and player movement 2026-06-08 00:01:46 +09:00
maple
1299d718e2 Disable monster patrol movement 2026-06-07 23:56:20 +09:00
maple
913b4f1721 Avoid client state transition for monsters 2026-06-07 23:42:05 +09:00
maple
a9926feea3 Freeze monsters for turn combat 2026-06-07 23:39:58 +09:00
maple
8eab9a75ac Fix generated UI GUIDs 2026-06-07 23:33:44 +09:00
maple
8c397cbc09 Add main menu flow 2026-06-07 23:27:44 +09:00
142 changed files with 247800 additions and 44182 deletions

20
.claude/settings.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"deny": [
"Read(./ui/*.ui)",
"Read(./map/*.map)",
"Read(./RootDesk/MyDesk/*.codeblock)",
"Edit(./ui/*.ui)",
"Edit(./map/*.map)",
"Edit(./RootDesk/MyDesk/*.codeblock)",
"Edit(./Global/common.gamelogic)",
"Edit(./Global/SectorConfig.config)",
"Write(./ui/*.ui)",
"Write(./map/*.map)",
"Write(./RootDesk/MyDesk/*.codeblock)",
"Write(./Global/common.gamelogic)",
"Write(./Global/SectorConfig.config)"
]
}
}

7
.gitignore vendored
View File

@@ -4,8 +4,11 @@
# Codex CLI 로컬 설정 — Authorization Bearer 토큰 포함
.codex/
.agents/
# Claude Code 로컬 설정
.claude/
# Claude Code 로컬 설정 — 단, 팀 공유 하네스 설정(settings.json)은 커밋 (RULES.md 참조)
.claude/*
!.claude/settings.json
# 개인 스킬(superpowers) 브레인스토밍/계획 산출물 — 로컬 전용, 협업 공유 X (프로젝트 설계 문서 docs/*.md 는 추적 유지)
docs/superpowers/
# === OS / 에디터 잡파일 ===
Thumbs.db

7
CLAUDE.md Normal file
View File

@@ -0,0 +1,7 @@
# SlayMaple — CLAUDE.md
MapleStory Worlds 기반 Slay the Spire 풍 덱빌더. 게임 전체가 데이터(`data/*.json`) + 생성기(`tools/`) 단일 소스이고, `ui/DefaultGroup.ui`(~7.1MB)·codeblock·map 파일은 **생성 산출물**이다.
@RULES.md
위 RULES.md의 하네스 규칙(산출물 Read/Edit 금지·카운트 검증·gitea-pr.mjs PR 절차)을 항상 따른다. `.claude/settings.json`이 산출물에 대한 Read/Edit/Write를 도구 수준에서 차단한다.

View File

@@ -23,7 +23,6 @@
"MOD.Core.SpriteRendererComponent",
"MOD.Core.RigidbodyComponent",
"MOD.Core.MovementComponent",
"MOD.Core.AIChaseComponent",
"MOD.Core.StateComponent",
"MOD.Core.HitComponent",
"MOD.Core.DamageSkinSpawnerComponent",
@@ -150,4 +149,4 @@
"Children": []
}
}
}
}

View File

@@ -30,7 +30,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 1.0
"Value": 0
},
{
"TargetType": null,
@@ -39,7 +39,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 1.0
"Value": 0
},
{
"TargetType": null,
@@ -48,7 +48,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 1.0
"Value": 0
},
{
"TargetType": null,
@@ -57,7 +57,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 1.0
"Value": 1
},
{
"TargetType": null,
@@ -118,7 +118,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 0.0
"Value": 0
},
{
"TargetType": null,
@@ -129,8 +129,8 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"y": 0.0
"x": 0,
"y": 0
}
},
{
@@ -185,7 +185,7 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"x": 0,
"y": 0.35
}
},
@@ -198,7 +198,7 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"x": 0,
"y": 0.35
}
},
@@ -218,7 +218,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 500.0
"Value": 500
},
{
"TargetType": "script.PlayerHit",
@@ -254,7 +254,7 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"x": 0,
"y": 0.35
}
},
@@ -265,7 +265,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 1.0
"Value": 0
},
{
"TargetType": "MOD.Core.MovementComponent",
@@ -274,7 +274,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 1.0
"Value": 0
},
{
"TargetType": "MOD.Core.PlayerComponent",
@@ -302,4 +302,4 @@
"Children": []
}
}
}
}

View File

@@ -23,7 +23,6 @@
"MOD.Core.SpriteRendererComponent",
"MOD.Core.RigidbodyComponent",
"MOD.Core.MovementComponent",
"MOD.Core.AIWanderComponent",
"MOD.Core.StateComponent",
"MOD.Core.HitComponent",
"MOD.Core.DamageSkinSpawnerComponent",
@@ -141,4 +140,4 @@
"Children": []
}
}
}
}

View File

@@ -24,12 +24,7 @@
"map://map03",
"map://map04",
"map://map05",
"map://map06",
"map://map07",
"map://map08",
"map://map09",
"map://map10",
"map://map11"
"map://lobby"
]
}
],

View File

@@ -32,10 +32,10 @@
{
"@type": "script.SlayDeckController",
"Enable": true,
"Energy": 0,
"MaxEnergy": 3,
"Turn": 0,
"TweenEventId": 0
"Energy": 0.0,
"MaxEnergy": 3.0,
"Turn": 0.0,
"TweenEventId": 0.0
}
],
"@version": 1

183
README.md
View File

@@ -1,7 +1,7 @@
# SlayMaple
[MapleStory Worlds(MSW)](https://maplestoryworlds.nexon.com/) 기반으로 제작하는 **Slay the Spire 풍 덱빌더 로그라이크** 월드.
턴제 카드 전투, 덱 구성, 보상 선택, 맵 노드 진행을 메이플 월드 위에서 구현하는 것을 목표로 합니다.
로비 마을에서 NPC와 상호작용해 런을 시작하고, 턴제 카드 전투·덱 구성·보상 선택·절차 생성 맵 진행·전직·영혼 메타 성장을 메이플 월드 위에서 구현합니다.
> 이 저장소는 MSW **로컬 워크스페이스(Local Workspace)** 데이터를 git으로 형상관리하기 위한 것입니다.
> 공동작업자는 이 저장소를 통해 월드 데이터를 주고받습니다. (클라우드 공동제작 모드 미사용)
@@ -32,9 +32,10 @@ git push
```bash
git pull
```
받아온 뒤, 메이커에서 **로컬 워크스페이스를 다시 로드(reload)** 해야 새 codeblock/모델 파일이 에디터 상태로 반영됩니다.
받아온 뒤, 메이커에서 **로컬 워크스페이스를 다시 로드(reload)** 해야 새 codeblock/모델/맵 파일이 에디터 상태로 반영됩니다.
> 💡 같은 파일을 동시에 수정하면 git 충돌이 날 수 있으니, **서로 다른 맵/codeblock/UI를 나눠서** 작업하는 것을 권장합니다.
> ⚠️ git pull 후 reload를 빠뜨리면 메이커의 stale 상태가 디스크를 덮어쓸 수 있습니다. 재생성 후에도 reload → 빌드 콘솔 0 에러 확인.
---
@@ -42,95 +43,150 @@ git pull
```
slaymaple/
├── data/ # 게임 데이터 단일 소스 (생성기가 읽어 주입). 맵은 정적 데이터 없음(절차 생성)
│ ├── cards.json # 카드 123장(클래스·2차전직별 + 저주 + 표창 토큰) + 클래스별 시작 덱
│ ├── enemies.json # 적 12종(일반/정예/보스, 디버프 인텐트 포함)
│ ├── potions.json # 물약 6종 + 드랍률·슬롯·상점가
│ ├── relics.json # 유물 19종(StS 효과 × 메이플 장비) + 시작 유물 + 풀
│ ├── cardframes.json # 커스텀 카드 프레임 3종(전사/마법사/도적 × normal/unique/legend) + 보상 등급 가중치
│ └── camera.json # 맵별 카메라 설정값(줌·오프셋·고정 영역)
├── Global/ # 월드 전역 설정 · 공용 모델 · 게임로직
│ ├── common.gamelogic # SlayDeckController 부착 지점 (카드 UI 전투)
│ ├── Player.model # 플레이어 모델
│ ├── *.model # 몬스터 공용 모델
│ ├── common.gamelogic # SlayDeckController 부착 지점 (산출물)
│ ├── DefaultPlayer.model # 플레이어 모델 (턴전투용 이동 정지 freeze 적용)
│ ├── ChaseMonster.model · MoveMonster.model # 몬스터 공용 모델
│ ├── SectorConfig.config # 섹터/맵 등록 (lobby + map01~05 = 6 entries)
│ ├── WorldConfig.config # 월드 설정
│ └── ...
├── RootDesk/
│ └── MyDesk/ # 작업용 책상 — codeblock(스크립트)·모델·타일셋
│ ├── SlayDeckController.codeblock # 카드 UI 컨트롤러 (생성물)
│ ├── Monster.codeblock # 필드 액션 몬스터 (HP·피격·리스폰, 카드 전투와 별개)
│ ├── MonsterAttack.codeblock
│ ├── PlayerAttack.codeblock
│ ├── PlayerHit.codeblock
│ ├── UIPopup.codeblock
│ ├── UIToast.codeblock
│ └── RectTileData_Henesys.tileset
├── map/
│ ├── map01.map ~ map11.map # 맵 11종 (공식 배경 + STS풍 우측 배치)
├── tools/ # 결정적 생성기 (단일 소스)
│ ├── gen-slaydeck.mjs # 카드/덱 UI · SlayDeckController · common 생성
│ ├── gen-cardhand.mjs # 손패 카드 엔티티 생성
── gen-maps.mjs # 맵 생성
├── ui/ # UI 그룹 (Default / Popup / Toast)
│ └── MyDesk/ # 작업용 책상 — codeblock(스크립트)·타일셋
│ ├── SlayDeckController.codeblock # 게임 컨트롤러 (★산출물, 직접 편집 금지)
│ ├── Monster.codeblock · MonsterAttack.codeblock # 필드 액션 몬스터 (카드 전투와 별개)
│ ├── PlayerAttack.codeblock · PlayerHit.codeblock · UIPopup.codeblock · UIToast.codeblock
│ ├── CombatMonster.codeblock # 맵 몬스터 EnemyId 마커 + /common 자기등록
│ ├── MapCamera.codeblock # 맵별 카메라 적용
│ ├── PlayerLock.codeblock # 전투맵 플레이어 입력·이동 잠금
│ ├── LobbyNpc.codeblock # 로비 NPC 상호작용(근접·클릭)
│ └── LobbyMobility.codeblock # 로비 이동·공격 해제 + 카메라 추종
├── map/ # 맵 6종 (산출물)
│ ├── lobby.map # 로비 허브 맵 (마을 배경, NPC 4종, 전투 없음)
│ └── map01.map ~ map05.map # 5막 전투/맵 노드 (공식 배경 + STS풍 우측 배치)
├── tools/ # 결정적 생성기·도구 (주체별 폴더, 단일 소스)
│ ├── deck/ # ★게임 전체 생성 — gen-slaydeck.mjs(오케스트레이터) + lib/(ui-helpers·data·codeblock 공유) + hud/(화면별 UI emit 15종) + cb/(codeblock 메서드 17종). 출력=DefaultGroup.ui·SlayDeckController·common. gen-cardhand.mjs(보조)
── map/ # gen-maps.mjs(맵 배경/타일) · gen-lobby-map.mjs(로비 맵+NPC) · gen-map-encounters.mjs(노드별 몬스터 그룹) · rogue-map.mjs(절차 생성 JS 미러)+test
│ ├── camera/ # gen-camera.mjs(맵별 고정 카메라 codeblock)
│ ├── player/ # gen-player-lock.mjs(전투맵 입력 잠금) · freeze-turn-player.mjs(모델 이동 정지) · gen-lobby-npc.mjs(LobbyNpc·LobbyMobility codeblock)
│ ├── monster/ # gen-combat-monster.mjs(EnemyId 마커) · freeze-turn-monsters.mjs(필드 AI 정지)
│ ├── balance/ # sim-balance.mjs(전투 밸런스 몬테카를로 시뮬) · sim-balance.test.mjs
│ ├── verify/ # count.mjs(산출물 카운트 검증) · diffcheck.mjs(워킹트리 vs ref 바이트동일 검증 — 리팩터·머지용)
│ └── git/ # gitea-pr.mjs(UTF-8 안전 PR 생성/수정/머지 — RULES.md 참조)
├── ui/ # UI 그룹 (DefaultGroup ~6.8MB 산출물 / PopupGroup / ToastGroup)
├── docs/
── slaymaple_basic_framework.md # 전투 프레임워크 설계 문서
── slaymaple_basic_framework.md # 전투 프레임워크 설계 문서
│ └── ui-generation-structure.md # UI 생성 구조 문서
│ └── (superpowers/ — 개인 스킬 브레인스토밍/계획 산출물: `.gitignore` 처리·로컬 전용, 저장소 미포함)
├── RULES.md # 협업·AI 에이전트 하네스 규칙 (토큰 가드·검증·PR 절차)
├── CLAUDE.md # Claude Code 자동 로드 (RULES.md 임포트)
└── README.md
```
> ⚠️ **`map/*.map` · `ui/DefaultGroup.ui` · `*.codeblock` · `Global/*.gamelogic`는 생성 산출물**입니다 — 직접 편집하면 다음 재생성 때 사라집니다. 게임 변경은 `data/*.json` 또는 `tools/`의 생성기를 고친 뒤 재생성하세요(자세한 규칙은 [`RULES.md`](RULES.md)).
> `.mcp.json`, `.codex/` 는 **Authorization 토큰이 포함**되어 있어 git에서 제외됩니다(`.gitignore`). 각자 로컬에서 직접 구성하세요.
> `docs/superpowers/`(개인 스킬 브레인스토밍·계획 산출물)도 `.gitignore` 처리되어 **로컬 전용**입니다 — 프로젝트 설계 문서(`docs/*.md`)만 형상관리합니다.
---
## 게임 프레임워크 현황
현재 전투는 `Global/common.gamelogic``/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작합니다. 모든 카드/덱/전투 관련 산출물(`ui/DefaultGroup.ui` · `RootDesk/MyDesk/SlayDeckController.codeblock` · `common.gamelogic`)은 **`tools/gen-slaydeck.mjs` 단일 소스에서 생성**됩니다(직접 편집 금지, 결정적 출력).
**StS2풍 덱빌더 로그라이크가 end-to-end로 완성**됐고, 이제 **로비 마을을 기점으로 반복 런**이 돕니다:
| 컴포넌트 | 상태 | 역할 |
|---|---|---|
| `SlayDeckController` | ✅ 구현됨 | 카드 손패 UI 전투 — 드로우/버림/재셔플, 에너지, 카드 효과(데미지/방어), 적 HP·방어·의도, 턴 진행, 승패 |
| `Monster.codeblock` | ✅ 구현됨 | 필드 액션 몬스터(HP·피격·리스폰) — 카드 전투와는 **별개** 시스템 |
```
로비 맵(NPC 4종) → 모험가 NPC → 캐릭터 선택(전사/도적/마법사) → 절차 생성 맵(5막)
→ 전투/엘리트/상점/휴식/유물 방 → 보상·전직·덱 성장 → 보스 → 다음 막
→ 런 클리어(승천 해금) → 로비 복귀(영혼 정산) → 다음 런 …
```
### 구현된 카드 전투 (단일 전투 루프)
- **카드 손패 UI**: 에너지 3, 매 턴 5장 드로우, 버림 더미·재셔플, 카드 클릭 사용, 종류별 색상.
- **카드 3종**: 타격(피해 6) · 방어(방어도 5) · 강타(피해 10). 각 카드에 `damage`/`block` 수치 필드. 시작 덱 10장.
- **전투 상태**: 플레이어 `HP`/`Block`, 적 `HP`/`Block`/`Intent(의도)`. 적 의도는 **결정적 사이클**(공격10 → 공격6 → 방어8)로 다음 행동을 미리 표시.
- **규칙**: 데미지는 방어도 먼저 차감 후 잔여만 HP에 적용. 플레이어 Block은 턴 시작 시 리셋.
- **턴 흐름**: 카드 사용(`Attack`→적 HP↓, `Skill`→플레이어 Block↑) → 턴 종료 → 적 턴(의도 실행) → 다음 플레이어 턴.
- **승패**: 적 HP 0 → 승리, 플레이어 HP 0 → 패배. 승패 시 입력 잠금 + 결과 표시(전투 보상 훅 자리 = E 예정).
- **UI(CombatHud)**: 적 패널(이름·HP·방어·의도)·플레이어 패널(HP·방어)·승패 결과 텍스트.
게임 전체는 `/common` 엔티티에 부착된 **`SlayDeckController` 단일 컴포넌트**로 동작하며, 모든 산출물(`ui/DefaultGroup.ui` · `SlayDeckController.codeblock` · `common.gamelogic`)은 **`tools/deck/gen-slaydeck.mjs` 단일 소스에서 생성**됩니다(결정적 출력, 직접 편집 금지 — `RULES.md` 참조). 게임 데이터는 **`data/*.json`** 가 단일 소스, 맵 구조는 **런타임 절차 생성**(`GenerateMap` Lua ↔ `tools/map/rogue-map.mjs` JS 미러).
> ⚠️ 플레이어 HP(80)·적 HP(45)·의도 수치(10·6·8)는 **루프 검증용 임시 placeholder**입니다.
> 향후 캐릭터 특성별/몬스터별 데이터로 분리할 예정입니다(아래 D 참조).
### 구현된 기능 (배포 퀄리티 P1~P15 + 이후 개선, PR #34~#69)
| 영역 | 내용 |
|---|---|
| **로비 마을** | 전용 물리 맵 `lobby.map`(마을 배경). **NPC 4종 월드 엔티티** — 모험가(런 시작)·사서(카드 도감)·상인(영혼 상점)·안내원(게시판). 근접 시 머리 위 마크 + `↑`**또는 직접 클릭**으로 상호작용. **이동·공격 모션은 로비 맵에서만** 풀림(전투맵은 잠금), 카메라는 로비에서 **플레이어 추종**(전투맵은 고정) |
| **캐릭터·전직** | 시작 시 **전사(HP80)/도적(HP70)/마법사(HP70)** 3종 선택(**캐릭터 이미지 카드**, 선택 시 금색 테두리), 클래스별 시작 덱. 보스 클리어 시 [유물] vs [**2차 전직**] — 각 클래스 3종(전사→파이터/페이지/스피어맨, 법사→위자드불독/위자드썬콜/클레릭, 도적→Shiv/Poison/Trickster). 전용 카드는 해당 클래스 풀만 획득 |
| **카드 전투** | 에너지 3·드로우·**드래그 사용**(공격=적에 드롭, 스킬=위로 스윕). 카드 **123장** — kind **Attack/Skill/Power/Status**. 메커니즘: 다단히트·방어 무시·자가 디버프·드로·회복·**전체 공격(AoE)**·**독(DoT)**·**retain**(턴 종료 손패 유지)·**sly discard**(버림 트리거)·**소멸(exhaust)**·**가시(thorns)**·**민첩(dex)**·**표창 토큰**(손패에 Shiv 생성) |
| **버프/디버프** | StS 표준 — **힘**(+N 영구)·**약화**(주는 피해 25%)·**취약**(받는 피해 +50%)·**독**(매 행동 틱). 양방향(적 디버프 인텐트 포함), 인텐트는 최종 예상치 표시 |
| **전투 연출** | 공격 이펙트·**몬스터 데미지 팝업(자릿수 스킨)**·드래그 타깃 마커·적 개별 차례·**공격/피격/독뎀 모션**(아바타 상태 전이·몬스터 hit 클립·런지/넉백) |
| **절차 생성 맵** | 막 시작마다 **경로 생성**(런마다 다름, **가로 진행**). 층 규칙: 1~2층 전투만 → 3층~ 상점/휴식 → 4층~ 엘리트/**유물 방** → 보스 수렴. **노드맵 UI**: 타입별 공식 메이플 아이콘 노드(전투=버섯·엘리트=골렘·보스=발록·상점=돈주머니·휴식=모닥불·보물=상자) + scenic 배경 + 우하단 범례. 노드 타입별 **몬스터 랜덤 구성**(일반 1~3 / 엘리트 / 보스) + intent 랜덤 행동 |
| **유물 19종 / 물약 6종** | 유물: StS 효과 × 메이플 장비 외형, TopBar 아이콘 + 마우스오버 툴팁, 8종 훅. 물약: 승리 40% 드랍·상점·슬롯 메뉴. 보물 방=상자 연출 → 유물+메소 |
| **카드 프레임·등급** | 커스텀 프레임 3종(전사/마법사/도적 × normal/unique/legend), 카드 5개 사이트 통합 레이아웃. 보상 등급 가중 추첨 70/25/5 |
| **영혼(Soul) 메타 성장** | 승천과 별개의 영구 강화 화폐. 2차 전직 상태로 보스 클리어 시 적립 → 로비 영혼 상점 4종 해금(시작 메소 +60·HP +15·덱 정제·시작 유물 +1). **UserDataStorage 영구 저장** |
| **승천(Ascension)** | A1~A10 누적 모디파이어(적 강화·시작 HP 감소·보상 감소). UserDataStorage 유저별 영구 저장, 런 클리어 시 다음 단계 해금 |
| **멀티 act** | **5막** 진행(보스 클리어→다음 막 텔레포트, 맵·인카운터 변경, 적 스케일 `1+(막-1)*0.45`), 5막 클리어 시 런 종료 |
| **경제** | 화폐 표기 **메소**(코인 아이콘), 카드/유물/물약 메소 가격. 내부 식별자는 Gold 유지 |
| **밸런스 시뮬** | `tools/balance/sim-balance.mjs` — 전투 규칙 JS 미러(몬테카를로) + `tools/map/rogue-map.mjs`(맵 생성 미러) + node 단위테스트 |
> ⚠️ 수치(적 스탯·경제·승천 배율)는 1차 조정 상태입니다. 정밀 밸런싱은 `sim-balance.mjs`로 검증하며 진행합니다.
> 도적(Silent) 카드 88장은 효과·프레임은 적용됐으나 **카드 아이콘(image/fx) 미할당** 상태입니다(전사·마법사 카드는 실 스킬 아이콘 적용 완료).
### 유용한 스크립트 호출
`/common` 엔티티(또는 Play Test 컨텍스트)에서:
```lua
local c = _EntityService:GetEntityByPath("/common").SlayDeckController
c:PlayCard(1) -- 손패 slot 카드 사용
c:EndPlayerTurn() -- 턴 종료 → 적 턴 → 다음 턴
c:StartCombat() -- 전투 재시작(상태 초기화)
-- 로비
c:OnLobbyNpcInteract("run") -- 모험가(런 시작) / "codex"(도감) / "shop"(영혼상점) / "board"(게시판)
c:ShowLobby() -- 로비 맵 복귀 + 상태 초기화
-- 런
c:SelectClass("warrior") -- "warrior" / "bandit" / "magician"
c:StartNewGame() -- 캐릭터 선택 → 런 시작(map01 텔레포트)
c:PickNode("r1c2") -- 맵 노드 선택(절차 생성 그리드 id) / "boss"
c:PlayCard(1) -- 손패 slot 카드 사용
c:EndPlayerTurn() -- 턴 종료 → 적 턴 → 다음 턴
c:PickReward(1) -- 보상 카드 1택(0=건너뛰기)
c:BuyCard(1) / c:BuyRelic() / c:BuyPotion() -- 상점 구매(메소)
c:SetJob("fighter") -- 전직 (보스 보상 선택 화면)
c:AdjustAscension(1) -- 메뉴에서 승천 단계 +1
```
밸런스 검증: `node tools/balance/sim-balance.mjs [N] [--seed S]` · 테스트: `node --test tools/balance/sim-balance.test.mjs tools/map/rogue-map.test.mjs`.
상세 설계는 [`docs/slaymaple_basic_framework.md`](docs/slaymaple_basic_framework.md) 참조.
---
## 향후 설계 (미구현 — 목표 아키텍처)
아래는 로그라이크 메타까지 확장했을 때의 **목표 컴포넌트 구조**로, 현재는 미구현입니다. (현재는 `SlayDeckController` 하나가 카드 전투만 담당)
| 컴포넌트 (계획) | 역할 |
|---|---|
| `SlayCardCatalog` | 카드 데이터, 시작 덱 구성, 보상 풀, 카드 복제 정의 |
| `SlayRunState` | HP·골드·층수·덱·유물·카드 보상 등 런(run) 영속 데이터 관리 |
| `SlayCombatManager` | 턴 진행, 드로우/버림/소멸 더미, 에너지, 적 의도, 방어도, 데미지, 승패 처리 |
> 위 구조로 가더라도 카드/적 데이터는 `tools/`의 결정적 생성기를 단일 소스로 유지하는 방향을 권장합니다.
### 산출물 재생성
```bash
node tools/deck/gen-slaydeck.mjs # 게임 전체(UI·컨트롤러·common·맵 인카운터)
node tools/map/gen-maps.mjs # map01~05 배경/타일
node tools/map/gen-lobby-map.mjs # 로비 맵 + NPC 배치
node tools/player/gen-lobby-npc.mjs # 로비 codeblock(LobbyNpc·LobbyMobility)
node tools/camera/gen-camera.mjs # 맵별 카메라
node tools/player/gen-player-lock.mjs # 전투맵 입력 잠금
node tools/monster/gen-combat-monster.mjs # 몬스터 EnemyId 마커
```
> 산출물 검증은 내용 출력 없이 카운트만: `node tools/verify/count.mjs <ui|cb|common> <regex>...` (자세한 가드는 [`RULES.md`](RULES.md)).
---
## 다음 구현 단계
- [x] HP·방어도·에너지·적 의도·손패 카드를 렌더링하는 전투 UI **(완료 — `SlayDeckController` + CombatHud)**
- [x] 카드 사용이 실제 데미지/방어/적 의도/승패에 반영되는 단일 전투 루프 **(완료)**
- [ ] 카드/적 데이터를 `data/cards.json` · `data/enemies.json`로 외부화 (D)
- [ ] 전투를 N회 자동 시뮬레이션하는 밸런스 검증 도구 `tools/sim-balance.mjs` (F, D 선행)
- [ ] 전투/엘리트/상점/휴식/이벤트/보스 노드를 가진 맵 노드 UI (E)
- [ ] `OnCombatStart` / `OnCardPlayed` / `OnTurnStart` / `OnCombatReward` 훅을 가진 유물 시스템 (E)
- [ ] 적 행동 패턴을 데이터로 정의 (현재 단순 결정적 의도 사이클 → 무브셋)
- [ ] 런 영속(HP/골드/층/덱/유물) + 저장/불러오기 (E, 루프 end-to-end 후)
## 아키텍처 메모
현재 게임 전체 로직이 `SlayDeckController` 단일 codeblock에 모여 있습니다. 초기 설계의 3분할(`SlayCardCatalog`/`SlayRunState`/`SlayCombatManager`)은 **기능적으로 모두 구현**됐으나 아직 한 컴포넌트 안에 있습니다. 맵 NPC·카메라·입력 잠금 등 **맵 단위 동작은 별도 codeblock**(LobbyNpc/LobbyMobility/MapCamera/PlayerLock/CombatMonster)으로 분리해 각 맵 루트/엔티티에 부착합니다. 카드/적/맵/유물/프레임/카메라 데이터는 `data/*.json`로 외부화돼 있습니다.
**생성기 모듈화 (진행 중, PR #70~#72)**: 위 *출력*(단일 `SlayDeckController` 컴포넌트)은 그대로지만 **생성기**는 모듈화됐습니다 — `tools/deck/gen-slaydeck.mjs`(오케스트레이터)가 `lib/`(공유 헬퍼·데이터·codeblock 헬퍼)·`hud/`(화면별 UI emit 15종)·`cb/`(codeblock 메서드 17종)를 조합합니다(출력 바이트동일 순수 리팩터 — `tools/verify/diffcheck.mjs`로 검증, 의존 단방향 orchestrator→{hud,cb}→lib). 특정 화면 UI는 `hud/<name>.mjs`, 특정 메서드는 `cb/<name>.mjs`만 고치면 됩니다. 더해 **캐릭터 선택 화면을 메이커 저작 stock으로 이관**(레이아웃=메이커 시각 편집·재생성에 안 덮임, 이미지·선택은 컨트롤러가 경로로 런타임 주입)하는 **하이브리드 UI 파일럿**이 진행 중입니다(자세한 규칙은 `RULES.md` §1).
> ⚠️ **전투 규칙과 맵 생성은 Lua(gen-slaydeck 내장)와 JS 미러(sim-balance/rogue-map)로 이중 구현**입니다. 한쪽을 고치면 반드시 다른 쪽도 동기화하고 테스트하세요(`RULES.md` §6).
---
## 향후 개선 계획 (후속 후보)
- [x] 전투 루프 · 런 루프 · 절차 생성 맵 · 상점/휴식/유물 방 · 유물 19종 · 물약 · 버프/디버프 · Power · 전직(전사/법사/도적 2차) · 승천+개인 저장 · 전투 모션 · 커스텀 프레임 · **반복 런·로비 맵·NPC·영혼·메소·카메라 추종 (P1~P15 완료)**
- [x] **노드맵 UI 강화(아이콘 노드+배경+범례) · 캐릭터 선택 이미지 · exhaust/dex/thorns·표창 토큰 카드 (PR #58~#69)**
- [~] **생성기 모듈화(`lib`/`hud`/`cb`, 출력 바이트동일) + 캐릭터 선택 메이커 저작 stock 파일럿 (PR #70~#72 — 리뷰·플레이테스트 대기)**
- [ ] **도적 카드 아이콘** — Silent 88장에 실 스킬 아이콘(image/fx) 할당, 2차 전직 설명 한글화
- [ ] **런 이어하기** — 진행 중 런 직렬화 저장(UserDataStorage 확장, 메뉴 "이어하기" 활성화)
- [ ] **카드 제거/업그레이드** — 상점 카드 제거 슬롯, 휴식 노드에서 카드 강화
- [ ] **이벤트 노드(?)** — 랜덤 텍스트 이벤트(선택지·리스크/리워드)
- [ ] **3차 전직** — 후반 막 보상으로 확장
- [ ] **궁수 등 추가 클래스** — 캐릭터 선택 슬롯 확장
- [ ] **정밀 밸런싱** — 첫 인카운터 승률 완화·직업별 카드 효율 튜닝(`sim-balance.mjs` 리포트 기반)
- [ ] **상점 보장 규칙** — 막당 상점 최소 1회 등장
- [ ] **연출 보강** — 사운드(타격·획득), 맵 화면에 유물/물약 표시
---
@@ -141,4 +197,5 @@ c:StartCombat() -- 전투 재시작(상태 초기화)
```
2. MSW Maker에서 이 폴더를 **로컬 워크스페이스 경로**로 지정해 월드 열기
3. `.mcp.json` / `.codex/` 는 git에 없으므로, 본인 토큰으로 직접 생성 (MCP·Codex 사용 시)
4. 작업 전 항상 `git pull`, 작업 후 `git add/commit/push`
4. 작업 전 항상 `git pull` + 메이커 reload, 작업 후 `git add/commit/push`
5. **AI 에이전트(Claude Code 등)로 작업한다면 [`RULES.md`](RULES.md) 필독** — 생성 산출물 접근 금지(토큰 가드)·검증 절차·PR 도구(`tools/git/gitea-pr.mjs`) 규칙. Claude Code는 `CLAUDE.md`가 자동 적용

88
RULES.md Normal file
View File

@@ -0,0 +1,88 @@
# RULES.md — SlayMaple 하네스 엔지니어링 규칙
AI 에이전트(Claude Code 등)와 협업자가 이 저장소에서 **토큰을 낭비하지 않고 안전하게** 작업하기 위한 공용 규칙.
Claude Code는 `CLAUDE.md`가 이 파일을 임포트하므로 자동 적용된다. 다른 도구(Codex 등)를 쓰면 세션 시작 시 이 파일을 읽혀라.
---
## 1. 생성 산출물은 읽지도, 고치지도 않는다 (가장 중요)
이 저장소의 큰 파일들은 전부 **생성기 산출물**이다. 직접 읽으면 토큰이 증발하고, 직접 고치면 다음 재생성 때 사라진다.
| 산출물 (절대 Read/Edit 금지) | 크기 | 단일 소스 (여기만 편집) | 재생성 명령 |
|---|---|---|---|
| `ui/DefaultGroup.ui` | **~7.1MB** | `data/*.json` + `tools/deck/`(`gen-slaydeck.mjs`+`lib/`+`hud/`) | `node tools/deck/gen-slaydeck.mjs` |
| `RootDesk/MyDesk/SlayDeckController.codeblock` | ~270KB | 〃 | 〃 |
| `Global/common.gamelogic` | ~1KB | 〃 | 〃 |
| `map/map01.map`~`map05.map`, `map/lobby.map` | 각 ~210KB | `tools/map/`·`tools/monster/`·`tools/camera/`·`tools/player/` (↓ 보조 생성기) | 해당 생성기 |
| `RootDesk/MyDesk/CombatMonster.codeblock` | ~2KB | `tools/monster/gen-combat-monster.mjs` | `node tools/monster/gen-combat-monster.mjs` |
| `RootDesk/MyDesk/PlayerLock.codeblock` | ~2KB | `tools/player/gen-player-lock.mjs` | `node tools/player/gen-player-lock.mjs` |
| `RootDesk/MyDesk/MapCamera.codeblock` | ~2KB | `tools/camera/gen-camera.mjs` (값: `data/camera.json`) | `node tools/camera/gen-camera.mjs` |
| `RootDesk/MyDesk/LobbyNpc.codeblock`·`LobbyMobility.codeblock` | 각 ~2-3KB | `tools/player/gen-lobby-npc.mjs` | `node tools/player/gen-lobby-npc.mjs` |
| `Global/SectorConfig.config` | ~1KB | `tools/map/gen-maps.mjs`·`gen-lobby-map.mjs` (패치) | 해당 생성기 |
- `.claude/settings.json`의 permissions.deny가 위 파일의 Read/Edit/Write 도구 사용을 차단한다 (이 저장소를 열면 자동 적용). deny는 **glob**`ui/*.ui`·`map/*.map`·`RootDesk/MyDesk/*.codeblock`·`Global/common.gamelogic`·`Global/SectorConfig.config`. 따라서 **메이커 저작 codeblock/UI**(`Monster`·`MonsterAttack`·`PlayerAttack`·`PlayerHit`·`UIPopup`·`UIToast`.codeblock, `ui/PopupGroup.ui`·`ui/ToastGroup.ui`)**도** Read/Edit 금지 — 이들은 생성기가 없으니 **메이커에서** 편집한다(텍스트 도구로 X). codeblock은 한 줄짜리 JSON이라 Read 시 토큰 폭발.
- 게임 로직·UI 수정 = **`tools/deck/gen-slaydeck.mjs`(오케스트레이터 + codeblock Lua) 또는 `data/*.json`(데이터)을 수정** → 재생성 → 산출물은 통째로 커밋.
- **UI emit은 HUD별 모듈** `tools/deck/hud/*.mjs`(shop·combat·map·deckall·soulshop 등 15종 — **charselect는 제외: Phase 2에서 메이커 저작 stock으로 이관**, 아래), **codeblock 메서드(Lua)는 기능별 모듈** `tools/deck/cb/*.mjs`(boot·state·combat·hand·deckview·items·map·shop 등 17종, 메서드 161개를 연속런으로). **공유분**: UI 헬퍼·상수·데이터·lua 테이블 = `tools/deck/lib/ui-helpers.mjs`·`tools/deck/lib/data.mjs`, method/prop/codeblock 헬퍼·writeCodeblocks 상수 = `tools/deck/lib/codeblock.mjs`. 특정 화면 UI 수정은 `hud/<name>.mjs`, 특정 메서드 수정은 `cb/<name>.mjs`만(의존: orchestrator→{hud,cb}→lib 단방향). prop 103개는 오케스트레이터 writeCodeblocks에 유지. **cb 모듈은 원본 메서드 순서 보존이 바이트동일 조건** — 새 메서드는 해당 기능 모듈의 알맞은 위치에 추가하고 writeCodeblocks의 spread 순서 유지.
- 리팩터 시 **출력 바이트-동일 검증**: `node tools/deck/gen-slaydeck.mjs``node tools/verify/diffcheck.mjs [ref]`(워킹트리 vs ref(기본 HEAD) 줄바꿈 정규화 비교 — 산출물 경로를 명령줄에 노출 안 해 deny 회피). 산출물 ` M`은 보통 autocrlf churn이니 `git checkout --`로 복원.
- **하이브리드(메이커 저작) 화면 — charselect 파일럿(Phase 2)**: `CharacterSelectHud``GENERATED_UI_SECTIONS`에서 빠져 **생성기가 안 만들고 안 덮음** = `ui/DefaultGroup.ui`의 charselect 엔티티는 **메이커에서 시각 편집하는 stock**. 컨트롤러(`cb/charselect.mjs`)가 경로(`CharacterSelectHud/{Warrior,Thief,Mage}Button/Art` 등 — 메이커가 이 경로 유지 필수)로 이미지(`self.ClassPortraits`, `data/characters.json` 시드)·선택테두리·상태를 **런타임 주입**. 즉 레이아웃=메이커, 내용=컨트롤러. charselect 레이아웃 수정은 `hud/`가 아니라 **메이커에서**.
- **머지 충돌(gen-slaydeck.mjs)**: 다른 브랜치가 단일체를 수정해 충돌나면, 그쪽 버전(`git checkout --theirs tools/deck/gen-slaydeck.mjs`)을 취해 **콘텐츠 마커 기반으로 재모듈화**(라인인덱스 X — 줄 추가에 안전·export 이름 자동 파생·`const x=[]` 직전 전문 상수 walk-back 포함) 후 `node tools/verify/diffcheck.mjs origin/main`으로 ui·codeblock 바이트-동일 확인(손실 0 증명). codeblock 메서드·patchCommon은 오케스트레이터 잔류라 그쪽 변경은 자동 보존됨.
- **보조 생성기**(각자 자기 산출물의 단일 소스 — 위 표의 메인 `gen-slaydeck.mjs` 외):
- `tools/camera/gen-camera.mjs``MapCamera.codeblock` + map01~05 카메라 부착 (값 `data/camera.json`)
- `tools/map/gen-maps.mjs``map02~05` + `Global/SectorConfig.config` (map01 템플릿 클론)
- `tools/map/gen-lobby-map.mjs``map/lobby.map` + `SectorConfig.config`
- `tools/map/gen-map-encounters.mjs` → map01~05 노드 타입별 몬스터 그룹 재구성
- `tools/monster/gen-combat-monster.mjs``CombatMonster.codeblock` + map01~05 부착
- `tools/monster/freeze-turn-monsters.mjs` → 몬스터 `.model`·맵 AI 컴포넌트 제거
- `tools/player/gen-player-lock.mjs``PlayerLock.codeblock` + map01~05 부착
- `tools/player/gen-lobby-npc.mjs``LobbyNpc.codeblock`·`LobbyMobility.codeblock`
- `tools/player/freeze-turn-player.mjs``Global/DefaultPlayer.model` 이동 0 고정
- `tools/deck/gen-cardhand.mjs``DefaultGroup.ui` 카드핸드 보조 패처
## 2. 산출물 검증은 카운트로, 내용 출력 금지
재생성 결과 확인이 필요하면 **본문을 출력하지 말고** 존재/개수만 확인한다:
```bash
node tools/deck/gen-slaydeck.mjs # 성공 메시지 1줄
grep -c "TreasureHud" ui/DefaultGroup.ui # 개수만
grep -c "CalcPlayerAttack" RootDesk/MyDesk/SlayDeckController.codeblock
```
- ⚠️ codeblock은 **한 줄이 수만 자**(JSON 직렬화)다. `grep`(내용 출력)·`sed -n` 광역 범위 출력은 한 줄만 걸려도 토큰 폭발 → `grep -c`/`grep -l`/`grep -o '짧은패턴'`만 사용.
- Claude Code의 Grep 도구를 산출물에 쓸 때는 `output_mode: count` 또는 `files_with_matches`만. content 모드 금지.
- 진짜 내용 확인이 필요하면 좁은 `grep -o` 또는 `python`으로 슬라이스해서 **수 줄 이내**로.
## 3. 탐색 규칙
- 코드 탐색은 `tools/`·`data/`·`docs/`만 대상으로. 저장소 전체 grep은 산출물이 걸리므로 경로를 지정한다.
- `git add -A``git status --short`로 산출물 외 의도치 않은 변경이 없는지 확인 (산출물 diff는 보지 않는다 — 생성기가 결정적이므로 소스 리뷰로 충분).
- 게임 동작 확인은 메이커 플레이테스트(스크린샷·로그)로 한다. 산출물 정독으로 동작을 추론하지 않는다.
## 4. Git/PR 절차
- 브랜치 → 커밋(기능 단위) → push → **PR은 반드시 `node tools/git/gitea-pr.mjs`로** (인라인 `curl -d` 한글 본문은 Windows에서 CP949로 깨짐 — PR #34~41 사고).
- 제목/본문은 UTF-8 spec JSON 파일로 작성 후 `create <spec.json>` / `merge <번호>`.
- PR 제목과 본문은 한국어로 작성한다.
- 산출물 재생성 커밋은 소스 변경 커밋과 분리하거나, 메시지에 "산출물 재생성"을 명시.
## 5. 메이커(MSW) 연동 주의
- git pull 후 메이커에서 **로컬 워크스페이스 reload** 필수 (안 하면 메이커의 stale 상태가 디스크를 덮어씀).
- 재생성 후 메이커가 켜져 있으면 refresh → 빌드 콘솔 0 에러 확인.
- 카드/유물/물약 이미지는 **공식 maplestory 리소스 RUID**만 (계정 업로드 리소스는 로컬 워크스페이스에서 흰 박스).
## 6. 밸런스·맵 규칙 동기화
전투 규칙과 맵 생성은 Lua(생성기 내장)와 JS가 **이중 구현**이다. 한쪽을 고치면 반드시 다른 쪽도:
| 영역 | Lua (gen-slaydeck.mjs 내) | JS 미러 | 테스트 |
|---|---|---|---|
| 전투 규칙 | PlayCard·CalcPlayerAttack 등 | `tools/balance/sim-balance.mjs` | `node --test tools/balance/sim-balance.test.mjs` |
| 맵 생성 | GenerateMap | `tools/map/rogue-map.mjs` | `node --test tools/map/rogue-map.test.mjs` |
## 7. UI 숫자 표기
- UI 텍스트에서는 정수값인 숫자에 `.0`을 붙이지 않는다. `1.0/1.0`이 아니라 `1/1`처럼 표시한다.
- 생성기 내 Lua UI 코드에서 number 또는 숫자 문자열을 텍스트에 붙일 때는 `FormatNumber` 같은 포맷 헬퍼를 우선 사용한다.
- 소수부가 플레이어에게 의미 있을 때만 소수점 표기를 유지한다.

View File

@@ -0,0 +1,74 @@
{
"Id": "",
"GameId": "",
"EntryKey": "codeblock://combatmonster",
"ContentType": "x-mod/codeblock",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"CoreVersion": {
"Major": 0,
"Minor": 2
},
"ScriptVersion": {
"Major": 1,
"Minor": 0
},
"Description": "",
"Id": "CombatMonster",
"Language": 1,
"Name": "CombatMonster",
"Type": 1,
"Source": 0,
"Target": null,
"Properties": [
{
"Type": "string",
"DefaultValue": "\"\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "EnemyId"
},
{
"Type": "string",
"DefaultValue": "\"combat\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "Group"
},
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "RegTries"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.RegTries = 0\nlocal eventId = 0\nlocal function reg()\n\tself.RegTries = self.RegTries + 1\n\tlocal c = _EntityService:GetEntityByPath(\"/common\")\n\tif c ~= nil and c.SlayDeckController ~= nil then\n\t\tlocal mapName = \"\"\n\t\tif self.Entity.CurrentMapName ~= nil then\n\t\t\tmapName = self.Entity.CurrentMapName\n\t\tend\n\t\tc.SlayDeckController:RegisterMonster(self.Entity, self.EnemyId, self.Group, mapName)\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.RegTries > 50 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(reg, 0.1)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
}
],
"EntityEventHandlers": []
}
}
}

View File

@@ -0,0 +1,60 @@
{
"Id": "",
"GameId": "",
"EntryKey": "codeblock://lobbymobility",
"ContentType": "x-mod/codeblock",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"CoreVersion": {
"Major": 0,
"Minor": 2
},
"ScriptVersion": {
"Major": 1,
"Minor": 0
},
"Description": "",
"Id": "LobbyMobility",
"Language": 1,
"Name": "LobbyMobility",
"Type": 1,
"Source": 0,
"Target": null,
"Properties": [
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "Tries"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.Tries = 0\nlocal eventId = 0\nlocal function apply()\n\tself.Tries = self.Tries + 1\n\tlocal lp = _UserService.LocalPlayer\n\tif lp ~= nil and lp.PlayerControllerComponent ~= nil then\n\t\tlocal pc = lp.PlayerControllerComponent\n\t\tpc.Enable = true\n\t\tpc.FixedLookAt = 0\n\t\tlocal rb = lp.RigidbodyComponent\n\t\tif rb ~= nil then rb.WalkAcceleration = 0.7 end\n\t\tlocal mv = lp.MovementComponent\n\t\tif mv ~= nil then\n\t\t\tmv.InputSpeed = 1.4\n\t\t\tmv.JumpForce = 1.23\n\t\tend\n\t\tlocal cam = lp.CameraComponent\n\t\tif cam == nil then cam = _CameraService:GetCurrentCameraComponent() end\n\t\tif cam ~= nil then\n\t\t\tcam.ZoomRatio = 90\n\t\t\tcam.ConfineCameraArea = false\n\t\t\tcam.ScreenOffset = Vector2(0.5, 0.5)\n\t\t\tcam.CameraOffset = Vector2(0, 0)\n\t\tend\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.Tries > 50 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(apply, 0.1)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
}
],
"EntityEventHandlers": []
}
}
}

View File

@@ -0,0 +1,89 @@
{
"Id": "",
"GameId": "",
"EntryKey": "codeblock://lobbynpc",
"ContentType": "x-mod/codeblock",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"CoreVersion": {
"Major": 0,
"Minor": 2
},
"ScriptVersion": {
"Major": 1,
"Minor": 0
},
"Description": "",
"Id": "LobbyNpc",
"Language": 1,
"Name": "LobbyNpc",
"Type": 1,
"Source": 0,
"Target": null,
"Properties": [
{
"Type": "string",
"DefaultValue": "\"\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "NpcId"
},
{
"Type": "string",
"DefaultValue": "\"\"",
"SyncDirection": 0,
"Attributes": [],
"Name": "MarkName"
},
{
"Type": "boolean",
"DefaultValue": "false",
"SyncDirection": 0,
"Attributes": [],
"Name": "InRange"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.InRange = false\nlocal mark = _EntityService:GetEntityByPath(\"/maps/lobby/\" .. self.MarkName)\nif mark ~= nil then mark:SetVisible(false) end\nself.Entity:ConnectEvent(TouchEvent, function(e)\n\tself:Interact()\nend)\n_InputService:ConnectEvent(KeyDownEvent, function(e)\n\tif self.InRange and e.key == KeyboardKey.UpArrow then\n\t\tself:Interact()\n\tend\nend)\nlocal eventId = 0\nlocal function tick()\n\tlocal lp = _UserService.LocalPlayer\n\tif lp == nil then return end\n\tif mark == nil then mark = _EntityService:GetEntityByPath(\"/maps/lobby/\" .. self.MarkName) end\n\tlocal a = lp.TransformComponent.WorldPosition\n\tlocal b = self.Entity.TransformComponent.WorldPosition\n\tlocal d = Vector2.Distance(Vector2(a.x, a.y), Vector2(b.x, b.y))\n\tlocal near = d < 1.2\n\tif near ~= self.InRange then\n\t\tself.InRange = near\n\t\tif mark ~= nil then mark:SetVisible(near) end\n\tend\nend\neventId = _TimerService:SetTimerRepeat(tick, 0.15)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
},
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "local c = _EntityService:GetEntityByPath(\"/common\")\nif c ~= nil and c.SlayDeckController ~= nil then\n\tc.SlayDeckController:OnLobbyNpcInteract(self.NpcId)\nend",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "Interact"
}
],
"EntityEventHandlers": []
}
}
}

View File

@@ -0,0 +1,60 @@
{
"Id": "",
"GameId": "",
"EntryKey": "codeblock://mapcamera",
"ContentType": "x-mod/codeblock",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"CoreVersion": {
"Major": 0,
"Minor": 2
},
"ScriptVersion": {
"Major": 1,
"Minor": 0
},
"Description": "",
"Id": "MapCamera",
"Language": 1,
"Name": "MapCamera",
"Type": 1,
"Source": 0,
"Target": null,
"Properties": [
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "CamTries"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.CamTries = 0\nlocal eventId = 0\nlocal function apply()\n\tself.CamTries = self.CamTries + 1\n\tlocal cam = nil\n\tlocal lp = _UserService.LocalPlayer\n\tif lp ~= nil then\n\t\tcam = lp.CameraComponent\n\tend\n\tif cam == nil then\n\t\tcam = _CameraService:GetCurrentCameraComponent()\n\tend\n\tif cam ~= nil then\n\t\tcam.ZoomRatio = 90\n\t\tcam.ScreenOffset = Vector2(0.5, 0.655)\n\t\tcam.ConfineCameraArea = true\n\t\tcam.CameraOffset = Vector2(1.5, -0.83)\n\tend\n\tif cam ~= nil then\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.CamTries > 30 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(apply, 0.1)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
}
],
"EntityEventHandlers": []
}
}
}

View File

@@ -23,7 +23,6 @@
"MOD.Core.SpriteRendererComponent",
"MOD.Core.RigidbodyComponent",
"MOD.Core.MovementComponent",
"MOD.Core.AIWanderComponent",
"MOD.Core.StateComponent",
"MOD.Core.HitComponent",
"MOD.Core.DamageSkinSpawnerComponent",
@@ -57,10 +56,10 @@
},
"Value": {
"$type": "MOD.Core.MODQuaternion, MOD.Core",
"x": 0.0,
"y": 0.0,
"z": 0.0,
"w": 1.0
"x": 0,
"y": 0,
"z": 0,
"w": 1
}
},
{
@@ -186,8 +185,8 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"y": 0.0
"x": 0,
"y": 0
}
},
{
@@ -199,8 +198,8 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"y": 0.0
"x": 0,
"y": 0
}
},
{
@@ -219,7 +218,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 1.0
"Value": 0
},
{
"TargetType": "MOD.Core.MovementComponent",
@@ -228,7 +227,7 @@
"$type": "MODNativeType",
"type": "System.Single, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": 0.0
"Value": 0
},
{
"TargetType": "MOD.Core.MovementComponent",
@@ -239,24 +238,6 @@
},
"Value": true
},
{
"TargetType": "MOD.Core.AIWanderComponent",
"Name": "IsLegacy",
"ValueType": {
"$type": "MODNativeType",
"type": "System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": false
},
{
"TargetType": "MOD.Core.AIWanderComponent",
"Name": "Enable",
"ValueType": {
"$type": "MODNativeType",
"type": "System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"
},
"Value": true
},
{
"TargetType": "MOD.Core.StateComponent",
"Name": "IsLegacy",
@@ -376,8 +357,8 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"y": 0.0
"x": 0,
"y": 0
}
},
{
@@ -389,8 +370,8 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"y": 0.0
"x": 0,
"y": 0
}
},
{
@@ -402,8 +383,8 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"y": 0.0
"x": 0,
"y": 0
}
},
{
@@ -424,8 +405,8 @@
},
"Value": {
"$type": "MOD.Core.MODVector2, MOD.Core",
"x": 0.0,
"y": 0.0
"x": 0,
"y": 0
}
},
{
@@ -460,4 +441,4 @@
"Children": []
}
}
}
}

View File

@@ -85,7 +85,7 @@
"Name": null
},
"Arguments": [],
"Code": "local monster = self.Entity.Monster\nif not monster then\n\treturn\nend\n\nself.Shape = BoxShape(Vector2.zero, Vector2.one, 0)\n\n-- sprite 사이즈를 가져와 공격 영역으로 사용한다\n_ResourceService:PreloadAsync({self.Entity.SpriteRendererComponent.SpriteRUID}, function()\n\tlocal clip = _ResourceService:LoadAnimationClipAndWait(self.Entity.SpriteRendererComponent.SpriteRUID)\n\tlocal firstFrameSprite = clip.Frames[1].FrameSprite\n\tlocal firstSpriteSizeInPixel = Vector2(firstFrameSprite.Width, firstFrameSprite.Height)\n\tlocal ppu = firstFrameSprite.PixelPerUnit\n\n\tself.SpriteSize = firstSpriteSizeInPixel / ppu\n\tself.PositionOffset = (firstSpriteSizeInPixel / 2 - firstFrameSprite.PivotPixel:ToVector2()) / ppu\n\t\n\t_TimerService:SetTimerRepeat(function() \n\t\tif monster.IsDead == false then\n\t\t\tself:AttackNear()\n\t\tend\n\tend, self.AttackInterval)\nend)",
"Code": "local monster = self.Entity.Monster\nif not monster then\n\treturn\nend\n\nself.Shape = BoxShape(Vector2.zero, Vector2.one, 0)\n\n-- sprite 사이즈를 가져와 공격 영역으로 사용한다\n_ResourceService:PreloadAsync({self.Entity.SpriteRendererComponent.SpriteRUID}, function()\n\tif _ResourceService:GetTypeAndWait(self.Entity.SpriteRendererComponent.SpriteRUID) ~= ResourceType.AnimationClip then\n\t\treturn\n\tend\n\tlocal clip = _ResourceService:LoadAnimationClipAndWait(self.Entity.SpriteRendererComponent.SpriteRUID)\n\tif clip == nil then\n\t\treturn\n\tend\n\tlocal firstFrameSprite = clip.Frames[1].FrameSprite\n\tlocal firstSpriteSizeInPixel = Vector2(firstFrameSprite.Width, firstFrameSprite.Height)\n\tlocal ppu = firstFrameSprite.PixelPerUnit\n\n\tself.SpriteSize = firstSpriteSizeInPixel / ppu\n\tself.PositionOffset = (firstSpriteSizeInPixel / 2 - firstFrameSprite.PivotPixel:ToVector2()) / ppu\n\t\n\t_TimerService:SetTimerRepeat(function() \n\t\tif monster.IsDead == false then\n\t\t\tself:AttackNear()\n\t\tend\n\tend, self.AttackInterval)\nend)",
"Scope": 2,
"ExecSpace": 1,
"Attributes": [],

View File

@@ -0,0 +1,60 @@
{
"Id": "",
"GameId": "",
"EntryKey": "codeblock://playerlock",
"ContentType": "x-mod/codeblock",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"CoreVersion": {
"Major": 0,
"Minor": 2
},
"ScriptVersion": {
"Major": 1,
"Minor": 0
},
"Description": "",
"Id": "PlayerLock",
"Language": 1,
"Name": "PlayerLock",
"Type": 1,
"Source": 0,
"Target": null,
"Properties": [
{
"Type": "number",
"DefaultValue": "0",
"SyncDirection": 0,
"Attributes": [],
"Name": "LockTries"
}
],
"Methods": [
{
"Return": {
"Type": "void",
"DefaultValue": null,
"SyncDirection": 0,
"Attributes": [],
"Name": null
},
"Arguments": [],
"Code": "self.LockTries = 0\nlocal eventId = 0\nlocal function apply()\n\tself.LockTries = self.LockTries + 1\n\tlocal pc = nil\n\tlocal lp = _UserService.LocalPlayer\n\tif lp ~= nil then\n\t\tpc = lp.PlayerControllerComponent\n\tend\n\tif pc ~= nil then\n\t\tpc.LookDirectionX = 1\n\t\tpc.FixedLookAt = true\n\t\tpc.Enable = false\n\tend\n\tif lp ~= nil then\n\t\tif lp.RigidbodyComponent ~= nil then lp.RigidbodyComponent.WalkAcceleration = 0 end\n\t\tif lp.MovementComponent ~= nil then lp.MovementComponent.InputSpeed = 0; lp.MovementComponent.JumpForce = 0 end\n\tend\n\tif pc ~= nil then\n\t\t_TimerService:ClearTimer(eventId)\n\telseif self.LockTries > 30 then\n\t\t_TimerService:ClearTimer(eventId)\n\tend\nend\neventId = _TimerService:SetTimerRepeat(apply, 0.1)",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],
"Name": "OnBeginPlay"
}
],
"EntityEventHandlers": []
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://2d659478140c4b1c8f37febbb61bdaa0",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/2d659478140c4b1c8f37febbb61bdaa0/639171361295360405",
"upload_hash": "13E6D3B629261148095059F7C1D8EDC012C7A60422FC769ECB574A7C5A75759E",
"name": "archmage(fire_poison)",
"resource_guid": "2d659478140c4b1c8f37febbb61bdaa0",
"resource_version": "6a3022013e53f03801a4ac58"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://ddd0a729328f452b9ff0802ab1f6f579",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/ddd0a729328f452b9ff0802ab1f6f579/639171361295458274",
"upload_hash": "7B874B7774FA37E570B16E369112EC467EEA5EC1395A32E4DF01FB6165062E67",
"name": "archmage(thun_cold)",
"resource_guid": "ddd0a729328f452b9ff0802ab1f6f579",
"resource_version": "6a3022012c6a274be88a0819"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://efa920e58d31426486ef974106e7dc8b",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/efa920e58d31426486ef974106e7dc8b/639171361295547945",
"upload_hash": "C4B0469A46B70C2356DC8B0F99D36FD9480BFDA5832E251960DD1FF30C201B7F",
"name": "bandit",
"resource_guid": "efa920e58d31426486ef974106e7dc8b",
"resource_version": "6a302201a81bed5f59770e23"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://c357d2daf31a489d95b8fa47e50dd879",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/c357d2daf31a489d95b8fa47e50dd879/639168711224334184",
"upload_hash": "A1639991C7A8CB1025C97E6BE93F618088971DC609F03106C50D8B6EA145F6A2",
"name": "bandit_legend",
"resource_guid": "c357d2daf31a489d95b8fa47e50dd879",
"resource_version": "6a2c16d2cc7e89479f128ffb"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://9487b06867bc46269ed1d855420f457f",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/9487b06867bc46269ed1d855420f457f/639168711224307886",
"upload_hash": "FD9F2140D16EFC7A77F0B09CA230702CA6CA25E3178AAF6ACD63EF07D5C2C83E",
"name": "bandit_normal",
"resource_guid": "9487b06867bc46269ed1d855420f457f",
"resource_version": "6a2c16d23d5de2eb0c7d16a2"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://b3081fb2fb1445fa90b12b01481a78ef",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/b3081fb2fb1445fa90b12b01481a78ef/639168711224396185",
"upload_hash": "1F5218270148F873D060223A617DFC184AEEE61344D26C85705E40EECC086D5E",
"name": "bandit_unique",
"resource_guid": "b3081fb2fb1445fa90b12b01481a78ef",
"resource_version": "6a2c16d21a7908d59b5dc059"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://93e615d645e948f5a76656bfdd9dce15",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/93e615d645e948f5a76656bfdd9dce15/639171361295501823",
"upload_hash": "A5A1263A9E1F5D58B0F985AC58DE7DDE1EA13E0CC5CEE56FC34C3FD792A8D39E",
"name": "bowmaster",
"resource_guid": "93e615d645e948f5a76656bfdd9dce15",
"resource_version": "6a302201a0766b148f66ec2c"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://3c752ffcd4984dcb9f04baab06544f02",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/3c752ffcd4984dcb9f04baab06544f02/639171361295496567",
"upload_hash": "ED1ABC3DBCB04FCBD365BAD0408C8CA1D2458FB337DBDEB4A7EC5DF1BAC32496",
"name": "cleric",
"resource_guid": "3c752ffcd4984dcb9f04baab06544f02",
"resource_version": "6a302201d03493c632770e41"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://e207e6839a4a4bd0aab681bd296a609a",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/e207e6839a4a4bd0aab681bd296a609a/639171361295401732",
"upload_hash": "D5931943781C46611D43595594234BFB133F8BCCAA920C13BCA7E2F8AC9C09D0",
"name": "darkknight",
"resource_guid": "e207e6839a4a4bd0aab681bd296a609a",
"resource_version": "6a302201644d4c175c75d435"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://0efbf37bb7414aea82b257781068372b",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/0efbf37bb7414aea82b257781068372b/639171361295431877",
"upload_hash": "DE22318162E1F93E7B90A0B6A59BE31FC76DEEC122914979915D6D575A4D99B5",
"name": "hero",
"resource_guid": "0efbf37bb7414aea82b257781068372b",
"resource_version": "6a302201c377d9630d82c463"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://fd460e6ee38a40e3b6b05580d00773b6",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/fd460e6ee38a40e3b6b05580d00773b6/639171361295408991",
"upload_hash": "B832DE85217EA6544BA4477F0DBA5D2148E4E392A170944336E0DA74E8D41961",
"name": "hunter",
"resource_guid": "fd460e6ee38a40e3b6b05580d00773b6",
"resource_version": "6a3022013d5de2eb0c7d2a51"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://3b9ea1f066a744bb859df47fef817277",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/3b9ea1f066a744bb859df47fef817277/639171361295599801",
"upload_hash": "770C9D83241F8CE2C0EA8B742887D8351A580E2DE15A6380380653855A974F14",
"name": "mage",
"resource_guid": "3b9ea1f066a744bb859df47fef817277",
"resource_version": "6a302201cc7e89479f12a1ac"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://cff71f2e472041ce80c6fbd296f42e2d",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/cff71f2e472041ce80c6fbd296f42e2d/639168711224422293",
"upload_hash": "040CA63C5D3D52E1661F3A008D229A182B1A7C09D919BB74E23D1305B1AA56A7",
"name": "mage_legend",
"resource_guid": "cff71f2e472041ce80c6fbd296f42e2d",
"resource_version": "6a2c16d2a0766b148f66d799"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://d788d09f6f50467ebc67f01dec45f9e2",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/d788d09f6f50467ebc67f01dec45f9e2/639168711224258598",
"upload_hash": "5D91E6E333564F15A76554AE07402DC0ED39ABC02439F65C2CCB818C71FB6994",
"name": "mage_normal",
"resource_guid": "d788d09f6f50467ebc67f01dec45f9e2",
"resource_version": "6a2c16d2e75b0d4ccdfcee8b"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://f5def2e8022b4e59a17d3c16414034fe",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/f5def2e8022b4e59a17d3c16414034fe/639168711224271729",
"upload_hash": "B07075BE5F13B1D7BFB4AE36F4C47D97A10A3CC7EF763F02DD2A11C2AFC61E67",
"name": "mage_unique",
"resource_guid": "f5def2e8022b4e59a17d3c16414034fe",
"resource_version": "6a2c16d2e75b0d4ccdfcee8a"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://994c64290e6d4248bd60aba03a595f72",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/994c64290e6d4248bd60aba03a595f72/639171361295422460",
"upload_hash": "A0B7146F6D8E9D72CEACDB7E21A2CD6EEBD4135554D3965B4E1C06BC98FC1211",
"name": "nightlord",
"resource_guid": "994c64290e6d4248bd60aba03a595f72",
"resource_version": "6a30220139613d284615a1e1"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://415d423954764b659574fe829f9aff52",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/415d423954764b659574fe829f9aff52/639171361329450040",
"upload_hash": "EB94BA457C18E04D87964201DD50042695A3C7F93651CBC8ED9509BDDE07F331",
"name": "palladin",
"resource_guid": "415d423954764b659574fe829f9aff52",
"resource_version": "6a302205a0766b148f66ec2d"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://94d6417c55da48e9861964c405991219",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/94d6417c55da48e9861964c405991219/639171361329410075",
"upload_hash": "EBEC4B43CDBB1759C0BC92C051252A7DF84E6B89C9C56ECBFEFF543A0E260889",
"name": "pirate",
"resource_guid": "94d6417c55da48e9861964c405991219",
"resource_version": "6a3022052c6a274be88a081a"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://06c1060586e3457f897f2c596eb5cd71",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/06c1060586e3457f897f2c596eb5cd71/639171361329387468",
"upload_hash": "42EE2E63508762E86391486107A23322E11038070F48851D03FE91255CF41596",
"name": "shadower",
"resource_guid": "06c1060586e3457f897f2c596eb5cd71",
"resource_version": "6a302205a81bed5f59770e24"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://b2e099f2e5334705af122e3f88840ba7",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/b2e099f2e5334705af122e3f88840ba7/639171361329408374",
"upload_hash": "2AB66279D07E64A17CD2AD05BB03F732632805B0076278EC94F51C0227341CC5",
"name": "singung",
"resource_guid": "b2e099f2e5334705af122e3f88840ba7",
"resource_version": "6a302204c377d9630d82c464"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://6d741a60c60743cb98ee740a1e2dbfed",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/6d741a60c60743cb98ee740a1e2dbfed/639168711258121433",
"upload_hash": "80E2D8FFC8E56ECE4694BED5A387413F49633841DD37B7AA9FA9AC713962602C",
"name": "warior_legend",
"resource_guid": "6d741a60c60743cb98ee740a1e2dbfed",
"resource_version": "6a2c16d59c3c6c308bfd4bf8"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://4bb57ef88ef449fdaf958f6cf37fe44b",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/4bb57ef88ef449fdaf958f6cf37fe44b/639168711258172386",
"upload_hash": "2832F3B6C4C9530DE69DF061E590FD314D8646BE6BDB65931AFBE68D38DBB0ED",
"name": "warior_normal",
"resource_guid": "4bb57ef88ef449fdaf958f6cf37fe44b",
"resource_version": "6a2c16d53d5de2eb0c7d16a3"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://4f71c124c8bc4e13b5e9fad392995f68",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/4f71c124c8bc4e13b5e9fad392995f68/639168711257524633",
"upload_hash": "F27B2D713455FB18C10E924D381C4582E5E039D5DCCA9CECD2FB0D8E9A4C8135",
"name": "warior_unique",
"resource_guid": "4f71c124c8bc4e13b5e9fad392995f68",
"resource_version": "6a2c16d5a43134e1b8a34b65"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"Id": "",
"GameId": "",
"EntryKey": "sprite://28c88fdc5ab44f34a8b3fc1e19d4ce78",
"ContentType": "x-mod/sprite",
"Content": "",
"Usage": 0,
"UsePublish": 1,
"UseService": 0,
"CoreVersion": "26.5.0.0",
"StudioVersion": "0.1.0.0",
"DynamicLoading": 0,
"ContentProto": {
"Use": "Json",
"Json": {
"upload_keyname": "30/54/30542a379cb74d2d807104635740a8ea/sprite/28c88fdc5ab44f34a8b3fc1e19d4ce78/639171361330139251",
"upload_hash": "2929F452FBB26215631886FFB430EE6035D55EB42B1770E880C1B0A34D97BDA0",
"name": "warrior",
"resource_guid": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
"resource_version": "6a30220539613d284615a1e2"
}
}
}

8
data/camera.json Normal file
View File

@@ -0,0 +1,8 @@
{
"zoomRatio": 90,
"screenOffsetX": 0.5,
"screenOffsetY": 0.655,
"confineCameraArea": true,
"cameraOffsetX": 1.5,
"cameraOffsetY": -0.83
}

39
data/cardframes.json Normal file
View File

@@ -0,0 +1,39 @@
{
"frames": {
"warrior": {
"normal": "4bb57ef88ef449fdaf958f6cf37fe44b",
"unique": "4f71c124c8bc4e13b5e9fad392995f68",
"legend": "6d741a60c60743cb98ee740a1e2dbfed"
},
"magician": {
"normal": "d788d09f6f50467ebc67f01dec45f9e2",
"unique": "f5def2e8022b4e59a17d3c16414034fe",
"legend": "cff71f2e472041ce80c6fbd296f42e2d"
},
"bandit": {
"normal": "9487b06867bc46269ed1d855420f457f",
"unique": "b3081fb2fb1445fa90b12b01481a78ef",
"legend": "c357d2daf31a489d95b8fa47e50dd879"
}
},
"classToFrame": {
"warrior": "warrior",
"fighter": "warrior",
"page": "warrior",
"spearman": "warrior",
"magician": "magician",
"firepoison": "magician",
"icelightning": "magician",
"cleric": "magician",
"bandit": "bandit",
"curse": "bandit",
"shiv": "bandit",
"poisoner": "bandit",
"trickster": "bandit"
},
"rewardWeights": {
"normal": 70,
"unique": 25,
"legend": 5
}
}

File diff suppressed because it is too large Load Diff

7
data/characters.json Normal file
View File

@@ -0,0 +1,7 @@
{
"portraits": {
"warrior": "28c88fdc5ab44f34a8b3fc1e19d4ce78",
"magician": "3b9ea1f066a744bb859df47fef817277",
"bandit": "efa920e58d31426486ef974106e7dc8b"
}
}

View File

@@ -15,7 +15,8 @@
"intents": [
{ "kind": "Attack", "value": 14 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Defend", "value": 10 }
{ "kind": "Defend", "value": 10 },
{ "kind": "Debuff", "effect": "weak", "value": 1 }
]
},
"slime_boss": {
@@ -24,10 +25,102 @@
"intents": [
{ "kind": "Attack", "value": 18 },
{ "kind": "Defend", "value": 12 },
{ "kind": "Debuff", "effect": "vuln", "value": 2 },
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 22 }
]
},
"orange_mushroom": {
"name": "주황버섯",
"maxHp": 16,
"intents": [
{ "kind": "Attack", "value": 5 },
{ "kind": "Attack", "value": 5 },
{ "kind": "Defend", "value": 4 },
{ "kind": "Attack", "value": 8 }
]
},
"blue_mushroom": {
"name": "파란버섯",
"maxHp": 22,
"intents": [
{ "kind": "Attack", "value": 4 },
{ "kind": "Attack", "value": 4 },
{ "kind": "Attack", "value": 10 },
{ "kind": "AddCard", "card": "Wound", "count": 1 }
]
},
"pig": {
"name": "돼지",
"maxHp": 18,
"intents": [
{ "kind": "Attack", "value": 6 },
{ "kind": "Attack", "value": 6 },
{ "kind": "Defend", "value": 5 }
]
},
"green_mushroom": {
"name": "초록버섯",
"maxHp": 20,
"intents": [
{ "kind": "Attack", "value": 7 },
{ "kind": "Defend", "value": 3 },
{ "kind": "Attack", "value": 9 }
]
},
"red_snail": {
"name": "빨간 달팽이",
"maxHp": 14,
"intents": [
{ "kind": "Attack", "value": 5 },
{ "kind": "Defend", "value": 6 },
{ "kind": "Attack", "value": 7 }
]
},
"stump": {
"name": "나무토막",
"maxHp": 19,
"intents": [
{ "kind": "Defend", "value": 5 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Attack", "value": 6 }
]
},
"mushmom": {
"name": "머쉬맘",
"maxHp": 75,
"intents": [
{ "kind": "Defend", "value": 10 },
{ "kind": "Debuff", "effect": "weak", "value": 2 },
{ "kind": "Attack", "value": 16 },
{ "kind": "Attack", "value": 9 },
{ "kind": "Defend", "value": 6 },
{ "kind": "AddCard", "card": "Burn", "count": 1 }
]
},
"modified_snail": {
"name": "변형된 달팽이",
"maxHp": 60,
"intents": [
{ "kind": "Attack", "value": 12 },
{ "kind": "Defend", "value": 8 },
{ "kind": "Attack", "value": 7 },
{ "kind": "Attack", "value": 14 },
{ "kind": "Debuff", "effect": "weak", "value": 1 }
]
},
"king_slime": {
"name": "킹 슬라임",
"maxHp": 130,
"intents": [
{ "kind": "Attack", "value": 18 },
{ "kind": "Defend", "value": 14 },
{ "kind": "Debuff", "effect": "vuln", "value": 2 },
{ "kind": "Attack", "value": 12 },
{ "kind": "Attack", "value": 24 }
]
}
},
"activeEnemy": "slime"
"activeEnemy": "slime",
"simEncounter": ["orange_mushroom", "orange_mushroom", "blue_mushroom"]
}

View File

@@ -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": [] }
}
}

11
data/nodeicons.json Normal file
View File

@@ -0,0 +1,11 @@
{
"icons": {
"combat": "f98db6823e894a4f90308d61f75894ac",
"elite": "793ed8a757534b89a82f460747d2df24",
"boss": "423056cdbbc04f4da131b9721c404d96",
"shop": "da37e1fac55d455b9ade08569f09f798",
"rest": "b86c1b0568bd45f3ae4a4b97e1b4a594",
"treasure": "f8a6d58e20f54e2ca899485055df1ce4"
},
"background": "ef89906dd9844fcbaafc0b2313812eca"
}

14
data/potions.json Normal file
View File

@@ -0,0 +1,14 @@
{
"potions": {
"redPotion": { "name": "빨간 포션", "desc": "HP 20 회복", "effect": "heal", "value": 20, "icon": "393e2a0d8da544899eaa8b22c97f832b" },
"firebomb": { "name": "화염병", "desc": "적에게 피해 20", "effect": "damage", "value": 20, "icon": "7ddb464c2574456289a4eb72ce86f193" },
"warriorElixir": { "name": "전사의 물약", "desc": "힘 +2", "effect": "strength", "value": 2, "icon": "7cfbd410581e4073815daaf5f3e6c72f" },
"guardPotion": { "name": "수호의 물약", "desc": "방어도 +12", "effect": "block", "value": 12, "icon": "8f8402dfa0f746e18bf606ed74302c0a" },
"manaElixir": { "name": "마나 엘릭서", "desc": "에너지 +2", "effect": "energy", "value": 2, "icon": "ec2778c366f6477ab0f8e7f06bcd73f4" },
"cursedVial": { "name": "저주의 병", "desc": "적에게 약화 3", "effect": "weak", "value": 3, "icon": "a9a2763fdb6849dcba3028c737487680" }
},
"dropChance": 0.4,
"baseSlots": 3,
"beltSlots": 5,
"shopPrice": 20
}

30
data/relics.json Normal file
View File

@@ -0,0 +1,30 @@
{
"relics": {
"ironHeart": { "name": "강철 심장", "desc": "전투 시작 시 방어도 +6", "hook": "combatStart", "effect": "block", "value": 6, "icon": "e555b3a62f3c49dbb2c53784e6bd481f" },
"energyCore": { "name": "에너지 코어", "desc": "턴 시작 시 에너지 +1", "hook": "turnStart", "effect": "energy", "value": 1, "icon": "a41014f28b47434ab9f49ef104523862" },
"vampire": { "name": "흡혈 송곳니", "desc": "공격 카드 사용 시 HP +1", "hook": "cardPlayed", "effect": "healOnAttack", "value": 1, "icon": "ed64cde7e6c44b9e99502847e54f04e9" },
"goldIdol": { "name": "황금 우상", "desc": "전투 승리 시 메소 +10", "hook": "combatReward", "effect": "gold", "value": 10, "icon": "03bb05c92b8f45edb0f3dad2e118fd5a" },
"potionBelt": { "name": "장인의 벨트", "desc": "물약 슬롯이 5칸으로 늘어난다", "hook": "passive", "effect": "potionSlots", "value": 5, "icon": "36725b4566ac40d4902e2ab2113c2096" },
"burningBlood": { "name": "자쿰의 투구", "desc": "전투 승리 시 HP 6 회복", "hook": "combatEnd", "effect": "healOnWin", "value": 6, "icon": "07f994825ce34131b419d43e890c878d" },
"vajra": { "name": "미스릴 해머", "desc": "전투 시작 시 힘 +1", "hook": "combatStart", "effect": "strength", "value": 1, "icon": "59d2579d46dc41d590a9e6b141ad458b" },
"anchor": { "name": "메이플 실드", "desc": "첫 턴 방어도 +10", "hook": "combatStart", "effect": "block", "value": 10, "icon": "6349413e08cc49848862591863d056a0" },
"bagOfPrep": { "name": "모험가의 배낭", "desc": "첫 턴 드로우 +2", "hook": "combatStart", "effect": "draw", "value": 2, "icon": "77b240cb8af245b4801a714380267ae9" },
"bloodVial": { "name": "피의 목걸이", "desc": "전투 시작 시 HP 2 회복", "hook": "combatStart", "effect": "heal", "value": 2, "icon": "c782e949506a42c49eb139c7e65527d7" },
"bronzeScales": { "name": "브론즈 체인메일", "desc": "피격 시 공격자에게 3 반사", "hook": "onPlayerDamaged", "effect": "thorns", "value": 3, "icon": "87272346b145412391622cf803f888d1" },
"strawberry": { "name": "건강의 반지", "desc": "획득 시 최대 HP +7", "hook": "passive", "effect": "maxHp", "value": 7, "icon": "58f643e29c354c2783a5ce9a72ec155c" },
"penNib": { "name": "황금 깃펜", "desc": "10번째 공격마다 피해 2배", "hook": "attackCalc", "effect": "penNib", "value": 10, "icon": "4d38d721cc064d14b31b9e9a92754139" },
"boot": { "name": "브론즈 부츠", "desc": "5 미만 공격 피해가 5로", "hook": "attackCalc", "effect": "boot", "value": 5, "icon": "d572b3aa4dac4162aa0d9e551b055dce" },
"akabeko": { "name": "황소 투구", "desc": "전투 첫 공격 피해 +8", "hook": "attackCalc", "effect": "akabeko", "value": 8, "icon": "eb3330a6e2274eff958639f8792119d3" },
"centennialPuzzle": { "name": "백년의 부적", "desc": "전투 첫 피격 시 드로우 3", "hook": "onPlayerDamaged", "effect": "firstLossDraw", "value": 3, "icon": "cfe5ed6556b944fc83ab58b774bb2b73" },
"meatOnBone": { "name": "고기 망치", "desc": "승리 시 HP 50% 이하면 12 회복", "hook": "combatEnd", "effect": "healIfLow", "value": 12, "icon": "a93e8e87f184411c98c96b877d9f8b10" },
"selfFormingClay": { "name": "점토 갑옷", "desc": "피해를 받으면 다음 턴 방어 +3", "hook": "onPlayerDamaged", "effect": "clayBlock", "value": 3, "icon": "bb446793c5204d5db7d33563fe79f648" },
"championBelt": { "name": "챔피언 벨트", "desc": "취약 부여 시 약화 1 추가", "hook": "cardDebuff", "effect": "vulnAddsWeak", "value": 1, "icon": "7ca8c63026034113a561d6adf679fed2" }
},
"startingRelic": "ironHeart",
"relicPool": [
"energyCore", "vampire", "goldIdol",
"potionBelt", "burningBlood", "vajra", "anchor", "bagOfPrep", "bloodVial",
"bronzeScales", "strawberry", "penNib", "boot", "akabeko",
"centennialPuzzle", "meatOnBone", "selfFormingClay", "championBelt"
]
}

View File

@@ -1,402 +0,0 @@
# 하단 카드 손패 UI 목업 Implementation Plan
> **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:** 전투 화면 하단에 카드 5장이 수평 일렬로 보이는 정적(static) 손패 UI 목업을 `ui/DefaultGroup.ui`에 추가한다.
**Architecture:** 카드 데이터 테이블 + MSW UI 엔티티 템플릿으로 21개 엔티티(컨테이너 1 + 카드 5 + 카드별 텍스트 3×5=15)를 생성하는 일회성 Node 스크립트(`tools/gen-cardhand.mjs`)를 만든다. 스크립트는 기존 엔티티를 변경하지 않고 `ContentProto.Entities` 배열 끝에 새 엔티티 JSON 텍스트만 삽입한다(텍스트 splice, 전체 재직렬화 없음). Maker에서 reload 후 Play 모드 스크린샷으로 시각 검증한다.
**Tech Stack:** MSW Maker `.ui`(JSON) 엔티티, Node.js(ESM, 표준 라이브러리만), MSW Maker MCP(`maker_refresh_workspace`/`maker_play`/`maker_screenshot`/`maker_stop`).
---
## File Structure
- Create: `tools/gen-cardhand.mjs` — 카드 손패 엔티티 생성기. 카드 데이터 + 컴포넌트 빌더(transform/sprite/text) + entity 빌더로 21개 엔티티를 만들고 `ui/DefaultGroup.ui`에 삽입. 멱등(이미 CardHand 있으면 무변경).
- Modify: `ui/DefaultGroup.ui` — 스크립트가 `ContentProto.Entities` 끝에 CardHand 계층을 추가(기존 엔티티 불변).
좌표 공식(기존 `Button_Attack`로 검증 완료):
- `OffsetMin = pos - pivot*size`, `OffsetMax = pos + (1-pivot)*size`
- `Position.x = anchor.x*parentW - parentW/2 + pos.x` (y도 동일, parentH 사용)
- 여기서 `pos`(=anchoredPosition)는 pivot 지점의 앵커 기준 오프셋, `parentW/H`는 **직속 부모**의 크기.
배치 요약:
- CardHand: 부모 DefaultGroup(1920×1080), anchor(0.5,0), pivot(0.5,0), size 1020×280, pos(0,30)
- Card i(0..4): 부모 CardHand(1020×280), anchor(0.5,0.5), pivot(0.5,0.5), size 180×250, pos((-2+i)*200, 0)
- Cost: 부모 Card(180×250), anchor(0,1), pivot(0.5,0.5), size 50×50, pos(32,-32)
- Name: anchor(0.5,1), size 160×50, pos(0,-70)
- Desc: anchor(0.5,0), size 160×80, pos(0,55)
---
### Task 1: 생성 스크립트 작성
**Files:**
- Create: `tools/gen-cardhand.mjs`
- [ ] **Step 1: 스크립트 파일 작성**
`tools/gen-cardhand.mjs`에 아래 내용을 그대로 작성한다.
```js
import { readFileSync, writeFileSync } from 'node:fs';
const FILE = 'ui/DefaultGroup.ui';
// ---- card data ----
const ATTACK = { r: 0.86, g: 0.42, b: 0.38, a: 1.0 };
const DEFEND = { r: 0.42, g: 0.55, b: 0.85, a: 1.0 };
const cards = [
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '강타', cost: '2', desc: '피해 10', tint: ATTACK },
];
const CARD_BG_RUID = 'cd0560c4fc7f3b14994b90a502f00a21'; // 기존 버튼 스프라이트 재사용
const CARD_W = 180, CARD_H = 250;
// ---- guid helper (deterministic, hex-safe) ----
const guid = (n) =>
`cad0${n.toString(16).padStart(2, '0')}-0000-4000-8000-${n.toString(16).padStart(12, '0')}`;
// ---- component builders ----
function transform({ parentW, parentH, anchor, pivot, size, pos }) {
const offMin = { x: pos.x - pivot.x * size.x, y: pos.y - pivot.y * size.y };
const offMax = { x: pos.x + (1 - pivot.x) * size.x, y: pos.y + (1 - pivot.y) * size.y };
const position = {
x: anchor.x * parentW - parentW / 2 + pos.x,
y: anchor.y * parentH - parentH / 2 + pos.y,
z: 0.0,
};
return {
'@type': 'MOD.Core.UITransformComponent',
ActivePlatform: 255,
AlignmentOption: 0,
AnchorsMax: { x: anchor.x, y: anchor.y },
AnchorsMin: { x: anchor.x, y: anchor.y },
MobileOnly: false,
OffsetMax: offMax,
OffsetMin: offMin,
Pivot: { x: pivot.x, y: pivot.y },
RectSize: { x: size.x, y: size.y },
UIMode: 1,
UIScale: { x: 1.0, y: 1.0, z: 1.0 },
UIVersion: 2,
anchoredPosition: { x: pos.x, y: pos.y },
Position: position,
QuaternionRotation: { x: 0.0, y: 0.0, z: 0.0, w: 1.0 },
Scale: { x: 1.0, y: 1.0, z: 1.0 },
Enable: true,
};
}
function sprite({ dataId = '', color, type = 1, raycast = true }) {
return {
'@type': 'MOD.Core.SpriteGUIRendererComponent',
AnimClipPlayType: 0,
EndFrameIndex: 2147483647,
ImageRUID: { DataId: dataId },
LocalPosition: { x: 0.0, y: 0.0 },
LocalScale: { x: 1.0, y: 1.0 },
OverrideSorting: false,
PlayRate: 1.0,
PreserveSprite: 0,
StartFrameIndex: 0,
Color: color,
DropShadow: false,
DropShadowAngle: 30.0,
DropShadowColor: { r: 0.0, g: 0.0, b: 0.0, a: 0.72 },
DropShadowDistance: 32.0,
FillAmount: 1.0,
FillCenter: true,
FillClockWise: true,
FillMethod: 0,
FillOrigin: 0,
FlipX: false,
FlipY: false,
FrameColumn: 1,
FrameRate: 0,
FrameRow: 1,
Outline: false,
OutlineColor: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
OutlineWidth: 3.0,
RaycastTarget: raycast,
Type: type,
Enable: true,
};
}
function text({ value, fontSize, bold, alignment = 4 }) {
return {
'@type': 'MOD.Core.TextComponent',
Alignment: alignment,
Bold: bold,
DropShadow: false,
DropShadowAngle: 30.0,
DropShadowColor: { r: 0.0, g: 0.0, b: 0.0, a: 0.72 },
DropShadowDistance: 32.0,
Font: 0,
FontColor: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
FontSize: fontSize,
MaxSize: fontSize,
MinSize: 8,
OutlineColor: { r: 0.1, g: 0.1, b: 0.1, a: 1.0 },
OutlineDistance: { x: 1.0, y: -1.0 },
OutlineWidth: 1.0,
Overflow: 0,
OverrideSorting: false,
Padding: { left: 0, right: 0, top: 0, bottom: 0 },
SizeFit: false,
Text: value,
UseOutLine: true,
Enable: true,
};
}
function entity({ id, path, modelId, entryId, componentNames, components, displayOrder }) {
const parts = path.split('/');
const name = parts[parts.length - 1];
const slashes = '/'.repeat(parts.length - 1);
return {
id,
path,
componentNames,
jsonString: {
name,
path,
nameEditable: true,
enable: true,
visible: true,
localize: true,
displayOrder,
pathConstraints: slashes,
revision: 1,
origin: {
type: 'Model',
entry_id: entryId,
sub_entity_id: null,
root_entity_id: null,
replaced_model_id: null,
},
modelId,
'@components': components,
'@version': 1,
},
};
}
// ---- build entities ----
const TRANSPARENT = { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
const ents = [];
let g = 0;
// CardHand container
ents.push(entity({
id: guid(g++),
path: '/ui/DefaultGroup/CardHand',
modelId: 'uiempty',
entryId: 'UIEmpty',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 4,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0 }, pivot: { x: 0.5, y: 0 }, size: { x: 1020, y: 280 }, pos: { x: 0, y: 30 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
],
}));
cards.forEach((c, i) => {
const cardPath = `/ui/DefaultGroup/CardHand/Card${i + 1}`;
// card background
ents.push(entity({
id: guid(g++),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: i,
components: [
transform({ parentW: 1020, parentH: 280, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: (-2 + i) * 200, y: 0 } }),
sprite({ dataId: CARD_BG_RUID, color: c.tint, type: 0, raycast: true }),
],
}));
// cost (top-left)
ents.push(entity({
id: guid(g++),
path: `${cardPath}/Cost`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0, y: 1 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 50, y: 50 }, pos: { x: 32, y: -32 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.cost, fontSize: 34, bold: true }),
],
}));
// name (upper-center)
ents.push(entity({
id: guid(g++),
path: `${cardPath}/Name`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 1 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 50 }, pos: { x: 0, y: -70 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.name, fontSize: 28, bold: true }),
],
}));
// desc (lower-center)
ents.push(entity({
id: guid(g++),
path: `${cardPath}/Desc`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 2,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 80 }, pos: { x: 0, y: 55 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.desc, fontSize: 22, bold: false }),
],
}));
});
// ---- splice into file ----
let txt = readFileSync(FILE, 'utf8');
if (txt.includes('/ui/DefaultGroup/CardHand')) {
console.log('CardHand already present — no changes made.');
process.exit(0);
}
const matches = txt.match(/\n {4}\]/g); // Entities 닫는 대괄호(4-space indent)는 파일 내 유일
if (!matches || matches.length !== 1) {
console.error(`Expected exactly one Entities closing bracket, found ${matches ? matches.length : 0}. Aborting.`);
process.exit(1);
}
const blocks = ents
.map((e) => JSON.stringify(e, null, 2).split('\n').map((l) => ' ' + l).join('\n'))
.join(',\n');
txt = txt.replace('\n ]', ',\n' + blocks + '\n ]');
JSON.parse(txt); // 유효성 검증 (실패 시 throw)
writeFileSync(FILE, txt, 'utf8');
console.log(`Inserted ${ents.length} CardHand entities.`);
```
- [ ] **Step 2: 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add tools/gen-cardhand.mjs
git commit -m "하단 카드 손패 엔티티 생성 스크립트 추가"
```
---
### Task 2: 스크립트 실행 및 결과 검증
**Files:**
- Modify: `ui/DefaultGroup.ui` (스크립트가 수정)
- [ ] **Step 1: 스크립트 실행**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-cardhand.mjs
```
Expected 출력:
```
Inserted 21 CardHand entities.
```
- [ ] **Step 2: JSON 유효성 + 엔티티 수 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const c=j.ContentProto.Entities.filter(e=>e.path.includes('CardHand'));console.log('count:',c.length);console.log(c.map(e=>e.path).join('\n'))"
```
Expected: `count: 21` 그리고 경로 목록에 `/ui/DefaultGroup/CardHand`, `.../Card1`~`.../Card5`, 각 카드의 `/Cost`,`/Name`,`/Desc`가 모두 나타남.
- [ ] **Step 3: 멱등성 확인 (재실행 시 무변경)**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-cardhand.mjs
```
Expected 출력:
```
CardHand already present — no changes made.
```
- [ ] **Step 4: 기존 엔티티 불변 확인**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git diff ui/DefaultGroup.ui | findstr /R "^-"
```
Expected: 삭제(`-`)된 줄이 **마지막 엔티티 뒤 `]` 직전 한 줄 외에는 없음** — 즉 기존 엔티티 내용은 그대로이고 끝에만 추가됨. (삭제 라인은 splice 지점의 ` ]` 한 줄뿐이어야 함)
---
### Task 3: Maker 시각 검증
**Files:** (없음 — 검증 전용)
- [ ] **Step 1: 워크스페이스 reload**
MCP 도구 `maker_refresh_workspace` 호출 (edit 모드여야 함). Expected: `status: ok`.
- [ ] **Step 2: Play 모드 진입**
MCP 도구 `maker_play` 호출. (UI는 edit 캔버스가 아닌 Play 렌더에서 보임)
- [ ] **Step 3: 스크린샷 촬영 및 확인**
MCP 도구 `maker_screenshot` 호출 후 반환된 path를 Read로 열어 확인.
Expected: 화면 **하단 중앙에 카드 5장이 수평 일렬**로 보이고, 각 카드에 코스트(1/2)·이름(타격/방어/강타)·설명(피해6/방어도5/피해10)이 표시되며, 공격 카드는 붉은톤·방어 카드는 푸른톤.
문제가 보이면(위치 어긋남/텍스트 안 보임/색 이상) 수치를 조정해 Task 1의 스크립트 파라미터를 고치고, `ui/DefaultGroup.ui`의 CardHand 블록을 되돌린 뒤(아래 명령) Task 2부터 재실행한다.
되돌리기:
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git checkout ui/DefaultGroup.ui
```
- [ ] **Step 4: Play 모드 종료**
MCP 도구 `maker_stop` 호출.
---
### Task 4: 최종 커밋
**Files:**
- `ui/DefaultGroup.ui`
- [ ] **Step 1: 변경 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add ui/DefaultGroup.ui
git commit -m "전투 화면 하단에 카드 손패 5장 목업 추가"
```
---
## 검증 요약
- 스크립트 단위 검증: `node tools/gen-cardhand.mjs` → 21개 삽입, 재실행 시 멱등
- 데이터 검증: `JSON.parse` 성공 + CardHand 경로 21개 + 기존 엔티티 불변(diff)
- 시각 검증: Maker Play 스크린샷에서 하단 5장 카드 렌더 확인

View File

@@ -1,235 +0,0 @@
# 카드 슬롯 이미지 적용 Implementation Plan
> **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:** 하단 손패 5번 슬롯(강타)의 외형을 완성형 카드 이미지 `invincible belief.png`("리부트 프로토콜")로 교체한다.
**Architecture:** PNG를 MSW 계정 sprite 리소스로 업로드해 RUID를 발급받고, 그 RUID를 생성기 `gen-cardhand.mjs`의 5번 카드 데이터에 `image` 필드로 넣는다. 생성기는 `image`가 있는 카드를 단색 배경 대신 해당 RUID 스프라이트(흰색 틴트, 180×270)로 만들고 텍스트 자식을 생성하지 않는다. 재생성 후 Maker reload→play 스크린샷으로 검증한다.
**Tech Stack:** MSW 에셋 MCP(`asset_create_account_resource_storage_item`, 2단계 업로드), curl PUT, Node.js 생성기, msw-maker-mcp(reload/play/screenshot).
---
## File Structure
- Modify: `tools/gen-cardhand.mjs` — 카드 빌드 루프에 `image` 분기 추가, 5번 카드에 RUID 부여.
- Modify: `ui/DefaultGroup.ui` — 생성기가 5번 카드를 이미지 스프라이트로 재생성.
- 외부: MSW 계정 리소스 스토리지에 PNG 업로드(저장소엔 RUID만 들어감).
원본 이미지: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\invincible belief.png` (세로 약 2:3, 완성형 카드).
---
### Task 1: 이미지 업로드 및 RUID 확보 (컨트롤러/MCP 실행)
**Files:** 없음 (외부 리소스 생성)
- [ ] **Step 1: 파일 크기 확인**
```bash
node -e "console.log(require('fs').statSync('C:/Users/jaeoh/Desktop/workspace/source/images/maple/invincible belief.png').size)"
```
출력된 바이트 수를 `<BYTES>`로 사용한다.
- [ ] **Step 2: 업로드 1단계 — presigned URL 발급**
MCP `asset_create_account_resource_storage_item` 호출:
- `category`: `sprite`
- `subcategory`: `etc`
- `name`: `slaymaple_card_reboot_protocol`
- `description`: `SlayMaple 손패 카드 이미지 (리부트 프로토콜)`
- `contentLength`: `<BYTES>`
- `fileUrl`: 생략
응답에서 `presignedUrl``<PRESIGNED_URL>`로 확보.
- [ ] **Step 3: 파일 PUT 업로드**
```bash
curl.exe -X PUT --data-binary "@C:/Users/jaeoh/Desktop/workspace/source/images/maple/invincible belief.png" "<PRESIGNED_URL>"
```
Expected: HTTP 200 (출력 없음 또는 빈 본문). 오류 시 응답 본문 확인.
- [ ] **Step 4: 업로드 2단계 — 리소스 생성 완료**
MCP `asset_create_account_resource_storage_item` 다시 호출, 이번엔 동일 파라미터 + `fileUrl`: `<PRESIGNED_URL>`.
응답에서 발급된 리소스 **RUID(GUID/DataId)**`<RUID>`로 확보.
- [ ] **Step 5: RUID 검증**
MCP `asset_list_account_resources` (`category`: `sprite`, `subcategory`: `etc`, `searchWord`: `reboot`) 호출 → 방금 만든 리소스가 목록에 있고 RUID가 일치하는지 확인. `<RUID>`를 기록해 Task 2에서 사용.
---
### Task 2: 생성기에 image 분기 추가
**Files:**
- Modify: `tools/gen-cardhand.mjs`
- [ ] **Step 1: 5번 카드 데이터에 image 필드 추가**
`cards` 배열의 마지막 원소(강타)를 다음으로 교체 (`<RUID>`는 Task 1에서 확보한 실제 값):
```js
{ name: '강타', cost: '2', desc: '피해 10', tint: ATTACK, image: '<RUID>' },
```
- [ ] **Step 2: 카드 빌드 루프를 image 분기로 교체**
`cards.forEach((c, i) => { ... });` 블록 전체(현재 카드 배경 + cost/name/desc 생성)를 다음으로 교체:
```js
cards.forEach((c, i) => {
const cardPath = `/ui/DefaultGroup/CardHand/Card${i + 1}`;
const cardH = c.image ? 270 : CARD_H;
const cardSprite = c.image
? sprite({ dataId: c.image, color: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, type: 0, raycast: true })
: sprite({ color: c.tint, type: 1, raycast: true });
// card background (or full image)
ents.push(entity({
id: guid(g++),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: i,
components: [
transform({ parentW: 1020, parentH: 280, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: cardH }, pos: { x: (i - (cards.length - 1) / 2) * CARD_SPACING, y: 0 }, align: ALIGN_CENTER }),
cardSprite,
],
}));
// 이미지 카드는 텍스트 오버레이를 만들지 않는다 (이미지에 이미 포함)
if (c.image) return;
// cost (top-left)
ents.push(entity({
id: guid(g++),
path: `${cardPath}/Cost`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 0,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 50, y: 50 }, pos: { x: -60, y: 95 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.cost, fontSize: 34, bold: true }),
],
}));
// name (upper-center)
ents.push(entity({
id: guid(g++),
path: `${cardPath}/Name`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 50 }, pos: { x: 0, y: 50 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.name, fontSize: 28, bold: true }),
],
}));
// desc (lower-center)
ents.push(entity({
id: guid(g++),
path: `${cardPath}/Desc`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 2,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 160, y: 80 }, pos: { x: 0, y: -80 } }),
sprite({ color: TRANSPARENT, type: 1, raycast: false }),
text({ value: c.desc, fontSize: 22, bold: false }),
],
}));
});
```
- [ ] **Step 3: 스크립트 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add tools/gen-cardhand.mjs
git commit -m "카드 손패 생성기: image 필드 지원 (5번 카드 이미지 적용)"
```
---
### Task 3: 재생성 및 데이터 검증
**Files:**
- Modify: `ui/DefaultGroup.ui`
- [ ] **Step 1: 카드 없는 베이스로 되돌린 뒤 재생성**
직전 카드 커밋(`c9c761d`) 이전 베이스에서 ui를 받아 재생성한다. (생성기는 CardHand가 이미 있으면 no-op이므로 베이스가 필요)
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git checkout 2c39066 -- ui/DefaultGroup.ui
node tools/gen-cardhand.mjs
```
Expected: `Inserted 18 CardHand entities.` (컨테이너 1 + 카드 5 + 텍스트 12 = 18)
- [ ] **Step 2: 5번 카드 = 이미지, 텍스트 없음 / 나머지 4장 텍스트 유지 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=j.ContentProto.Entities;const card5=E.find(e=>e.path==='/ui/DefaultGroup/CardHand/Card5');const sp=card5.jsonString['@components'][1];const tr=card5.jsonString['@components'][0];console.log('card5 image:', sp.ImageRUID.DataId);console.log('card5 height:', tr.RectSize.y);console.log('card5 has text children:', E.some(e=>e.path.startsWith('/ui/DefaultGroup/CardHand/Card5/')));console.log('card1 has text children:', E.some(e=>e.path.startsWith('/ui/DefaultGroup/CardHand/Card1/')));console.log('total CardHand entities:', E.filter(e=>e.path.includes('CardHand')).length)"
```
Expected:
- `card5 image:``<RUID>` 와 일치
- `card5 height: 270`
- `card5 has text children: false`
- `card1 has text children: true`
- `total CardHand entities: 18`
- [ ] **Step 3: JSON 유효성 + 기존(우리 외) 엔티티 불변**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));console.log('JSON ok')"
```
Expected: `JSON ok`. (Button_Attack/Jump/UIJoystick/UIChat 4개 기본 엔티티는 splice가 끝에만 추가하므로 불변)
---
### Task 4: Maker 시각 검증 (컨트롤러 실행)
**Files:** 없음
- [ ] **Step 1: reload** — msw-maker-mcp `maker_refresh_workspace` (edit 모드). Expected `status: ok`.
- [ ] **Step 2: play**`maker_play`.
- [ ] **Step 3: 로드 확인**`maker_execute_script` (context client):
```lua
local c5 = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card5")
log("CARD5="..tostring(c5 ~= nil))
if c5 ~= nil then local r = c5.SpriteGUIRendererComponent; log("CARD5_IMG="..tostring(r.ImageRUID.DataId)) end
```
→ `maker_logs(kind=normal)` 에서 `CARD5=true`, `CARD5_IMG=<RUID>` 확인. (이미지 미로드 시 `maker_logs(kind=build)` 도 확인)
- [ ] **Step 4: screenshot** — `maker_screenshot` 후 Read로 열어 5번 자리에 "리부트 프로토콜" 카드 이미지가 왜곡 없이 표시되는지 확인. 나머지 4장은 단색 목업 유지.
- [ ] **Step 5: stop** — `maker_stop`.
문제 시(이미지 안 보임/깨짐): subcategory를 `item`으로 바꿔 재업로드하거나, 스프라이트 Type/PreserveSprite를 조정. ui 되돌리기: `git checkout ui/DefaultGroup.ui` 후 Task 3부터 재실행.
---
### Task 5: 최종 커밋
**Files:**
- `ui/DefaultGroup.ui`
- [ ] **Step 1: 디스크 무결성 후 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add ui/DefaultGroup.ui
git commit -m "5번 카드 슬롯에 리부트 프로토콜 이미지 카드 적용"
```
---
## 검증 요약
- RUID 발급/검증 (asset_list)
- 생성기: `Inserted 18`, 5번=이미지·텍스트없음·270, 나머지 텍스트 유지, JSON 유효
- Maker: Lua로 Card5 이미지 RUID 확인 + 스크린샷 시각 확인

View File

@@ -1,217 +0,0 @@
# 맵 개선(다양한 몬스터 + 타일셋 + StS2 배치) Implementation Plan
> **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:** map02~map11에 공식 맵에서 수확한 다양한 몬스터 2종(기존 4종 미사용)을 StS2 우측 배치로, 맵마다 다른 타일셋으로 재생성한다.
**Architecture:** 공식 맵 import로 몬스터 변형 `{sprite,stand,hit,die}`과 타일셋 RUID를 수확(배경 수확과 동일 기법) → `tools/gen-maps.mjs``MONSTER_VARIANTS`/`TILESETS`에 반영 → 몬스터 선택을 "서로 다른 2종 + 정적 베이스 + StS2 우측 고정위치"로, TileSetRUID를 맵별로 교체 → map02~map11 재생성. map02 스파이크로 렌더 검증 후 확대.
**Tech Stack:** Node.js, MSW `.map` JSON, msw-maker-mcp(import/save/play/screenshot/execute_script), msw-mcp.
---
## File Structure
- Modify: `tools/gen-maps.mjs``MONSTER_VARIANTS`/`TILESETS` 데이터 + 몬스터 선택/배치 로직 + TileSetRUID 교체.
- Modify(재생성): `map/map02.map`~`map11.map`.
기준 사실:
- 몬스터 엔티티: `SpriteRendererComponent.SpriteRUID` + `StateAnimationComponent.ActionSheet{stand,hit,die}`. 정적 베이스로 쓸 템플릿은 path에 `Static` 포함(StaticMonsterTemplate, 배회 안 함).
- 타일: 맵의 `/TileMap` 엔티티 `TileMapComponent.TileSetRUID.DataId`. map01 기본 `9dfea3808bbd49a5877d8624df21b1c7`.
- 배경: 기존 `BACKGROUNDS` 10종 유지.
- import는 현재 맵(map02, 재생성 가능)을 교체 → save → 파일에서 추출.
---
### Task 1: 몬스터 변형 + 타일셋 수확 (컨트롤러/MCP, 스파이크 포함)
**목표:** `MONSTER_VARIANTS`(≥12종 `{sprite,stand,hit,die}`) + `TILESETS`(10종 RUID) 확정.
- [ ] **Step 1: 몬스터 엔티티 구조 스파이크**
몬스터가 있는 공식 **필드맵** 1개를 import(`maker_import_maplestory_map`) → `maker_save``map/map02.map`에서 `script.Monster`를 포함하는 엔티티를 찾아 `SpriteRendererComponent.SpriteRUID` + `StateAnimationComponent.ActionSheet`(stand/hit/die)가 존재하는지 확인.
- 존재 → 그 형태로 변형 추출.
- 부재(구조 다름) → 폴백: `SpriteRUID`만 추출하고 `ActionSheet`는 map01 템플릿 유지(생성기에서 변형에 stand/hit/die가 없으면 ActionSheet 미변경하도록 처리).
필드맵 후보 id는 `maker_list_maplestory_maps`로 탐색(영문/지역명). 몬스터가 있는 사냥/필드맵을 고른다.
- [ ] **Step 2: 변형 ≥12종 수확**
필드맵 여러 개를 import→save→추출 반복. 각 맵의 몬스터 엔티티들에서 `{sprite, stand, hit, die}`를 모아 **중복 sprite 제거**해 ≥12종 확보. map01의 4종 sprite(`8ef238e0…`,`6c7130f5…`,`3e76c89a…`,`6d381bea…`,`c96c11f9…`)는 **제외**.
- [ ] **Step 3: 타일셋 10종 수확**
import한 맵들의 `TileMapComponent.TileSetRUID.DataId`를 수집해 **distinct 10종**(map01의 `9dfea380…` 제외). (배경 수확 때처럼 import 1회로 타일셋+몬스터 동시 수확 가능)
- [ ] **Step 4: 결과 정리**
`MONSTER_VARIANTS = [{sprite,stand,hit,die}, ...]`(≥12)와 `TILESETS = [ruid, ...]`(10)를 Task 2에 넘길 형태로 기록. (코드 변경 없음; 데이터 산출)
---
### Task 2: 생성기 로직·데이터 갱신
**Files:** Modify `tools/gen-maps.mjs`
- [ ] **Step 1: TILESETS 상수 추가**
`BACKGROUNDS = [...]` 정의 바로 아래에 추가(값은 Task 1 결과):
```js
// 공식 맵에서 수확한 타일셋 RUID 10종 (맵마다 다르게). map01 기본(9dfea380…) 제외.
const TILESETS = [
// Task 1에서 수확한 10개 RUID
];
```
- [ ] **Step 2: MONSTER_VARIANTS 채우기**
기존 `const MONSTER_VARIANTS = [];` 를 Task 1에서 수확한 ≥12종으로 교체:
```js
// 공식 맵에서 수확한 몬스터 변형 (기존 map01 4종 미사용).
const MONSTER_VARIANTS = [
// { sprite: '...', stand: '...', hit: '...', die: '...' }, ... (≥12종)
];
```
- [ ] **Step 3: 몬스터 배치 로직 교체 (서로 다른 2종 + StS2 + 정적 베이스)**
`buildMap` 안의 몬스터 추가 루프(`const ents = ...` 이후 `for (let i = 0; i < 2; i++) { ... }` 블록 전체)를 다음으로 교체:
```js
const ents = map.ContentProto.Entities.filter((e) => !isMonster(e));
// 정적 베이스(StS2 위치 고정 — 배회 방지). 변형이 sprite/animation을 덮어쓰므로 외형은 베이스와 무관.
const base = monsterTemplates.find((e) => (e.path || '').includes('Static')) || monsterTemplates[0];
// 서로 다른 변형 2종 선택 (맵 내 중복 금지)
const vi = Math.floor(rand() * MONSTER_VARIANTS.length);
const vj = (vi + 1 + Math.floor(rand() * (MONSTER_VARIANTS.length - 1))) % MONSTER_VARIANTS.length;
const chosen = [MONSTER_VARIANTS[vi], MONSTER_VARIANTS[vj]];
const STS2_X = [3.5, 5.5]; // 화면 우측 전투 포메이션
for (let i = 0; i < 2; i++) {
const m = JSON.parse(JSON.stringify(base));
m.jsonString.name = `Monster${i + 1}`;
m.path = `/maps/map${tag}/Monster${i + 1}`;
m.jsonString.path = m.path;
const tr = compOf(m, 'MOD.Core.TransformComponent');
if (tr) tr.Position.x = STS2_X[i];
const v = chosen[i];
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent');
if (sp) sp.SpriteRUID = v.sprite;
const sa = compOf(m, 'MOD.Core.StateAnimationComponent');
if (sa && v.stand) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die };
ents.push(m);
}
```
(`v.stand`가 없으면 ActionSheet를 유지 → 폴백 호환)
- [ ] **Step 4: TileSetRUID 교체 추가**
`buildMap`의 경로/배경 설정 루프 `for (const e of ents) { ... }` 안, 배경 설정 블록 다음에 추가:
```js
if ((e.path || '').endsWith('/TileMap')) {
const tm = compOf(e, 'MOD.Core.TileMapComponent');
if (tm && TILESETS.length > 0) tm.TileSetRUID = { DataId: TILESETS[(nn - 2) % TILESETS.length] };
}
```
- [ ] **Step 5: 구문 확인 + 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node --check tools/gen-maps.mjs
git add tools/gen-maps.mjs
git commit -m "맵 생성기: 수확한 다양한 몬스터 2종(StS2 배치) + 맵별 타일셋 교체"
```
---
### Task 3: map02 스파이크 — 재생성 + Maker 검증
**Files:** Modify `map/map02.map`
- [ ] **Step 1: map02 재생성**
수확 import로 오염된 map02를 깨끗이 재생성:
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git checkout map/map01.map # 혹시 모를 보호(템플릿). map01은 변경 대상 아님
node tools/gen-maps.mjs 2
```
Expected: `Generated: map02`
- [ ] **Step 2: 데이터 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('map/map02.map','utf8'));const E=j.ContentProto.Entities;const ms=E.filter(e=>(e.componentNames||'').includes('script.Monster'));const old=['8ef238e0d0ca4bb783aca526cff35d11','6c7130f51a654803a1c39cbe30e2f427','3e76c89ae8e7477ca871f5bbcd6f6f29','6d381bea1bcb4504b518a1fbfa0904ac','c96c11f9a3f845a4b6a27d9ca10ab103'];const sprs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.SpriteRendererComponent').SpriteRUID);const xs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.TransformComponent').Position.x);const tm=E.find(e=>(e.path||'').endsWith('/TileMap')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.TileMapComponent').TileSetRUID.DataId;console.log('monsters:',ms.length);console.log('sprites:',sprs.join(','));console.log('distinct sprites:',new Set(sprs).size===2);console.log('no old sprite:',sprs.every(s=>!old.includes(s)));console.log('positions x:',xs.join(','));console.log('tileset:',tm,'changed:',tm!=='9dfea3808bbd49a5877d8624df21b1c7')"
```
Expected: `monsters: 2`, 2개 sprite distinct, `no old sprite: true`, positions x = `3.5,5.5`, tileset이 `9dfea380…`이 아님(교체됨).
- [ ] **Step 3: Maker 렌더 검증 (컨트롤러)**
1. `maker_refresh_workspace`
2. map02가 활성인지 확인(`maker_get_current_map`). 아니면 사용자에게 map02 열기 요청.
3. `maker_play``maker_screenshot` → Read로 확인: 몬스터 2마리가 **수확된(기존과 다른) 외형**으로 **우측에** 보이고, **타일 텍스처가 바뀌었는지**.
4. `maker_execute_script`(client)로 확인:
```lua
local m1=_EntityService:GetEntityByPath("/maps/map02/Monster1")
local m2=_EntityService:GetEntityByPath("/maps/map02/Monster2")
if m1 then log("M1 spr="..tostring(m1.SpriteRendererComponent.SpriteRUID).." x="..tostring(m1.TransformComponent.Position.x)) end
if m2 then log("M2 spr="..tostring(m2.SpriteRendererComponent.SpriteRUID).." x="..tostring(m2.TransformComponent.Position.x)) end
```
→ `maker_logs(normal)`로 sprite/x 확인.
5. `maker_stop`.
- [ ] **Step 4: 게이트 판정**
- 몬스터 외형 변경 + 우측 배치 + 타일 변경 정상 → Task 4.
- 몬스터가 흰박스/안 보임 → 변형 sprite/animation 로드 문제 → Task 1 폴백(SpriteRUID만, ActionSheet 유지) 적용 후 재생성.
- 타일이 깨져 보임 → 해당 타일셋 제외하거나 호환 타일셋으로 교체(`TILESETS` 조정) 후 재생성.
---
### Task 4: 전체 재생성 + 검증
**Files:** Modify `map/map02.map`~`map11.map`, `Global/SectorConfig.config`
- [ ] **Step 1: 전체 재생성**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-maps.mjs
```
Expected: `Generated: map02 … map11`, `SectorConfig entries: 11`.
- [ ] **Step 2: 전체 데이터 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const fs=require('fs');const old=['8ef238e0d0ca4bb783aca526cff35d11','6c7130f51a654803a1c39cbe30e2f427','3e76c89ae8e7477ca871f5bbcd6f6f29','6d381bea1bcb4504b518a1fbfa0904ac','c96c11f9a3f845a4b6a27d9ca10ab103'];let ids=new Set(),dup=false,ts=new Set(),bad=false;for(let n=2;n<=11;n++){const t=String(n).padStart(2,'0');const j=JSON.parse(fs.readFileSync('map/map'+t+'.map','utf8'));const E=j.ContentProto.Entities;const ms=E.filter(e=>(e.componentNames||'').includes('script.Monster'));if(ms.length!==2)throw new Error('monsters '+t);const sprs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.SpriteRendererComponent').SpriteRUID);if(new Set(sprs).size!==2)bad=true;if(sprs.some(s=>old.includes(s)))bad=true;ts.add(E.find(e=>(e.path||'').endsWith('/TileMap')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.TileMapComponent').TileSetRUID.DataId);for(const e of E){if(ids.has(e.id))dup=true;ids.add(e.id);}}console.log('cross-map id dup:',dup);console.log('any old/dup-in-map sprite:',bad);console.log('distinct tilesets:',ts.size)"
```
Expected: `cross-map id dup: false`, `any old/dup-in-map sprite: false`, `distinct tilesets: 10`.
- [ ] **Step 3: Maker 표본 검증 (컨트롤러)**
`maker_refresh_workspace` 후 표본 맵(map05, map09)을 각각 열어(사용자 협조) `maker_play`→`maker_screenshot`로 몬스터 외형·타일이 맵마다 다른지 확인. `maker_stop`.
---
### Task 5: 최종 커밋
- [ ] **Step 1: 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add tools/gen-maps.mjs Global/SectorConfig.config map/map02.map map/map03.map map/map04.map map/map05.map map/map06.map map/map07.map map/map08.map map/map09.map map/map10.map map/map11.map
git commit -m "맵 10개: 다양한 몬스터 2종(StS2 우측 배치) + 맵별 타일셋 적용"
```
---
## 검증 요약
- 수확: 몬스터 변형 ≥12 / 타일셋 10 (스파이크로 구조 확인)
- map02 스파이크: 데이터(2 distinct sprite·old 미사용·x=3.5/5.5·타일셋 교체) + Maker 렌더
- 전체: cross-map id 무중복, old sprite 미사용, 타일셋 10 distinct
- Maker 표본 시각 확인

View File

@@ -1,273 +0,0 @@
# 맵 10개 생성 (랜덤 배경 + 몬스터 2마리) Implementation Plan
> **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:** `map01`을 템플릿으로 독립 맵 10개(`map02`~`map11`)를 생성하고, 맵마다 다른 공식 배경 + 랜덤 위치 몬스터 2마리를 배치한다.
**Architecture:** Node 생성기 `tools/gen-maps.mjs``map/map01.map`을 JSON으로 읽어 맵마다 deep-clone → 경로/EntryKey/name 치환, 전 엔티티 GUID 재발급(자기참조 보정), `Background.TemplateRUID` 교체, 몬스터 2마리 배치 → `map/mapNN.map`(JSON.stringify)로 기록. `SectorConfig.config`에 등록. 몬스터 다양화(A)는 `MONSTER_VARIANTS` 데이터로 주입하며, map02 스파이크로 렌더 검증 후 10개로 확대(실패 시 B=기존 몬스터 폴백).
**Tech Stack:** Node.js(ESM, 표준 라이브러리), MSW `.map`(JSON 엔티티), msw-mcp 에셋 검색, msw-maker-mcp reload/play/screenshot/execute_script.
---
## File Structure
- Create: `tools/gen-maps.mjs` — 맵 생성기 (템플릿 클론·GUID 재발급·배경/몬스터 주입·SectorConfig 갱신).
- Create: `map/map02.map` ~ `map/map11.map` — 생성 결과.
- Modify: `Global/SectorConfig.config``entries`에 map02~map11 추가.
배경 RUID 풀(공식 라이브러리, 확보 완료, 10개):
`79c95db9fdbb4c4796771733d069e3e2`, `1d4a335a5416401f8e289d78a03fd0c3`, `731a9cd1cce045e19d50fdcdc9a20be9`, `695805b1809243fd9376e2bba113ebde`, `454804df4c7e4701997ec8a8de088597`, `01992685f5d147b3a5c18fabf584807f`, `c861e9cb2d0b4d91be5d4d6aedf796b1`, `ee2e13a352d64611906760c1b722df67`, `8e89019c54d14aed875e54f13fa14109`, `fa936edd365f47e4b5622c19b1a80a0c`
맵 구조(map01): 엔티티 `/maps/map01`(Map+Foothold), `/Background`(BackgroundComponent.TemplateRUID), `/MapleMapLayer`, `/TileMap`, `/SpawnLocation`, 몬스터들(componentNames에 `script.Monster` 포함: StaticMonsterTemplate/MoveMonsterTemplate/ChaseMonsterTemplate/monster-43). 엔티티 id는 대시 GUID(8-4-4-4-12), 리소스 RUID는 대시 없는 32hex.
---
### Task 1: 라이브러리 몬스터 변형 후보 조사 (컨트롤러/MCP, 타임박스)
**목표:** 완결된 라이브러리 몬스터 변형(스프라이트 + stand/hit/die 액션 RUID 세트)을 ≥3종 확보 시도. 액션 그룹핑/이름을 얻을 수 없으면 **B 폴백**(빈 변형)으로 결정.
- [ ] **Step 1: 라이브러리 몬스터 리소스 조사**
MCP `asset_search_resources``cat="animationclip"`/`"sprite"`, `source="maplestory"`, `query`로 몬스터 후보를 찾고, 가능하면 `detail=true` 및 메타데이터로 action(stand/hit/die) 식별을 시도한다.
- [ ] **Step 2: 변형 세트 확정 또는 폴백 결정**
각 변형을 `{ sprite, stand, hit, die }`(RUID) 형태로 ≥3개 확보하면 → 그 배열을 Task 2의 `MONSTER_VARIANTS`로 사용.
액션 식별이 불가하거나 불확실하면 → `MONSTER_VARIANTS = []`로 두고 **B 폴백**(기존 템플릿 몬스터 그대로 사용)으로 진행한다. 결정 결과를 한 줄로 기록(`log` 또는 보고).
> 이 태스크의 산출물은 "MONSTER_VARIANTS 배열(또는 빈 배열) + 결정 사유" 한 가지다. 코드 변경 없음.
---
### Task 2: 생성기 작성
**Files:** Create `tools/gen-maps.mjs`
- [ ] **Step 1: 스크립트 작성**
`tools/gen-maps.mjs`에 아래를 그대로 작성한다. `MONSTER_VARIANTS`는 Task 1 결과로 채우거나 빈 배열로 둔다(빈 배열 = B 폴백).
```js
import { readFileSync, writeFileSync } from 'node:fs';
const TEMPLATE = 'map/map01.map';
const SECTOR = 'Global/SectorConfig.config';
const MAP_NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
// 공식 라이브러리 배경 RUID 풀 (맵마다 1개씩, 서로 다르게)
const BACKGROUNDS = [
'79c95db9fdbb4c4796771733d069e3e2', '1d4a335a5416401f8e289d78a03fd0c3',
'731a9cd1cce045e19d50fdcdc9a20be9', '695805b1809243fd9376e2bba113ebde',
'454804df4c7e4701997ec8a8de088597', '01992685f5d147b3a5c18fabf584807f',
'c861e9cb2d0b4d91be5d4d6aedf796b1', 'ee2e13a352d64611906760c1b722df67',
'8e89019c54d14aed875e54f13fa14109', 'fa936edd365f47e4b5622c19b1a80a0c',
];
// Task 1 결과. 비어 있으면 기존 템플릿 몬스터를 그대로 사용(B 폴백).
// 각 항목: { sprite, stand, hit, die } (모두 RUID 문자열)
const MONSTER_VARIANTS = [];
// 결정론적 시드 RNG (맵 번호 기반)
function rng(seed) {
let s = seed >>> 0;
return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
// 결정론적 대시 GUID (맵번호, 인덱스)
function mapGuid(nn, idx) {
const n = (nn * 1000 + idx) >>> 0;
const h8 = n.toString(16).padStart(8, '0');
const h12 = n.toString(16).padStart(12, '0');
return `${h8}-0000-4000-8000-${h12}`;
}
const isMonster = (e) => (e.componentNames || '').includes('script.Monster');
const compOf = (e, type) => e.jsonString['@components'].find((c) => c['@type'] === type);
const template = JSON.parse(readFileSync(TEMPLATE, 'utf8'));
const monsterTemplates = template.ContentProto.Entities.filter(isMonster);
if (monsterTemplates.length === 0) throw new Error('템플릿에서 몬스터 엔티티를 못 찾음');
function buildMap(nn) {
const tag = String(nn).padStart(2, '0');
const rand = rng(nn * 7919);
const map = JSON.parse(JSON.stringify(template)); // deep clone
map.EntryKey = `map://map${tag}`;
// 비-몬스터 엔티티만 유지
const ents = map.ContentProto.Entities.filter((e) => !isMonster(e));
// 몬스터 2마리 추가 (템플릿 몬스터 복제)
for (let i = 0; i < 2; i++) {
const src = monsterTemplates[Math.floor(rand() * monsterTemplates.length)];
const m = JSON.parse(JSON.stringify(src));
m.jsonString.name = `Monster${i + 1}`;
m.path = `/maps/map${tag}/Monster${i + 1}`;
m.jsonString.path = m.path;
const tr = compOf(m, 'MOD.Core.TransformComponent');
if (tr) tr.Position.x = Math.round((rand() * 8 - 4) * 100) / 100; // -4..4 바닥 위
if (MONSTER_VARIANTS.length > 0) {
const v = MONSTER_VARIANTS[Math.floor(rand() * MONSTER_VARIANTS.length)];
const sp = compOf(m, 'MOD.Core.SpriteRendererComponent');
if (sp) sp.SpriteRUID = v.sprite;
const sa = compOf(m, 'MOD.Core.StateAnimationComponent');
if (sa) sa.ActionSheet = { stand: v.stand, hit: v.hit, die: v.die };
}
ents.push(m);
}
// 경로/이름 치환 + 배경 설정
for (const e of ents) {
if (typeof e.path === 'string') e.path = e.path.replace('/maps/map01', `/maps/map${tag}`);
if (e.jsonString) {
if (typeof e.jsonString.path === 'string') e.jsonString.path = e.jsonString.path.replace('/maps/map01', `/maps/map${tag}`);
if (e.jsonString.name === 'map01') e.jsonString.name = `map${tag}`;
}
if ((e.path || '').endsWith('/Background')) {
const bg = compOf(e, 'MOD.Core.BackgroundComponent');
if (bg) bg.TemplateRUID = BACKGROUNDS[(nn - 2) % BACKGROUNDS.length];
}
}
// GUID 재발급 (자기참조 root/sub_entity_id 보정)
ents.forEach((e, idx) => {
const oldId = e.id;
const newId = mapGuid(nn, idx);
e.id = newId;
const o = e.jsonString && e.jsonString.origin;
if (o) {
if (o.root_entity_id === oldId) o.root_entity_id = newId;
if (o.sub_entity_id === oldId) o.sub_entity_id = newId;
}
});
map.ContentProto.Entities = ents;
writeFileSync(`map/map${tag}.map`, JSON.stringify(map, null, 2), 'utf8');
return `map${tag}`;
}
// 인자: 생성할 맵 번호(미지정 시 전체). 예: node tools/gen-maps.mjs 2
const arg = process.argv[2];
const targets = arg ? [Number(arg)] : MAP_NUMBERS;
const made = targets.map(buildMap);
console.log('Generated:', made.join(', '));
// SectorConfig 등록 (전체 생성 시에만, 중복 방지)
if (!arg) {
const sector = JSON.parse(readFileSync(SECTOR, 'utf8'));
const entries = sector.ContentProto.Json.Sectors[0].entries;
for (const nn of MAP_NUMBERS) {
const key = `map://map${String(nn).padStart(2, '0')}`;
if (!entries.includes(key)) entries.push(key);
}
writeFileSync(SECTOR, JSON.stringify(sector, null, 2), 'utf8');
console.log('SectorConfig entries:', entries.length);
}
```
- [ ] **Step 2: 구문 확인**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node --check tools/gen-maps.mjs
```
Expected: 출력 없음(exit 0).
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-maps.mjs
git commit -m "맵 생성기 추가 (map01 템플릿 복제·배경/몬스터 주입)"
```
---
### Task 3: map02 스파이크 — 생성 + Maker 렌더 검증
**Files:** Create `map/map02.map`
- [ ] **Step 1: map02만 생성**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-maps.mjs 2
```
Expected: `Generated: map02`
- [ ] **Step 2: 데이터 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('map/map02.map','utf8'));const E=j.ContentProto.Entities;console.log('EntryKey:',j.EntryKey);const ids=E.map(e=>e.id);console.log('unique ids:', new Set(ids).size===ids.length);console.log('monsters:', E.filter(e=>(e.componentNames||'').includes('script.Monster')).length);const bg=E.find(e=>(e.path||'').endsWith('/Background'));console.log('bg RUID:', bg.jsonString['@components'].find(c=>c['@type']==='MOD.Core.BackgroundComponent').TemplateRUID);console.log('paths ok:', E.every(e=>!(e.path||'').includes('/maps/map01')))"
```
Expected: `EntryKey: map://map02`, `unique ids: true`, `monsters: 2`, `bg RUID:` 가 배경 풀의 첫 값(`79c95db9...`), `paths ok: true`.
- [ ] **Step 3: Maker에서 map02 열어 렌더 검증 (컨트롤러)**
1. `maker_refresh_workspace` (edit)
2. Maker에서 map02를 활성 맵으로 연다(에디터에서 map02 더블클릭). MCP로 직접 맵 전환이 안 되면, 사용자에게 "Maker에서 map02 열기"를 요청한다.
3. `maker_play``maker_screenshot` → Read로 확인: **배경이 map01과 다른 배경으로 표시**되고 **몬스터 2마리가 보이는지**.
4. `maker_execute_script`(client)로 몬스터 로드 확인:
```lua
local m1 = _EntityService:GetEntityByPath("/maps/map02/Monster1")
local m2 = _EntityService:GetEntityByPath("/maps/map02/Monster2")
log("M1="..tostring(m1~=nil).." M2="..tostring(m2~=nil))
```
→ `maker_logs(normal)`에서 `M1=true M2=true` 확인.
5. `maker_stop`.
- [ ] **Step 4: 게이트 판정**
- 배경·몬스터 정상 → 그대로 진행(Task 4).
- 배경이 흰/검 박스이거나 몬스터 안 보임:
- 배경 문제: 배경 RUID 풀이 로컬 워크스페이스에서 로드 안 됨 → 사용자와 상의(공식 배경 로드 가능 여부). 우선 다른 배경 RUID로 교체 시도.
- 몬스터 변형(A) 문제(MONSTER_VARIANTS 사용 중일 때만): `MONSTER_VARIANTS = []`로 비우고(B 폴백) Step 1부터 재실행.
- ui 되돌리기 필요 시: `git checkout map/map02.map` 후 재생성.
---
### Task 4: 나머지 맵 생성 + SectorConfig 등록
**Files:** Create `map/map03.map`~`map/map11.map`, Modify `Global/SectorConfig.config`
- [ ] **Step 1: 전체 생성**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-maps.mjs
```
Expected: `Generated: map02, map03, ... map11` 와 `SectorConfig entries: 11`
- [ ] **Step 2: 전체 데이터 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const fs=require('fs');let allIds=new Set(),dup=false,bgs=new Set();for(let n=2;n<=11;n++){const t=String(n).padStart(2,'0');const j=JSON.parse(fs.readFileSync('map/map'+t+'.map','utf8'));const E=j.ContentProto.Entities;if(j.EntryKey!=='map://map'+t)throw new Error('EntryKey '+t);if(E.filter(e=>(e.componentNames||'').includes('script.Monster')).length!==2)throw new Error('monsters '+t);for(const e of E){if(allIds.has(e.id))dup=true;allIds.add(e.id);}bgs.add(E.find(e=>(e.path||'').endsWith('/Background')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.BackgroundComponent').TemplateRUID);}const sec=JSON.parse(fs.readFileSync('Global/SectorConfig.config','utf8'));console.log('cross-map id dup:',dup);console.log('distinct backgrounds:',bgs.size);console.log('sector entries:',sec.ContentProto.Json.Sectors[0].entries.length)"
```
Expected: `cross-map id dup: false`, `distinct backgrounds: 10`, `sector entries: 11`.
- [ ] **Step 3: Maker 표본 검증 (컨트롤러)**
`maker_refresh_workspace` 후, 표본 맵 2~3개(map05, map08, map11)를 각각 열어 `maker_play`→`maker_screenshot`로 배경이 서로 다르고 몬스터 2마리가 보이는지 확인. 맵 전환이 MCP로 안 되면 사용자에게 해당 맵 열기를 요청. 확인 후 `maker_stop`.
---
### Task 5: 최종 커밋
**Files:** `map/map02.map`~`map/map11.map`, `Global/SectorConfig.config`
- [ ] **Step 1: 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git add map/map02.map map/map03.map map/map04.map map/map05.map map/map06.map map/map07.map map/map08.map map/map09.map map/map10.map map/map11.map Global/SectorConfig.config
git commit -m "맵 10개(map02~map11) 생성: 랜덤 배경 + 몬스터 2마리, sector 등록"
```
---
## 검증 요약
- 생성기 `node --check` 통과
- map02 스파이크: 데이터(고유 id/2몬스터/배경) + Maker 렌더(배경 상이·몬스터 2)로 A/B 게이트 판정
- 전체: cross-map id 중복 없음, 배경 10종 distinct, sector 11개
- Maker 표본 맵 시각 확인

View File

@@ -1,481 +0,0 @@
# 카드 전투 통합 (TODO B) Implementation Plan
> **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:** 카드 사용이 실제 적 HP·플레이어 Block·적 의도·승패에 반영되는 단일 전투 루프를 완성한다.
**Architecture:** 모든 변경은 `tools/gen-slaydeck.mjs` 단일 생성기에서 만든다. 적/플레이어 전투 상태는 `SlayDeckController` codeblock 내부 속성으로 보유(필드 `Monster.codeblock`과 분리). UI는 `CombatHud` 그룹으로 DeckHud와 별도 생성. 수치(플레이어 80 / 적 45 / 의도 10·6·방8)는 임시 placeholder.
**Tech Stack:** Node.js ESM 생성기(`gen-slaydeck.mjs`), MSW Lua codeblock, MSW UI JSON. 검증은 `node --check` + 재생성 + sha1 결정성 + 메이커 Play.
---
## File Structure
- Modify: `tools/gen-slaydeck.mjs` — 유일한 변경 대상.
- `upsertUi()`: `CombatHud` 그룹(적/플레이어 패널·결과 텍스트) 생성 추가, 정리 필터 확장.
- `writeCodeblocks()`: `SlayDeckController` 속성·메서드 추가/수정.
- 생성물(자동, 직접 편집 금지): `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`.
검증 한계: MSW codeblock Lua는 단위 테스트 러너가 없다. 자동 검증은 생성기 문법·재생성·결정성·JSON 유효성까지, 실제 동작은 메이커 Play(사용자)로 확인.
---
### Task 1: 카드 데이터 수치화 (Cards 테이블 + UI 카드 배열)
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`upsertUi``cards` 배열, `writeCodeblocks``StartCombat``self.Cards`)
- [ ] **Step 1: `upsertUi`의 카드 배열은 표시용 그대로 두되, codeblock `Cards`에 수치 필드 추가**
`writeCodeblocks()``StartCombat` 메서드 코드에서 `self.Cards` 정의를 아래로 교체:
```lua
self.Cards = {
Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack", damage = 6 },
Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill", block = 5 },
Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack", damage = 10 },
}
```
- [ ] **Step 2: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음 (출력 없음, exit 0)
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): 카드 데이터에 damage/block 수치 필드 추가"
```
---
### Task 2: 전투 상태 속성 + StartCombat 초기화
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`writeCodeblocks` 속성 배열, `StartCombat` 메서드)
- [ ] **Step 1: codeblock 속성 추가**
`codeblock('SlayDeckController', ...)`의 properties 배열 끝에 추가:
```js
prop('number', 'PlayerHp', '0'),
prop('number', 'PlayerMaxHp', '80'),
prop('number', 'PlayerBlock', '0'),
prop('number', 'EnemyHp', '0'),
prop('number', 'EnemyMaxHp', '45'),
prop('number', 'EnemyBlock', '0'),
prop('number', 'EnemyIntentIndex', '1'),
prop('boolean', 'CombatOver', 'false'),
prop('any', 'EnemyIntents'),
prop('any', 'EnemyName'),
```
- [ ] **Step 2: `StartCombat`에 전투 상태 초기화 추가**
`StartCombat` 코드의 맨 위(`self.MaxEnergy = 3` 직후)에 삽입:
```lua
self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.PlayerBlock = 0
self.EnemyName = "슬라임"
self.EnemyMaxHp = 45
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = {
{ kind = "Attack", value = 10 },
{ kind = "Attack", value = 6 },
{ kind = "Defend", value = 8 },
}
self.EnemyIntentIndex = 1
self.CombatOver = false
```
그리고 `StartCombat` 끝(`self:StartPlayerTurn()` 직전)에 `self:RenderCombat()` 추가.
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): 플레이어/적 전투 상태 속성·초기화 추가"
```
---
### Task 3: 전투 헬퍼 메서드 (데미지/적턴/승패/렌더)
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`writeCodeblocks` methods 배열에 신규 메서드 추가)
`SetText`는 엔티티 nil 가드가 있어, 참조하는 UI가 Task 5에서 생성되기 전이어도 안전(no-op).
- [ ] **Step 1: 신규 메서드들을 methods 배열에 추가 (`Toast` 메서드 정의 뒤)**
```js
method('DealDamageToEnemy', `local dmg = amount
if self.EnemyBlock > 0 then
local absorbed = math.min(self.EnemyBlock, dmg)
self.EnemyBlock = self.EnemyBlock - absorbed
dmg = dmg - absorbed
end
self.EnemyHp = self.EnemyHp - dmg
if self.EnemyHp < 0 then
self.EnemyHp = 0
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
method('DealDamageToPlayer', `local dmg = amount
if self.PlayerBlock > 0 then
local absorbed = math.min(self.PlayerBlock, dmg)
self.PlayerBlock = self.PlayerBlock - absorbed
dmg = dmg - absorbed
end
self.PlayerHp = self.PlayerHp - dmg
if self.PlayerHp < 0 then
self.PlayerHp = 0
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
method('EnemyTurn', `self.EnemyBlock = 0
local intent = self.EnemyIntents[self.EnemyIntentIndex]
if intent ~= nil then
if intent.kind == "Attack" then
self:DealDamageToPlayer(intent.value)
elseif intent.kind == "Defend" then
self.EnemyBlock = self.EnemyBlock + intent.value
end
end
self.EnemyIntentIndex = self.EnemyIntentIndex + 1
if self.EnemyIntentIndex > #self.EnemyIntents then
self.EnemyIntentIndex = 1
end
self:RenderCombat()`),
method('CheckCombatEnd', `if self.EnemyHp <= 0 then
self.CombatOver = true
self:ShowResult("승리!")
-- TODO(E): 전투 보상 훅 — 카드 보상/골드/유물 선택 진입점
elseif self.PlayerHp <= 0 then
self.CombatOver = true
self:ShowResult("패배...")
end`),
method('ShowResult', `self:SetText("/ui/DefaultGroup/CombatHud/Result", text)
local entity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/Result")
if entity ~= nil then
entity.Enable = true
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'text' }]),
method('RenderCombat', `self:SetText("/ui/DefaultGroup/CombatHud/EnemyName", self.EnemyName)
self:SetText("/ui/DefaultGroup/CombatHud/EnemyHp", "HP " .. tostring(self.EnemyHp) .. "/" .. tostring(self.EnemyMaxHp))
self:SetText("/ui/DefaultGroup/CombatHud/EnemyBlock", "방어 " .. tostring(self.EnemyBlock))
local intent = self.EnemyIntents[self.EnemyIntentIndex]
local intentText = ""
if intent ~= nil then
if intent.kind == "Attack" then
intentText = "의도: 공격 " .. tostring(intent.value)
elseif intent.kind == "Defend" then
intentText = "의도: 방어 " .. tostring(intent.value)
end
end
self:SetText("/ui/DefaultGroup/CombatHud/EnemyIntent", intentText)
self:SetText("/ui/DefaultGroup/CombatHud/PlayerHp", "HP " .. tostring(self.PlayerHp) .. "/" .. tostring(self.PlayerMaxHp))
self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. tostring(self.PlayerBlock))`),
```
- [ ] **Step 2: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): 데미지/적턴/승패/전투렌더 헬퍼 메서드 추가"
```
---
### Task 4: 턴 흐름 배선 (PlayCard 효과·EndPlayerTurn·StartPlayerTurn)
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`StartPlayerTurn`, `EndPlayerTurn`, `PlayCard` 메서드 코드)
- [ ] **Step 1: `StartPlayerTurn` 교체**
```lua
self.Turn = self.Turn + 1
self.Energy = self.MaxEnergy
self.PlayerBlock = 0
self:DrawCards(5)
self:RenderHand(true)
self:RenderCombat()
```
- [ ] **Step 2: `EndPlayerTurn` 교체**
```lua
if self.CombatOver == true then
return
end
for i = 1, #self.Hand do
table.insert(self.DiscardPile, self.Hand[i])
end
self.Hand = {}
self:RenderHand(false)
self:RenderPiles()
self:EnemyTurn()
self:CheckCombatEnd()
if self.CombatOver == true then
return
end
_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)
```
- [ ] **Step 3: `PlayCard` 효과 분기 교체**
`PlayCard` 코드를 아래로 교체(에너지 차감 후 Toast 대신 효과 적용):
```lua
if self.CombatOver == true then
return
end
if self.Hand == nil then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
local c = self.Cards[cardId]
if c == nil then
return
end
if self.Energy < c.cost then
self:Toast("에너지가 부족합니다")
return
end
self.Energy = self.Energy - c.cost
if c.kind == "Attack" then
if c.damage ~= nil then
self:DealDamageToEnemy(c.damage)
end
elseif c.kind == "Skill" then
if c.block ~= nil then
self.PlayerBlock = self.PlayerBlock + c.block
end
end
table.remove(self.Hand, slot)
table.insert(self.DiscardPile, cardId)
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()
self:CheckCombatEnd()
```
- [ ] **Step 4: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 5: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): PlayCard 효과 분기·적턴·승패 턴흐름 배선"
```
---
### Task 5: CombatHud UI 엔티티 생성
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`upsertUi`: 정리 필터 확장 + CombatHud 그룹 생성)
- [ ] **Step 1: 정리 필터 확장**
`upsertUi()` 시작부의 필터를 CombatHud도 제거하도록 교체:
```js
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud'));
```
- [ ] **Step 2: DeckHud `hud` push 직후, CombatHud 엔티티 생성 블록 추가**
`ui.ContentProto.Entities.push(...hud);` 직전에 아래 블록 삽입(헬퍼 `entity`/`transform`/`sprite`/`text`/`guid` 재사용):
```js
const PANEL_BG = { r: 0.08, g: 0.09, b: 0.11, a: 0.78 };
const combat = [];
combat.push(entity({
id: guid('cmb', 0),
path: '/ui/DefaultGroup/CombatHud',
modelId: 'uiempty',
entryId: 'UIEmpty',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 4,
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: TRANSPARENT }),
],
}));
// 적 패널 배경
combat.push(entity({
id: guid('cmb', 1),
path: '/ui/DefaultGroup/CombatHud/EnemyBg',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 0,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 380, y: 170 }, pos: { x: 0, y: 300 }, align: ALIGN_CENTER }),
sprite({ color: PANEL_BG, type: 1 }),
],
}));
const enemyTexts = [
['EnemyName', { x: 0, y: 58 }, { x: 360, y: 44 }, '슬라임', 28, true, GOLD, 1],
['EnemyHp', { x: 0, y: 16 }, { x: 360, y: 40 }, 'HP 45/45', 24, true, { r: 1, g: 1, b: 1, a: 1 }, 2],
['EnemyBlock', { x: 0, y: -20 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }, 3],
['EnemyIntent', { x: 0, y: -56 }, { x: 360, y: 38 }, '의도: 공격 10', 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }, 4],
];
let cmbN = 2;
for (const [suffix, pos, size, value, fontSize, bold, color] of enemyTexts) {
combat.push(entity({
id: guid('cmb', cmbN++),
path: `/ui/DefaultGroup/CombatHud/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: enemyTexts.findIndex(([s]) => s === suffix) + 1,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }),
sprite({ color: TRANSPARENT }),
text({ value, fontSize, bold, color }),
],
}));
}
// 플레이어 패널 배경 + 텍스트
combat.push(entity({
id: guid('cmb', cmbN++),
path: '/ui/DefaultGroup/CombatHud/PlayerBg',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 5,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 110 }, pos: { x: -760, y: -260 }, align: ALIGN_CENTER }),
sprite({ color: PANEL_BG, type: 1 }),
],
}));
const playerTexts = [
['PlayerHp', { x: -760, y: -238 }, { x: 280, y: 44 }, 'HP 80/80', 26, true, { r: 1, g: 1, b: 1, a: 1 }],
['PlayerBlock', { x: -760, y: -284 }, { x: 280, y: 38 }, '방어 0', 22, false, { r: 0.6, g: 0.8, b: 1, a: 1 }],
];
for (const [suffix, pos, size, value, fontSize, bold, color] of playerTexts) {
combat.push(entity({
id: guid('cmb', cmbN++),
path: `/ui/DefaultGroup/CombatHud/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 6 + playerTexts.findIndex(([s]) => s === suffix),
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size, pos }),
sprite({ color: TRANSPARENT }),
text({ value, fontSize, bold, color }),
],
}));
}
// 결과 텍스트 (기본 숨김)
const result = entity({
id: guid('cmb', cmbN++),
path: '/ui/DefaultGroup/CombatHud/Result',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 8,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 140 }, pos: { x: 0, y: 120 } }),
sprite({ color: TRANSPARENT }),
text({ value: '', fontSize: 64, bold: true, color: GOLD, alignment: 4 }),
],
});
result.jsonString.enable = false;
combat.push(result);
ui.ContentProto.Entities.push(...combat);
```
`guid` 프리픽스 `'cmb'`를 위해 `guid()`의 ns 매핑에 분기 추가:
```js
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : 0xfe;
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(B): CombatHud(적/플레이어 패널·결과) UI 엔티티 생성"
```
---
### Task 6: 재생성 + 검증
**Files:** 생성물 3종 (생성기 실행 결과)
- [ ] **Step 1: 생성기 실행**
Run: `node tools/gen-slaydeck.mjs`
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: 생성물 JSON 유효성 확인**
Run: `node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); console.log('JSON OK')"`
Expected: `JSON OK`
- [ ] **Step 3: 결정성 확인 (2회 실행 동일)**
Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC`
Expected: `DETERMINISTIC`
- [ ] **Step 4: CombatHud 엔티티·전투 메서드 생성 확인**
Run: `grep -c "CombatHud" ui/DefaultGroup.ui; grep -c "DealDamageToEnemy\|EnemyTurn\|RenderCombat" RootDesk/MyDesk/SlayDeckController.codeblock`
Expected: 두 값 모두 > 0
- [ ] **Step 5: 의도한 파일만 변경됐는지 확인**
Run: `git status --short`
Expected: `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (그리고 필요 시 `Global/common.gamelogic`)만 변경.
- [ ] **Step 6: 생성물 커밋**
```bash
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic
git commit -m "재생성(B): 카드 전투 통합 — 적/플레이어 전투 상태·CombatHud 반영"
```
- [ ] **Step 7: 메이커 Play 수동 검증 (사용자)**
메이커에서 로컬 워크스페이스 reload 후 Play:
- 타격 카드 클릭 → 적 HP 감소(적 Block 있으면 먼저 차감).
- 방어 카드 클릭 → 플레이어 `방어` 수치 증가.
- 턴 종료 → 적이 표시된 의도대로 공격(플레이어 Block이 피해 흡수) 또는 방어, 다음 의도 갱신.
- 적 HP 0 → "승리!" 표시·입력 잠금 / 플레이어 HP 0 → "패배..." 표시·입력 잠금.
---
## Self-Review
- **Spec coverage:** 전투 상태(Task 2), 카드 수치화(Task 1), 효과 분기(Task 4), 적 의도·적 턴(Task 3·4), 승패(Task 3·4), UI 노출(Task 5) — 스펙 5개 절 모두 태스크로 매핑됨. 검증은 Task 6.
- **Placeholder scan:** 모든 코드 단계에 실제 코드 포함. "TODO(E)"는 의도된 미래 훅 주석(스펙 명시)으로 placeholder 아님.
- **Type consistency:** UI 경로(`/ui/DefaultGroup/CombatHud/EnemyHp` 등)가 codeblock `RenderCombat`/`ShowResult`와 Task 5 생성 경로에서 동일. 메서드명(`DealDamageToEnemy`/`DealDamageToPlayer`/`EnemyTurn`/`CheckCombatEnd`/`ShowResult`/`RenderCombat`)이 호출부(Task 4)와 정의부(Task 3)에서 일치. 카드 필드(`damage`/`block`/`kind`)가 Cards 정의(Task 1)와 PlayCard 사용(Task 4)에서 일치.

View File

@@ -1,256 +0,0 @@
# 덱 컨트롤러 코드리뷰 수정 Implementation Plan
> **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:** 코드리뷰 6건(①self바인딩 ②Card5통일 ③카드클릭=사용 ④카드데이터단일화 ⑤매직넘버 ⑥pcall)을 `tools/gen-slaydeck.mjs`에서 수정·재생성한다.
**Architecture:** 모든 산출물(카드 UI·DeckHud·`SlayDeckController.codeblock`·`common.gamelogic`)을 생성하는 `tools/gen-slaydeck.mjs` 단일 소스를 수정하고 재실행한다. DRY는 카드 정의를 codeblock의 `self.Cards` 테이블 프로퍼티로 단일화하고, 카드 클릭은 카드 엔티티에 `ButtonComponent`를 추가한 뒤 `PlayCard(slot)` 메서드를 클로저로 연결해 구현한다.
**Tech Stack:** Node.js 생성기, MSW codeblock(MapleScript/Lua), msw-maker-mcp(검증).
---
## File Structure
- Modify: `tools/gen-slaydeck.mjs` — 모든 수정의 단일 소스.
- 재생성(출력): `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`.
기준: codeblock 메서드는 `method('Name', `<lua>`, [args])`로 정의되고 끝에서 전부 `ExecSpace=6`로 설정됨. 카드 엔티티(Card1~5)는 `upsertUi`의 루프가 스타일링함. `button()` 헬퍼 존재.
---
### Task 1: 생성기 수정 (① ③ ④ ⑥ + ⑤ 일부)
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: 카드에 ButtonComponent + raycast 추가 (③ 클릭 가능)**
`upsertUi`의 카드 루프에서 `sp.Color = cards[i - 1].tint;` 줄 바로 다음에 아래를 추가:
```js
sp.RaycastTarget = true;
const comps = card.jsonString['@components'];
if (!comps.some((c) => c['@type'] === 'MOD.Core.ButtonComponent')) {
comps.push(button());
}
if (!card.componentNames.includes('MOD.Core.ButtonComponent')) {
card.componentNames += ',MOD.Core.ButtonComponent';
}
```
- [ ] **Step 2: `Cards` 프로퍼티 추가 (④ 단일화 준비)**
`writeCodeblocks`의 properties 배열(`prop('any', 'EndTurnHandler')` 가 있는 배열)에 항목 추가:
```js
prop('any', 'Cards'),
```
- [ ] **Step 3: StartCombat 메서드 교체 (④ 카드 테이블 정의)**
`method('StartCombat', ...)` 의 Lua 본문을 아래로 교체:
```
self.MaxEnergy = 3
self.Turn = 0
self.DiscardPile = {}
self.Hand = {}
self.Cards = {
Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack" },
Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill" },
Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack" },
}
self.DrawPile = { "Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash" }
self:Shuffle(self.DrawPile)
self:BindButtons()
self:StartPlayerTurn()
```
- [ ] **Step 4: BindButtons 교체 (① 클로저 + ③ 카드 클릭 바인딩)**
`method('BindButtons', ...)` 의 Lua 본문을 아래로 교체:
```
local endTurn = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/EndTurnButton")
if endTurn ~= nil and endTurn.ButtonComponent ~= nil then
if self.EndTurnHandler ~= nil then
endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)
self.EndTurnHandler = nil
end
self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)
end
for i = 1, 5 do
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then
cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)
end
end
```
- [ ] **Step 5: ApplyCardVisual 교체 (④ self.Cards 사용 + ⑥ pcall 제거)**
`method('ApplyCardVisual', ...)` 의 Lua 본문을 아래로 교체(인자 slot, cardId 유지):
```
local c = self.Cards[cardId]
if c == nil then
c = { name = cardId, cost = 0, desc = "", kind = "Skill" }
end
self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Cost", tostring(c.cost))
self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Name", c.name)
self:SetText("/ui/DefaultGroup/CardHand/Card" .. tostring(slot) .. "/Desc", c.desc)
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot))
if cardEntity ~= nil and cardEntity.SpriteGUIRendererComponent ~= nil then
if c.kind == "Attack" then
cardEntity.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)
elseif c.kind == "Skill" then
cardEntity.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)
else
cardEntity.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)
end
end
```
- [ ] **Step 6: PlayCard + Toast 메서드 추가 (③)**
`method('AnimateCardFrom', ...)` 항목 다음(메서드 배열 안)에 두 메서드를 추가:
```js
method('PlayCard', `if self.Hand == nil then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
local c = self.Cards[cardId]
if c == nil then
return
end
if self.Energy < c.cost then
self:Toast("에너지가 부족합니다")
return
end
self.Energy = self.Energy - c.cost
self:Toast(c.name .. " — " .. c.desc)
table.remove(self.Hand, slot)
table.insert(self.DiscardPile, cardId)
self:RenderHand(false)
self:RenderPiles()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]),
```
(⑤: 손패/슬롯 수 5는 UI 카드 엔티티가 정확히 5개라 고정값으로 둠 — 별도 상수 불필요. 시작 에너지/MaxEnergy는 이미 프로퍼티.)
- [ ] **Step 7: 구문 확인 + 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node --check tools/gen-slaydeck.mjs
git add tools/gen-slaydeck.mjs
git commit -m "덱 컨트롤러 생성기: 핸들러 클로저화·카드데이터 단일화·카드클릭 사용·pcall 제거"
```
Expected: `node --check` 무출력(exit 0).
---
### Task 2: 재생성 + 데이터 검증
**Files:** Modify `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`, `Global/common.gamelogic`
- [ ] **Step 1: 재생성**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node tools/gen-slaydeck.mjs
```
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: codeblock 검증**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));const ms=j.ContentProto.Json.Methods;const names=ms.map(m=>m.Name);console.log('has PlayCard:',names.includes('PlayCard'));console.log('has Toast:',names.includes('Toast'));const bind=ms.find(m=>m.Name==='BindButtons').Code;console.log('endturn closure:',bind.includes('function() self:EndPlayerTurn() end'));console.log('card click bind:',bind.includes('function() self:PlayCard(i) end'));const av=ms.find(m=>m.Name==='ApplyCardVisual').Code;console.log('no pcall:',!av.includes('pcall'));console.log('uses self.Cards:',av.includes('self.Cards[cardId]'));const sc=ms.find(m=>m.Name==='StartCombat').Code;console.log('Cards table:',sc.includes('self.Cards ='))"
```
Expected: 모두 `true`.
- [ ] **Step 3: UI 검증 (카드 버튼 + Card5 통일)**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const j=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));const E=j.ContentProto.Entities;let okBtn=true,okImg=true;for(let i=1;i<=5;i++){const c=E.find(e=>e.path==='/ui/DefaultGroup/CardHand/Card'+i);if(!c){okBtn=false;continue;}if(!(c.componentNames||'').includes('MOD.Core.ButtonComponent'))okBtn=false;const sp=c.jsonString['@components'].find(x=>x['@type']==='MOD.Core.SpriteGUIRendererComponent');if(sp.ImageRUID.DataId!=='')okImg=false;}const c5=E.find(e=>e.path==='/ui/DefaultGroup/CardHand/Card5');const hasDesc=E.some(e=>e.path==='/ui/DefaultGroup/CardHand/Card5/Desc');console.log('all cards have Button:',okBtn);console.log('all cards no image (uniform):',okImg);console.log('Card5 has Desc child:',hasDesc)"
```
Expected: `all cards have Button: true`, `all cards no image (uniform): true`, `Card5 has Desc child: true`.
- [ ] **Step 4: JSON 유효성 + 커밋**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8'));JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8'));JSON.parse(require('fs').readFileSync('Global/common.gamelogic','utf8'));console.log('JSON ok')"
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock Global/common.gamelogic
git commit -m "재생성: 카드 클릭 사용·균일 카드·핸들러 수정 반영"
```
Expected: `JSON ok`.
---
### Task 3: Maker Play 검증 (컨트롤러)
**Files:** 없음
- [ ] **Step 1: reload**: `maker_refresh_workspace`.
- [ ] **Step 2: 시작 맵 활성화 확인**: `maker_get_current_map`. (어느 맵이든 카드 UI는 전역이라 표시됨)
- [ ] **Step 3: play**: `maker_play`.
- [ ] **Step 4: 클릭 시뮬레이션 + 상태 확인**: `maker_execute_script`(client)로 PlayCard 직접 호출해 동작 확인:
```lua
local ctrl = _EntityService:GetEntityByPath("/common")
-- 초기 상태
local c = ctrl.SlayDeckController
log("BEFORE energy="..tostring(c.Energy).." hand="..tostring(#c.Hand).." discard="..tostring(#c.DiscardPile))
c:PlayCard(1)
log("AFTER energy="..tostring(c.Energy).." hand="..tostring(#c.Hand).." discard="..tostring(#c.DiscardPile))
```
→ `maker_logs(normal)`에서 카드 사용 후 energy 감소·hand 감소·discard 증가 확인. (또는 `maker_mouse_input`으로 카드 클릭)
- [ ] **Step 5: screenshot**: `maker_screenshot` → Read로 5장 균일·DeckHud(에너지/덱 카운트) 확인.
- [ ] **Step 6: stop**: `maker_stop`.
문제 시: 핸들러 self·PlayCard 동작 로그로 진단 후 Task 1 수정·재생성.
---
### Task 4: stash 복구 + 무결성 검증
**Files:** `map/map02.map`, `map/map05.map`, `map/map06.map`, `map/map07.map`, `map/map10.map`, `map/map11.map` (복구 대상)
- [ ] **Step 1: stash 적용**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
git stash list
git stash apply 2>&1 | head -20
```
(충돌 시 해당 파일은 main 버전 유지하고 stash 변경만 수동 반영하거나, 무의미하면 제외 — 아래 검증으로 판단)
- [ ] **Step 2: 무결성 검증 (몬스터/타일셋 유지 확인)**
```bash
cd "C:/Users/jaeoh/Desktop/workspace/slaymaple"
node -e "const old=['8ef238e0d0ca4bb783aca526cff35d11','6c7130f51a654803a1c39cbe30e2f427','3e76c89ae8e7477ca871f5bbcd6f6f29','6d381bea1bcb4504b518a1fbfa0904ac','c96c11f9a3f845a4b6a27d9ca10ab103'];for(const t of ['02','05','06','07','10','11']){const j=JSON.parse(require('fs').readFileSync('map/map'+t+'.map','utf8'));const E=j.ContentProto.Entities;const ms=E.filter(e=>(e.componentNames||'').includes('script.Monster'));const sprs=ms.map(m=>m.jsonString['@components'].find(c=>c['@type']==='MOD.Core.SpriteRendererComponent').SpriteRUID);const okNoOld=sprs.every(s=>!old.includes(s));const ts=E.find(e=>(e.path||'').endsWith('/TileMap')).jsonString['@components'].find(c=>c['@type']==='MOD.Core.TileMapComponent').TileSetRUID.DataId;console.log('map'+t,'monsters='+ms.length,'noOldSprite='+okNoOld,'tileset='+(ts!=='9dfea3808bbd49a5877d8624df21b1c7'))}"
```
Expected: 각 맵 `monsters=2`, `noOldSprite=true`, `tileset=true`. (= 몬스터/타일셋 작업 유지됨)
- [ ] **Step 3: 판정 및 커밋**
- 무결성 OK → 복구분 커밋:
```bash
git add map/map02.map map/map05.map map/map06.map map/map07.map map/map10.map map/map11.map
git commit -m "Maker 세션 재저장분(맵 02/05/06/07/10/11) 복구 포함"
git stash drop
```
- 무결성 실패(작업 되돌려짐/손상) → 복구 취소하고 사용자에게 보고:
```bash
git checkout -- map/map02.map map/map05.map map/map06.map map/map07.map map/map10.map map/map11.map
```
(stash는 보존)
---
## 검증 요약
- 생성기 `node --check` 통과
- codeblock: PlayCard/Toast 존재, EndTurn·카드클릭 클로저, self.Cards 사용, pcall 없음
- UI: Card1~5 ButtonComponent+raycast, 5장 균일(이미지 없음·Desc 존재)
- Maker Play: PlayCard 호출 시 energy↓·hand↓·discard↑, 5장 균일 렌더
- stash 복구분 무결성(몬스터2·old미사용·타일셋교체) 검증 후 포함

View File

@@ -1,475 +0,0 @@
# AI 전투 시뮬레이터 (TODO F) Implementation Plan
> **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:** `data/*.json`을 입력으로 전투를 몬테카를로 N회 시뮬레이션해 승률·턴·OP 카드 리포트를 출력하는 오프라인 CLI `tools/sim-balance.mjs`.
**Architecture:** 순수 함수(PRNG·applyDamage·chooseAction·simulateCombat·runBatch)로 분리해 `node:test`로 단위 테스트. CLI main은 직접 실행 시에만 동작. 전투 규칙은 gen-slaydeck.mjs의 Lua를 JS로 미러, 데이터는 D의 JSON 공유.
**Tech Stack:** Node.js ESM, `node:test`+`node:assert`. 검증은 단위 테스트 + CLI 실행 + 결정성 + 데이터 반영.
---
## File Structure
- Create: `tools/sim-balance.mjs` — 시뮬레이터(엔진·정책·집계·리포트·CLI). 순수 함수 export.
- Create: `tools/sim-balance.test.mjs` — 단위 테스트(node:test).
전투 규칙은 `tools/gen-slaydeck.mjs` Lua와 중복 → 파일 상단 동기화 주석.
---
### Task 1: PRNG·applyDamage·loadData (기반 순수 함수)
**Files:**
- Create: `tools/sim-balance.mjs`
- Create: `tools/sim-balance.test.mjs`
- [ ] **Step 1: 테스트 작성 `tools/sim-balance.test.mjs`**
```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mulberry32, applyDamage } from './sim-balance.mjs';
test('applyDamage: 방어 우선 차감 후 hp', () => {
assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 });
assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 });
assert.deepEqual(applyDamage(80, 12, 10), { hp: 80, block: 2 });
assert.deepEqual(applyDamage(3, 0, 10), { hp: 0, block: 0 });
});
test('mulberry32: 동일 시드 동일 수열', () => {
const a = mulberry32(1), b = mulberry32(1);
assert.equal(a(), b());
assert.equal(a(), b());
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: FAIL (`Cannot find module './sim-balance.mjs'` 또는 export 없음)
- [ ] **Step 3: `tools/sim-balance.mjs` 작성(기반부)**
```js
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
// ⚠️ 전투 규칙은 tools/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것.
import { readFileSync } from 'node:fs';
export const PLAYER_HP = 80; // 데이터 미포함 placeholder (codeblock과 일치)
export const ENERGY = 3;
export const HAND_SIZE = 5;
export const MAX_TURNS = 100;
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function shuffle(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// 방어 우선 차감 후 hp 적용 → { hp, block }
export function applyDamage(hp, block, amount) {
let dmg = amount;
if (block > 0) {
const absorbed = Math.min(block, dmg);
block -= absorbed;
dmg -= absorbed;
}
hp -= dmg;
if (hp < 0) hp = 0;
return { hp, block };
}
export function loadData() {
const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
const enemy = enemiesData.enemies[enemiesData.activeEnemy];
if (!enemy) throw new Error(`activeEnemy 없음: ${enemiesData.activeEnemy}`);
return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, enemy };
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (2 tests)
- [ ] **Step 5: 커밋**
```bash
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
git commit -m "sim-balance(F): PRNG·applyDamage·loadData 기반 함수 + 테스트"
```
---
### Task 2: chooseAction 정책 (휴리스틱 A)
**Files:**
- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs`
- [ ] **Step 1: 테스트 추가 (test.mjs 하단)**
```js
import { chooseAction } from './sim-balance.mjs';
const CARDS = {
Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 },
Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 },
Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 },
};
test('chooseAction: 치사 가능하면 공격 선택', () => {
// 적 hp 5, block 0, 손패 Strike(6) → 공격(인덱스 0)
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 5, 0, { kind: 'Attack', value: 10 });
assert.equal(idx, 0);
});
test('chooseAction: 치사 불가 + 적 공격 의도면 방어 선택', () => {
// 적 hp 40(이번 턴 못 죽임), 의도 공격 → Defend(인덱스 1)
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 40, 0, { kind: 'Attack', value: 10 });
assert.equal(idx, 1);
});
test('chooseAction: 적 방어 의도면 공격 우선', () => {
const idx = chooseAction(['Defend', 'Strike'], CARDS, 3, 40, 0, { kind: 'Defend', value: 8 });
assert.equal(idx, 1);
});
test('chooseAction: 사용 가능 카드 없으면 -1', () => {
const idx = chooseAction(['Bash'], CARDS, 1, 40, 0, { kind: 'Attack', value: 10 });
assert.equal(idx, -1);
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: FAIL (`chooseAction is not a function`)
- [ ] **Step 3: 구현 추가 (sim-balance.mjs)**
```js
// 손패에서 다음에 낼 카드의 인덱스 반환(-1=턴 종료). hand=카드 id 배열.
export function chooseAction(hand, cards, energy, enemyHp, enemyBlock, enemyIntent) {
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy);
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
const dmgEff = (x) => (cards[x.id].damage || 0) / cards[x.id].cost;
const blkEff = (x) => (cards[x.id].block || 0) / cards[x.id].cost;
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
// 1) 치사: 에너지 한도 내 효율순 공격 데미지 합 >= 적 유효 hp?
let e = energy, lethalDmg = 0;
for (const x of attacks.slice().sort((a, b) => dmgEff(b) - dmgEff(a))) {
if (cards[x.id].cost <= e) { e -= cards[x.id].cost; lethalDmg += cards[x.id].damage || 0; }
}
if (attacks.length && lethalDmg >= enemyHp + enemyBlock) return bestBy(attacks, dmgEff).i;
// 2) 적 공격 의도면 방어 우선
if (enemyIntent && enemyIntent.kind === 'Attack' && skills.length) return bestBy(skills, blkEff).i;
// 3) 공격 우선, 없으면 스킬, 없으면 종료
if (attacks.length) return bestBy(attacks, dmgEff).i;
if (skills.length) return bestBy(skills, blkEff).i;
return -1;
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (6 tests)
- [ ] **Step 5: 커밋**
```bash
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
git commit -m "sim-balance(F): 플레이어 휴리스틱 정책 chooseAction + 테스트"
```
---
### Task 3: simulateCombat 엔진
**Files:**
- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs`
- [ ] **Step 1: 테스트 추가**
```js
import { simulateCombat, mulberry32 as m32 } from './sim-balance.mjs';
const DATA = {
cards: {
Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 },
Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 },
Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 },
},
starterDeck: ['Strike','Strike','Strike','Strike','Strike','Defend','Defend','Defend','Defend','Bash'],
enemy: { name: '슬라임', maxHp: 45, intents: [
{ kind: 'Attack', value: 10 }, { kind: 'Attack', value: 6 }, { kind: 'Defend', value: 8 },
] },
};
test('simulateCombat: 결정적 결과(동일 시드)', () => {
const r1 = simulateCombat(DATA, m32(1));
const r2 = simulateCombat(DATA, m32(1));
assert.deepEqual(r1, r2);
assert.equal(typeof r1.win, 'boolean');
assert.ok(r1.turns >= 1);
});
test('simulateCombat: 약한 적이면 대체로 승리', () => {
let wins = 0;
for (let i = 0; i < 50; i++) if (simulateCombat(DATA, m32(i + 1)).win) wins++;
assert.ok(wins >= 40, `예상 승리 다수, 실제 ${wins}/50`);
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: FAIL (`simulateCombat is not a function`)
- [ ] **Step 3: 구현 추가 (sim-balance.mjs)**
```js
function bump(s, cost, dmg, blk) {
s = s || { plays: 0, energy: 0, damage: 0, block: 0 };
s.plays++; s.energy += cost; s.damage += dmg; s.block += blk;
return s;
}
// 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적.
// 반환: { win, turns, playerHpRemaining, draw? }
export function simulateCombat(data, rng, stats) {
const { cards, starterDeck, enemy } = data;
let drawPile = shuffle(starterDeck, rng);
let discard = [];
let hand = [];
let pHp = PLAYER_HP, pBlock = 0;
let eHp = enemy.maxHp, eBlock = 0, intentIdx = 0;
let turns = 0;
function draw(n) {
for (let k = 0; k < n; k++) {
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
if (drawPile.length === 0) break;
hand.push(drawPile.pop());
}
}
while (turns < MAX_TURNS) {
turns++;
let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE);
while (true) {
const intent = enemy.intents[intentIdx];
const idx = chooseAction(hand, cards, energy, eHp, eBlock, intent);
if (idx < 0) break;
const id = hand[idx], c = cards[id];
energy -= c.cost;
if (c.kind === 'Attack') {
const r = applyDamage(eHp, eBlock, c.damage || 0); eHp = r.hp; eBlock = r.block;
if (stats) stats[id] = bump(stats[id], c.cost, c.damage || 0, 0);
} else {
pBlock += c.block || 0;
if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0);
}
hand.splice(idx, 1); discard.push(id);
if (eHp <= 0) return { win: true, turns, playerHpRemaining: pHp };
}
discard.push(...hand); hand = [];
eBlock = 0;
const intent = enemy.intents[intentIdx];
if (intent.kind === 'Attack') { const r = applyDamage(pHp, pBlock, intent.value); pHp = r.hp; pBlock = r.block; }
else if (intent.kind === 'Defend') { eBlock += intent.value; }
intentIdx = (intentIdx + 1) % enemy.intents.length;
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
}
return { win: false, turns, playerHpRemaining: pHp, draw: true };
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (8 tests)
- [ ] **Step 5: 커밋**
```bash
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
git commit -m "sim-balance(F): 단일 전투 시뮬 엔진 simulateCombat + 테스트"
```
---
### Task 4: runBatch·리포트·OP 탐지·CLI
**Files:**
- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs`
- [ ] **Step 1: 테스트 추가**
```js
import { runBatch } from './sim-balance.mjs';
test('runBatch: 집계 필드·승률 범위', () => {
const r = runBatch(100, 1);
assert.equal(r.N, 100);
assert.ok(r.winRate >= 0 && r.winRate <= 1);
assert.ok(r.avgTurns > 0);
assert.ok(r.cardStats.Strike.plays > 0);
});
test('runBatch: 동일 시드 동일 결과', () => {
assert.deepEqual(runBatch(100, 7), runBatch(100, 7));
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: FAIL (`runBatch is not a function`)
- [ ] **Step 3: 구현 추가 (sim-balance.mjs)**
```js
function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; }
function median(a) {
if (!a.length) return 0;
const s = a.slice().sort((x, y) => x - y), m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
export function runBatch(N, seed) {
const data = loadData();
const rng = mulberry32(seed);
const cardStats = {};
let wins = 0, draws = 0;
const turnsArr = [], hpArr = [];
for (let i = 0; i < N; i++) {
const r = simulateCombat(data, rng, cardStats);
if (r.draw) draws++;
if (r.win) { wins++; hpArr.push(r.playerHpRemaining); }
turnsArr.push(r.turns);
}
return {
N, wins, draws, losses: N - wins - draws,
winRate: wins / N,
avgTurns: mean(turnsArr), medianTurns: median(turnsArr),
avgHpOnWin: mean(hpArr),
cardStats, cards: data.cards, enemy: data.enemy, seed,
};
}
export function formatReport(r) {
const L = [];
L.push(`=== 밸런스 시뮬레이션 (적: ${r.enemy.name} HP ${r.enemy.maxHp}) ===`);
L.push(`시뮬 ${r.N}회 (seed=${r.seed})`);
L.push(`승률: ${(r.winRate * 100).toFixed(1)}% (승 ${r.wins} / 패 ${r.losses}${r.draws ? ` / 무 ${r.draws}` : ''})`);
L.push(`평균 턴: ${r.avgTurns.toFixed(2)} 중앙값 턴: ${r.medianTurns}`);
L.push(`승리 시 평균 잔여 HP: ${r.avgHpOnWin.toFixed(1)} / ${PLAYER_HP}`);
if (r.draws) L.push(`⚠️ 무승부 ${r.draws}건 (턴 상한 ${MAX_TURNS} 초과)`);
L.push('');
L.push('카드별:');
// 효율 계산 + kind별 중앙값으로 OP 플래그
const rows = Object.entries(r.cardStats).map(([id, s]) => {
const kind = r.cards[id].kind;
const eff = kind === 'Attack' ? s.damage / s.energy : s.block / s.energy;
return { id, name: r.cards[id].name, kind, plays: s.plays, eff };
});
for (const kind of ['Attack', 'Skill']) {
const kr = rows.filter((x) => x.kind === kind);
if (!kr.length) continue;
const med = median(kr.map((x) => x.eff));
for (const x of kr) {
const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : '';
const unit = kind === 'Attack' ? '뎀/E' : '블록/E';
L.push(` ${x.name}(${id2(x.id)}): 사용 ${x.plays}, 효율 ${x.eff.toFixed(2)} ${unit}${op}`);
}
}
const sorted = rows.slice().sort((a, b) => b.plays - a.plays);
if (sorted.length) L.push(`최다 사용: ${sorted[0].name} / 최소 사용: ${sorted[sorted.length - 1].name}`);
return L.join('\n');
}
function id2(id) { return id; }
function main() {
const args = process.argv.slice(2);
let N = 2000, seed = 1;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--seed') seed = parseInt(args[++i], 10);
else if (/^\d+$/.test(args[i])) N = parseInt(args[i], 10);
}
console.log(formatReport(runBatch(N, seed)));
}
if (process.argv[1] && process.argv[1].endsWith('sim-balance.mjs')) main();
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (10 tests)
- [ ] **Step 5: 커밋**
```bash
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
git commit -m "sim-balance(F): runBatch·리포트·OP 탐지·CLI + 테스트"
```
---
### Task 5: 검증 (CLI 실행·결정성·데이터 반영)
**Files:** 없음(실행 검증)
- [ ] **Step 1: 전체 테스트**
Run: `node --test tools/sim-balance.test.mjs`
Expected: PASS (10 tests, 0 fail)
- [ ] **Step 2: CLI 실행 (기본)**
Run: `node tools/sim-balance.mjs 2000`
Expected: 승률·평균턴·승리시 잔여HP·카드별 효율 리포트 출력.
- [ ] **Step 3: 결정성 (동일 시드 동일 출력)**
Run: `node tools/sim-balance.mjs 500 --seed 3 > /tmp/r1.txt && node tools/sim-balance.mjs 500 --seed 3 > /tmp/r2.txt && diff /tmp/r1.txt /tmp/r2.txt && echo DETERMINISTIC`
Expected: `DETERMINISTIC`
- [ ] **Step 4: 데이터 반영 (강타 데미지↑ → 승률·턴 변동)**
Run: `node tools/sim-balance.mjs 1000 --seed 1 | grep 승률` (기준값 기록) → `data/cards.json`에서 Bash.damage 10→20으로 임시 변경 → `node tools/sim-balance.mjs 1000 --seed 1 | grep 승률`(변동 확인) → `git checkout -- data/cards.json`(원복).
Expected: 두 승률/턴 수치가 다름(데이터 반영). 원복 후 기준 복귀.
- [ ] **Step 5: 최종 커밋(있다면 없음 — 검증 전용)**
검증 전용 태스크. 변경 없음. `git status``data/cards.json` 원복 확인.
---
## Self-Review
- **Spec coverage:** PRNG·applyDamage·loadData(Task1), 정책(Task2), 엔진(Task3), 집계·리포트·OP·CLI(Task4), 검증·데이터반영(Task5). 스펙 전 항목 매핑.
- **Placeholder scan:** 모든 단계 실제 코드/명령. 동기화 주석은 의도된 문서.
- **Type consistency:** `mulberry32/shuffle/applyDamage/loadData/chooseAction/simulateCombat/runBatch/formatReport` 시그니처가 정의(Task1·2·3·4)와 사용(테스트·CLI)에서 일치. `cardStats` 형태 `{plays,energy,damage,block}``bump`·`runBatch`·`formatReport`에서 일치. 카드 필드 `kind/damage/block/cost`가 데이터·정책·엔진에서 일치.

View File

@@ -1,341 +0,0 @@
# 카드/적 데이터 외부화 (TODO D) Implementation Plan
> **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:** 카드·적 데이터를 `data/cards.json`·`data/enemies.json`로 분리하고, `gen-slaydeck.mjs`가 읽어 codeblock·UI에 주입한다(데이터만 바꿔 재생성하면 반영).
**Architecture:** 신규 JSON 2개가 데이터 단일 소스. 생성기는 상단에서 JSON을 로드·검증하고, Lua 직렬화 헬퍼로 `self.Cards`/`self.DrawPile`/적 상태를 만들어 `StartCombat`에 주입한다. DeckHud 카드 미리보기·CombatHud 초기 텍스트도 동일 데이터에서 파생.
**Tech Stack:** Node.js ESM 생성기, JSON 데이터, MSW Lua codeblock/UI JSON. 검증은 `node --check`+재생성+sha1 결정성+데이터변경 반영 확인+메이커 Play.
---
## File Structure
- Create: `data/cards.json` — 카드 정의(`cards`) + 시작 덱(`starterDeck`).
- Create: `data/enemies.json` — 적 정의(`enemies`) + 활성 적(`activeEnemy`).
- Modify: `tools/gen-slaydeck.mjs` — JSON 로드·검증·Lua 직렬화 헬퍼, `StartCombat`/`upsertUi`/속성 데이터화.
검증 한계: MSW Lua 단위 테스트 러너 없음 → 자동 검증은 생성기 문법·재생성·결정성·데이터 반영·JSON 유효성. 실제 동작은 메이커 Play(사용자).
---
### Task 1: 데이터 파일 생성
**Files:**
- Create: `data/cards.json`
- Create: `data/enemies.json`
- [ ] **Step 1: `data/cards.json` 작성**
```json
{
"cards": {
"Strike": { "name": "타격", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6" },
"Defend": { "name": "방어", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5" },
"Bash": { "name": "강타", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10" }
},
"starterDeck": ["Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash"]
}
```
- [ ] **Step 2: `data/enemies.json` 작성**
```json
{
"enemies": {
"slime": {
"name": "슬라임",
"maxHp": 45,
"intents": [
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 6 },
{ "kind": "Defend", "value": 8 }
]
}
},
"activeEnemy": "slime"
}
```
- [ ] **Step 3: JSON 유효성 확인**
Run: `node -e "JSON.parse(require('fs').readFileSync('data/cards.json','utf8')); JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('JSON OK')"`
Expected: `JSON OK`
- [ ] **Step 4: 커밋**
```bash
git add data/cards.json data/enemies.json
git commit -m "data(D): 카드/적 데이터 JSON 외부화 파일 추가"
```
---
### Task 2: 생성기에 JSON 로드·검증·Lua 직렬화 헬퍼 추가
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (상단 import 직후)
- [ ] **Step 1: 파일 상단 `import { readFileSync, writeFileSync } from 'node:fs';` 바로 다음에 추가**
```js
const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
// 검증 (fail-fast): 잘못된 데이터면 생성 중단
for (const id of CARDS.starterDeck) {
if (!CARDS.cards[id]) {
throw new Error(`[gen-slaydeck] starterDeck에 없는 카드 id 참조: ${id}`);
}
}
if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
}
const ACTIVE_ENEMY = ENEMIES.enemies[ENEMIES.activeEnemy];
// Lua 직렬화 헬퍼
function luaStr(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
function luaCardsTable(cards) {
const lines = Object.entries(cards).map(([id, c]) => {
const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
if (c.damage != null) fields.push(`damage = ${c.damage}`);
if (c.block != null) fields.push(`block = ${c.block}`);
return `\t${id} = { ${fields.join(', ')} },`;
});
return `self.Cards = {\n${lines.join('\n')}\n}`;
}
function luaDeckTable(deck) {
return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`;
}
function luaIntentsTable(intents) {
const lines = intents.map((it) => `\t{ kind = ${luaStr(it.kind)}, value = ${it.value} },`);
return `self.EnemyIntents = {\n${lines.join('\n')}\n}`;
}
function intentText(it) {
if (it.kind === 'Attack') return `의도: 공격 ${it.value}`;
if (it.kind === 'Defend') return `의도: 방어 ${it.value}`;
return '';
}
```
- [ ] **Step 2: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(D): JSON 로드·검증·Lua 직렬화 헬퍼 추가"
```
---
### Task 3: StartCombat·EnemyMaxHp 속성을 데이터에서 생성
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`prop('number', 'EnemyMaxHp', ...)`, `method('StartCombat', ...)`)
- [ ] **Step 1: EnemyMaxHp 속성 기본값을 데이터로**
`prop('number', 'EnemyMaxHp', '45'),` 를 아래로 교체:
```js
prop('number', 'EnemyMaxHp', String(ACTIVE_ENEMY.maxHp)),
```
- [ ] **Step 2: `StartCombat` 메서드 본문을 데이터 주입형으로 교체**
기존 `method('StartCombat', \`...\`)` 호출 전체(아래 "현재" 블록)를 "신규"로 교체.
현재(교체 대상):
```
self.MaxEnergy = 3
self.Turn = 0
self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.PlayerBlock = 0
self.EnemyName = "슬라임"
self.EnemyMaxHp = 45
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = {
{ kind = "Attack", value = 10 },
{ kind = "Attack", value = 6 },
{ kind = "Defend", value = 8 },
}
self.EnemyIntentIndex = 1
self.CombatOver = false
self.DiscardPile = {}
self.Hand = {}
self.Cards = {
Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack", damage = 6 },
Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill", block = 5 },
Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack", damage = 10 },
}
self.DrawPile = { "Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash" }
self:Shuffle(self.DrawPile)
self:BindButtons()
self:RenderCombat()
self:StartPlayerTurn()
```
신규 — `method('StartCombat', ...)`의 코드 인자를 템플릿으로 생성:
```js
method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.PlayerBlock = 0
self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)}
self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp}
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
${luaIntentsTable(ACTIVE_ENEMY.intents)}
self.EnemyIntentIndex = 1
self.CombatOver = false
self.DiscardPile = {}
self.Hand = {}
${luaCardsTable(CARDS.cards)}
${luaDeckTable(CARDS.starterDeck)}
self:Shuffle(self.DrawPile)
self:BindButtons()
self:RenderCombat()
self:StartPlayerTurn()`),
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(D): StartCombat·EnemyMaxHp를 데이터에서 생성"
```
---
### Task 4: DeckHud 카드 미리보기·CombatHud 초기 텍스트를 데이터에서 파생
**Files:**
- Modify: `tools/gen-slaydeck.mjs` (`upsertUi`의 `cards` 배열, `enemyTexts` 초기값)
- [ ] **Step 1: `upsertUi`의 카드 미리보기 배열을 데이터 파생으로 교체**
기존:
```js
const cards = [
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
{ name: '강타', cost: '2', desc: '피해 10', tint: ATTACK },
];
```
교체:
```js
const cards = CARDS.starterDeck.slice(0, 5).map((id) => {
const c = CARDS.cards[id];
return { name: c.name, cost: String(c.cost), desc: c.desc, tint: c.kind === 'Attack' ? ATTACK : DEFEND };
});
```
- [ ] **Step 2: CombatHud `enemyTexts` 초기값을 데이터에서 파생**
기존:
```js
const enemyTexts = [
['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, '슬라임', 28, true, GOLD],
['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, 'HP 45/45', 24, true, { r: 1, g: 1, b: 1, a: 1 }],
['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }],
['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, '의도: 공격 10', 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }],
];
```
교체:
```js
const enemyTexts = [
['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, ACTIVE_ENEMY.name, 28, true, GOLD],
['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, `HP ${ACTIVE_ENEMY.maxHp}/${ACTIVE_ENEMY.maxHp}`, 24, true, { r: 1, g: 1, b: 1, a: 1 }],
['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }],
['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, intentText(ACTIVE_ENEMY.intents[0]), 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }],
];
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(D): 카드 미리보기·CombatHud 초기 텍스트 데이터 파생"
```
---
### Task 5: 재생성 + 검증
**Files:** 생성물 3종 (생성기 실행 결과)
- [ ] **Step 1: 생성기 실행**
Run: `node tools/gen-slaydeck.mjs`
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: 생성물이 B와 동치인지 — codeblock에 데이터 값이 반영됐는지 확인**
Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/Strike = { name = \"타격\".*damage = 6/.test(sc) && /슬라임/.test(sc) && /value = 10/.test(sc) ? 'DATA INJECTED OK' : 'MISMATCH')"`
Expected: `DATA INJECTED OK`
- [ ] **Step 3: 결정성 확인**
Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC`
Expected: `DETERMINISTIC`
- [ ] **Step 4: 데이터 변경이 반영되는지 확인 (D의 핵심 검증)**
Run: `node -e "const fs=require('fs'); const f='data/cards.json'; const o=JSON.parse(fs.readFileSync(f,'utf8')); o.cards.Strike.damage=9; o.cards.Strike.desc='피해 9'; fs.writeFileSync(f, JSON.stringify(o,null,2));" && node tools/gen-slaydeck.mjs >/dev/null && node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/Strike = { name = \"타격\", cost = 1, desc = \"피해 9\", kind = \"Attack\", damage = 9/.test(sc) ? 'CHANGE REFLECTED' : 'NOT REFLECTED')"`
Expected: `CHANGE REFLECTED`
- [ ] **Step 5: 변경 되돌리고 재생성 (원복)**
Run: `git checkout -- data/cards.json && node tools/gen-slaydeck.mjs >/dev/null && echo reverted`
Expected: `reverted`
- [ ] **Step 6: 잘못된 데이터 fail-fast 확인**
Run: `node -e "const fs=require('fs'); const o=JSON.parse(fs.readFileSync('data/enemies.json','utf8')); o.activeEnemy='nope'; fs.writeFileSync('/tmp/bad-enemies.json', JSON.stringify(o));" && cp data/enemies.json /tmp/enemies.bak && cp /tmp/bad-enemies.json data/enemies.json; node tools/gen-slaydeck.mjs; echo "exit=$?"; cp /tmp/enemies.bak data/enemies.json`
Expected: 에러 메시지 `activeEnemy가 enemies에 없음: nope` + `exit=1`, 이후 원복
- [ ] **Step 7: 최종 재생성 + git status 확인**
Run: `node tools/gen-slaydeck.mjs >/dev/null; git checkout -- Global/common.gamelogic 2>/dev/null; git status --short`
Expected: `data/*.json`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock`만 변경(내용 동일한 common 제외).
- [ ] **Step 8: 생성물 커밋**
```bash
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
git commit -m "재생성(D): 데이터 기반 카드/적 주입 반영"
```
- [ ] **Step 9: 메이커 Play 수동 검증 (사용자)**
메이커 reload→Play: 기존 B 동작과 동일(데이터 동치라 회귀 없음). 적 슬라임 HP 45·의도 공격10, 카드 3종 효과 정상.
---
## Self-Review
- **Spec coverage:** cards.json/enemies.json 생성(Task1), 로드·검증·직렬화(Task2), StartCombat·속성 데이터화(Task3), UI 파생(Task4), 검증·데이터변경 반영(Task5). 스펙 전 항목 매핑됨.
- **Placeholder scan:** 모든 단계 실제 코드/명령 포함. "TODO(E)"류 미래 훅은 본 작업 범위 아님.
- **Type consistency:** `luaStr`/`luaCardsTable`/`luaDeckTable`/`luaIntentsTable`/`intentText`/`ACTIVE_ENEMY`/`CARDS`/`ENEMIES` 명칭이 정의부(Task2)와 사용부(Task3·4)에서 일치. 카드 필드(`name/cost/kind/damage/block/desc`)가 데이터(Task1)·직렬화(Task2)·검증(Task5)에서 일치.

View File

@@ -1,493 +0,0 @@
# 분기 맵 노드 진행 (TODO E3) Implementation Plan
> **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:** 플레이어가 작성된 분기 맵(DAG)에서 다음 노드를 선택해 전투/엘리트/보스로 진행, 보스 클리어 시 "런 클리어".
**Architecture:** `data/map.json`(그래프)·`data/enemies.json`(다중 적)을 `gen-slaydeck.mjs`가 로드·주입. SlayDeckController에 맵 상태·네비게이션 메서드 추가, MapHud UI 생성. 자동 진행 대신 ShowMap→PickNode→StartCombat→보상→ShowMap 루프.
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play.
---
## File Structure
- Create: `data/map.json` — 분기 맵.
- Modify: `data/enemies.json` — slime_elite·slime_boss 추가.
- Modify: `tools/gen-slaydeck.mjs` — 맵/적 로드·검증·직렬화 헬퍼, method() returnType, 속성·메서드·MapHud UI.
검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play.
---
### Task 1: 데이터 + 로드·검증·직렬화 헬퍼
**Files:** Create `data/map.json`; Modify `data/enemies.json`, `tools/gen-slaydeck.mjs`
- [ ] **Step 1: `data/map.json` 작성**
```json
{
"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": ["D", "E"] },
"C": { "type": "elite", "enemy": "slime_elite", "row": 2, "col": -2, "next": ["BOSS"] },
"D": { "type": "combat", "enemy": "slime", "row": 2, "col": 0, "next": ["BOSS"] },
"E": { "type": "combat", "enemy": "slime", "row": 2, "col": 2, "next": ["BOSS"] },
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 3, "col": 0, "next": [] }
}
}
```
- [ ] **Step 2: `data/enemies.json`에 엘리트·보스 추가**`slime` 항목 다음에:
```json
"slime_elite": {
"name": "정예 슬라임",
"maxHp": 70,
"intents": [
{ "kind": "Attack", "value": 14 },
{ "kind": "Attack", "value": 8 },
{ "kind": "Defend", "value": 10 }
]
},
"slime_boss": {
"name": "슬라임 킹",
"maxHp": 120,
"intents": [
{ "kind": "Attack", "value": 18 },
{ "kind": "Defend", "value": 12 },
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 22 }
]
}
```
- [ ] **Step 3: 생성기 상단에 map 로드·검증·헬퍼 추가**`const ACTIVE_ENEMY = ...;` 다음에:
```js
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 (!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));
function luaIntentsArray(intents) {
return '{ ' + intents.map((it) => `{ kind = ${luaStr(it.kind)}, value = ${it.value} }`).join(', ') + ' }';
}
function luaEnemiesTable(enemies) {
const lines = Object.entries(enemies).map(([id, e]) =>
`\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(', ') + ' }';
return `\t${id} = { type = ${luaStr(n.type)}, enemy = ${luaStr(n.enemy)}, 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(', ') + ' }';
}
```
- [ ] **Step 4: method()에 ReturnType 파라미터 추가** — 기존 method 함수를:
```js
function method(Name, Code, Arguments = [], ExecSpace = 0, ReturnType = 'void') {
return {
Return: { Type: ReturnType, DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
Arguments,
Code,
Scope: 2,
ExecSpace,
Attributes: [],
Name,
};
}
```
- [ ] **Step 5: JSON·문법 검사**
Run: `node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs`
Expected: `JSON OK` + 오류 없음
- [ ] **Step 6: 커밋**
```bash
git add data/map.json data/enemies.json tools/gen-slaydeck.mjs
git commit -m "data(E3): 분기 맵 map.json·엘리트/보스 적 + 직렬화 헬퍼"
```
---
### Task 2: 맵 속성 + StartRun(맵 빌드·ShowMap)
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: 맵 상태 속성 추가**`prop('boolean', 'RunActive', 'false'),` 다음에:
```js
prop('any', 'Enemies'),
prop('any', 'MapNodes'),
prop('any', 'MapStart'),
prop('string', 'CurrentNodeId', '""'),
prop('string', 'CurrentEnemyId', '""'),
```
- [ ] **Step 2: StartRun 교체** — 맵 빌드 + ShowMap:
```js
method('StartRun', `self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.Gold = 0
self.Floor = 0
self.RunLength = ${MAX_ROW}
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
self.RunActive = true
${luaEnemiesTable(ENEMIES.enemies)}
${luaMapNodesTable(MAP.nodes)}
${luaStartArray(MAP.start)}
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:BindButtons()
self:ShowMap()`),
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): 맵 상태 속성·StartRun 맵 빌드/ShowMap"
```
---
### Task 3: StartCombat·CheckCombatEnd·PickReward (맵 연동)
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: StartCombat 교체** — 적을 self.Enemies에서 로드, Floor=노드 row:
```js
method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
local node = self.MapNodes[self.CurrentNodeId]
if node ~= nil then
self.Floor = node.row
end
local enemy = self.Enemies[self.CurrentEnemyId]
self.PlayerBlock = 0
self.EnemyName = enemy.name
self.EnemyMaxHp = enemy.maxHp
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = enemy.intents
self.EnemyIntentIndex = 1
self.CombatOver = false
self.DiscardPile = {}
self.Hand = {}
${luaCardsTable(CARDS.cards)}
self.DrawPile = {}
for i = 1, #self.RunDeck do
self.DrawPile[i] = self.RunDeck[i]
end
self:Shuffle(self.DrawPile)
self:RenderCombat()
self:StartPlayerTurn()`),
```
- [ ] **Step 2: CheckCombatEnd 교체** — 보스 노드면 런 클리어:
```js
method('CheckCombatEnd', `if self.EnemyHp <= 0 then
self.CombatOver = true
self.Gold = self.Gold + ${GOLD_PER_WIN}
self:RenderRun()
local node = self.MapNodes[self.CurrentNodeId]
if node ~= nil and node.type == "boss" then
self:ShowResult("런 클리어!")
self.RunActive = false
else
self:OfferReward()
end
elseif self.PlayerHp <= 0 then
self.CombatOver = true
self:ShowResult("패배...")
self.RunActive = false
end`),
```
- [ ] **Step 3: PickReward 마지막을 ShowMap으로** — PickReward 코드의 마지막 `self:StartCombat()``self:ShowMap()`로 교체. (그 외 동일)
```js
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
return
end
if slot ~= 0 and self.RewardChoices ~= nil then
local id = self.RewardChoices[slot]
if id ~= nil then
table.insert(self.RunDeck, id)
end
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
hud.Enable = false
end
self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
```
- [ ] **Step 4: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 5: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): StartCombat 적 데이터화·보스 런클리어·보상후 맵복귀"
```
---
### Task 4: ShowMap·IsReachable·PickNode·RenderMap + BindButtons
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: 맵 메서드 추가** — PickReward 메서드 다음(마지막 `]);` 직전)에 삽입:
```js
method('ShowMap', `self:RenderMap()
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
if hud ~= nil then
hud.Enable = true
end`),
method('IsReachable', `local list
if self.CurrentNodeId == "" then
list = self.MapStart
else
local node = self.MapNodes[self.CurrentNodeId]
if node == nil then
return false
end
list = node.next
end
for i = 1, #list do
if list[i] == id then
return true
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)
else
e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
end
end
if e.ButtonComponent ~= nil then
e.ButtonComponent.Enable = reachable
end
end
end`),
method('PickNode', `if self.RunActive ~= true then
return
end
if self:IsReachable(id) ~= true then
return
end
self.CurrentNodeId = id
self.CurrentEnemyId = self.MapNodes[id].enemy
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
if hud ~= nil then
hud.Enable = false
end
self:StartCombat()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
```
- [ ] **Step 2: BindButtons에 맵 노드 버튼 바인딩 추가** — BindButtons 코드의 마지막 `end`(skip 바인딩) 다음에 추가. BindButtons 끝부분의 skip 블록 다음에 붙이도록, skip 블록을 아래로 교체:
```js
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(', ')} }
for i = 1, #mapNodeIds do
local nid = mapNodeIds[i]
local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid)
if mn ~= nil and mn.ButtonComponent ~= nil then
mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)
end
end`),
```
(BindButtons 전체에서 기존 skip 블록 `local skip = ... end`)` 부분을 위 블록으로 교체)
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): ShowMap/IsReachable/PickNode/RenderMap·맵 노드 바인딩"
```
---
### Task 5: MapHud UI 생성
**Files:** Modify `tools/gen-slaydeck.mjs` (`guid`, `upsertUi`)
- [ ] **Step 1: guid 'map' 분기** — ns 매핑에 추가:
```js
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : 0xfe;
```
- [ ] **Step 2: 필터 확장** — upsertUi 필터에 MapHud 추가:
```js
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud') && !e.path.startsWith('/ui/DefaultGroup/MapHud'));
```
- [ ] **Step 3: MapHud 그룹 생성** — `ui.ContentProto.Entities.push(...reward);` 다음에 삽입:
```js
const TYPE_KO = { combat: '전투', elite: '엘리트', boss: '보스' };
const map = [];
const mapHud = entity({
id: guid('map', 0),
path: '/ui/DefaultGroup/MapHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 7,
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.9 }, type: 1, raycast: true }),
],
});
mapHud.jsonString.enable = false;
map.push(mapHud);
map.push(entity({
id: guid('map', 1),
path: '/ui/DefaultGroup/MapHud/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: 510 } }),
sprite({ color: TRANSPARENT }),
text({ value: '다음 노드 선택', fontSize: 40, bold: true, color: GOLD, alignment: 4 }),
],
}));
let mapN = 2;
for (const [id, node] of Object.entries(MAP.nodes)) {
const nodePath = `/ui/DefaultGroup/MapHud/Node_${id}`;
const pos = { x: node.col * 180, y: node.row * 170 - 80 };
map.push(entity({
id: guid('map', mapN++),
path: nodePath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: node.row,
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 }),
button(),
],
}));
map.push(entity({
id: guid('map', mapN++),
path: `${nodePath}/Label`,
modelId: 'uitext',
entryId: 'UIText',
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 } }),
sprite({ color: TRANSPARENT }),
text({ value: `${TYPE_KO[node.type]}\n${ENEMIES.enemies[node.enemy].name}`, fontSize: 20, bold: true }),
],
}));
}
ui.ContentProto.Entities.push(...map);
```
- [ ] **Step 4: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 5: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E3): MapHud 노드 맵 UI 생성"
```
---
### Task 6: 재생성 + 검증
**Files:** 생성물
- [ ] **Step 1: 생성**
Run: `node tools/gen-slaydeck.mjs`
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: 메서드·UI·적 주입 확인**
Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['ShowMap','PickNode','IsReachable','RenderMap'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartRun').Code; console.log(/slime_boss/.test(sc)&&/슬라임 킹/.test(sc)?'ENEMIES OK':'NO ENEMIES'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); const has=p=>u.ContentProto.Entities.some(e=>e.path===p); console.log(has('/ui/DefaultGroup/MapHud')&&has('/ui/DefaultGroup/MapHud/Node_BOSS')&&has('/ui/DefaultGroup/MapHud/Node_A/Label')?'UI OK':'UI MISSING')"`
Expected: `METHODS OK` / `ENEMIES OK` / `UI OK`
- [ ] **Step 3: 결정성**
Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC`
Expected: `DETERMINISTIC`
- [ ] **Step 4: git status**
Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short`
Expected: `data/*`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs).
- [ ] **Step 5: 생성물 커밋**
```bash
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
git commit -m "재생성(E3): 분기 맵·다중 적 반영"
```
- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)**
reload→Play: StartRun → MapHud(A·B만 클릭 가능) → PickNode("A") → 슬라임 전투 → 승리 → 보상 → 맵(C·D 활성) → PickNode("C") → 정예 슬라임(HP70) → ... → BOSS → 슬라임킹(HP120) → 승리 → "런 클리어!". 도달 불가 노드 PickNode → 무시. MCP는 `PickNode`/`PlayCard`/`PickReward` 직접 호출 + 상태 로그로 검증.
---
## Self-Review
- **Spec coverage:** map.json/적(Task1), 맵 상태·StartRun(Task2), StartCombat 적데이터·보스클리어·보상후맵(Task3), Show/Pick/Reachable/RenderMap·바인딩(Task4), MapHud UI(Task5), 검증(Task6). 스펙 전 항목 매핑.
- **Placeholder scan:** 모든 단계 실제 코드/명령.
- **Type consistency:** 메서드 `StartRun/ShowMap/IsReachable/PickNode/RenderMap/StartCombat/CheckCombatEnd/PickReward` 정의·호출 일치. 속성 `Enemies/MapNodes/MapStart/CurrentNodeId/CurrentEnemyId` 정의(Task2)·사용(Task3·4) 일치. UI 경로 `/ui/DefaultGroup/MapHud/Node_{id}`·`/Label`가 codeblock(RenderMap/PickNode/BindButtons)·생성(Task5)에서 동일(노드 id는 map.json 키). `IsReachable`는 boolean 반환(method returnType param, Task1). enemy 필드 `name/maxHp/intents`가 데이터·luaEnemiesTable·StartCombat에서 일치.

View File

@@ -1,438 +0,0 @@
# 런 루프 코어 (TODO E1+E2) Implementation Plan
> **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:** 단일 전투를 연속 N전투 런으로 확장 — 런 상태(HP/골드/덱) 영속 + 승리 후 카드 1택 보상 + 다음 전투 + N전투 후 "런 클리어".
**Architecture:** 기존 `SlayDeckController`(gen-slaydeck.mjs 생성)에 런 상태·보상 메서드 추가. StartRun(영속 초기화·버튼 1회 바인딩) vs StartCombat(전투별 초기화, RunDeck에서 드로) 분리. RewardHud UI 생성.
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play.
---
## File Structure
- Modify: `tools/gen-slaydeck.mjs` — 유일 변경 대상.
- `writeCodeblocks`: 런 상수, 새 속성, OnBeginPlay/StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat.
- `upsertUi`: CombatHud에 Floor/Gold, RewardHud 그룹 생성, 필터 확장, guid 'rwd' 분기.
MSW Lua 단위 테스트 불가 → 검증은 생성기 문법·재생성·결정성·메이커 Play.
---
### Task 1: 런 상수·속성·StartRun
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: 런 상수 추가**`writeCodeblocks()` 함수 본문 첫 줄에 삽입:
```js
const RUN_LENGTH = 3;
const GOLD_PER_WIN = 15;
```
- [ ] **Step 2: 새 속성 추가** — 속성 배열의 `prop('any', 'EnemyName'),` 다음에:
```js
prop('any', 'RunDeck'),
prop('number', 'Gold', '0'),
prop('number', 'Floor', '0'),
prop('number', 'RunLength', String(RUN_LENGTH)),
prop('any', 'RewardChoices'),
prop('boolean', 'RunActive', 'false'),
```
- [ ] **Step 3: OnBeginPlay → StartRun**`method('OnBeginPlay', \`self:StartCombat()\`),` 를:
```js
method('OnBeginPlay', `self:StartRun()`),
```
- [ ] **Step 4: StartRun 메서드 추가** — OnBeginPlay 다음에 삽입:
```js
method('StartRun', `self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.Gold = 0
self.Floor = 0
self.RunLength = ${RUN_LENGTH}
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
self.RunActive = true
self:BindButtons()
self:StartCombat()`),
```
- [ ] **Step 5: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 6: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E1): 런 상태 속성·StartRun 추가"
```
---
### Task 2: StartCombat 수정 + BindButtons 수정
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: StartCombat 본문 교체**`method('StartCombat', \`...\`)`의 코드를 아래로(HP 보존·Floor++·RunDeck에서 드로·BindButtons 호출 제거):
```js
method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
self.Floor = self.Floor + 1
self.PlayerBlock = 0
self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)}
self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp}
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
${luaIntentsTable(ACTIVE_ENEMY.intents)}
self.EnemyIntentIndex = 1
self.CombatOver = false
self.DiscardPile = {}
self.Hand = {}
${luaCardsTable(CARDS.cards)}
self.DrawPile = {}
for i = 1, #self.RunDeck do
self.DrawPile[i] = self.RunDeck[i]
end
self:Shuffle(self.DrawPile)
self:RenderCombat()
self:StartPlayerTurn()`),
```
- [ ] **Step 2: BindButtons에 보상 버튼 바인딩 추가** — BindButtons 코드 끝(마지막 `end` 다음)에 추가. 현재 마지막 부분:
```
for i = 1, 5 do
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then
cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)
end
end
```
뒤에 이어붙이도록 BindButtons 코드를 아래 전체로 교체:
```js
method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/EndTurnButton")
if endTurn ~= nil and endTurn.ButtonComponent ~= nil then
if self.EndTurnHandler ~= nil then
endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)
self.EndTurnHandler = nil
end
self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)
end
for i = 1, 5 do
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then
cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)
end
end
for i = 1, 3 do
local rc = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Reward" .. tostring(i))
if rc ~= nil and rc.ButtonComponent ~= nil then
rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end)
end
end
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`),
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E1): StartCombat 런 분리(HP보존·RunDeck드로)·BindButtons 1회+보상버튼"
```
---
### Task 3: CheckCombatEnd·OfferReward·PickReward·RenderRun
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: CheckCombatEnd 교체** — 보상/런클리어/패배 분기:
```js
method('CheckCombatEnd', `if self.EnemyHp <= 0 then
self.CombatOver = true
self.Gold = self.Gold + ${GOLD_PER_WIN}
self:RenderRun()
if self.Floor >= self.RunLength then
self:ShowResult("런 클리어!")
self.RunActive = false
else
self:OfferReward()
end
elseif self.PlayerHp <= 0 then
self.CombatOver = true
self:ShowResult("패배...")
self.RunActive = false
end`),
```
- [ ] **Step 2: OfferReward·ApplyRewardVisual·PickReward·RenderRun 추가** — RenderCombat 메서드 다음에 삽입:
```js
method('OfferReward', `local pool = {}
for id, _ in pairs(self.Cards) do
table.insert(pool, id)
end
self.RewardChoices = {}
for i = 1, 3 do
self.RewardChoices[i] = pool[math.random(1, #pool)]
self:ApplyRewardVisual(i, self.RewardChoices[i])
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
hud.Enable = true
end`),
method('ApplyRewardVisual', `local c = self.Cards[cardId]
if c == nil then
return
end
local base = "/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot)
self:SetText(base .. "/Name", c.name)
self:SetText(base .. "/Cost", tostring(c.cost))
self:SetText(base .. "/Desc", c.desc)
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
if c.kind == "Attack" then
e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)
elseif c.kind == "Skill" then
e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)
else
e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)
end
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
return
end
if slot ~= 0 and self.RewardChoices ~= nil then
local id = self.RewardChoices[slot]
if id ~= nil then
table.insert(self.RunDeck, id)
end
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
hud.Enable = false
end
self:StartCombat()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/Floor", "층 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength))
self:SetText("/ui/DefaultGroup/CombatHud/Gold", "골드 " .. string.format("%d", self.Gold))`),
```
- [ ] **Step 3: RenderCombat 끝에 RenderRun 호출 추가** — RenderCombat 코드의 마지막 줄(`...PlayerBlock...`) 다음에 `\nself:RenderRun()` 추가. 즉 RenderCombat 마지막을:
```
self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock))
self:RenderRun()
```
로. (Edit: 기존 마지막 줄 끝에 `\nself:RenderRun()` 삽입)
- [ ] **Step 4: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 5: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E2): 보상(OfferReward/PickReward)·런 클리어·층/골드 렌더"
```
---
### Task 4: UI — CombatHud 층/골드 + RewardHud
**Files:** Modify `tools/gen-slaydeck.mjs` (`upsertUi`, `guid`)
- [ ] **Step 1: guid 'rwd' 분기 추가** — guid()의 ns 매핑을:
```js
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : 0xfe;
```
- [ ] **Step 2: 정리 필터 확장** — upsertUi 시작부 필터를:
```js
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud'));
```
- [ ] **Step 3: CombatHud에 Floor·Gold 텍스트 추가**`const result = entity({` 선언 직전(즉 result 추가 전)에 삽입:
```js
for (const [suffix, pos, value, color] of [
['Floor', { x: -820, y: 480 }, '층 1/3', GOLD],
['Gold', { x: 820, y: 480 }, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }],
]) {
combat.push(entity({
id: guid('cmb', cmbN++),
path: `/ui/DefaultGroup/CombatHud/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 9,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 240, y: 44 }, pos }),
sprite({ color: TRANSPARENT }),
text({ value, fontSize: 26, bold: true, color, alignment: 4 }),
],
}));
}
```
- [ ] **Step 4: RewardHud 그룹 생성**`ui.ContentProto.Entities.push(...combat);` 직후, `JSON.parse(JSON.stringify(ui));` 직전에 삽입:
```js
const reward = [];
const rewardHud = entity({
id: guid('rwd', 0),
path: '/ui/DefaultGroup/RewardHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 6,
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.04, g: 0.05, b: 0.07, a: 0.86 }, type: 1, raycast: true }),
],
});
rewardHud.jsonString.enable = false;
reward.push(rewardHud);
reward.push(entity({
id: guid('rwd', 1),
path: '/ui/DefaultGroup/RewardHud/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: 64 }, pos: { x: 0, y: 300 } }),
sprite({ color: TRANSPARENT }),
text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
],
}));
let rwdN = 2;
const rewardXs = [-300, 0, 300];
for (let i = 1; i <= 3; i++) {
const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`;
reward.push(entity({
id: guid('rwd', rwdN++),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: i,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: rewardXs[i - 1], y: 0 } }),
sprite({ color: ATTACK, type: 1, raycast: true }),
button(),
],
}));
for (const [suffix, cfg] of [
['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true }],
['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true }],
['Desc', { size: { x: 160, y: 82 }, pos: { x: 0, y: -80 }, value: '', fontSize: 20, bold: false }],
]) {
reward.push(entity({
id: guid('rwd', rwdN++),
path: `${cardPath}/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : 2,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
sprite({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold }),
],
}));
}
}
reward.push(entity({
id: guid('rwd', rwdN++),
path: '/ui/DefaultGroup/RewardHud/Skip',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 10,
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: -260 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '건너뛰기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
ui.ContentProto.Entities.push(...reward);
```
- [ ] **Step 5: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 6: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E2): CombatHud 층/골드 + RewardHud(보상 카드 3+건너뛰기) UI"
```
---
### Task 5: 재생성 + 검증
**Files:** 생성물 2종
- [ ] **Step 1: 생성**
Run: `node tools/gen-slaydeck.mjs`
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: 메서드·UI 생성 확인**
Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['StartRun','OfferReward','PickReward','RenderRun','ApplyRewardVisual'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); console.log(u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/RewardHud')&&u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/CombatHud/Gold')?'UI OK':'UI MISSING')"`
Expected: `METHODS OK` / `UI OK`
- [ ] **Step 3: 결정성**
Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC`
Expected: `DETERMINISTIC`
- [ ] **Step 4: git status (의도 파일만)**
Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short`
Expected: `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs).
- [ ] **Step 5: 생성물 커밋**
```bash
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
git commit -m "재생성(E1+E2): 런 루프·보상 UI 반영"
```
- [ ] **Step 6: 메이커 Play 수동 검증 (사용자/MCP)**
reload→Play: 승리 → RewardHud 카드 3장·골드+15·층 표시 → 1택 시 RunDeck+1·다음 전투(HP 유지) → 3전투째 승리 시 "런 클리어!". 패배 시 "패배...". MCP는 `PlayCard`/`EndPlayerTurn`/`PickReward` 직접 호출 + 상태 로그로 검증.
---
## Self-Review
- **Spec coverage:** 상수·속성·StartRun(Task1), StartCombat분리·BindButtons1회(Task2), 보상·런클리어·렌더(Task3), 층/골드·RewardHud UI(Task4), 검증(Task5). 스펙 전 항목 매핑.
- **Placeholder scan:** 모든 단계 실제 코드/명령.
- **Type consistency:** 메서드명 `StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat` 정의·호출 일치. UI 경로 `/ui/DefaultGroup/RewardHud/Reward{1..3}/{Name,Cost,Desc}`·`/Skip`·`/CombatHud/{Floor,Gold}`가 codeblock(ApplyRewardVisual/RenderRun/BindButtons)과 생성(Task4) 일치. 속성 `RunDeck/Gold/Floor/RunLength/RewardChoices/RunActive` 정의(Task1)·사용(Task2·3) 일치.

View File

@@ -1,488 +0,0 @@
# 상점/휴식 노드 (TODO E4) Implementation Plan
> **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:** 맵에 상점(골드→카드)·휴식(HP 회복) 노드를 추가하고, 진입 시 전투 대신 상점/휴식 UI로 분기.
**Architecture:** `data/map.json`에 shop/rest 노드 추가(enemy 없음). SlayDeckController에 상점/휴식 메서드, PickNode 타입 분기, ShopHud/RestHud UI. 모두 `gen-slaydeck.mjs`에서 생성.
**Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play.
---
## File Structure
- Modify: `data/map.json` — 4행, shop/rest 노드.
- Modify: `tools/gen-slaydeck.mjs` — 검증 완화, enemy 조건부 직렬화, 상수, 속성, PickNode 분기, 상점/휴식 메서드, ShopHud/RestHud UI, MapHud y 중앙정렬.
검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play.
---
### Task 1: 데이터 + 검증완화 + enemy 조건부 직렬화 + 상수·속성
**Files:** Modify `data/map.json`, `tools/gen-slaydeck.mjs`
- [ ] **Step 1: `data/map.json` 교체**
```json
{
"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": [] }
}
}
```
- [ ] **Step 2: 검증 완화 (enemy 선택적)** — 생성기의 맵 검증 루프를 교체:
```js
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}`);
}
}
```
- [ ] **Step 3: luaMapNodesTable enemy 조건부** — 함수를 교체:
```js
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}`;
}
```
- [ ] **Step 4: 상수 추가**`writeCodeblocks()``const GOLD_PER_WIN = 15;` 다음에:
```js
const CARD_PRICE = 30;
const REST_HEAL = 30;
```
- [ ] **Step 5: 상점 상태 속성 추가**`prop('string', 'CurrentEnemyId', '""'),` 다음에:
```js
prop('any', 'ShopChoices'),
prop('any', 'ShopBought'),
```
- [ ] **Step 6: JSON·문법 검사**
Run: `node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs`
Expected: `JSON OK` + 오류 없음
- [ ] **Step 7: 커밋**
```bash
git add data/map.json tools/gen-slaydeck.mjs
git commit -m "data(E4): 상점/휴식 노드 맵 + enemy 선택적 검증/직렬화 + 상수/속성"
```
---
### Task 2: PickNode 분기 + 상점/휴식 메서드
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: PickNode 교체 (타입 분기)**
```js
method('PickNode', `if self.RunActive ~= true then
return
end
if self:IsReachable(id) ~= true then
return
end
self.CurrentNodeId = id
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud")
if hud ~= nil then
hud.Enable = false
end
local node = self.MapNodes[id]
if node.type == "shop" then
self:ShowShop()
elseif node.type == "rest" then
self:ShowRest()
else
self.CurrentEnemyId = node.enemy
self:StartCombat()
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
```
- [ ] **Step 2: 상점/휴식 메서드 추가** — PickNode 메서드 다음에 삽입:
```js
method('ShowShop', `local pool = {}
for cid, _ in pairs(self.Cards) do
table.insert(pool, cid)
end
self.ShopChoices = {}
self.ShopBought = { false, false, false }
for i = 1, 3 do
self.ShopChoices[i] = pool[math.random(1, #pool)]
end
self:RenderShop()
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud")
if hud ~= nil then
hud.Enable = true
end`),
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:SetText(base .. "/Name", c.name)
self:SetText(base .. "/Cost", tostring(c.cost))
self:SetText(base .. "/Desc", c.desc)
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
e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6)
elseif c.kind == "Attack" then
e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1)
elseif c.kind == "Skill" then
e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1)
else
e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1)
end
end
end
end`),
method('BuyCard', `if self.ShopBought == nil or self.ShopBought[slot] == true then
return
end
if self.Gold < ${CARD_PRICE} then
return
end
self.Gold = self.Gold - ${CARD_PRICE}
table.insert(self.RunDeck, self.ShopChoices[slot])
self.ShopBought[slot] = true
self:RenderShop()
self:RenderRun()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('ShowRest', `local old = self.PlayerHp
self.PlayerHp = self.PlayerHp + ${REST_HEAL}
if self.PlayerHp > self.PlayerMaxHp then
self.PlayerHp = self.PlayerMaxHp
end
local healed = self.PlayerHp - old
self:SetText("/ui/DefaultGroup/RestHud/Info", "HP " .. string.format("%d", old) .. " → " .. string.format("%d", self.PlayerHp) .. " (+" .. string.format("%d", healed) .. ")")
self:RenderCombat()
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud")
if hud ~= nil then
hud.Enable = true
end`),
method('LeaveNode', `local s = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud")
if s ~= nil then
s.Enable = false
end
local r = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud")
if r ~= nil then
r.Enable = false
end
self:ShowMap()`),
```
- [ ] **Step 3: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 4: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E4): PickNode 타입 분기·상점(구매)/휴식(회복) 메서드"
```
---
### Task 3: BindButtons 바인딩
**Files:** Modify `tools/gen-slaydeck.mjs`
- [ ] **Step 1: BindButtons 맵 노드 루프 다음에 상점/휴식 바인딩 추가** — BindButtons 코드의 맵 노드 for-loop(`...PickNode(nid)...end\nend`) 다음, 닫는 백틱 직전에 삽입. 맵 노드 루프 끝 부분을 아래로 교체:
```js
for i = 1, #mapNodeIds do
local nid = mapNodeIds[i]
local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid)
if mn ~= nil and mn.ButtonComponent ~= nil then
mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)
end
end
for i = 1, 3 do
local sc = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Card" .. tostring(i))
if sc ~= nil and sc.ButtonComponent ~= nil then
sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end)
end
end
local shopLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Leave")
if shopLeave ~= nil and shopLeave.ButtonComponent ~= nil then
shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end
local restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave")
if restLeave ~= nil and restLeave.ButtonComponent ~= nil then
restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end`),
```
- [ ] **Step 2: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 3: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E4): 상점 구매/나가기·휴식 나가기 버튼 바인딩"
```
---
### Task 4: ShopHud·RestHud UI + MapHud 4행 정렬
**Files:** Modify `tools/gen-slaydeck.mjs` (`guid`, `upsertUi`)
- [ ] **Step 1: guid 'shp'·'rst' 분기** — ns 매핑에 추가(map 분기 다음):
```js
const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : 0xfe;
```
- [ ] **Step 2: 필터 확장** — upsertUi 필터에 ShopHud·RestHud 추가:
```js
ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud') && !e.path.startsWith('/ui/DefaultGroup/MapHud') && !e.path.startsWith('/ui/DefaultGroup/ShopHud') && !e.path.startsWith('/ui/DefaultGroup/RestHud'));
```
- [ ] **Step 3: MapHud 노드 y 중앙정렬** — upsertUi의 노드 pos 계산을 교체:
```js
const pos = { x: node.col * 180, y: (node.row - (MAX_ROW + 1) / 2) * 140 };
```
- [ ] **Step 4: ShopHud·RestHud 생성**`ui.ContentProto.Entities.push(...map);` 다음에 삽입:
```js
const shop = [];
const shopHud = entity({
id: guid('shp', 0),
path: '/ui/DefaultGroup/ShopHud',
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 }),
],
});
shopHud.jsonString.enable = false;
shop.push(shopHud);
shop.push(entity({
id: guid('shp', 1),
path: '/ui/DefaultGroup/ShopHud/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: 400 } }),
sprite({ color: TRANSPARENT }),
text({ value: '상점', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
],
}));
shop.push(entity({
id: guid('shp', 2),
path: '/ui/DefaultGroup/ShopHud/Gold',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
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 }),
],
}));
let shpN = 3;
const shopXs = [-300, 0, 300];
for (let i = 1; i <= 3; i++) {
const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`;
shop.push(entity({
id: guid('shp', shpN++),
path: cardPath,
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent',
displayOrder: i,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: shopXs[i - 1], y: 20 } }),
sprite({ color: ATTACK, type: 1, raycast: true }),
button(),
],
}));
for (const [suffix, cfg] of [
['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }],
['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }],
['Desc', { size: { x: 160, y: 60 }, pos: { x: 0, y: -50 }, value: '', fontSize: 20, 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 } }],
]) {
shop.push(entity({
id: guid('shp', shpN++),
path: `${cardPath}/${suffix}`,
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : suffix === 'Desc' ? 2 : 3,
components: [
transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }),
sprite({ color: TRANSPARENT }),
text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color }),
],
}));
}
}
shop.push(entity({
id: guid('shp', shpN++),
path: '/ui/DefaultGroup/ShopHud/Leave',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent',
displayOrder: 10,
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: -300 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
ui.ContentProto.Entities.push(...shop);
const rest = [];
const restHud = entity({
id: guid('rst', 0),
path: '/ui/DefaultGroup/RestHud',
modelId: 'uisprite',
entryId: 'UISprite',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
displayOrder: 9,
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.08, b: 0.06, a: 0.92 }, type: 1, raycast: true }),
],
});
restHud.jsonString.enable = false;
rest.push(restHud);
rest.push(entity({
id: guid('rst', 1),
path: '/ui/DefaultGroup/RestHud/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: 140 } }),
sprite({ color: TRANSPARENT }),
text({ value: '휴식', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
],
}));
rest.push(entity({
id: guid('rst', 2),
path: '/ui/DefaultGroup/RestHud/Info',
modelId: 'uitext',
entryId: 'UIText',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
displayOrder: 1,
components: [
transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 600, y: 50 }, pos: { x: 0, y: 30 } }),
sprite({ color: TRANSPARENT }),
text({ value: 'HP 회복', fontSize: 30, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }),
],
}));
rest.push(entity({
id: guid('rst', 3),
path: '/ui/DefaultGroup/RestHud/Leave',
modelId: 'uibutton',
entryId: 'UIButton',
componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,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: 200, y: 60 }, pos: { x: 0, y: -120 } }),
sprite({ color: DARK, type: 1, raycast: true }),
button(),
text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
],
}));
ui.ContentProto.Entities.push(...rest);
```
- [ ] **Step 5: 문법 검사**
Run: `node --check tools/gen-slaydeck.mjs`
Expected: 오류 없음
- [ ] **Step 6: 커밋**
```bash
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E4): ShopHud/RestHud UI·MapHud 4행 중앙정렬"
```
---
### Task 5: 재생성 + 검증
**Files:** 생성물
- [ ] **Step 1: 생성**
Run: `node tools/gen-slaydeck.mjs`
Expected: `Slay deck UI and combat codeblocks generated.`
- [ ] **Step 2: 메서드·UI 확인**
Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['ShowShop','BuyCard','ShowRest','LeaveNode','RenderShop'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); const has=p=>u.ContentProto.Entities.some(e=>e.path===p); console.log(has('/ui/DefaultGroup/ShopHud/Card1/Price')&&has('/ui/DefaultGroup/RestHud/Info')&&has('/ui/DefaultGroup/MapHud/Node_D')?'UI OK':'UI MISSING')"`
Expected: `METHODS OK` / `UI OK`
- [ ] **Step 3: 결정성**
Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC`
Expected: `DETERMINISTIC`
- [ ] **Step 4: git status**
Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short`
Expected: `data/map.json`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs).
- [ ] **Step 5: 생성물 커밋**
```bash
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
git commit -m "재생성(E4): 상점/휴식 노드·UI 반영"
```
- [ ] **Step 6: 메이커 Play 수동 검증 (MCP)**
reload→Play: 맵(4행, 2행에 휴식C·상점D) → PickNode("D")→상점(카드3·골드) → BuyCard(골드≥30이면 -30·RunDeck+1·비활성; 부족하면 무시) → LeaveNode→맵 / PickNode("C")→휴식(HP+30 클램프)→LeaveNode→맵 / 전투·보스·런 클리어 회귀 확인. MCP는 PickNode/BuyCard/LeaveNode 직접 호출 + 로그.
---
## Self-Review
- **Spec coverage:** 맵/검증/직렬화/상수/속성(Task1), PickNode분기·상점·휴식(Task2), 바인딩(Task3), UI·MapHud정렬(Task4), 검증(Task5). 스펙 전 항목 매핑.
- **Placeholder scan:** 모든 단계 실제 코드/명령.
- **Type consistency:** 메서드 `PickNode/ShowShop/RenderShop/BuyCard/ShowRest/LeaveNode` 정의·호출·바인딩 일치. 속성 `ShopChoices/ShopBought` 정의(Task1)·사용(Task2) 일치. UI 경로 `/ui/DefaultGroup/ShopHud/Card{1..3}/{Name,Cost,Desc,Price}`·`/Gold`·`/Leave`, `/RestHud/{Info,Leave}`가 codeblock(RenderShop/ShowRest/BindButtons)·생성(Task4)에서 동일. 상수 `CARD_PRICE/REST_HEAL` Task1 정의·Task2 사용 일치.

View File

@@ -1,96 +0,0 @@
# 하단 카드 손패 UI 목업 설계 (Slay the Spire 2 스타일)
- 날짜: 2026-06-06
- 브랜치: feature/sts2-combat-layout
- 대상 파일: `ui/DefaultGroup.ui`
## 목표
전투 화면 **하단에 카드 5장이 수평 일렬로 보이는** 시각 결과를 만든다.
Slay the Spire 2 처럼 손패가 화면 아래쪽에 펼쳐진 느낌을 정적(static) 목업으로 구현한다.
## 범위
### 포함
- `DefaultGroup.ui`에 카드 손패 UI 엔티티 추가
- 카드 5장을 하단 중앙에 수평 일렬 배치
- 각 카드는 "풀 카드 면": 에너지 코스트(좌상단) + 카드 이름(상단 중앙) + 설명(하단)
- 샘플 손패 내용으로 채움 (정적)
### 명시적 제외 (YAGNI)
- 클릭/터치 동작, 에너지 소모, 실제 카드 사용 로직
- `SlayCombatManager` / `SlayCardCatalog` / `SlayRunState` 등 전투 로직 구현
- 부채꼴(fan) 회전·곡선 배치
- 드래그 앤 드롭, 호버 확대, 애니메이션
- 데이터 기반 동적 손패 (드로우/버림에 따른 카드 수 변화)
이들은 이후 "데이터 연동" 단계에서 별도 스펙으로 다룬다.
## 구현 방식
`DefaultGroup.ui``ContentProto.Entities` 배열에 신규 엔티티를 직접 추가한다.
기존 MSW UI 엔티티 패턴(`uisprite`, `uitext`)을 그대로 따르고, 각 엔티티에 새 UUID를 부여한다.
이유:
- 이 프로젝트는 로컬 워크스페이스 + git 방식이므로 `.ui` 파일 직접 편집이 형상관리와 일치
- 결정론적·재현 가능하며 diff로 변경 내역이 명확히 남음
- 작업 후 Maker에서 워크스페이스 reload로 반영
(대안 — Maker MCP 라이브 조작, 런타임 Lua 생성 — 은 재현성/범위 측면에서 부적합하여 제외)
## 엔티티 구조
```
/ui/DefaultGroup/CardHand 컨테이너 (uiempty, 하단 중앙 앵커)
├ Card1 (uisprite, 카드 면)
│ ├ Cost (uitext, 좌상단 코스트)
│ ├ Name (uitext, 상단 중앙 이름)
│ └ Desc (uitext, 하단 설명)
├ Card2 … Card5 (Card1과 동일한 하위 구조)
```
## 배치 수치
- 좌표 공간: `DefaultGroup` 기준 1920 × 1080 (기존 UITransform과 동일)
- `CardHand` 컨테이너: 하단 중앙 앵커 (AnchorsMin/Max = {0.5, 0}), RectSize 약 1020 × 300, 바닥에서 위로 약 30px 띄움
- 카드 크기: 180 × 250 (폭 × 높이)
- 카드 간격: 20px
- 5장 총폭: `5 × 180 + 4 × 20 = 980px` → CardHand 내부에서 중앙 정렬
- 카드 i의 x 중심 (컨테이너 중앙 기준): `(-2 + i) × 200` (i = 0..4) → -400, -200, 0, 200, 400
### 카드 내부 텍스트 배치 (카드 로컬 좌표, 180×250 기준)
- Cost: 좌상단, RectSize 약 48×48, 카드 좌상단 모서리 부근
- Name: 상단 중앙, FontSize 약 28
- Desc: 하단, FontSize 약 22
## 비주얼
- 카드 면 배경: 기존 버튼 스프라이트 RUID `cd0560c4fc7f3b14994b90a502f00a21` 재사용
- 카드 타입별 색 틴트 (SpriteGUIRendererComponent.Color):
- 공격 카드(타격/강타): 붉은톤 (예: r 0.9, g 0.55, b 0.5)
- 방어 카드(방어): 푸른톤 (예: r 0.55, g 0.7, b 0.95)
- 텍스트 컴포넌트는 기존 `PopupMessage` TextComponent 스키마를 템플릿으로 사용 (FontColor, OutlineColor 등 기본값 유지)
## 샘플 손패 5장
| # | 이름 | 코스트 | 설명 | 타입 틴트 |
|---|------|--------|----------|-----------|
| 1 | 타격 | ① | 피해 6 | 공격(붉은) |
| 2 | 타격 | ① | 피해 6 | 공격(붉은) |
| 3 | 방어 | ① | 방어도 5 | 방어(푸른) |
| 4 | 방어 | ① | 방어도 5 | 방어(푸른) |
| 5 | 강타 | ② | 피해 10 | 공격(붉은) |
(StS 시작덱 느낌의 대표 손패. 코스트 표기는 텍스트 "1"/"2"로 입력)
## 검증
1. 파일 저장 후 Maker에서 로컬 워크스페이스 reload
2. `maker_screenshot`으로 전투 화면 하단에 카드 5장이 수평 일렬로 렌더되는지 확인
3. 각 카드에 코스트·이름·설명 텍스트가 보이는지, 공격/방어 색 구분이 되는지 확인
## 후속 단계 (이 스펙 밖)
- 데이터 연동: `SlayCardCatalog` / `SlayCombatManager` 구현 후 손패를 동적 렌더링
- 카드 클릭 → 카드 사용, 에너지 소모, 손패 수 변화
- 부채꼴 배치·호버·드래그 등 인터랙션 고도화

View File

@@ -1,59 +0,0 @@
# 카드 슬롯에 이미지 카드 적용 설계
- 날짜: 2026-06-06
- 브랜치: feature/sts2-combat-layout
- 대상: `ui/DefaultGroup.ui`, `tools/gen-cardhand.mjs`
- 원본 이미지: `C:\Users\jaeoh\Desktop\workspace\source\images\maple\invincible belief.png`
## 목표
하단 손패 5장 중 **5번 자리(현재 강타)** 의 카드 외형을 `invincible belief.png`(완성된 세로형 카드 이미지 "리부트 프로토콜")로 교체한다.
## 배경
해당 PNG는 단순 아트가 아니라 코스트·이름·타입·등급·아트·설명·플레이버까지 포함한 **완성된 카드 한 장 전체**(세로 약 2:3 비율)다. 따라서 슬롯의 외형 전체를 이 이미지로 대체하고, 그 슬롯에는 우리가 생성하던 텍스트 오버레이(코스트/이름/설명)를 넣지 않는다(이미지에 이미 포함 → 겹치면 중복/지저분).
## 범위
### 포함
- PNG를 MSW 계정 sprite 리소스로 업로드 → RUID 발급
- 생성기에 카드별 선택적 `image`(RUID) 데이터 추가
- `image`가 있는 카드: 단색 배경 대신 해당 RUID 스프라이트(틴트 흰색)로 렌더, 텍스트 자식(Cost/Name/Desc) 생성 안 함
- 5번 카드 크기를 이미지 비율에 맞춰 **180×270** 으로 조정(가로 유지, 세로만 +20), 행 중앙 정렬 유지
- 나머지 4장은 기존 단색 목업 유지
### 제외 (YAGNI)
- 나머지 4장의 이미지화
- 카드 클릭/효과/실제 전투 로직
- 이미지 카드의 동적 데이터 연동
## 구현 방식
### 1. 에셋 업로드 (`asset_create_account_resource_storage_item`, 2단계)
- 1차 호출: `category=sprite`, `subcategory=etc`, `name`/`description` 지정, `contentLength`=파일 바이트 수, `fileUrl` 생략 → `presignedUrl` 수신
- presignedUrl로 PNG를 HTTP PUT(raw 바이너리)
- 2차 호출: `fileUrl=presignedUrl` → 리소스 생성 완료, 응답에서 **RUID(DataId)** 확보
- 업로드 결과 RUID는 재현 가능하도록 생성기 스크립트에 하드코딩한다.
### 2. 생성기 변경 (`tools/gen-cardhand.mjs`)
- `cards[4]`(강타 슬롯)에 `image: '<RUID>'` 필드 추가. (이름/코스트/설명 데이터는 남겨두되 image가 있으면 렌더에 사용하지 않음)
- 카드 빌드 루프 수정:
- 카드 배경 스프라이트: `image`가 있으면 `sprite({ dataId: image, color: {1,1,1,1}, type: 0 })`, 없으면 기존 `sprite({ color: tint, type: 1 })`
- 카드 크기: `image`가 있으면 180×270, 없으면 180×250
- 텍스트 자식(Cost/Name/Desc): `image`가 없을 때만 생성
- 멱등성/줄바꿈 보존/splice 로직은 그대로 유지
### 3. 형상관리
- 이미지는 MSW 클라우드 리소스로 저장되고, `.ui`·스크립트에는 **RUID 문자열만** 포함. PNG 원본은 slaymaple 저장소에 커밋하지 않는다(원본은 `workspace/source/images/maple/`에 유지).
## 검증
1. 업로드 응답에서 유효한 RUID 수신 확인
2. 생성기 재실행 → JSON 유효, 5번 카드가 image 스프라이트 + 텍스트 자식 없음, 나머지 4장 불변
3. Maker `refresh_workspace``play``screenshot`로 5번 자리에 "리부트 프로토콜" 카드 이미지가 왜곡 없이 표시되는지 확인
4. (커밋 전) 디스크 무결성 확인 후 커밋
## 리스크
- 업로드한 sprite가 SpriteGUIRenderer에서 곧바로 렌더되는지(서브카테고리 무관 가정) — 검증 단계에서 확인, 안 되면 subcategory를 item 등으로 재시도
- 카드 크기 180×270이 행 정렬에서 약간 위로 솟음 — 의도된 허용 범위

View File

@@ -1,64 +0,0 @@
# 맵 개선: 다양한 preset 몬스터 + 맵별 타일셋 + StS2 배치 설계
- 날짜: 2026-06-06
- 브랜치: feature/maps-batch (기존 맵 작업 이어서)
- 대상: `tools/gen-maps.mjs`, `map/map02.map`~`map11.map` (재생성)
## 목표
map02~map11 각 맵에서:
1. **다양한 몬스터** 2마리를 배치하되, 기존 map01의 4종(StaticMonster/MoveMonster/ChaseMonster/monster-43) **스프라이트를 재사용하지 않고**, 공식 맵에서 수확한 다양한 몬스터로 채운다.
2. 몬스터를 **Slay the Spire 2 배치**(플레이어 좌측, 몬스터 우측 전투 포메이션)로 둔다.
3. 맵마다 **다른 타일셋**(TileSetRUID)을 적용한다(같은 바닥 지형, 다른 타일 텍스처).
4. 배경은 기존에 수확한 10종(맵별 상이) 유지.
## 범위
### 포함
- 공식 맵 import로 **몬스터 변형 세트 + 타일셋 RUID** 수확
- 생성기에 `MONSTER_VARIANTS`(수확 변형), `TILESETS`(타일셋 풀) 반영
- 맵당 서로 다른 몬스터 2종, StS2 우측 배치
- 맵당 다른 TileSetRUID
- map02~map11 재생성
### 제외 (YAGNI)
- 지형(Tiles/Foothold) 통째 교체 — 타일셋(텍스처)만 교체
- 포털 연결, 카드-전투 로직 연동
- map01 변경
## 수확 (공식 맵 import 기법)
배경 수확과 동일: `maker_import_maplestory_map(id)`가 현재 맵을 그 공식 맵으로 교체 → `maker_save``map/<current>.map`에서 데이터 추출.
- **몬스터 변형** `{ sprite, stand, hit, die }`(RUID): 몬스터가 있는 **필드/사냥맵**을 import해 몬스터 엔티티의 `SpriteRendererComponent.SpriteRUID` + `StateAnimationComponent.ActionSheet`(stand/hit/die)를 추출. ≥12종 distinct 목표. (타운맵은 몬스터 없을 수 있어 필드맵 선택)
- **타일셋** `TileSetRUID`: import한 맵의 `TileMapComponent.TileSetRUID` 추출. 10종 distinct (map01의 `9dfea380…`과 겹치지 않게).
> **스파이크 선행**: 필드맵 1개를 import해 몬스터 엔티티 구조가 `{sprite, stand, hit, die}` 추출 가능한지 먼저 확인. 구조가 다르면 폴백(아래).
## 생성기 변경 (`tools/gen-maps.mjs`)
- `MONSTER_VARIANTS = [ {sprite, stand, hit, die}, ... ]` — 수확 결과로 채움(≥12종).
- `TILESETS = [ ruid, ... ]` — 수확한 타일셋 10종.
- `buildMap(nn)`:
- 몬스터 2마리: `MONSTER_VARIANTS`에서 **서로 다른 2종**을 맵 시드로 선택(맵 내 중복 금지). 클론 몬스터 엔티티의 `SpriteRUID` + `ActionSheet`를 변형으로 덮어씀. (기존 map01 스프라이트 미사용 보장 — 항상 변형으로 덮어쓰므로)
- 위치: **StS2 배치** — 화면 우측에 2자리 고정(예: Position.x ≈ +3.5, +5.5; y는 map01 몬스터 y값). map01 전투 구도를 기준으로 우측 포메이션.
- 타일셋: `TileMap` 엔티티의 `TileMapComponent.TileSetRUID.DataId``TILESETS[(nn-2)%len]`로 설정.
- 배경: 기존 `BACKGROUNDS` 유지.
- GUID 재발급·경로 치환·SectorConfig 로직은 그대로.
## 검증
1. **스파이크**(map02): reload→play→screenshot + Lua로
- 몬스터 2마리의 `SpriteRUID`가 수확 변형과 일치(= map01 4종 아님), 우측 배치
- `TileMap.TileSetRUID`가 새 타일셋
- 화면상 몬스터 외형·타일 텍스처가 바뀌어 보임
2. 전체: 맵별 몬스터 2종 distinct, 타일셋 distinct, 배경 distinct, 엔티티 id 중복 없음
3. Maker 표본 맵 시각 확인
## 리스크/폴백
- 몬스터 엔티티 구조가 `{sprite,stand,hit,die}`로 안 맞으면 → `SpriteRUID`만 교체하고 `ActionSheet`는 map01 템플릿 유지(최소 시각 변화 보장).
- 타일셋 교체 시 tileIndex 의미 차이로 타일이 어색하면 → 스파이크에서 확인 후 호환 타일셋만 선별하거나 사용자와 상의.
- 수확 시 import는 현재 맵(map02, 재생성 가능)에 적용 → 수확 후 generator로 map02 재생성하여 정리.
## 산출물/형상관리
- `tools/gen-maps.mjs` 갱신, `map/map02.map`~`map11.map` 재생성을 커밋. 수확 RUID는 문자열만 포함(공식 콘텐츠).

View File

@@ -1,62 +0,0 @@
# 맵 10개 생성 (랜덤 배경 + 몬스터 2마리) 설계
- 날짜: 2026-06-06
- 브랜치: feature/maps-batch (신규)
- 대상: `map/map02.map`~`map11.map`(신규), `Global/SectorConfig.config`, `tools/gen-maps.mjs`(신규)
## 목표
`map01`을 템플릿으로 **독립 맵 10개(`map02`~`map11`)** 를 생성한다. 각 맵은 **서로 다른 공식 배경**을 갖고, **몬스터 2마리**가 랜덤 위치에 배치된다.
## 범위
### 포함
- `map02`~`map11` (신규 10개 맵 파일)
- 맵마다 다른 배경(`BackgroundComponent.TemplateRUID`) — 공식 MapleStory 배경 라이브러리에서 10개 서로 다르게
- 맵마다 몬스터 2마리, x 위치 랜덤(바닥 위), y는 바닥 높이 고정
- `SectorConfig.config``map://map02`~`map://map11` 등록
- 재현용 생성기 `tools/gen-maps.mjs`
### 제외 (YAGNI)
- 맵 간 포털/이동 연결 (독립 맵)
- 맵별 다른 타일맵/지형 (map01 타일·바닥 그대로 복제)
- 카드-전투 로직 연동
- map01 변경
## 몬스터 전략 (스파이크 게이트)
사용자 선택: **라이브러리에서 다양한 몬스터**. 단, 리소스 검색이 RUID만 반환하고 action(stand/hit/die) 그룹핑·이름을 주지 않아 "완결된 몬스터" 조립이 불확실하다. 따라서:
- **A. 라이브러리 다양 몬스터 (1차 시도)**: 라이브러리에서 완결된 몬스터 2~3종(스프라이트 + stand/hit/die 애니메이션 RUID 세트)을 조립한다. **먼저 1개 맵으로 스파이크** → Maker Play에서 로드·렌더 검증.
- **게이트**: 스파이크에서 라이브러리 몬스터가 정상 렌더되면 → 10개 맵에 A로 확대. 조립/로드 실패 시 → **B로 폴백**.
- **B. 폴백 — 기존 몬스터 변형**: 이미 정상 로딩되는 기존 템플릿(StaticMonster/MoveMonster/ChaseMonster/monster-43)의 검증된 RUID 세트에서 맵당 랜덤 2종 + 랜덤 위치. 다양성은 ~4종으로 제한되지만 확실히 동작.
> 핵심 리스크: 이전에 **사용자 업로드(계정) 리소스는 로컬 워크스페이스 플레이에서 로드 실패**했다. 공식 라이브러리 리소스(배경/몬스터)는 shipped 콘텐츠라 로드될 것으로 보지만(기존 배경·몬스터 RUID가 정상 로딩 중), **확정 전 스파이크로 검증**한다.
## 구현 방식
### 생성기 `tools/gen-maps.mjs`
1. `map/map01.map`을 텍스트로 읽어 JSON 파싱(템플릿)
2. 배경 RUID 풀(10개, 공식 라이브러리에서 확보), 몬스터 정의 풀(A: 라이브러리 세트 / B: 기존 템플릿 세트)을 데이터로 보유
3. `NN = 02..11` 각각:
- 엔티티 deep-copy, **모든 엔티티 `id` GUID 재발급**(oldId→newId 매핑). `root_entity_id`/`sub_entity_id`가 엔티티 id를 가리키면 함께 치환. (component 안의 리소스 RUID — SpriteRUID, ActionSheet, TemplateRUID, CollisionGroup.Id, DamageSkinId, 타일셋 id — 는 엔티티 id가 아니므로 유지)
- `EntryKey``map://mapNN`, 모든 `path``/maps/map01``/maps/mapNN`, `name``mapNN`로 치환
- `Background` 엔티티의 `TemplateRUID``backgrounds[NN]`로 설정
- 기존 몬스터 엔티티들을 제거하고, 선택된 몬스터 2종을 랜덤 x 위치로 추가(각 몬스터는 템플릿 몬스터 엔티티를 복제하고 SpriteRUID/ActionSheet[A] 또는 그대로[B] + Position.x 랜덤)
- `map/mapNN.map`로 기록(원본 줄바꿈/포맷 보존 방식은 가능하면, 아니면 표준 JSON 직렬화)
4. `Global/SectorConfig.config``Sectors[0].entries``map://map02`~`map11` 추가(중복 방지)
랜덤은 결정론을 위해 인덱스 기반 시드(맵 번호로 위치/선택 산출) 사용 — 재실행 시 동일 결과.
### GUID 재발급 주의
- 엔티티 id 충돌 방지를 위해 맵마다 고유 GUID 필요. 자기참조(`root_entity_id`==자기 id)는 매핑으로 일관되게 치환.
## 검증
1. **스파이크(A)**: map02 1개만 생성 → reload→play→screenshot + Lua로 몬스터 엔티티/스프라이트 로드 확인. 실패 시 B로 전환.
2. 전체 생성 후: 각 맵(또는 표본)에서 reload→play(해당 맵)→screenshot으로 배경 상이·몬스터 2마리 확인. 맵 전환은 Maker에서 해당 맵을 열거나 sector 이동으로.
3. JSON 유효성(JSON.parse) + SectorConfig 10개 등록 확인 + 엔티티 id 중복 없음 확인.
## 산출물/형상관리
- 신규 파일 `map/map02.map`~`map11.map`, `tools/gen-maps.mjs`, `SectorConfig.config` 변경을 커밋.
- 배경/몬스터는 공식 라이브러리 RUID(문자열)만 참조 — 별도 리소스 파일 불필요(공식 콘텐츠). (단 A가 로컬 임포트를 요구하면 그 리소스 파일도 포함)

View File

@@ -1,74 +0,0 @@
# 카드 전투 통합 (TODO 항목 B) — 설계
> 작성: 2026-06-08 / 상태: 승인됨 / 근거: TODO.md 항목 B + 코드 직접 분석.
> 선행 작업: 항목 C(미커밋 노이즈 정리) 완료 — 작업 트리 클린.
## 문제
현재 `SlayDeckController.codeblock``PlayCard`는 에너지만 차감하고 `Toast(log)`만 띄운다.
실제 전투 상태(적 HP/방어, 플레이어 HP/Block, 적 의도, 승패)가 없어 STS식 덱빌딩 루프가
닫히지 않는다. 필드 액션 몬스터(`Monster.codeblock` — HP·피격·리스폰)는 카드 시스템과 분리돼 있다.
## 범위
**포함**: 단일 적 카드 전투 루프(데미지·방어·적 의도·턴 진행·승패), 카드 수치화, DeckHud UI 노출.
**제외(금지)**: 로그라이크 메타(E), 신규 카드 대량 추가, 전체 데이터 외부화(D — 본 작업은 인라인 수치화까지).
## 단일 소스 원칙
모든 변경은 `tools/gen-slaydeck.mjs`에서 생성한다. `SlayDeckController.codeblock` /
`ui/DefaultGroup.ui` / `Global/common.gamelogic`을 직접 손으로 편집하지 않는다.
변경 = 생성기 수정 → `node tools/gen-slaydeck.mjs` 재실행.
## 수치는 임시 placeholder
> 플레이어 수치는 향후 **캐릭터 특성별**, 몬스터 수치는 **몬스터별**로 다르게 설정 예정.
> 본 작업의 값(플레이어 80 / 적 45 / 의도 10·6·방8)은 루프 검증용 임시값이며,
> D(데이터 외부화) 단계에서 캐릭터/몬스터별 데이터로 분리한다.
## 설계
### 1) 전투 상태 (codeblock 속성 추가)
- 플레이어: `PlayerHp`, `PlayerMaxHp`(임시 80), `PlayerBlock`
- 적: `EnemyHp`, `EnemyMaxHp`(임시 45), `EnemyBlock`, `EnemyIntentIndex`
- `CombatOver`(승패 후 입력 잠금)
- 적은 codeblock 내부 상태로 보유(필드 `Monster.codeblock`과 분리).
### 2) 카드 데이터 수치화 (desc 파싱 폐기)
| id | 이름 | cost | kind | 효과 |
|----|------|------|------|------|
| Strike | 타격 | 1 | Attack | damage 6 |
| Defend | 방어 | 1 | Skill | block 5 |
| Bash | 강타 | 2 | Attack | damage 10 |
`desc`는 표시용으로만 유지. 효과는 `damage`/`block` 숫자 필드로 처리.
시작 덱: Strike×5, Defend×4, Bash×1 (10장).
### 3) 적 의도 — 결정적 사이클 (사용자 선택: A안)
- 의도 사이클(3스텝 회전): `[공격 10] → [공격 6] → [방어 8]`
- 매 플레이어 턴 시작 시 **다음 의도를 미리 표시**.
- 결정적이라 F(밸런스 시뮬레이터)에서 동일 규칙 재현 가능.
### 4) 전투 규칙 (STS 관례)
- 데미지는 **방어도 먼저 차감** 후 잔여만 HP에 적용.
- 플레이어 Block은 **플레이어 턴 시작 시 0 리셋**, 적 Block은 **적 턴 시작 시 리셋**.
- `PlayCard(slot)`: `kind=="Attack"` → 적 HP 감소(적 Block 우선 차감);
`kind=="Skill"` → 플레이어 Block 증가.
- `EndPlayerTurn` → 적 턴: 적 Block 리셋 → 현재 의도 실행(공격이면 플레이어에 피해,
방어면 적 Block↑) → 의도 인덱스 전진 → 패배 체크 → 다음 플레이어 턴(Block/에너지 리셋·드로우)
→ 다음 의도 표시.
- 승패: 적 HP≤0 → 승리 / 플레이어 HP≤0 → 패배. 승패 시 `CombatOver=true`로 입력 잠금 +
결과 텍스트 표시 + **보상 훅 자리(E용 주석)**.
### 5) UI — DeckHud 엔티티 추가 (생성기 생성)
- 상단 적 패널: 적 이름 · `HP 45/45` · `방어 0` · `의도: 공격 10`
- 좌측 플레이어 패널: `HP 80/80` · `방어 0`
- 승패 결과 텍스트(중앙, 평소 숨김 → 승패 시 표시).
## 검증 (메이커 Play)
- 타격 카드 → 적 HP 감소(적 Block 있으면 먼저 차감).
- 방어 카드 → 플레이어 Block 증가.
- 턴 종료 → 적이 표시된 의도대로 공격(플레이어 Block이 피해 흡수) 또는 방어.
- 적 HP 0 → 승리 / 플레이어 HP 0 → 패배, 입력 잠금.
- `node tools/gen-slaydeck.mjs` 2회 실행 결과 동일(결정적).
- `git status` — 의도한 생성물만 변경.

View File

@@ -1,37 +0,0 @@
# 덱 컨트롤러 코드리뷰 수정 설계
- 날짜: 2026-06-08
- 브랜치: feature/deck-controller-fixes (main 기준)
- 대상: `tools/gen-slaydeck.mjs` (단일 소스) → 재생성으로 `ui/DefaultGroup.ui`·`RootDesk/MyDesk/SlayDeckController.codeblock`·`Global/common.gamelogic` 갱신
## 배경
PR #6의 `SlayDeckController` 코드 리뷰에서 6건을 발견. 모든 산출물(카드 UI·DeckHud·codeblock·common 패치)은 `tools/gen-slaydeck.mjs` 한 곳에서 생성되므로, 이 생성기를 고치고 재실행하면 전부 반영된다.
## 수정 항목
- **① [Important] EndTurn 핸들러 self 바인딩**: `buttonEntity:ConnectEvent(ButtonClickEvent, self.EndPlayerTurn)``ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)`. 메서드 직접 전달 시 self가 event로 잘못 바인딩되는 문제 제거. (타이머는 이미 클로저 사용 — 일관성)
- **② [Important] Card5 이미지 충돌**: 이미 `gen-slaydeck.upsertUi`가 Card1~5를 동일 텍스트 카드로 통일(ImageRUID='', 틴트, Cost/Name/Desc 추가)하므로 재생성으로 해결됨. 추가 코드 변경 없음 — 검증만.
- **③ [기능] 카드 클릭 = 사용**:
- `upsertUi`의 카드 스타일 루프에서 Card1~5에 `ButtonComponent` 추가 + 카드 스프라이트 `RaycastTarget=true`.
- codeblock에 `PlayCard(slot)` 메서드 추가: `Hand[slot]`의 카드 코스트를 `CARDS`에서 조회 → `Energy >= cost``Energy -= cost`, 효과 표시(토스트/로그, 예: "타격 — 피해 6"), `Hand`에서 제거 후 `DiscardPile`에 삽입, `RenderHand(false)`+`RenderPiles()`. 부족하면 사용 불가(토스트/로그).
- `BindButtons`에서 각 카드의 `ButtonClickEvent``function() self:PlayCard(i) end` 클로저로 연결(루프 변수 i는 Lua에서 반복마다 새 지역변수라 안전). 재연결 전 이전 핸들러 해제.
- **④ [Minor] 카드 데이터 단일화**: `CARDS = { Strike={name,cost,desc,kind}, Defend={...}, Bash={...} }` 테이블을 codeblock 상단에 두고, 시작덱 구성·`ApplyCardVisual`·`PlayCard`가 공유(if/elseif 중복 제거).
- **⑤ [Minor] 매직넘버 상수화**: 손패/드로우 수(5), 시작 에너지(3) 등 의미 있는 상수로.
- **⑥ [Nit] pcall 제거**: `ApplyCardVisual``pcall(function() return Color(...) end)` → 직접 `Color(...)` 호출(틴트는 `CARDS[id].kind`별 색).
## 효과 표시(③)
적/데미지 시스템이 없으므로 카드 사용 효과는 **토스트 또는 로그**로만 표현(예: `log("타격 — 피해 6")` 또는 UIToast). 실제 데미지 적용은 범위 밖.
## 재생성·검증
1. `node --check tools/gen-slaydeck.mjs``node tools/gen-slaydeck.mjs`
2. 검증(데이터): codeblock에 `PlayCard` 존재, `BindButtons`/EndTurn이 클로저, `CARDS` 단일 테이블, `ApplyCardVisual`에 pcall 없음. DefaultGroup.ui의 Card1~5에 `ButtonComponent` + RaycastTarget true, Card5가 균일 텍스트 카드(ImageRUID 빈값·Cost/Name/Desc 존재).
3. Maker Play: 카드 클릭 → 에너지 감소·카드가 버림더미로·재렌더, EndTurn 버튼 동작, 5장 균일.
## stash 복구
이전 Maker 세션에서 stash해 둔 로컬 맵 변경(map02/05/06/07/10/11)을 이 브랜치에 복구해 포함. 단 복구분이 몬스터/타일셋 작업을 유지하는지(되돌리지 않는지) 무결성 검증 후 커밋. 손상/무의미하면 사용자에게 알리고 제외.
## 범위 밖 (YAGNI)
적 턴, 카드 효과의 실제 전투 적용, 신규 카드 종류.

View File

@@ -1,68 +0,0 @@
# AI 전투 시뮬레이터 (TODO 항목 F) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 F + gen-slaydeck.mjs 전투 규칙 + D 데이터.
> 선행: D(데이터 외부화) 완료.
## 문제
박재오 강점(백엔드·AI 자동화) 활용처로 기획됐으나 코드 없음. 카드/적 밸런싱을 손으로
검증해야 한다. 데이터 기반 자동 밸런스 검증 도구가 필요하다.
## 목표
`data/cards.json`·`data/enemies.json`를 입력으로, 전투를 몬테카를로로 N회 자동 시뮬레이션해
승률·평균 턴·OP 카드 탐지 리포트를 출력하는 오프라인 Node CLI(`tools/sim-balance.mjs`).
## 설계
### 구조
`tools/sim-balance.mjs` 단일 파일, 섹션 분리:
1. **데이터 로드**: `data/cards.json`·`data/enemies.json`(D와 동일 소스). `activeEnemy` 사용.
2. **시드 PRNG**: mulberry32(시드 고정 → 재현 가능, 데이터 바꾸면 결과 변동).
3. **전투 엔진**(Lua 규칙 미러): 아래 규칙을 JS로 재현.
4. **플레이어 정책**(휴리스틱 A).
5. **집계·리포트**.
6. **CLI 파싱·출력**.
### 전투 규칙 (gen-slaydeck.mjs Lua와 동일)
- 시작: 플레이어 `hp=PLAYER_HP(상수 80)`, `block=0`; 적 `hp=maxHp`, `block=0`, `intentIdx=0`(0-base).
덱 = `starterDeck` 셔플(PRNG).
- 플레이어 턴 시작: `energy=3`, `block=0`, 5장 드로우(덱 소진 시 버림 더미 셔플해 재활용).
- 플레이어 행동: 정책이 카드 선택 → 사용 시 `energy -= cost`, `Attack`→적에 `damage`(적 block 우선 차감),
`Skill`→플레이어 `block += block`. 사용 카드는 버림. 더 둘 수 없으면 턴 종료.
- 적 턴: 적 `block=0` → 현재 의도 실행(`Attack`→플레이어에 피해(플레이어 block 우선 차감),
`Defend`→적 `block += value`) → `intentIdx=(intentIdx+1)%len`.
- 승패: 적 hp≤0 승리, 플레이어 hp≤0 패배. 턴 상한 `MAX_TURNS=100`(초과 시 무승부로 집계, 경고).
### 플레이어 정책 (휴리스틱 A)
매 플레이어 행동 루프:
1. **치사 판단**: 손패의 Attack 카드들로 이번 턴 낼 수 있는 최대 데미지(에너지 한도 내) ≥
`적 hp + 적 block` 이면 → 그 Attack들을 사용(킬).
2. 아니면 **적 의도가 Attack**이면 → 손패 Defend(Skill+block) 카드를 사용(에너지 닿는 한),
이후 잔여 에너지로 Attack 사용.
3. 아니면(적 Defend 의도) → Attack 우선 사용.
4. 사용 가능한 카드(에너지≥cost)가 없으면 턴 종료.
- 동률 선택은 에너지 효율(뎀/E 또는 블록/E) 높은 카드 우선.
### 리포트 지표
- 전체: 승률(%), 평균·중앙값 턴 수, 승리 시 평균 잔여 HP, 패배율, (무승부 시 경고).
- 카드별: 사용 횟수, 누적 데미지/방어, **에너지당 효율**(Attack=총뎀/총E, Skill=총블록/총E).
- **OP 탐지**: 같은 kind 내 효율이 그 kind 중앙값의 ≥1.5배인 카드를 ⚠️로 플래그. 최다/최소 사용 카드 표기.
### CLI
`node tools/sim-balance.mjs [N] [--seed S]` — 기본 `N=2000`, `seed=1`. 표 형식 출력.
### 동기화 위험
JS 전투 규칙은 Lua(`gen-slaydeck.mjs`)와 **중복**이다(공유 불가). 데이터(JSON)는 공유.
파일 상단에 "전투 규칙 변경 시 gen-slaydeck.mjs Lua와 동기화" 주석 명시.
## 검증 (TDD + CLI)
- 전투 엔진/정책 핵심을 순수 함수로 분리해 단위 테스트(Node 내장 `node:test`):
데미지 방어차감, 치사 판단, 적 의도 사이클, 승/패 종료.
- `node tools/sim-balance.mjs` → 승률·턴·카드 통계 출력.
- `data/cards.json`에서 강타 damage↑ → 승률·강타 효율 상승(데이터 반영).
- 동일 시드 2회 → 동일 출력(결정성).
## 범위 밖 (금지)
- 상태이상·드로우·복합효과, 다중 적, 로그라이크 메타. 메이커 런타임 연동.
- 새 카드/적 추가(현 데이터로 검증). 정책 고도화(MCTS 등).

View File

@@ -1,89 +0,0 @@
# 카드/적 데이터 외부화 (TODO 항목 D) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 D + gen-slaydeck.mjs 분석.
> 선행: B(전투 통합)·A(문서 정합) 완료. F(밸런스 시뮬레이터)의 선행 조건.
## 문제
카드 정의(`self.Cards`)·시작 덱·적 정의(이름/HP/의도)가 `gen-slaydeck.mjs``StartCombat`
Lua 문자열에 하드코딩돼 있다. 카드/적 추가·밸런싱이 생성기 코드 수정을 요구한다.
## 목표
카드·적 데이터를 외부 JSON으로 분리하고, 생성기가 읽어 codeblock·UI에 주입한다.
데이터만 바꿔 재생성하면 게임에 반영(코드 수정 없이).
## 향후 방향 (참고)
추후 카드·적 공격은 **메이플스토리 IP**에 맞춰 디벨롭 예정. 본 스키마는 명시적 `desc`
키 기반 확장으로 이를 수용한다(새 카드/적은 JSON 항목 추가로 확장). 본 작업은 현 3종+적1
기준 **최소 스키마**까지만 — 새 효과 필드(상태이상/드로우 등)는 추가하지 않는다(YAGNI).
## 단일 소스 원칙
생성물(`SlayDeckController.codeblock` · `ui/DefaultGroup.ui` · `common.gamelogic`)은
`gen-slaydeck.mjs`가 생성한다. D 이후 **데이터의 단일 소스는 `data/*.json`**, 생성 로직의
단일 소스는 `gen-slaydeck.mjs`. 결정적 출력 유지.
## 설계
### 신규 파일
**`data/cards.json`**
```json
{
"cards": {
"Strike": { "name": "타격", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6" },
"Defend": { "name": "방어", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5" },
"Bash": { "name": "강타", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10" }
},
"starterDeck": ["Strike","Strike","Strike","Strike","Strike","Defend","Defend","Defend","Defend","Bash"]
}
```
**`data/enemies.json`**
```json
{
"enemies": {
"slime": {
"name": "슬라임", "maxHp": 45,
"intents": [
{ "kind": "Attack", "value": 10 },
{ "kind": "Attack", "value": 6 },
{ "kind": "Defend", "value": 8 }
]
}
},
"activeEnemy": "slime"
}
```
- `desc`는 명시적(작성자 작성). `kind``"Attack"` 또는 `"Skill"`. 효과는 `damage`/`block`.
- `activeEnemy`로 현재 단일 전투의 적을 데이터에서 지정. 향후 E(맵 노드)에서 노드별 선택으로 확장.
### 생성기(`gen-slaydeck.mjs`) 변경
1. 상단에서 `readFileSync`+`JSON.parse``data/cards.json`·`data/enemies.json` 로드.
2. **검증(fail-fast)**: `starterDeck`의 모든 id가 `cards`에 존재해야 함; `activeEnemy`
`enemies`에 존재해야 함. 아니면 명확한 에러로 `process.exit(1)`(또는 throw).
3. `writeCodeblocks()``StartCombat`에서:
- `self.Cards = {...}``cards`에서 생성(Lua 테이블 직렬화 헬퍼).
- `self.DrawPile = {...}``starterDeck`에서 생성.
- `self.EnemyName`/`EnemyMaxHp`/`EnemyIntents`/`EnemyIntentIndex``enemies[activeEnemy]`에서 생성.
- codeblock 속성 `EnemyMaxHp` DefaultValue도 데이터 값으로.
4. `upsertUi()`의 DeckHud 카드 미리보기 배열·CombatHud 초기 텍스트(적 이름·`HP n/n`·첫 의도)를
동일 데이터에서 파생.
5. Lua 문자열 직렬화 시 한글/따옴표 이스케이프 주의(데이터 값은 따옴표·역슬래시 없는 단순 문자열 가정,
필요 시 escape 헬퍼).
### 데이터 흐름
`data/*.json``gen-slaydeck.mjs`(로드·검증·직렬화) → `SlayDeckController.codeblock`(Lua 테이블)
+ `ui/DefaultGroup.ui`(초기 텍스트) → 메이커 런타임.
## 검증
- `node tools/gen-slaydeck.mjs` 정상; JSON 유효; 2회 실행 결정적.
- `data/cards.json`에서 카드 1장 수치만 변경 → 재생성 → codeblock의 해당 카드 수치 변경
(생성기/codeblock 직접 수정 없이).
- 잘못된 데이터(starterDeck에 없는 id, 잘못된 activeEnemy) → 생성기가 명확히 실패.
- 메이커 Play: 기존 B 동작과 동일(데이터 동치이므로 회귀 없음).
## 범위 밖 (금지)
- 새 효과 필드(상태이상·드로우·복합효과) 추가. 새 카드 종류 대량 추가. F(시뮬레이터)·E(메타).

View File

@@ -1,82 +0,0 @@
# 분기 맵 노드 진행 (TODO E3) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E3) + SlayDeckController/run-loop-core 분석.
> 선행: E1+E2(런 루프 코어) 완료. 후속: E4(상점/휴식)·E5(유물)·E6(보스 연출/저장).
## 문제
E1+E2는 보상 후 자동으로 다음 전투로 넘어간다(고정 N). 로그라이크는 **플레이어가 맵에서 경로를
선택**해야 한다. 분기 노드 맵과 노드별 적 차등이 필요하다.
## 범위
플레이어가 **분기 맵(작성된 DAG)** 에서 다음 노드를 선택 → 노드 타입(전투/엘리트/보스)대로 전투
(적은 데이터로 차등) → 보상 → 맵으로 복귀 → 보스 클리어 시 "런 클리어". **상점/휴식·유물·저장·
절차적 생성·연결선 그리기는 범위 밖**. 맵 스키마는 상점/휴식 타입을 미래 수용.
## 설계
### 데이터
**`data/map.json`** (분기 DAG):
```json
{
"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": ["D", "E"] },
"C": { "type": "elite", "enemy": "slime_elite", "row": 2, "col": -2, "next": ["BOSS"] },
"D": { "type": "combat", "enemy": "slime", "row": 2, "col": 0, "next": ["BOSS"] },
"E": { "type": "combat", "enemy": "slime", "row": 2, "col": 2, "next": ["BOSS"] },
"BOSS": { "type": "boss", "enemy": "slime_boss", "row": 3, "col": 0, "next": [] }
}
}
```
- `type` ∈ {combat, elite, boss} (이 슬라이스). `enemy`는 enemies.json id. `row`(1=시작), `col`(레이아웃 x 단위), `next`(도달 노드 ids).
**`data/enemies.json`** 확장:
```json
"slime_elite": { "name": "정예 슬라임", "maxHp": 70,
"intents": [ {Attack 14}, {Attack 8}, {Defend 10} ] },
"slime_boss": { "name": "슬라임 킹", "maxHp": 120,
"intents": [ {Attack 18}, {Defend 12}, {Attack 10}, {Attack 22} ] }
```
(`activeEnemy`는 유지하되 런은 맵 노드의 enemy로 전투. F 시뮬레이터는 여전히 activeEnemy 기준 — 맵 적 시뮬은 후속.)
### 상태 (SlayDeckController 속성 추가)
- `Enemies`(any) — 전체 적 테이블(id→정의). 생성기가 enemies.json 전체 주입.
- `MapNodes`(any) — 그래프(id→{type, enemy, row, col, next}).
- `MapStart`(any) — 1행 노드 id 배열.
- `CurrentNodeId`(string) — 현재 위치("" = 시작 전).
- `CurrentEnemyId`(string) — 현재 전투 적 id.
### 메서드
- `StartRun`(수정): 런 상태 초기화 + `Enemies`/`MapNodes`/`MapStart` 세팅 + `CurrentNodeId=""` +
BindButtons(맵 노드 버튼 포함, 1회) → `self:ShowMap()` (기존 StartCombat 대신).
- `ShowMap`(신규): 선택 가능 노드 결정(CurrentNodeId=="" 면 MapStart, 아니면 MapNodes[CurrentNodeId].next).
각 노드 버튼 활성/비활성·라벨 갱신, 전투 UI 가리고 MapHud 표시(Enable).
- `IsReachable(id)`(헬퍼) — 현재 선택 가능 목록에 id 포함 여부.
- `PickNode(id)`(신규): `IsReachable(id)` 아니면 무시. `CurrentNodeId=id`,
`CurrentEnemyId=MapNodes[id].enemy`, MapHud 숨김 → `StartCombat()`.
- `StartCombat`(수정): 적을 `self.Enemies[self.CurrentEnemyId]`에서 로드(이름/HP/의도). Floor 증가 로직 제거.
- `CheckCombatEnd`(수정): 승리 시 골드+15 → 현재 노드 `type=="boss"``ShowResult("런 클리어!")`+RunActive=false;
아니면 `OfferReward`. 패배 → "패배..."+RunActive=false.
- `PickReward`(수정): 카드 처리 후 `StartCombat` 대신 `self:ShowMap()`.
### UI (MapHud, 신규)
- 평소 숨김. 풀스크린 모달 배경 + 제목 "다음 노드 선택".
- 노드 버튼 6개: 위치 = (col×스페이싱, 화면중앙+row×행간), 라벨(전투/엘리트/보스 + 적 이름).
- 선택 가능 노드만 밝게·클릭, 나머지 어둡게(반투명). 클릭 → `PickNode(id)`.
- 연결선은 생략(도달성=활성/비활성으로 표현; 연결선 그리기는 후속 폴리시).
### 단일 소스
모든 변경은 `tools/gen-slaydeck.mjs`에서 생성. map.json/enemies.json은 데이터 단일 소스.
## 검증 (메이커 Play)
- StartRun → MapHud, 1행 A·B만 선택 가능(나머지 비활성).
- A 선택 → 슬라임 전투 → 승리 → 보상 → 맵 복귀, 이제 C·D 선택 가능(B쪽 E는 불가).
- 엘리트 노드 → 정예 슬라임(HP 70) 전투. 보스 노드 → 슬라임 킹(HP 120).
- 보스 승리 → "런 클리어!". 패배 → "패배...". 도달 불가 노드 클릭 → 무시.
- 생성기 결정적, JSON 유효. (버튼 클릭은 런타임 — MCP는 PickNode/PlayCard/PickReward 직접 호출로 검증.)
## 범위 밖 (금지)
- 상점/휴식 노드 동작(E4)·유물(E5)·저장(E6). 절차적 맵·무작위 분기·연결선 그리기. 새 카드.

View File

@@ -1,68 +0,0 @@
# 런 루프 코어 (TODO E1+E2) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 E(분해) + SlayDeckController 분석.
> E(로그라이크 메타)의 첫 하위 프로젝트. 선행: B·D 완료. 후속: E3(맵)·E4(상점)·E5(유물)·E6(보스/저장).
## 문제
단일 전투(B)는 닫혔으나 승리 후 보상·다음 전투·덱 성장이 없다(보상 훅 자리만 비어 있음).
전투를 한 "런"으로 확장해야 덱빌딩 로그라이크가 된다.
## 범위 (이 슬라이스)
전투를 **연속 N전투 런**으로 확장: 런 상태 영속(HP/골드/덱) + 승리 후 카드 1택 보상 +
다음 전투 연결 + 고정 N전투 후 "런 클리어". **맵 노드·상점·유물·보스·저장은 범위 밖**(후속 E3~E6).
아키텍처: 기존 `SlayDeckController` 확장(별도 RunState 분리는 후속).
## 설계
### 런 파라미터 (생성기 상수 — 향후 외부화)
- `RUN_LENGTH = 3` (런당 전투 수), `GOLD_PER_WIN = 15`.
### 새 상태 (SlayDeckController 속성)
- `RunDeck`(any) — 보유 카드 id 누적 배열(영속).
- `Gold`(number) — 누적 골드.
- `Floor`(number) — 현재 전투 번호(1-base).
- `RunLength`(number) — 런당 전투 수.
- `RewardChoices`(any) — 현재 제시 중인 보상 카드 id 3개.
- `RunActive`(boolean) — 런 진행 중.
- 플레이어 HP는 전투 간 **유지**(StartCombat에서 리셋 안 함).
### 메서드
- `OnBeginPlay``self:StartRun()`.
- **`StartRun`**(신규): `PlayerMaxHp=80`, `PlayerHp=PlayerMaxHp`, `Gold=0`, `Floor=0`,
`RunLength=RUN_LENGTH`, `RunDeck = starterDeck 복사`, `RunActive=true``BindButtons()`(1회) → `StartCombat()`.
- **`StartCombat`**(수정): `Floor += 1`; 적 데이터(activeEnemy) 세팅; 전투별 리셋(Energy/Turn/Block/
EnemyHp/EnemyBlock/EnemyIntentIndex/DiscardPile/Hand/CombatOver); `DrawPile = RunDeck 복사` → Shuffle;
`Cards` 테이블 세팅. **HP·Gold·RunDeck 보존, BindButtons 호출 제거.** → RenderCombat → StartPlayerTurn.
- **`BindButtons`**(수정): EndTurn·카드5·**보상카드3·건너뛰기** 버튼을 1회 바인딩(StartRun에서 호출).
- **`CheckCombatEnd`**(수정):
- 적 HP≤0(승리): `Gold += GOLD_PER_WIN`; `CombatOver=true`;
`Floor >= RunLength`이면 `ShowResult("런 클리어!")` + `RunActive=false`;
아니면 `self:OfferReward()`.
- 플레이어 HP≤0(패배): `CombatOver=true`; `ShowResult("패배...")`; `RunActive=false`.
- **`OfferReward`**(신규): `RewardChoices = 카드풀에서 3개 무작위`(math.random); 각 보상 카드 UI 갱신
(이름/코스트/설명/색); RewardHud 표시(Enable).
- **`PickReward(slot)`**(신규): `slot`(1~3)이면 `RewardChoices[slot]``RunDeck`에 추가; `slot=0`(건너뛰기)이면 추가 안 함;
RewardHud 숨김 → `StartCombat()`(다음 층).
- **`RenderRun`**(신규): `층 Floor/RunLength`·`골드 Gold` 텍스트 갱신. RenderCombat에서 호출.
### UI (생성기 신규)
- `RewardHud`(평소 숨김): 제목 "보상 카드 선택" + 보상 카드 3장(UISprite+버튼, 이름/코스트/설명 자식) + "건너뛰기" 버튼.
- HUD 표시 추가: `/ui/DefaultGroup/CombatHud/Floor`("층 1/3"), `/Gold`("골드 0").
- 보상 카드 클릭 → `PickReward(slot)`, 건너뛰기 → `PickReward(0)`.
### 버그 예방
- `BindButtons`가 매 전투(StartCombat)마다 카드 버튼에 `ConnectEvent` → 런에서 핸들러 중첩.
**StartRun에서 1회만 바인딩**으로 이동(StartCombat의 BindButtons 호출 제거).
## 검증 (메이커 Play)
- 전투 승리 → RewardHud에 카드 3장 표시; 골드 +15·층 표시.
- 보상 1택 → RunDeck +1(다음 전투 손패/덱에 등장 가능), RewardHud 숨김, 다음 전투 시작(HP 유지).
- 건너뛰기 → 덱 변화 없이 다음 전투.
- 3전투째 승리 → "런 클리어!"·런 종료. 도중 패배 → "패배..."·런 종료.
- 카드/보상 버튼 클릭은 런타임(MCP는 `PlayCard`/`EndPlayerTurn`/`PickReward` 직접 호출로 검증).
- 생성기 결정적, JSON 유효.
## 범위 밖 (금지)
- 맵 노드(E3)·상점/휴식(E4)·유물(E5)·보스/층전환/저장(E6). 골드 소비(E4). 보상 풀 확장(메이플 IP 추후).

View File

@@ -1,70 +0,0 @@
# 상점/휴식 노드 (TODO E4) — 설계
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO E 분해(E4) + E3 맵 노드 구조.
> 선행: E3(분기 맵) 완료. 후속: E5(유물)·E6(저장). 카드 제거는 덱 보기 UI 필요 → 후속 분리.
## 문제
E3로 분기 맵은 됐으나 모든 노드가 전투다. 골드는 적립만 되고 소비처가 없다. 상점(골드→카드)·
휴식(HP 회복) 노드가 필요하다.
## 범위
맵에 상점/휴식 노드 추가, 진입 시 전투 대신 상점/휴식 UI. 상점 = 카드 구매(골드). 휴식 = HP 회복.
**카드 제거(덱 보기 UI 필요)·유물·저장·휴식 업그레이드는 범위 밖.**
## 설계
### 데이터 (`data/map.json` 교체 — 4행)
```json
{
"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": [] }
}
}
```
- rest/shop 노드는 `enemy` 없음. 생성기 검증을 "`enemy` 있을 때만 ENEMIES 확인"으로 완화.
Lua MapNodes 직렬화도 enemy 있을 때만 `enemy = "..."` 포함.
### 파라미터 (생성기 상수)
- `CARD_PRICE = 30`, `REST_HEAL = 30`.
### 상태 추가
- `ShopChoices`(any) — 상점 제시 카드 id 3개.
- `ShopBought`(any) — 슬롯별 구매 여부 {bool×3}.
### 메서드
- `PickNode`(수정): CurrentNodeId 세팅·맵 숨김 후 타입 분기 —
`shop``ShowShop`, `rest``ShowRest`, 그 외→`CurrentEnemyId=node.enemy`·`StartCombat`.
- `ShowShop`(신규): 카드 풀에서 3개 무작위→ShopChoices, ShopBought 초기화(false),
각 슬롯 비주얼·가격·골드 갱신, ShopHud 표시.
- `BuyCard(slot)`(신규): ShopBought[slot]==true 또는 Gold<CARD_PRICE면 무시. 아니면
Gold-=CARD_PRICE, RunDeck에 ShopChoices[slot] 추가, ShopBought[slot]=true, 해당 카드 어둡게, 골드 갱신.
- `ShowRest`(신규): `PlayerHp = min(PlayerMaxHp, PlayerHp + REST_HEAL)`, RestHud에 "HP 옛→새 (+회복)" 표시, RestHud 표시.
- `LeaveNode`(신규): ShopHud·RestHud 숨김 → `ShowMap`. (상점·휴식 나가기 공용)
- `RenderShop`(신규): 3 카드 비주얼/가격/구매상태 + 골드 텍스트 갱신.
### UI (신규)
- `ShopHud`(모달, 숨김): 제목 "상점", 골드 텍스트, 카드 3장(sprite+button + Name/Cost/Desc/Price 자식), "나가기" 버튼.
- `RestHud`(모달, 숨김): 제목 "휴식", 정보 텍스트(런타임), "나가기" 버튼.
- BindButtons: 상점 카드 버튼 3(→BuyCard i)·상점 나가기(→LeaveNode)·휴식 나가기(→LeaveNode) 바인딩.
### MapHud (4행 대응)
- 노드 y = `(row - (MAX_ROW+1)/2) * 140` (행 수에 맞춰 세로 중앙 정렬). col×180 유지.
## 검증 (메이커 Play)
- 맵→상점(D) 진입 → 카드 3장·가격·골드 표시 → 구매 시 골드 -30·RunDeck +1·해당 카드 비활성 →
골드 부족 시 구매 무시 → 나가기 → 맵(다음 노드).
- 맵→휴식(C) 진입 → HP +30(상한 클램프) → 나가기 → 맵.
- 전투/엘리트/보스/런 클리어/패배 회귀 없음. 생성기 결정적·JSON 유효.
- (버튼 클릭은 런타임 — MCP는 PickNode/BuyCard/ShowRest/LeaveNode 직접 호출로 검증.)
## 범위 밖 (금지)
- 카드 제거·덱 보기 UI(후속)·유물(E5)·저장(E6)·휴식 업그레이드·상점 유물/물약.

View File

@@ -0,0 +1,54 @@
# UI Generation Structure
## Current Rule
Do not hand-edit `ui/DefaultGroup.ui` for SlayDeck UI changes. It is generated
by `tools/deck/gen-slaydeck.mjs`.
The `.ui` file is expected to stay large because MapleStory Worlds stores UI as
one JSON entity list. The maintainable source is the generator, not the output.
## Generated Sections
`tools/deck/gen-slaydeck.mjs` centralizes generated UI roots in
`GENERATED_UI_SECTIONS`:
- `DeckHud`
- `DeckInspectHud`
- `DeckAllHud`
- `CombatHud`
- `RewardHud`
- `MapHud`
- `ShopHud`
- `RestHud`
- `MainMenu`
- `CharacterSelectHud`
When the generator runs, existing entities under those roots are removed and
emitted again. Stock mobile controls are handled separately by
`DISABLED_STOCK_CONTROLS`.
## How To Change UI
1. Edit the relevant section in `tools/deck/gen-slaydeck.mjs`.
2. Add new top-level HUD roots to `GENERATED_UI_SECTIONS`.
3. Emit section entities with `emit('SectionName', entities)`.
4. Run `node tools/deck/gen-slaydeck.mjs`.
5. Verify JSON parsing for generated files.
`emit()` validates that a section only emits paths under its own root. This
keeps accidental cross-section UI changes from silently landing in
`DefaultGroup.ui`.
## Next Refactor Target
The next useful split is to move each large section builder into separate files,
for example:
- `tools/deck/ui/combat-hud.mjs`
- `tools/deck/ui/shop-hud.mjs`
- `tools/deck/ui/map-hud.mjs`
- `tools/deck/ui/menu-hud.mjs`
Keep shared helpers like `entity`, `transform`, `sprite`, `button`, and `text`
in one shared UI helper module.

View File

@@ -1,7 +1,7 @@
{
"Id": "",
"GameId": "",
"EntryKey": "map://map08",
"EntryKey": "map://lobby",
"ContentType": "x-mod/map",
"Content": "",
"Usage": 0,
@@ -14,12 +14,12 @@
"Use": "Binary",
"Entities": [
{
"id": "00001f40-0000-4000-8000-000000001f40",
"path": "/maps/map08",
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent",
"id": "000dbba0-0000-4000-8000-0000000dbba0",
"path": "/maps/lobby",
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent,script.LobbyMobility",
"jsonString": {
"name": "map08",
"path": "/maps/map08",
"name": "lobby",
"path": "/maps/lobby",
"nameEditable": false,
"enable": true,
"visible": true,
@@ -1103,18 +1103,22 @@
]
},
"Enable": true
},
{
"@type": "script.LobbyMobility",
"Enable": true
}
],
"@version": 1
}
},
{
"id": "00001f41-0000-4000-8000-000000001f41",
"path": "/maps/map08/Background",
"id": "000dbba1-0000-4000-8000-0000000dbba1",
"path": "/maps/lobby/Background",
"componentNames": "MOD.Core.BackgroundComponent",
"jsonString": {
"name": "Background",
"path": "/maps/map08/Background",
"path": "/maps/lobby/Background",
"nameEditable": false,
"enable": true,
"visible": true,
@@ -1132,7 +1136,7 @@
"b": 0.5019608,
"a": 0.7058824
},
"TemplateRUID": "0c398bbb2cf6400992532465b9d53024",
"TemplateRUID": "65c4167ea7484196b890022354e5a4a4",
"Type": 1,
"WebUrl": "eab37efa7f0d400f94259a2df836eb8a",
"Enable": true
@@ -1142,12 +1146,12 @@
}
},
{
"id": "00001f42-0000-4000-8000-000000001f42",
"path": "/maps/map08/MapleMapLayer",
"id": "000dbba2-0000-4000-8000-0000000dbba2",
"path": "/maps/lobby/MapleMapLayer",
"componentNames": "MOD.Core.MapLayerComponent",
"jsonString": {
"name": "MapleMapLayer",
"path": "/maps/map08/MapleMapLayer",
"path": "/maps/lobby/MapleMapLayer",
"nameEditable": false,
"enable": true,
"visible": true,
@@ -1178,12 +1182,12 @@
}
},
{
"id": "00001f43-0000-4000-8000-000000001f43",
"path": "/maps/map08/TileMap",
"id": "000dbba3-0000-4000-8000-0000000dbba3",
"path": "/maps/lobby/TileMap",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.TileMapComponent",
"jsonString": {
"name": "TileMap",
"path": "/maps/map08/TileMap",
"path": "/maps/lobby/TileMap",
"nameEditable": false,
"enable": true,
"visible": true,
@@ -1237,7 +1241,7 @@
"SortingLayer": "MapLayer0",
"TileMapVersion": 1,
"TileSetRUID": {
"DataId": "2667829326dd46de80ef26f6bb7f26ae"
"DataId": "9dfea3808bbd49a5877d8624df21b1c7"
},
"Tiles": [
{
@@ -6280,12 +6284,12 @@
}
},
{
"id": "00001f44-0000-4000-8000-000000001f44",
"path": "/maps/map08/SpawnLocation",
"id": "000dbba4-0000-4000-8000-0000000dbba4",
"path": "/maps/lobby/SpawnLocation",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.SpriteRendererComponent,MOD.Core.SpawnLocationComponent",
"jsonString": {
"name": "SpawnLocation",
"path": "/maps/map08/SpawnLocation",
"path": "/maps/lobby/SpawnLocation",
"nameEditable": true,
"enable": true,
"visible": true,
@@ -6356,12 +6360,12 @@
}
},
{
"id": "00001f45-0000-4000-8000-000000001f45",
"path": "/maps/map08/Monster1",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack",
"id": "000dbba5-0000-4000-8000-0000000dbba5",
"path": "/maps/lobby/NpcRun",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent,MOD.Core.TouchReceiveComponent,script.LobbyNpc",
"jsonString": {
"name": "Monster1",
"path": "/maps/map08/Monster1",
"name": "NpcRun",
"path": "/maps/lobby/NpcRun",
"nameEditable": true,
"enable": true,
"visible": true,
@@ -6371,17 +6375,17 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "StaticMonster",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00001f45-0000-4000-8000-000000001f45",
"root_entity_id": "000dbba5-0000-4000-8000-0000000dbba5",
"replaced_model_id": null
},
"modelId": "staticmonster",
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"x": -3,
"y": 0.03499998,
"z": 999.999
},
@@ -6401,9 +6405,9 @@
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "ed3908e24d694bb786023fc1ed073489",
"hit": "4763c9bebc9245998c9c499b6316aa9f",
"die": "b168793b92a844a3a3a6f4ce647a14d2"
"stand": "122095fd155c4633867b0da4f375bc3c",
"hit": "122095fd155c4633867b0da4f375bc3c",
"die": "122095fd155c4633867b0da4f375bc3c"
},
"Enable": true
},
@@ -6413,102 +6417,37 @@
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "ed3908e24d694bb786023fc1ed073489",
"SpriteRUID": "122095fd155c4633867b0da4f375bc3c",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.78,
"y": 0.86
},
"ColliderOffset": {
"x": 0.03999999,
"y": 0.43
},
"CollisionGroup": {
"Id": "8992acd1e8cd45838db6f10a7b41df09"
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"Enable": true
},
{
"@type": "script.Monster",
"@type": "MOD.Core.TouchReceiveComponent",
"Enable": true,
"IsDead": false
"AutoFitToSize": true
},
{
"@type": "script.MonsterAttack",
"@type": "script.LobbyNpc",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
"NpcId": "run",
"MarkName": "NpcRunMark"
}
],
"@version": 1
}
},
{
"id": "00001f46-0000-4000-8000-000000001f46",
"path": "/maps/map08/Monster2",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack",
"id": "000dbba6-0000-4000-8000-0000000dbba6",
"path": "/maps/lobby/NpcRunMark",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent",
"jsonString": {
"name": "Monster2",
"path": "/maps/map08/Monster2",
"name": "NpcRunMark",
"path": "/maps/lobby/NpcRunMark",
"nameEditable": true,
"enable": true,
"visible": true,
@@ -6518,17 +6457,88 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "StaticMonster",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00001f46-0000-4000-8000-000000001f46",
"root_entity_id": "000dbba6-0000-4000-8000-0000000dbba6",
"replaced_model_id": null
},
"modelId": "staticmonster",
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.5,
"x": -3,
"y": 1.6349999800000001,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "bd4afdde295f40318fceb4166978ebaa",
"hit": "bd4afdde295f40318fceb4166978ebaa",
"die": "bd4afdde295f40318fceb4166978ebaa"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "bd4afdde295f40318fceb4166978ebaa",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
}
],
"@version": 1
}
},
{
"id": "000dbba7-0000-4000-8000-0000000dbba7",
"path": "/maps/lobby/NpcCodex",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent,MOD.Core.TouchReceiveComponent,script.LobbyNpc",
"jsonString": {
"name": "NpcCodex",
"path": "/maps/lobby/NpcCodex",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "000dbba7-0000-4000-8000-0000000dbba7",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": -0.5,
"y": 0.03499998,
"z": 999.999
},
@@ -6548,9 +6558,9 @@
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "d8f014043ce8418f96700c2b6c9ebf6c",
"hit": "c3cf643b618346c7bfa6574187b396f9",
"die": "a88d9b3d60f941e4890dc89a6ccaa8ee"
"stand": "4c264be6a64f4ac3970b2e6818d04e40",
"hit": "4c264be6a64f4ac3970b2e6818d04e40",
"die": "4c264be6a64f4ac3970b2e6818d04e40"
},
"Enable": true
},
@@ -6560,90 +6570,402 @@
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "d8f014043ce8418f96700c2b6c9ebf6c",
"SpriteRUID": "4c264be6a64f4ac3970b2e6818d04e40",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.78,
"y": 0.86
},
"ColliderOffset": {
"x": 0.03999999,
"y": 0.43
},
"CollisionGroup": {
"Id": "8992acd1e8cd45838db6f10a7b41df09"
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"Enable": true
},
{
"@type": "script.Monster",
"@type": "MOD.Core.TouchReceiveComponent",
"Enable": true,
"IsDead": false
"AutoFitToSize": true
},
{
"@type": "script.MonsterAttack",
"@type": "script.LobbyNpc",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
"NpcId": "codex",
"MarkName": "NpcCodexMark"
}
],
"@version": 1
}
},
{
"id": "000dbba8-0000-4000-8000-0000000dbba8",
"path": "/maps/lobby/NpcCodexMark",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent",
"jsonString": {
"name": "NpcCodexMark",
"path": "/maps/lobby/NpcCodexMark",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "000dbba8-0000-4000-8000-0000000dbba8",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": -0.5,
"y": 1.6349999800000001,
"z": 999.999
},
"PositionOffset": {
"QuaternionRotation": {
"x": 0,
"y": 0
}
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "bd4afdde295f40318fceb4166978ebaa",
"hit": "bd4afdde295f40318fceb4166978ebaa",
"die": "bd4afdde295f40318fceb4166978ebaa"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "bd4afdde295f40318fceb4166978ebaa",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
}
],
"@version": 1
}
},
{
"id": "000dbba9-0000-4000-8000-0000000dbba9",
"path": "/maps/lobby/NpcShop",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent,MOD.Core.TouchReceiveComponent,script.LobbyNpc",
"jsonString": {
"name": "NpcShop",
"path": "/maps/lobby/NpcShop",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "000dbba9-0000-4000-8000-0000000dbba9",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 2,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "69987ccdc486423f8bedd786bd6cb5d9",
"hit": "69987ccdc486423f8bedd786bd6cb5d9",
"die": "69987ccdc486423f8bedd786bd6cb5d9"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "69987ccdc486423f8bedd786bd6cb5d9",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.TouchReceiveComponent",
"Enable": true,
"AutoFitToSize": true
},
{
"@type": "script.LobbyNpc",
"Enable": true,
"NpcId": "shop",
"MarkName": "NpcShopMark"
}
],
"@version": 1
}
},
{
"id": "000dbbaa-0000-4000-8000-0000000dbbaa",
"path": "/maps/lobby/NpcShopMark",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent",
"jsonString": {
"name": "NpcShopMark",
"path": "/maps/lobby/NpcShopMark",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "000dbbaa-0000-4000-8000-0000000dbbaa",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 2,
"y": 1.6349999800000001,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "bd4afdde295f40318fceb4166978ebaa",
"hit": "bd4afdde295f40318fceb4166978ebaa",
"die": "bd4afdde295f40318fceb4166978ebaa"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "bd4afdde295f40318fceb4166978ebaa",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
}
],
"@version": 1
}
},
{
"id": "000dbbab-0000-4000-8000-0000000dbbab",
"path": "/maps/lobby/NpcBoard",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent,MOD.Core.TouchReceiveComponent,script.LobbyNpc",
"jsonString": {
"name": "NpcBoard",
"path": "/maps/lobby/NpcBoard",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "000dbbab-0000-4000-8000-0000000dbbab",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 4.5,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "8a99bd87d667482cb1f3b2193f8a19c1",
"hit": "8a99bd87d667482cb1f3b2193f8a19c1",
"die": "8a99bd87d667482cb1f3b2193f8a19c1"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "8a99bd87d667482cb1f3b2193f8a19c1",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.TouchReceiveComponent",
"Enable": true,
"AutoFitToSize": true
},
{
"@type": "script.LobbyNpc",
"Enable": true,
"NpcId": "board",
"MarkName": "NpcBoardMark"
}
],
"@version": 1
}
},
{
"id": "000dbbac-0000-4000-8000-0000000dbbac",
"path": "/maps/lobby/NpcBoardMark",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.StateComponent",
"jsonString": {
"name": "NpcBoardMark",
"path": "/maps/lobby/NpcBoardMark",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "000dbbac-0000-4000-8000-0000000dbbac",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 4.5,
"y": 1.6349999800000001,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "bd4afdde295f40318fceb4166978ebaa",
"hit": "bd4afdde295f40318fceb4166978ebaa",
"die": "bd4afdde295f40318fceb4166978ebaa"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "bd4afdde295f40318fceb4166978ebaa",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
}
],
"@version": 1

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
{
"id": "00000bb8-0000-4000-8000-000000000bb8",
"path": "/maps/map03",
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent",
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent,script.MapCamera,script.PlayerLock",
"jsonString": {
"name": "map03",
"path": "/maps/map03",
@@ -1103,6 +1103,14 @@
]
},
"Enable": true
},
{
"@type": "script.MapCamera",
"Enable": true
},
{
"@type": "script.PlayerLock",
"Enable": true
}
],
"@version": 1
@@ -6356,12 +6364,12 @@
}
},
{
"id": "00000bbd-0000-4000-8000-000000000bbd",
"path": "/maps/map03/Monster1",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack",
"id": "00000dac-0000-4000-8000-000000000dac",
"path": "/maps/map03/combat_1",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "Monster1",
"path": "/maps/map03/Monster1",
"name": "combat_1",
"path": "/maps/map03/combat_1",
"nameEditable": true,
"enable": true,
"visible": true,
@@ -6371,17 +6379,17 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "StaticMonster",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00000bbd-0000-4000-8000-000000000bbd",
"root_entity_id": "00000dac-0000-4000-8000-000000000dac",
"replaced_model_id": null
},
"modelId": "staticmonster",
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"x": 2.3,
"y": 0.03499998,
"z": 999.999
},
@@ -6401,9 +6409,9 @@
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "d8f014043ce8418f96700c2b6c9ebf6c",
"hit": "c3cf643b618346c7bfa6574187b396f9",
"die": "a88d9b3d60f941e4890dc89a6ccaa8ee"
"stand": "ed3908e24d694bb786023fc1ed073489",
"hit": "4763c9bebc9245998c9c499b6316aa9f",
"die": "b168793b92a844a3a3a6f4ce647a14d2"
},
"Enable": true
},
@@ -6413,42 +6421,10 @@
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "d8f014043ce8418f96700c2b6c9ebf6c",
"SpriteRUID": "ed3908e24d694bb786023fc1ed073489",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.78,
"y": 0.86
},
"ColliderOffset": {
"x": 0.03999999,
"y": 0.43
},
"CollisionGroup": {
"Id": "8992acd1e8cd45838db6f10a7b41df09"
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
@@ -6461,24 +6437,32 @@
},
"Enable": true
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
@@ -6497,18 +6481,47 @@
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "pig",
"Group": "combat"
}
],
"@version": 1
}
},
{
"id": "00000bbe-0000-4000-8000-000000000bbe",
"path": "/maps/map03/Monster2",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack",
"id": "00000dad-0000-4000-8000-000000000dad",
"path": "/maps/map03/combat_2",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "Monster2",
"path": "/maps/map03/Monster2",
"name": "combat_2",
"path": "/maps/map03/combat_2",
"nameEditable": true,
"enable": true,
"visible": true,
@@ -6518,17 +6531,169 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "StaticMonster",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00000bbe-0000-4000-8000-000000000bbe",
"root_entity_id": "00000dad-0000-4000-8000-000000000dad",
"replaced_model_id": null
},
"modelId": "staticmonster",
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.5,
"x": 3.8,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "a2204a21d88942b281d2cac6053ffbaa",
"hit": "afc08936b8a64b26bc3dd8c03ead1f26",
"die": "fc1c6d9ba9bc413ab53b6dbfae3ac45b"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "a2204a21d88942b281d2cac6053ffbaa",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "red_snail",
"Group": "combat"
}
],
"@version": 1
}
},
{
"id": "00000dae-0000-4000-8000-000000000dae",
"path": "/maps/map03/combat_3",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "combat_3",
"path": "/maps/map03/combat_3",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00000dae-0000-4000-8000-000000000dae",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.2,
"y": 0.03499998,
"z": 999.999
},
@@ -6564,38 +6729,6 @@
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.78,
"y": 0.86
},
"ColliderOffset": {
"x": 0.03999999,
"y": 0.43
},
"CollisionGroup": {
"Id": "8992acd1e8cd45838db6f10a7b41df09"
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
@@ -6608,24 +6741,32 @@
},
"Enable": true
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
@@ -6644,6 +6785,491 @@
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "orange_mushroom",
"Group": "combat"
}
],
"@version": 1
}
},
{
"id": "00000daf-0000-4000-8000-000000000daf",
"path": "/maps/map03/elite_4",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "elite_4",
"path": "/maps/map03/elite_4",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00000daf-0000-4000-8000-000000000daf",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "d8f014043ce8418f96700c2b6c9ebf6c",
"hit": "c3cf643b618346c7bfa6574187b396f9",
"die": "a88d9b3d60f941e4890dc89a6ccaa8ee"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "d8f014043ce8418f96700c2b6c9ebf6c",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "modified_snail",
"Group": "elite"
}
],
"@version": 1
}
},
{
"id": "00000db0-0000-4000-8000-000000000db0",
"path": "/maps/map03/elite_5",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "elite_5",
"path": "/maps/map03/elite_5",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00000db0-0000-4000-8000-000000000db0",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "4ca39dbfa1c6492283ba8bd352d12b0a",
"hit": "7ac78511036e4ebe988b97c35fc275d1",
"die": "740f3f2b2e7a4b71bec5eac84e8539f9"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "4ca39dbfa1c6492283ba8bd352d12b0a",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "mushmom",
"Group": "elite"
}
],
"@version": 1
}
},
{
"id": "00000db1-0000-4000-8000-000000000db1",
"path": "/maps/map03/boss_6",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "boss_6",
"path": "/maps/map03/boss_6",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00000db1-0000-4000-8000-000000000db1",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 4,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "f86992ba9c41487c8480fcb893fcbda6",
"hit": "d305b942b1704c8084548108ff3b7a6b",
"die": "5a563e5fd98c4132b61057dc6bb8aaf2"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "f86992ba9c41487c8480fcb893fcbda6",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "slime_boss",
"Group": "boss"
}
],
"@version": 1

View File

@@ -16,7 +16,7 @@
{
"id": "00000fa0-0000-4000-8000-000000000fa0",
"path": "/maps/map04",
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent",
"componentNames": "MOD.Core.MapComponent,MOD.Core.FootholdComponent,script.MapCamera,script.PlayerLock",
"jsonString": {
"name": "map04",
"path": "/maps/map04",
@@ -1103,6 +1103,14 @@
]
},
"Enable": true
},
{
"@type": "script.MapCamera",
"Enable": true
},
{
"@type": "script.PlayerLock",
"Enable": true
}
],
"@version": 1
@@ -6356,12 +6364,12 @@
}
},
{
"id": "00000fa5-0000-4000-8000-000000000fa5",
"path": "/maps/map04/Monster1",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack",
"id": "00001194-0000-4000-8000-000000001194",
"path": "/maps/map04/combat_1",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "Monster1",
"path": "/maps/map04/Monster1",
"name": "combat_1",
"path": "/maps/map04/combat_1",
"nameEditable": true,
"enable": true,
"visible": true,
@@ -6371,17 +6379,473 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "StaticMonster",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00000fa5-0000-4000-8000-000000000fa5",
"root_entity_id": "00001194-0000-4000-8000-000000001194",
"replaced_model_id": null
},
"modelId": "staticmonster",
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.5,
"x": 2.3,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "f86992ba9c41487c8480fcb893fcbda6",
"hit": "d305b942b1704c8084548108ff3b7a6b",
"die": "5a563e5fd98c4132b61057dc6bb8aaf2"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "f86992ba9c41487c8480fcb893fcbda6",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "blue_mushroom",
"Group": "combat"
}
],
"@version": 1
}
},
{
"id": "00001195-0000-4000-8000-000000001195",
"path": "/maps/map04/combat_2",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "combat_2",
"path": "/maps/map04/combat_2",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00001195-0000-4000-8000-000000001195",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3.8,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "d8f014043ce8418f96700c2b6c9ebf6c",
"hit": "c3cf643b618346c7bfa6574187b396f9",
"die": "a88d9b3d60f941e4890dc89a6ccaa8ee"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "d8f014043ce8418f96700c2b6c9ebf6c",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "stump",
"Group": "combat"
}
],
"@version": 1
}
},
{
"id": "00001196-0000-4000-8000-000000001196",
"path": "/maps/map04/combat_3",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "combat_3",
"path": "/maps/map04/combat_3",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00001196-0000-4000-8000-000000001196",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.2,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "a2204a21d88942b281d2cac6053ffbaa",
"hit": "afc08936b8a64b26bc3dd8c03ead1f26",
"die": "fc1c6d9ba9bc413ab53b6dbfae3ac45b"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "a2204a21d88942b281d2cac6053ffbaa",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "green_mushroom",
"Group": "combat"
}
],
"@version": 1
}
},
{
"id": "00001197-0000-4000-8000-000000001197",
"path": "/maps/map04/elite_4",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "elite_4",
"path": "/maps/map04/elite_4",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00001197-0000-4000-8000-000000001197",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 3,
"y": 0.03499998,
"z": 999.999
},
@@ -6417,38 +6881,6 @@
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.78,
"y": 0.86
},
"ColliderOffset": {
"x": 0.03999999,
"y": 0.43
},
"CollisionGroup": {
"Id": "8992acd1e8cd45838db6f10a7b41df09"
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
@@ -6461,24 +6893,32 @@
},
"Enable": true
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
@@ -6497,18 +6937,47 @@
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "modified_snail",
"Group": "elite"
}
],
"@version": 1
}
},
{
"id": "00000fa6-0000-4000-8000-000000000fa6",
"path": "/maps/map04/Monster2",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.DamageSkinSettingComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,MOD.Core.StateComponent,MOD.Core.RigidbodyComponent,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.MovementComponent,script.Monster,script.MonsterAttack",
"id": "00001198-0000-4000-8000-000000001198",
"path": "/maps/map04/elite_5",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "Monster2",
"path": "/maps/map04/Monster2",
"name": "elite_5",
"path": "/maps/map04/elite_5",
"nameEditable": true,
"enable": true,
"visible": true,
@@ -6518,17 +6987,17 @@
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "StaticMonster",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00000fa6-0000-4000-8000-000000000fa6",
"root_entity_id": "00001198-0000-4000-8000-000000001198",
"replaced_model_id": null
},
"modelId": "staticmonster",
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 5.5,
"x": 5,
"y": 0.03499998,
"z": 999.999
},
@@ -6548,9 +7017,9 @@
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "3109357701ae41a4bcc7543f52f1f4c3",
"hit": "ce0269079e884545b5bb6ea075e2a67f",
"die": "a5e65650e00e47878cac1be7a5b999a0"
"stand": "ed3908e24d694bb786023fc1ed073489",
"hit": "4763c9bebc9245998c9c499b6316aa9f",
"die": "b168793b92a844a3a3a6f4ce647a14d2"
},
"Enable": true
},
@@ -6560,42 +7029,10 @@
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "3109357701ae41a4bcc7543f52f1f4c3",
"SpriteRUID": "ed3908e24d694bb786023fc1ed073489",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.78,
"y": 0.86
},
"ColliderOffset": {
"x": 0.03999999,
"y": 0.43
},
"CollisionGroup": {
"Id": "8992acd1e8cd45838db6f10a7b41df09"
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
@@ -6608,24 +7045,32 @@
},
"Enable": true
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
@@ -6644,6 +7089,187 @@
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "mushmom",
"Group": "elite"
}
],
"@version": 1
}
},
{
"id": "00001199-0000-4000-8000-000000001199",
"path": "/maps/map04/boss_6",
"componentNames": "MOD.Core.TransformComponent,MOD.Core.StateAnimationComponent,MOD.Core.SpriteRendererComponent,MOD.Core.RigidbodyComponent,MOD.Core.MovementComponent,MOD.Core.StateComponent,MOD.Core.HitComponent,MOD.Core.DamageSkinSpawnerComponent,script.Monster,script.MonsterAttack,MOD.Core.KinematicbodyComponent,MOD.Core.SideviewbodyComponent,MOD.Core.DamageSkinSettingComponent,script.CombatMonster",
"jsonString": {
"name": "boss_6",
"path": "/maps/map04/boss_6",
"nameEditable": true,
"enable": true,
"visible": true,
"localize": false,
"displayOrder": 4,
"pathConstraints": "///",
"revision": 2,
"origin": {
"type": "Model",
"entry_id": "ChaseMonster",
"sub_entity_id": null,
"root_entity_id": "00001199-0000-4000-8000-000000001199",
"replaced_model_id": null
},
"modelId": "chasemonster",
"@components": [
{
"@type": "MOD.Core.TransformComponent",
"Position": {
"x": 4,
"y": 0.03499998,
"z": 999.999
},
"QuaternionRotation": {
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"Scale": {
"x": 1,
"y": 1,
"z": 1
},
"Enable": true
},
{
"@type": "MOD.Core.StateAnimationComponent",
"ActionSheet": {
"stand": "48c10437ae8344a9b2a1d3f36185728f",
"hit": "9044063647854f5e9128efcf80e909be",
"die": "f414577d18c94cc387c275df4abdbc3b"
},
"Enable": true
},
{
"@type": "MOD.Core.SpriteRendererComponent",
"ActionSheet": {},
"EndFrameIndex": 0,
"RenderSetting": 1,
"SortingLayer": "MapLayer0",
"SpriteRUID": "48c10437ae8344a9b2a1d3f36185728f",
"StartFrameIndex": 0,
"Enable": true
},
{
"@type": "MOD.Core.RigidbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"RealMoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.MovementComponent",
"InputSpeed": 0,
"JumpForce": 6,
"Enable": false
},
{
"@type": "MOD.Core.StateComponent",
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.HitComponent",
"BoxSize": {
"x": 0.63,
"y": 0.58
},
"ColliderOffset": {
"x": 0.0449999869,
"y": 0.29
},
"IsLegacy": false,
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSpawnerComponent",
"Enable": true
},
{
"@type": "script.Monster",
"Enable": true,
"IsDead": false
},
{
"@type": "script.MonsterAttack",
"Enable": true,
"SpriteSize": {
"x": 0,
"y": 0
},
"PositionOffset": {
"x": 0,
"y": 0
}
},
{
"@type": "MOD.Core.KinematicbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.SideviewbodyComponent",
"MoveVelocity": {
"x": 0,
"y": 0
},
"Enable": true
},
{
"@type": "MOD.Core.DamageSkinSettingComponent",
"DamageSkinId": {
"DataId": "02c22d93421b4038b3c413b3e40b57ec"
},
"Enable": true
},
{
"@type": "script.CombatMonster",
"Enable": true,
"EnemyId": "slime_boss",
"Group": "boss"
}
],
"@version": 1

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,369 @@
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
// ⚠️ 전투 규칙은 tools/deck/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것.
// (데이터는 data/*.json 공유, 규칙 로직은 JS로 중복 재현)
import { readFileSync } from 'node:fs';
export const PLAYER_HP = 80; // 데이터 미포함 placeholder (codeblock과 일치)
export const ENERGY = 3;
export const HAND_SIZE = 5;
export const MAX_TURNS = 100;
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function shuffle(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// 공격 피해 공식 — Lua CalcPlayerAttack(힘·약화) + DealDamageToTarget(취약)과 동기화.
// floor((base + str) * (weak>0 ? 0.75 : 1)) → floor(... * (vulnOnTarget>0 ? 1.5 : 1))
// 보상 카드 등급 추첨 (Lua OfferReward 미러) — roll ∈ 1..100, normal 70 / unique 25 / legend 5
export function rarityForRoll(roll) {
if (roll > 95) return 'legend';
if (roll > 70) return 'unique';
return 'normal';
}
export function calcAttack(base, str, weak, vulnOnTarget) {
let dmg = base + str;
if (weak > 0) dmg = Math.floor(dmg * 0.75);
if (vulnOnTarget > 0) dmg = Math.floor(dmg * 1.5);
if (dmg < 0) dmg = 0;
return dmg;
}
// 방어 우선 차감 후 hp 적용 → { hp, block }
export function applyDamage(hp, block, amount) {
let dmg = amount;
if (block > 0) {
const absorbed = Math.min(block, dmg);
block -= absorbed;
dmg -= absorbed;
}
hp -= dmg;
if (hp < 0) hp = 0;
return { hp, block };
}
export function loadData() {
const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8'));
const ids = enemiesData.simEncounter || [enemiesData.activeEnemy];
const monsters = ids.map((id) => {
const e = enemiesData.enemies[id];
if (!e) throw new Error(`simEncounter 적 없음: ${id}`);
return { name: e.name, maxHp: e.maxHp, intents: e.intents };
});
// 시뮬 기본 덱은 전사 시작 덱 (클래스별 시뮬은 starterDeck 직접 주입으로 가능)
return { cards: cardsData.cards, starterDeck: cardsData.starterDecks.warrior, monsters };
}
// 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
export function chooseAction(hand, cards, energy) {
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable);
const powers = entries.filter((x) => cards[x.id].kind === 'Power');
const attacks = entries.filter((x) => cards[x.id].kind === 'Attack');
const skills = entries.filter((x) => cards[x.id].kind === 'Skill');
const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(cards[x.id].cost, 1);
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(cards[x.id].cost, 1);
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
if (powers.length) return powers[0].i;
if (attacks.length) return bestBy(attacks, dmgEff).i;
if (skills.length) return bestBy(skills, blkEff).i;
return -1;
}
// 공격 타겟 선택: 이번 타격으로 처치 가능한 최소 유효체력, 없으면 유효체력 최소.
export function chooseTarget(aliveMonsters, plannedDamage) {
const eff = (m) => m.hp + m.block;
const killable = aliveMonsters.filter((m) => eff(m) <= plannedDamage);
const pool = killable.length ? killable : aliveMonsters;
return pool.slice().sort((a, b) => eff(a) - eff(b) || pool.indexOf(a) - pool.indexOf(b))[0];
}
function bump(s, cost, dmg, blk) {
s = s || { plays: 0, energy: 0, damage: 0, block: 0 };
s.plays++; s.energy += cost; s.damage += dmg; s.block += blk;
return s;
}
// 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적.
// 반환: { win, turns, playerHpRemaining, draw? }
export function simulateCombat(data, rng, stats) {
const { cards, starterDeck, monsters } = data;
if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP };
let drawPile = shuffle(starterDeck, rng);
let discard = [];
const exhaust = [];
let hand = [];
let pHp = PLAYER_HP, pBlock = 0;
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0;
const powers = [];
const mob = monsters.map((m) => ({
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0,
intents: m.intents, intentIdx: 0, alive: true,
}));
let turns = 0;
function draw(n) {
for (let k = 0; k < n; k++) {
if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; }
if (drawPile.length === 0) break;
const card = drawPile.pop();
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
if (hand.length >= 10) {
discard.push(card);
triggerSly(card);
} else hand.push(card);
}
}
function addCardsToHand(id, n) {
for (let k = 0; k < n; k++) {
if (hand.length >= 10) discard.push(id);
else hand.push(id);
}
}
const aliveList = () => mob.filter((m) => m.alive);
function resolveCardEffects(id, c, costSpent, recordStats = true) {
const alive = aliveList();
let dmg = 0;
let blockGained = 0;
if (c.kind === 'Attack') {
if (alive.length && c.damage) {
const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0));
if (c.weak) target.weak += c.weak;
if (c.vuln) target.vuln += c.vuln;
const hitN = c.hits || 1;
let totalNv = 0;
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
dmg = totalNv;
if (c.aoe === true) {
for (const m2 of aliveList()) {
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
const r2 = applyDamage(m2.hp, m2.block, d2);
m2.hp = r2.hp; m2.block = r2.block;
if (m2.hp <= 0) m2.alive = false;
}
} else {
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
if (c.pierce === true) {
target.hp -= dmg;
if (target.hp < 0) target.hp = 0;
} else {
const r = applyDamage(target.hp, target.block, dmg);
target.hp = r.hp; target.block = r.block;
}
if (target.hp <= 0) target.alive = false;
}
}
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
} else if (c.kind === 'Power') {
if (recordStats) powers.push(id);
} else {
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
if ((c.weak || c.vuln || c.poison) && alive.length) {
const target = chooseTarget(alive, 0);
if (c.weak) target.weak += c.weak;
if (c.vuln) target.vuln += c.vuln;
if (c.poison) target.poison += c.poison;
}
}
if (c.strength) pStr += c.strength;
if (c.dex) pDex += c.dex;
if (c.thorns) pThorns += c.thorns;
if (c.selfVuln) pVuln += c.selfVuln;
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
if (c.draw) draw(c.draw);
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
}
function triggerSly(id) {
const c = cards[id];
if (!c?.sly) return;
resolveCardEffects(id, c, 0, false);
}
function discardHandCard(idx, trigger = true) {
const [id] = hand.splice(idx, 1);
if (!id) return;
discard.push(id);
if (trigger) triggerSly(id);
}
function applyDiscardEffects(c) {
let discarded = 0;
if (c.discardAll) {
while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; }
} else if (c.discard) {
const n = Math.min(c.discard, hand.length);
for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; }
}
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
}
while (turns < MAX_TURNS) {
turns++;
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
pBlock = 0;
let energyBonus = 0;
for (const pid of powers) {
const pc = cards[pid];
if (!pc) continue;
if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value;
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv);
}
let energy = ENERGY + energyBonus; draw(HAND_SIZE);
while (true) {
const alive = aliveList();
if (alive.length === 0) break;
const idx = chooseAction(hand, cards, energy);
if (idx < 0) break;
const id = hand[idx], c = cards[id];
energy -= c.cost;
resolveCardEffects(id, c, c.cost);
hand.splice(idx, 1);
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
else if (c.kind !== 'Power') discard.push(id);
applyDiscardEffects(c);
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
}
// 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화)
let burn = 0;
for (const hid of hand) { const hc = cards[hid]; if (hc && hc.endTurnDamage) burn += hc.endTurnDamage; }
if (burn > 0) { pHp -= burn; if (pHp < 0) pHp = 0; }
const kept = [];
for (const hid of hand) {
const hc = cards[hid];
if (hc?.retain === true) kept.push(hid);
else discard.push(hid);
}
hand = kept;
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
// 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전)
if (pWeak > 0) pWeak--;
if (pVuln > 0) pVuln--;
for (const m of mob) {
if (!m.alive) continue;
// 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략
if (m.poison > 0) {
m.hp -= m.poison;
m.poison--;
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
}
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
// 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤)
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
if (it) {
if (it.kind === 'Attack') {
const atk = calcAttack(it.value, m.str, m.weak, pVuln);
const beforeHp = pHp;
const r = applyDamage(pHp, pBlock, atk); pHp = r.hp; pBlock = r.block;
if (beforeHp > pHp && pThorns > 0) {
m.hp -= pThorns;
if (m.hp <= 0) m.alive = false;
}
} else if (it.kind === 'Defend') { m.block += it.value; }
else if (it.kind === 'Debuff') {
if (it.effect === 'weak') pWeak += it.value;
else if (it.effect === 'vuln') pVuln += it.value;
} else if (it.kind === 'AddCard') {
// StS2식 덱 오염 — 저주 카드를 버린 더미에 추가 (Lua 동기화)
const cnt = it.count || 1;
for (let k = 0; k < cnt; k++) discard.push(it.card);
}
}
// 적 디버프 감소 — Lua EnemyActStep 동기화 (자기 행동 후)
if (m.weak > 0) m.weak--;
if (m.vuln > 0) m.vuln--;
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
}
// 독 사망 등 적 페이즈 중 전멸 처리 (Lua FinishEnemyTurn→CheckCombatEnd 동기화)
if (!mob.some((m) => m.alive)) return { win: true, turns, playerHpRemaining: pHp };
}
return { win: false, turns, playerHpRemaining: pHp, draw: true };
}
function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; }
function median(a) {
if (!a.length) return 0;
const s = a.slice().sort((x, y) => x - y), m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
export function runBatch(N, seed) {
const data = loadData();
const rng = mulberry32(seed);
const cardStats = {};
let wins = 0, draws = 0;
const turnsArr = [], hpArr = [];
for (let i = 0; i < N; i++) {
const r = simulateCombat(data, rng, cardStats);
if (r.draw) draws++;
if (r.win) { wins++; hpArr.push(r.playerHpRemaining); }
turnsArr.push(r.turns);
}
return {
N, wins, draws, losses: N - wins - draws,
winRate: wins / N,
avgTurns: mean(turnsArr), medianTurns: median(turnsArr),
avgHpOnWin: mean(hpArr),
cardStats, cards: data.cards, monsters: data.monsters, seed,
};
}
export function formatReport(r) {
const L = [];
L.push(`=== 밸런스 시뮬레이션 (인카운터: ${r.monsters.map((m) => `${m.name}(${m.maxHp})`).join(', ')}) ===`);
L.push(`시뮬 ${r.N}회 (seed=${r.seed})`);
L.push(`승률: ${(r.winRate * 100).toFixed(1)}% (승 ${r.wins} / 패 ${r.losses}${r.draws ? ` / 무 ${r.draws}` : ''})`);
L.push(`평균 턴: ${r.avgTurns.toFixed(2)} 중앙값 턴: ${r.medianTurns}`);
L.push(`승리 시 평균 잔여 HP: ${r.avgHpOnWin.toFixed(1)} / ${PLAYER_HP}`);
if (r.draws) L.push(`⚠️ 무승부 ${r.draws}건 (턴 상한 ${MAX_TURNS} 초과)`);
L.push('');
L.push('카드별:');
const rows = Object.entries(r.cardStats).map(([id, s]) => {
const kind = r.cards[id].kind;
const eff = kind === 'Attack' ? s.damage / s.energy : s.block / s.energy;
return { id, name: r.cards[id].name, kind, plays: s.plays, eff };
});
for (const kind of ['Attack', 'Skill', 'Power']) {
const kr = rows.filter((x) => x.kind === kind);
if (!kr.length) continue;
const med = median(kr.map((x) => x.eff));
const unit = kind === 'Attack' ? '뎀/E' : kind === 'Power' ? '(지속)' : '블록/E';
for (const x of kr) {
const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : '';
L.push(` ${x.name}(${x.id}): 사용 ${x.plays}, 효율 ${x.eff.toFixed(2)} ${unit}${op}`);
}
}
const sorted = rows.slice().sort((a, b) => b.plays - a.plays);
if (sorted.length) L.push(`최다 사용: ${sorted[0].name} / 최소 사용: ${sorted[sorted.length - 1].name}`);
return L.join('\n');
}
function main() {
const args = process.argv.slice(2);
let N = 2000, seed = 1;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--seed') seed = parseInt(args[++i], 10);
else if (/^\d+$/.test(args[i])) N = parseInt(args[i], 10);
}
console.log(formatReport(runBatch(N, seed)));
}
if (process.argv[1] && process.argv[1].endsWith('sim-balance.mjs')) main();

View File

@@ -0,0 +1,463 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, rarityForRoll,
} from './sim-balance.mjs';
test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => {
assert.equal(rarityForRoll(1), 'normal');
assert.equal(rarityForRoll(70), 'normal');
assert.equal(rarityForRoll(71), 'unique');
assert.equal(rarityForRoll(95), 'unique');
assert.equal(rarityForRoll(96), 'legend');
assert.equal(rarityForRoll(100), 'legend');
});
test('applyDamage: 방어 우선 차감 후 hp', () => {
assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 });
assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 });
assert.deepEqual(applyDamage(80, 12, 10), { hp: 80, block: 2 });
assert.deepEqual(applyDamage(3, 0, 10), { hp: 0, block: 0 });
});
test('mulberry32: 동일 시드 동일 수열', () => {
const a = mulberry32(1), b = mulberry32(1);
assert.equal(a(), b());
assert.equal(a(), b());
});
const CARDS = {
Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 },
Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 },
Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 },
};
test('chooseAction: 공격을 스킬보다 먼저 선택', () => {
const idx = chooseAction(['Defend', 'Strike'], CARDS, 3);
assert.equal(idx, 1); // Strike
});
test('chooseAction: 공격 없으면 스킬 선택', () => {
const idx = chooseAction(['Defend'], CARDS, 3);
assert.equal(idx, 0);
});
test('chooseAction: 사용 가능 카드 없으면 -1', () => {
const idx = chooseAction(['Bash'], CARDS, 1);
assert.equal(idx, -1);
});
test('chooseTarget: 이번 타격으로 처치 가능한 최소 체력 우선', () => {
const mob = [
{ hp: 20, block: 0, alive: true },
{ hp: 5, block: 0, alive: true },
{ hp: 8, block: 0, alive: true },
];
assert.equal(chooseTarget(mob, 6), mob[1]); // 5<=6 처치 가능, 최소
});
test('chooseTarget: 처치 불가면 유효체력 최소 선택', () => {
const mob = [
{ hp: 20, block: 0, alive: true },
{ hp: 12, block: 5, alive: true },
{ hp: 14, block: 0, alive: true },
];
assert.equal(chooseTarget(mob, 6), mob[2]); // 유효 14 < 17 < 20
});
const DATA = {
cards: CARDS,
starterDeck: ['Strike', 'Strike', 'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend', 'Defend', 'Bash'],
monsters: [
{ name: '주황버섯', maxHp: 16, intents: [{ kind: 'Attack', value: 5 }, { kind: 'Defend', value: 4 }] },
{ name: '파란버섯', maxHp: 12, intents: [{ kind: 'Attack', value: 8 }] },
],
};
test('simulateCombat: 결정적 결과(동일 시드)', () => {
const r1 = simulateCombat(DATA, mulberry32(1));
const r2 = simulateCombat(DATA, mulberry32(1));
assert.deepEqual(r1, r2);
assert.equal(typeof r1.win, 'boolean');
assert.ok(r1.turns >= 1);
});
test('simulateCombat: 모든 몬스터 처치 시 승리', () => {
let wins = 0;
for (let i = 0; i < 50; i++) if (simulateCombat(DATA, mulberry32(i + 1)).win) wins++;
assert.ok(wins >= 40, `예상 승리 다수, 실제 ${wins}/50`);
});
test('simulateCombat: 강한 다수 적이면 패배 가능', () => {
const hard = {
cards: CARDS,
starterDeck: DATA.starterDeck,
monsters: Array.from({ length: 4 }, () => ({ name: '슬라임', maxHp: 60, intents: [{ kind: 'Attack', value: 12 }] })),
};
let losses = 0;
for (let i = 0; i < 30; i++) if (!simulateCombat(hard, mulberry32(i + 1)).win) losses++;
assert.ok(losses >= 1, `강한 적엔 패배가 나와야 함, 실제 패 ${losses}/30`);
});
test('simulateCombat: 턴 상한 초과 시 draw 반환', () => {
const immortal = {
cards: { Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 } },
starterDeck: Array(10).fill('Defend'),
monsters: [{ name: '불사', maxHp: 9999, intents: [{ kind: 'Attack', value: 1 }] }],
};
const r = simulateCombat(immortal, mulberry32(1));
assert.equal(r.draw, true);
assert.equal(r.win, false);
});
test('simulateCombat: 몬스터 없으면 즉시 승리', () => {
const r = simulateCombat({ cards: {}, starterDeck: [], monsters: [] }, mulberry32(1));
assert.equal(r.win, true);
assert.equal(r.turns, 0);
});
test('runBatch: 집계 필드·승률 범위', () => {
const r = runBatch(100, 1);
assert.equal(r.N, 100);
assert.ok(r.winRate >= 0 && r.winRate <= 1);
assert.ok(r.avgTurns > 0);
assert.ok(r.cardStats.Strike.plays > 0);
});
test('runBatch: 동일 시드 동일 결과', () => {
assert.deepEqual(runBatch(100, 7), runBatch(100, 7));
});
test('simulateCombat: 복합 카드(공격+방어) 블록이 적 공격을 흡수', () => {
const data = {
cards: { Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 1, block: 3 } },
starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'],
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 9 }] }],
};
const r = simulateCombat(data, mulberry32(1));
// 매 턴 3장(에너지3) → 블록 9 = 적 공격 9 전부 흡수 → 무피해로 MAX_TURNS 도달(draw), HP 유지.
// 블록 미적용이면 매턴 -9로 사망(win=false, draw 아님).
assert.equal(r.draw, true);
assert.equal(r.playerHpRemaining, 80);
});
test('calcAttack: 힘·약화·취약 공식 (Lua CalcPlayerAttack·DealDamageToTarget 동기화)', () => {
assert.equal(calcAttack(6, 0, 0, 0), 6); // 기본
assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2
assert.equal(calcAttack(6, 0, 1, 0), 4); // 약화 floor(6*0.75)
assert.equal(calcAttack(6, 0, 0, 1), 9); // 취약 floor(6*1.5)
assert.equal(calcAttack(10, 2, 1, 1), 13); // floor(floor(12*0.75)=9 → floor(9*1.5))=13
});
test('simulateCombat: 적 Debuff 인텐트만 사용 → 플레이어 무피해', () => {
const data = {
cards: { Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 } },
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'weak', value: 1 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.playerHpRemaining, 80);
});
test('simulateCombat: 플레이어 약화 시 공격 피해 감소 반영', () => {
// 약화 영구 부여 적: 4피해 카드가 floor(4*0.75)=3으로 감소
const data = {
cards: { Hit: { name: '타격', cost: 3, kind: 'Attack', damage: 4 } },
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '적', maxHp: 10, intents: [{ kind: 'Debuff', effect: 'weak', value: 99 }] }],
};
const r = simulateCombat(data, mulberry32(1));
// 턴1: 4 (약화 전), 이후 매턴 3 → 10피해 도달 = 턴3 (4+3+3)
assert.equal(r.win, true);
assert.equal(r.turns, 3);
});
test('simulateCombat: 카드 취약 부여가 같은 카드 피해에 선적용 (Lua 동기화)', () => {
const data = {
cards: { CB: { name: '차지', cost: 3, kind: 'Attack', damage: 8, vuln: 2 } },
starterDeck: ['CB', 'CB', 'CB', 'CB', 'CB'],
monsters: [{ name: '적', maxHp: 12, intents: [{ kind: 'Defend', value: 0 }] }],
};
const r = simulateCombat(data, mulberry32(1));
// 취약 선적용이면 floor(8*1.5)=12 → 1턴 처치. 후적용이면 8 → 2턴.
assert.equal(r.turns, 1);
});
test('simulateCombat: Power(매턴 힘) 누적', () => {
const data = {
cards: {
Rage: { name: '분노', cost: 1, kind: 'Power', powerEffect: 'strengthPerTurn', value: 5 },
Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 },
},
starterDeck: ['Rage', 'Hit', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '적', maxHp: 60, intents: [{ kind: 'Defend', value: 0 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, true);
assert.ok(r.turns <= 6, `파워 누적으로 빠른 처치 기대, 실제 ${r.turns}`);
});
test('simulateCombat: 적 약화 인텐트 → 적 공격력 감소는 적용 안 됨(적 자신은 약화 안 걸림)', () => {
// 회귀 가드: Debuff 인텐트는 플레이어에게만 적용
const data = {
cards: { Skip: { name: '대기', cost: 3, kind: 'Skill', block: 0 } },
starterDeck: ['Skip', 'Skip', 'Skip', 'Skip', 'Skip'],
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'vuln', value: 1 }, { kind: 'Attack', value: 10 }] }],
};
const r = simulateCombat(data, mulberry32(1));
// 턴1: 취약1 부여 → 플레이어 취약. 턴1 종료 시 1 감소 → 0. 턴2: 공격 10 (취약 소멸) → 정확히 10만 피해.
// MAX_TURNS 동안 2턴 주기 공격 → 사망까지 충분 → win=false
assert.equal(r.win, false);
});
test('simulateCombat: 다단히트(hits) — 힘이 타격마다 적용, 취약은 합산 1회 (Lua 동기화)', () => {
const data = {
cards: {
Buff: { name: '버프', cost: 1, kind: 'Skill', strength: 2 },
Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 5, hits: 2 },
},
starterDeck: ['Buff', 'Combo', 'Combo', 'Combo', 'Combo'],
monsters: [{ name: '적', maxHp: 200, intents: [{ kind: 'Defend', value: 0 }] }],
};
// 공격 우선 휴리스틱: 턴1 콤보×3 (힘0) = 10×3 = 30
const r = simulateCombat(data, mulberry32(1));
assert.equal(typeof r.win, 'boolean'); // 동작 보장 (수치는 아래 단위 검증)
});
test('hits 수치: 힘+2일 때 5×2회 = (5+2)*2 = 14', () => {
const data = {
cards: { Combo: { name: '콤보', cost: 3, kind: 'Attack', damage: 5, hits: 2, strength: 0 } },
starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'],
monsters: [{ name: '적', maxHp: 10, intents: [{ kind: 'Defend', value: 0 }] }],
};
// 턴1: 10 피해 → 정확히 처치 (5×2)
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});
test('simulateCombat: pierce — 적 방어도 무시', () => {
const data = {
cards: { P: { name: '피어스', cost: 3, kind: 'Attack', damage: 9, pierce: true } },
starterDeck: ['P', 'P', 'P', 'P', 'P'],
monsters: [{ name: '적', maxHp: 18, intents: [{ kind: 'Defend', value: 50 }] }],
};
// 턴1: 9 (방어 없음), 적이 방어 50. 턴2: pierce 9 → 처치. 비관통이면 흡수돼 불가.
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, true);
assert.equal(r.turns, 2);
});
test('simulateCombat: selfVuln — 자가 취약으로 받는 피해 증가', () => {
const data = {
cards: { B: { name: '버서크류', cost: 1, kind: 'Skill', selfVuln: 9, block: 0 } },
starterDeck: ['B', 'B', 'B', 'B', 'B'],
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 2 }] }],
};
// 매턴 스킬 사용으로 취약 유지 → 적 공격 2 → floor(2*1.5)=3 → 80/3 ≈ 27턴 사망 (취약 없으면 40턴)
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, false);
assert.ok(r.turns <= 30, `취약 반영 시 30턴 내 사망, 실제 ${r.turns}`);
});
test('simulateCombat: energyPerTurn 파워 — 다음 턴부터 에너지 증가', () => {
const data = {
cards: {
E: { name: '버서크', cost: 1, kind: 'Power', powerEffect: 'energyPerTurn', value: 1 },
Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 },
},
starterDeck: ['E', 'Hit', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '적', maxHp: 14, intents: [{ kind: 'Defend', value: 0 }] }],
};
// 턴1: 파워+히트2 = 2, 턴2~4: 에너지4·손패 히트4 = 4/턴 → 2+4+4+4 = 14 → 턴4 처치
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, true);
assert.equal(r.turns, 4);
});
test('simulateCombat: blockPerTurn 파워 — 매턴 방어로 약공 무효', () => {
const data = {
cards: {
B: { name: '하이퍼 바디', cost: 1, kind: 'Power', powerEffect: 'blockPerTurn', value: 3 },
S: { name: '대기', cost: 3, kind: 'Skill', block: 0 },
},
starterDeck: ['B', 'S', 'S', 'S', 'S'],
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 3 }] }],
};
// 턴1: 파워 설치, 적 3 피해(방어 없음) → 77. 턴2부터 매턴 방어3 = 공격3 전부 흡수 → draw, HP 77 유지
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.draw, true);
assert.equal(r.playerHpRemaining, 77);
});
test('simulateCombat: poison — 적 행동 시작 시 틱·1 감소·독 사망 시 승리 처리', () => {
const data = {
cards: { PB: { name: '포이즌', cost: 3, kind: 'Skill', poison: 4 } },
starterDeck: ['PB', 'PB', 'PB', 'PB', 'PB'],
monsters: [{ name: '적', maxHp: 10, intents: [{ kind: 'Defend', value: 0 }] }],
};
// T1: 독4 부여 → 틱 4 (hp 6, 독 3). T2: +4 → 7 틱 → hp 0 사망 → 승리
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, true);
assert.equal(r.turns, 2);
});
test('simulateCombat: aoe — 모든 생존 적에게 피해', () => {
const data = {
cards: { TB: { name: '썬더 볼트', cost: 3, kind: 'Attack', damage: 6, aoe: true } },
starterDeck: ['TB', 'TB', 'TB', 'TB', 'TB'],
monsters: [
{ name: 'A', maxHp: 6, intents: [{ kind: 'Attack', value: 5 }] },
{ name: 'B', maxHp: 6, intents: [{ kind: 'Attack', value: 5 }] },
{ name: 'C', maxHp: 6, intents: [{ kind: 'Attack', value: 5 }] },
],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});
test('simulateCombat: heal — 최대 HP 클램프', () => {
const data = {
cards: { H: { name: '힐', cost: 1, kind: 'Skill', heal: 10 } },
starterDeck: ['H', 'H', 'H', 'H', 'H'],
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 10 }] }],
};
// 매턴: 힐로 80까지 회복(클램프) → 적 10 → 70. MAX_TURNS 도달 시 hp 70
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.draw, true);
assert.equal(r.playerHpRemaining, 70);
});
test('simulateCombat: draw — 카드 드로로 손패 보충', () => {
const data = {
cards: {
D: { name: '텔레포트류', cost: 0, kind: 'Skill', draw: 1, block: 0 },
Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 },
},
starterDeck: ['D', 'D', 'D', 'D', 'D', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '적', maxHp: 4, intents: [{ kind: 'Defend', value: 0 }] }],
};
// 드로 덕에 첫 턴 히트 3장 전부 접근 → 늦어도 2턴 내 처치 (시드 무관)
for (let s = 1; s <= 10; s++) {
const r = simulateCombat(data, mulberry32(s));
assert.equal(r.win, true, `seed ${s}`);
assert.ok(r.turns <= 2, `seed ${s}: ${r.turns}`);
}
});
test('chooseAction: unplayable(저주) 카드는 건너뜀', () => {
const cards = { Strike: { cost: 1, kind: 'Attack', damage: 6 }, Wound: { cost: 0, kind: 'Status', unplayable: true } };
assert.equal(chooseAction(['Wound', 'Strike'], cards, 3), 1); // Strike 선택
assert.equal(chooseAction(['Wound'], cards, 3), -1); // 낼 카드 없음
});
test('simulateCombat: AddCard intent가 저주를 덱에 추가(오염)', () => {
const data = {
cards: { Hit: { name: '히트', cost: 1, kind: 'Attack', damage: 1 }, Wound: { name: '상처', cost: 0, kind: 'Status', unplayable: true } },
starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'],
monsters: [{ name: '오염자', maxHp: 9999, intents: [{ kind: 'AddCard', card: 'Wound', count: 1 }] }],
};
// 적은 공격 안 하고 매 턴 저주만 추가 → 플레이어 무피해(승리 불가, 9999hp) → 무승부, 사망 아님
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, false);
assert.equal(r.draw, true);
});
test('simulateCombat: endTurnDamage(화상)이 턴 종료 시 누적 피해', () => {
const data = {
cards: { Skip: { name: '대기', cost: 3, kind: 'Skill', block: 0 }, Burn: { name: '화상', cost: 0, kind: 'Status', unplayable: true, endTurnDamage: 2 } },
starterDeck: ['Burn', 'Skip', 'Skip', 'Skip', 'Skip'],
monsters: [{ name: '무공격', maxHp: 9999, intents: [{ kind: 'Defend', value: 0 }] }],
};
// 적은 방어만(무피해). 손패의 Burn이 매 턴 -2 → 80hp 잠식 → MAX_TURNS 전 사망 → win false(draw 아님)
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, false);
assert.notEqual(r.draw, true);
});
test("simulateCombat: sly discarded card resolves for free", () => {
const data = {
cards: {
Toss: { name: "Toss", cost: 1, kind: "Skill", discardAll: true },
SlyHit: { name: "SlyHit", cost: 99, kind: "Attack", damage: 10, sly: true },
Blank: { name: "Blank", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Toss", "SlyHit", "Blank", "Blank", "Blank"],
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Defend", value: 0 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});
test("simulateCombat: retain keeps card in hand across turns", () => {
const data = {
cards: {
Boost: { name: "Boost", cost: 3, kind: "Power", powerEffect: "energyPerTurn", value: 98 },
Hold: { name: "Hold", cost: 100, kind: "Attack", damage: 10, retain: true },
Blank: { name: "Blank", cost: 99, kind: "Skill", block: 0 },
},
starterDeck: ["Blank", "Blank", "Blank", "Blank", "Blank", "Boost", "Hold", "Blank", "Blank", "Blank"],
monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Defend", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 2);
});
test("simulateCombat: exhaust cards do not return through discard reshuffle", () => {
const data = {
cards: {
BurnOut: { name: "BurnOut", cost: 1, kind: "Attack", damage: 10, exhaust: true },
},
starterDeck: ["BurnOut"],
monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Defend", value: 0 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.win, false);
assert.equal(r.draw, true);
});
test("simulateCombat: dex increases block gained from cards", () => {
const data = {
cards: {
Footwork: { name: "Footwork", cost: 1, kind: "Power", dex: 2 },
Defend: { name: "Defend", cost: 1, kind: "Skill", block: 5 },
},
starterDeck: ["Footwork", "Defend"],
monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 6 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, false);
assert.equal(r.draw, true);
assert.equal(r.playerHpRemaining, 80);
});
test("simulateCombat: thorns reflects unblocked attack damage", () => {
const data = {
cards: {
Spikes: { name: "Spikes", cost: 1, kind: "Power", thorns: 4 },
},
starterDeck: ["Spikes"],
monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 1 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
assert.equal(r.playerHpRemaining, 79);
});
test("simulateCombat: addShiv creates shuriken cards in hand", () => {
const data = {
cards: {
MakeShiv: { name: "MakeShiv", cost: 0, kind: "Skill", addShiv: 2 },
Shiv: { name: "표창", cost: 0, kind: "Attack", damage: 4, exhaust: true },
},
starterDeck: ["MakeShiv"],
monsters: [{ name: "Dummy", maxHp: 8, intents: [{ kind: "Attack", value: 0 }] }],
};
const r = simulateCombat(data, () => 0.999999);
assert.equal(r.win, true);
assert.equal(r.turns, 1);
});

102
tools/camera/gen-camera.mjs Normal file
View File

@@ -0,0 +1,102 @@
import { readFileSync, writeFileSync } from 'node:fs';
// 맵별 고정 카메라: 맵 로드 시 플레이어 CameraComponent를 data/camera.json 값으로 설정.
// 새 CameraComponent를 만들지 않고(엔진 소유) 기존 카메라 속성만 런타임 설정한다.
// 플레이어 입력 차단·시선 고정은 tools/player/gen-player-lock.mjs(script.PlayerLock)로 분리됨.
const CAM = JSON.parse(readFileSync('data/camera.json', 'utf8'));
const MAP_NUMBERS = Array.from({ length: 5 }, (_, i) => i + 1); // map01~05
function prop(Type, Name, DefaultValue = 'nil') {
return { Type, DefaultValue, SyncDirection: 0, Attributes: [], Name };
}
function method(Name, Code, Arguments = [], ExecSpace = 6) {
return {
Return: { Type: 'void', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: null },
Arguments,
Code,
Scope: 2,
ExecSpace,
Attributes: [],
Name,
};
}
function writeCodeblock() {
const cb = {
Id: '',
GameId: '',
EntryKey: 'codeblock://mapcamera',
ContentType: 'x-mod/codeblock',
Content: '',
Usage: 0,
UsePublish: 1,
UseService: 0,
CoreVersion: '26.5.0.0',
StudioVersion: '',
DynamicLoading: 0,
ContentProto: {
Use: 'Json',
Json: {
CoreVersion: { Major: 0, Minor: 2 },
ScriptVersion: { Major: 1, Minor: 0 },
Description: '',
Id: 'MapCamera',
Language: 1,
Name: 'MapCamera',
Type: 1,
Source: 0,
Target: null,
Properties: [prop('number', 'CamTries', '0')],
Methods: [
method('OnBeginPlay', `self.CamTries = 0
local eventId = 0
local function apply()
self.CamTries = self.CamTries + 1
local cam = nil
local lp = _UserService.LocalPlayer
if lp ~= nil then
cam = lp.CameraComponent
end
if cam == nil then
cam = _CameraService:GetCurrentCameraComponent()
end
if cam ~= nil then
cam.ZoomRatio = ${CAM.zoomRatio}
cam.ScreenOffset = Vector2(${CAM.screenOffsetX}, ${CAM.screenOffsetY})
cam.ConfineCameraArea = ${CAM.confineCameraArea}
cam.CameraOffset = Vector2(${CAM.cameraOffsetX}, ${CAM.cameraOffsetY})
end
if cam ~= nil then
_TimerService:ClearTimer(eventId)
elseif self.CamTries > 30 then
_TimerService:ClearTimer(eventId)
end
end
eventId = _TimerService:SetTimerRepeat(apply, 0.1)`),
],
EntityEventHandlers: [],
},
},
};
writeFileSync('RootDesk/MyDesk/MapCamera.codeblock', JSON.stringify(cb, null, 2), 'utf8');
}
function patchMap(nn) {
const tag = String(nn).padStart(2, '0');
const file = `map/map${tag}.map`;
const map = JSON.parse(readFileSync(file, 'utf8'));
const root = map.ContentProto.Entities.find((e) => e.path === `/maps/map${tag}`);
if (!root) throw new Error(`[gen-camera] 맵 루트 없음: ${file}`);
// idempotent: 기존 script.MapCamera 제거 후 재추가
root.jsonString['@components'] = root.jsonString['@components'].filter((c) => c['@type'] !== 'script.MapCamera');
root.jsonString['@components'].push({ '@type': 'script.MapCamera', Enable: true });
const names = (root.componentNames || '').split(',').filter((s) => s && s !== 'script.MapCamera');
names.push('script.MapCamera');
root.componentNames = names.join(',');
writeFileSync(file, JSON.stringify(map, null, 2), 'utf8');
return `map${tag}`;
}
writeCodeblock();
const patched = MAP_NUMBERS.map(patchMap);
console.log('MapCamera codeblock written; patched maps:', patched.join(', '));

74
tools/deck/cb/boot.mjs Normal file
View File

@@ -0,0 +1,74 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const bootMethods = [
method('OnBeginPlay', `${luaCardsTable(CARDS.cards)}
${luaFramesTable()}
${luaNodeIconsTable()}
${luaCharsTable()}
${luaSoulShopTable(SOUL_UNLOCKS)}
self.SoulUnlocks = {}
self.SoulPoints = self.SoulPoints or 0
self:ShowLobby()
local lp = _UserService.LocalPlayer
if lp ~= nil then
self:ReqLoadAscension(lp.PlayerComponent.UserId)
self:ReqLoadSouls(lp.PlayerComponent.UserId)
end
_InputService:ConnectEvent(KeyDownEvent, function(e)
if e.key == KeyboardKey.LeftControl then
local lp2 = _UserService.LocalPlayer
if lp2 ~= nil and lp2.CurrentMapName == "${LOBBY_MAP}" and self.RunActive ~= true then
self:PlayerAttackMotion()
end
end
end)`),
method('ReqLoadAscension', `local ds = _DataStorageService:GetUserDataStorage(userId)
local errCode, value = ds:GetAndWait("ascensionUnlocked")
local n = 0
if errCode == 0 and value ~= nil and value ~= "" then
n = tonumber(value) or 0
end
self:RecvAscension(n, userId)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'userId' }], 5),
method('RecvAscension', `self.AscensionUnlocked = n
if self.AscensionLevel > self.AscensionUnlocked then
self.AscensionLevel = self.AscensionUnlocked
end
self:RenderAscension()`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'n' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'userId' },
], 6),
method('SaveAscension', `local ds = _DataStorageService:GetUserDataStorage(userId)
ds:SetAndWait("ascensionUnlocked", tostring(n))`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'n' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'userId' },
], 5),
method('AdjustAscension', `local v = self.AscensionLevel + delta
if v < 0 then v = 0 end
if v > self.AscensionUnlocked then v = self.AscensionUnlocked end
self.AscensionLevel = v
self:RenderAscension()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'delta' }]),
method('RenderAscension', `self:SetText("/ui/DefaultGroup/MainMenu/AscLabel", "승천 " .. string.format("%d", self.AscensionLevel) .. " / 해금 " .. string.format("%d", self.AscensionUnlocked))
self:SetText("/ui/DefaultGroup/LobbyHud/AscLabel", "승천 " .. string.format("%d", self.AscensionLevel) .. " / 해금 " .. string.format("%d", self.AscensionUnlocked))`),
method('AscHpMult', `local m = 1
if self.AscensionLevel >= 1 then m = m + 0.1 end
if self.AscensionLevel >= 6 then m = m + 0.1 end
return m`, [], 0, 'number'),
method('AscAtkMult', `local m = 1
if self.AscensionLevel >= 2 then m = m + 0.1 end
if self.AscensionLevel >= 7 then m = m + 0.1 end
return m`, [], 0, 'number'),
method('AscEliteBonus', `local b = 0
if self.AscensionLevel >= 4 then b = b + 0.2 end
if self.AscensionLevel >= 9 then b = b + 0.2 end
return b`, [], 0, 'number'),
method('AscGoldMult', `local m = 1
if self.AscensionLevel >= 5 then m = m - 0.25 end
if self.AscensionLevel >= 10 then m = m - 0.25 end
return m`, [], 0, 'number'),
method('AscStartHpPenalty', `local p = 0
if self.AscensionLevel >= 3 then p = p + 10 end
if self.AscensionLevel >= 8 then p = p + 10 end
return p`, [], 0, 'number'),
];

View File

@@ -0,0 +1,70 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const charSelectMethods = [
method('ShowCharacterSelect', `self.SelectedClass = ""
self:ShowState("charselect")
self:RenderCharacterSelect()`),
method('SelectClass', `self.SelectedClass = className
self:RenderCharacterSelect()`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' },
]),
method('RenderCharacterSelect', `local warriorArt = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/WarriorButton/Art")
if warriorArt ~= nil and warriorArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["warrior"] ~= nil then
warriorArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["warrior"]
end
local mageArt = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/MageButton/Art")
if mageArt ~= nil and mageArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["magician"] ~= nil then
mageArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["magician"]
end
local thiefArt = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/ThiefButton/Art")
if thiefArt ~= nil and thiefArt.SpriteGUIRendererComponent ~= nil and self.ClassPortraits ~= nil and self.ClassPortraits["bandit"] ~= nil then
thiefArt.SpriteGUIRendererComponent.ImageRUID = self.ClassPortraits["bandit"]
end
local warrior = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/WarriorButton")
if warrior ~= nil and warrior.SpriteGUIRendererComponent ~= nil then
if self.SelectedClass == "warrior" then
warrior.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
else
warrior.SpriteGUIRendererComponent.Color = Color(0.16, 0.2, 0.26, 1)
end
end
local mage = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/MageButton")
if mage ~= nil and mage.SpriteGUIRendererComponent ~= nil then
if self.SelectedClass == "magician" then
mage.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
else
mage.SpriteGUIRendererComponent.Color = Color(0.16, 0.2, 0.26, 1)
end
end
local thief = _EntityService:GetEntityByPath("/ui/DefaultGroup/CharacterSelectHud/ThiefButton")
if thief ~= nil and thief.SpriteGUIRendererComponent ~= nil then
if self.SelectedClass == "bandit" then
thief.SpriteGUIRendererComponent.Color = Color(1, 0.82, 0.3, 1)
else
thief.SpriteGUIRendererComponent.Color = Color(0.16, 0.2, 0.26, 1)
end
end
if self.SelectedClass == "warrior" then
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "전사 선택됨")
elseif self.SelectedClass == "bandit" then
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "도적 선택됨")
elseif self.SelectedClass == "magician" then
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "마법사 선택됨")
else
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "직업을 선택하고 시작하세요")
end`),
method('StartNewGame', `if self.SelectedClass ~= "warrior" and self.SelectedClass ~= "bandit" and self.SelectedClass ~= "magician" then
self:SetText("/ui/DefaultGroup/CharacterSelectHud/Status", "직업을 먼저 선택하세요")
return
end
self:StartRun()`),
method('SetEntityEnabled', `local e = _EntityService:GetEntityByPath(path)
if e ~= nil then
e.Enable = enabled
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enabled' },
]),
];

480
tools/deck/cb/combat.mjs Normal file
View File

@@ -0,0 +1,480 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const combatMethods = [
method('PlayCard', `if self:IsDiscardSelecting() == true then
self:SelectDiscardSlot(slot)
return
end
if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
return
end
if self.Hand == nil then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
local c = self.Cards[cardId]
if c == nil then
return
end
if c.unplayable == true then
self:Toast("사용할 수 없는 카드입니다")
return
end
if self.Energy < c.cost then
self:Toast("에너지가 부족합니다")
return
end
self.Energy = self.Energy - c.cost
self:ResolveCardEffects(cardId, c, false)
table.remove(self.Hand, slot)
if c.exhaust == true then
if self.ExhaustPile == nil then self.ExhaustPile = {} end
table.insert(self.ExhaustPile, cardId)
elseif c.kind ~= "Power" then
table.insert(self.DiscardPile, cardId)
end
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()
if self:BeginDiscardSelection(c) == true then
return
end
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()
self:CheckCombatEnd()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('OnCardButton', `if self:IsDiscardSelecting() == true then
self:SelectDiscardSlot(slot)
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('FindMonsterAtTouch', `local best = 0
local bestDist = 200
for i = 1, #self.Monsters do
local m = self.Monsters[i]
if m.alive == true and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
local wp = m.entity.TransformComponent.WorldPosition
local sp = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + 0.7))
local dx = sp.x - touchPoint.x
local dy = sp.y - touchPoint.y
local d = math.sqrt(dx * dx + dy * dy)
if d < bestDist then
bestDist = d
best = i
end
end
end
return best`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' }], 0, 'number'),
method('RenderTargetFrames', `local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
local shownTarget = self.TargetIndex
if dragActive == true then shownTarget = self.DragTargetIndex end
for i = 1, #self.Monsters do
local m = self.Monsters[i]
local active = false
if m ~= nil and m.alive == true and i == shownTarget then active = true end
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i) .. "/TargetFrame", active)
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i) .. "/TargetMarker", active and dragActive)
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i) .. "/TargetMarker/Label", active and dragActive)
end`),
method('OnCardDragBegin', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
return
end
if self.Hand == nil or self.Hand[slot] == nil then
return
end
if self.CardHoverTweenId ~= nil and self.CardHoverTweenId ~= 0 then
_TimerService:ClearTimer(self.CardHoverTweenId)
self.CardHoverTweenId = 0
end
for i = 1, 10 do
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
if e ~= nil and e.UITransformComponent ~= nil then
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
e.UITransformComponent.anchoredPosition = Vector2(self:GetHandSlotX(i), 0)
end
end
self.DragSlot = slot
self.DragTargetIndex = 0
self:RenderTargetFrames()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('OnCardDrag', `if self.DragSlot ~= slot then
return
end
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot))
if e ~= nil and e.UITransformComponent ~= nil then
local ui = _UILogic:ScreenToUIPosition(touchPoint)
e.UITransformComponent.anchoredPosition = Vector2(ui.x, ui.y + 360)
end
local cardId = self.Hand[slot]
local c = nil
if cardId ~= nil then c = self.Cards[cardId] end
if c ~= nil and c.kind == "Attack" then
local best = self:FindMonsterAtTouch(touchPoint)
if best ~= self.DragTargetIndex then
self.DragTargetIndex = best
self:RenderTargetFrames()
end
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' },
]),
method('OnCardDragEnd', `if self.DragSlot ~= slot then
return
end
self.DragSlot = 0
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot))
if e ~= nil and e.UITransformComponent ~= nil then
e.UITransformComponent.anchoredPosition = Vector2(self:GetHandSlotX(slot), 0)
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
end
self:ResolveCardDrop(slot, touchPoint)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' },
]),
method('ResolveCardDrop', `if self:IsDiscardSelecting() == true then
self:SelectDiscardSlot(slot)
return
end
if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
local c = self.Cards[cardId]
if c == nil then
return
end
if c.kind == "Attack" then
local best = self.DragTargetIndex or 0
if best <= 0 then best = self:FindMonsterAtTouch(touchPoint) end
self.DragTargetIndex = 0
if best > 0 then
self.TargetIndex = best
self:PlayCard(slot)
self:RenderTargetFrames()
else
self:RenderTargetFrames()
end
else
self.DragTargetIndex = 0
self:RenderTargetFrames()
local ui = _UILogic:ScreenToUIPosition(touchPoint)
if ui.y > -180 then
self:PlayCard(slot)
end
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'touchPoint' },
]),
method('Toast', `log(message)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'message' }]),
method('DealDamageToTarget', `local m = self.Monsters[self.TargetIndex]
if m == nil or m.alive ~= true then
m = nil
for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then m = self.Monsters[i]; self.TargetIndex = i; break end
end
end
if m == nil then
return
end
local dmg = amount
if m.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m.block > 0 and pierce ~= true then
local absorbed = math.min(m.block, dmg)
m.block = m.block - absorbed
dmg = dmg - absorbed
end
m.hp = m.hp - dmg
self:MonsterHitMotion(m.slot)
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
]),
method('PlayAttackFx', `local m = self.Monsters[targetIndex]
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
self:DealDamageToTarget(damage, pierce)
self:RenderCombat()
self:CheckCombatEnd()
return
end
self.FxBusy = true
local fx = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/SkillFx")
if fx ~= nil then
if fx.SpriteGUIRendererComponent ~= nil and image ~= nil and image ~= "" then
fx.SpriteGUIRendererComponent.ImageRUID = image
end
if fx.UITransformComponent ~= nil and m.entity.TransformComponent ~= nil then
local wp = m.entity.TransformComponent.WorldPosition
local sp = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + 0.7))
fx.UITransformComponent.anchoredPosition = _UILogic:ScreenToUIPosition(sp)
end
fx.Enable = true
end
_TimerService:SetTimerOnce(function()
if fx ~= nil then fx.Enable = false end
self.FxBusy = false
local shown = damage
local mt = self.Monsters[targetIndex]
if mt ~= nil and mt.alive == true and mt.vuln > 0 then
shown = math.floor(damage * 1.5)
end
self:DealDamageToTarget(damage, pierce)
self:ShowDmgPop(targetIndex, shown)
self:RenderCombat()
self:CheckCombatEnd()
end, 0.35)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'targetIndex' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'image' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pierce' },
]),
method('PlayAoeFx', `self.FxBusy = true
local fx = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/SkillFx")
if fx ~= nil then
if fx.SpriteGUIRendererComponent ~= nil and image ~= nil and image ~= "" then
fx.SpriteGUIRendererComponent.ImageRUID = image
end
if fx.UITransformComponent ~= nil then
fx.UITransformComponent.anchoredPosition = Vector2(300, 60)
end
fx.Enable = true
end
_TimerService:SetTimerOnce(function()
if fx ~= nil then fx.Enable = false end
self.FxBusy = false
for i = 1, #self.Monsters do
local m = self.Monsters[i]
if m ~= nil and m.alive == true then
local dmg = damage
if m.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m.block > 0 then
local absorbed = math.min(m.block, dmg)
m.block = m.block - absorbed
dmg = dmg - absorbed
end
m.hp = m.hp - dmg
self:ShowDmgPop(i, dmg)
self:MonsterHitMotion(i)
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
end
end
end
self:RenderCombat()
self:CheckCombatEnd()
end, 0.35)`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'image' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'damage' },
]),
method('KillMonster', `local m = self.Monsters[slot]
if m == nil then
return
end
m.alive = false
if m.entity ~= nil and isvalid(m.entity) then
local ent = m.entity
_TimerService:SetTimerOnce(function() if isvalid(ent) then ent:SetVisible(false) end end, 0.4)
end
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(slot), false)
for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then self.TargetIndex = i; break end
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('DealDamageToPlayer', `local dmg = amount
if self.PlayerBlock > 0 then
local absorbed = math.min(self.PlayerBlock, dmg)
self.PlayerBlock = self.PlayerBlock - absorbed
dmg = dmg - absorbed
end
if dmg > 0 then
self.PlayerHp = self.PlayerHp - dmg
local reflect = self.PlayerThorns or 0
if self:HasRelic("bronzeScales") then
reflect = reflect + 3
end
if reflect > 0 and attackerSlot ~= nil and attackerSlot > 0 then
local am = self.Monsters[attackerSlot]
if am ~= nil and am.alive == true then
am.hp = am.hp - reflect
self:ShowDmgPop(am.slot, reflect)
self:MonsterHitMotion(am.slot)
if am.hp <= 0 then
am.hp = 0
self:KillMonster(am.slot)
end
end
end
if self:HasRelic("selfFormingClay") then
self.ClayBlockNext = self.ClayBlockNext + 3
end
if self:HasRelic("centennialPuzzle") and self.FirstHpLossDone == false then
self.FirstHpLossDone = true
self:DrawCards(3)
self:RenderHand(false)
end
end
if self.PlayerHp < 0 then
self.PlayerHp = 0
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'attackerSlot' },
]),
method('EnemyTurn', `self.TurnBusy = true
self:EnemyActStep(1)`),
method('EnemyActStep', `local idx = 0
for i = fromIndex, #self.Monsters do
if self.Monsters[i].alive == true then idx = i; break end
end
if idx == 0 or self.PlayerHp <= 0 then
self:FinishEnemyTurn()
return
end
local m = self.Monsters[idx]
local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(idx)
self:SetEntityEnabled(base .. "/ActFrame", true)
_TimerService:SetTimerOnce(function()
if m.poison ~= nil and m.poison > 0 then
m.hp = m.hp - m.poison
self:ShowDmgPop(idx, m.poison)
self:MonsterHitMotion(idx)
m.poison = m.poison - 1
if m.hp <= 0 then
m.hp = 0
self:KillMonster(m.slot)
self:RenderCombat()
self:SetEntityEnabled(base .. "/ActFrame", false)
_TimerService:SetTimerOnce(function() self:EnemyActStep(idx + 1) end, 0.15)
return
end
end
m.block = 0
local intent = m.intents[m.intentIdx]
if intent ~= nil then
if intent.kind == "Attack" then
self:MonsterLunge(idx)
local atk = intent.value + m.str
if m.weak > 0 then
atk = math.floor(atk * 0.75)
end
if self.PlayerVuln > 0 then
atk = math.floor(atk * 1.5)
end
local before = self.PlayerHp
self:DealDamageToPlayer(atk, idx)
self:ShowPlayerDmgPop(before - self.PlayerHp)
self:PlayerHitMotion()
elseif intent.kind == "Defend" then
m.block = m.block + intent.value
elseif intent.kind == "Debuff" then
if intent.effect == "weak" then
self.PlayerWeak = self.PlayerWeak + intent.value
elseif intent.effect == "vuln" then
self.PlayerVuln = self.PlayerVuln + intent.value
end
elseif intent.kind == "AddCard" then
local cnt = intent.count or 1
for ci = 1, cnt do
table.insert(self.DiscardPile, intent.card)
end
self:RenderPiles()
local cn = intent.card
local cc = self.Cards[intent.card]
if cc ~= nil then cn = cc.name end
self:Toast(m.name .. ": " .. cn .. " 추가!")
end
end
if #m.intents > 0 then
m.intentIdx = math.random(1, #m.intents)
end
if m.weak > 0 then m.weak = m.weak - 1 end
if m.vuln > 0 then m.vuln = m.vuln - 1 end
self:RenderCombat()
self:SetEntityEnabled(base .. "/ActFrame", false)
_TimerService:SetTimerOnce(function() self:EnemyActStep(idx + 1) end, 0.15)
end, 0.45)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromIndex' }]),
method('FinishEnemyTurn', `self.TurnBusy = false
self:CheckCombatEnd()
if self.CombatOver == true then
return
end
_TimerService:SetTimerOnce(function() self:StartPlayerTurn() end, 0.45)`),
method('ClearCombatCards', `self.DrawPile = {}
self.DiscardPile = {}
self.ExhaustPile = {}
self.Hand = {}
self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self:UpdateDiscardPrompt()
self:RenderHand(false)
self:RenderPiles()`),
method('CheckCombatEnd', `local anyAlive = false
for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then anyAlive = true; break end
end
if anyAlive == false then
self.CombatOver = true
self:ClearCombatCards()
self.Gold = self.Gold + math.floor(${GOLD_PER_WIN} * self:AscGoldMult())
self:ApplyRelics("combatEnd")
self:ApplyRelics("combatReward")
self:MaybeDropPotion()
self:RenderRun()
local node = self.MapNodes[self.CurrentNodeId]
if node ~= nil and node.type == "elite" then
self.Gold = self.Gold + 15
local nid = self:PickNewRelic()
if nid ~= "" then
self:AddRelic(nid)
local nr = self.Relics[nid]
if nr ~= nil then
self:Toast("유물 획득: " .. nr.name)
end
end
end
if node ~= nil and node.type == "boss" then
if self.PlayerJob == "" and self.Floor < self.RunLength then
self:ShowJobChoice()
else
if self.PlayerJob ~= "" then self:AwardSouls(1) end
local bid = self:PickNewRelic()
if bid ~= "" then
self:AddRelic(bid)
local br = self.Relics[bid]
if br ~= nil then
self:Toast("유물 획득: " .. br.name)
end
end
self:ContinueAfterBoss()
end
else
self:OfferReward()
end
elseif self.PlayerHp <= 0 then
self.CombatOver = true
self:EndRun("패배...")
end`),
method('ContinueAfterBoss', `if self.Floor < self.RunLength then
self.Floor = self.Floor + 1
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self:GenerateMap()
self:RenderRun()
self:TeleportToActMap()
self:ShowMap()
else
self:EndRun("런 클리어!")
end`),
];

344
tools/deck/cb/deckturn.mjs Normal file
View File

@@ -0,0 +1,344 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const deckTurnMethods = [
method('Shuffle', `if list == nil then
\treturn
end
for i = #list, 2, -1 do
\tlocal j = math.random(1, i)
\tlist[i], list[j] = list[j], list[i]
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'list' }]),
method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/EndTurnButton")
if endTurn ~= nil and endTurn.ButtonComponent ~= nil then
if self.EndTurnHandler ~= nil then
endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)
self.EndTurnHandler = nil
end
self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)
end
local drawPile = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/DrawPile")
if drawPile ~= nil and drawPile.ButtonComponent ~= nil then
if self.DrawPileHandler ~= nil then
drawPile:DisconnectEvent(ButtonClickEvent, self.DrawPileHandler)
self.DrawPileHandler = nil
end
self.DrawPileHandler = drawPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("draw") end)
end
local discardPile = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/DiscardPile")
if discardPile ~= nil and discardPile.ButtonComponent ~= nil then
if self.DiscardPileHandler ~= nil then
discardPile:DisconnectEvent(ButtonClickEvent, self.DiscardPileHandler)
self.DiscardPileHandler = nil
end
self.DiscardPileHandler = discardPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("discard") end)
end
local exhaustPile = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/ExhaustPile")
if exhaustPile ~= nil and exhaustPile.ButtonComponent ~= nil then
if self.ExhaustPileHandler ~= nil then
exhaustPile:DisconnectEvent(ButtonClickEvent, self.ExhaustPileHandler)
self.ExhaustPileHandler = nil
end
self.ExhaustPileHandler = exhaustPile:ConnectEvent(ButtonClickEvent, function() self:OpenDeckInspect("exhaust") end)
end
local inspectClose = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud/Close")
if inspectClose ~= nil and inspectClose.ButtonComponent ~= nil then
if self.DeckInspectCloseHandler ~= nil then
inspectClose:DisconnectEvent(ButtonClickEvent, self.DeckInspectCloseHandler)
self.DeckInspectCloseHandler = nil
end
self.DeckInspectCloseHandler = inspectClose:ConnectEvent(ButtonClickEvent, function() self:CloseDeckInspect() end)
end
local allDeckButton = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/TopBar/AllDeckButton")
if allDeckButton ~= nil and allDeckButton.ButtonComponent ~= nil then
if self.AllDeckHandler ~= nil then
allDeckButton:DisconnectEvent(ButtonClickEvent, self.AllDeckHandler)
self.AllDeckHandler = nil
end
self.AllDeckHandler = allDeckButton:ConnectEvent(ButtonClickEvent, function() self:OpenAllDeck() end)
end
local allDeckClose = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud/Close")
if allDeckClose ~= nil and allDeckClose.ButtonComponent ~= nil then
if self.AllDeckCloseHandler ~= nil then
allDeckClose:DisconnectEvent(ButtonClickEvent, self.AllDeckCloseHandler)
self.AllDeckCloseHandler = nil
end
self.AllDeckCloseHandler = allDeckClose:ConnectEvent(ButtonClickEvent, function() self:CloseAllDeck() end)
end
self:BindClassDeckTabs()
for i = 1, 10 do
local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
if cardEntity ~= nil and cardEntity.UITouchReceiveComponent ~= nil then
local cardPath = "/ui/DefaultGroup/CardHand/Card" .. tostring(i)
cardEntity:ConnectEvent(UITouchEnterEvent, function() self:SetCardHover(cardPath, true) end)
cardEntity:ConnectEvent(UITouchExitEvent, function() self:SetCardHover(cardPath, false) end)
cardEntity:ConnectEvent(UITouchBeginDragEvent, function(ev) self:OnCardDragBegin(i) end)
cardEntity:ConnectEvent(UITouchDragEvent, function(ev) self:OnCardDrag(i, ev.TouchPoint) end)
cardEntity:ConnectEvent(UITouchEndDragEvent, function(ev) self:OnCardDragEnd(i, ev.TouchPoint) end)
cardEntity:ConnectEvent(UITouchEnterEvent, function() self:HoverCard(i) end)
cardEntity:ConnectEvent(UITouchExitEvent, function() self:UnhoverCard(i) end)
if cardEntity.ButtonComponent ~= nil then
cardEntity:ConnectEvent(ButtonClickEvent, function() self:OnCardButton(i) end)
end
end
end
for i = 1, 3 do
local rc = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Reward" .. tostring(i))
if rc ~= nil and rc.ButtonComponent ~= nil then
rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end)
if rc.UITouchReceiveComponent ~= nil then
local cardPath = "/ui/DefaultGroup/RewardHud/Reward" .. tostring(i)
rc:ConnectEvent(UITouchEnterEvent, function() self:SetCardHover(cardPath, true) end)
rc:ConnectEvent(UITouchExitEvent, function() self:SetCardHover(cardPath, false) end)
end
end
end
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 = {}
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)
if mn ~= nil and mn.ButtonComponent ~= nil then
mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end)
end
end
for i = 1, 3 do
local sc = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Card" .. tostring(i))
if sc ~= nil and sc.ButtonComponent ~= nil then
sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end)
if sc.UITouchReceiveComponent ~= nil then
local cardPath = "/ui/DefaultGroup/ShopHud/Card" .. tostring(i)
sc:ConnectEvent(UITouchEnterEvent, function() self:SetCardHover(cardPath, true) end)
sc:ConnectEvent(UITouchExitEvent, function() self:SetCardHover(cardPath, false) end)
end
end
end
local shopLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Leave")
if shopLeave ~= nil and shopLeave.ButtonComponent ~= nil then
shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end
local shopRelic = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Relic")
if shopRelic ~= nil and shopRelic.ButtonComponent ~= nil then
shopRelic:ConnectEvent(ButtonClickEvent, function() self:BuyRelic() end)
end
local restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave")
if restLeave ~= nil and restLeave.ButtonComponent ~= nil then
restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end)
end
for i = 1, ${MAX_MONSTERS} do
local ms = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i))
if ms ~= nil and ms.ButtonComponent ~= nil then
ms:ConnectEvent(ButtonClickEvent, function() self:SetTarget(i) end)
end
end
for i = 1, 10 do
local rs = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/TopBar/RelicSlot" .. tostring(i))
if rs ~= nil and rs.UITouchReceiveComponent ~= nil then
local idx = i
rs:ConnectEvent(UITouchEnterEvent, function()
local rid = nil
if self.RunRelics ~= nil then rid = self.RunRelics[idx] end
if rid ~= nil and self.Relics[rid] ~= nil then
self:ShowTooltip(self.Relics[rid].name, self.Relics[rid].desc, -240 + (idx - 1) * 48)
end
end)
rs:ConnectEvent(UITouchExitEvent, function() self:HideTooltip() end)
end
end
for i = 1, 5 do
local ps = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/TopBar/PotionSlot" .. tostring(i))
if ps ~= nil and ps.UITouchReceiveComponent ~= nil then
local idx = i
ps:ConnectEvent(UITouchEnterEvent, function()
local pid = nil
if self.RunPotions ~= nil then pid = self.RunPotions[idx] end
if pid ~= nil and self.Potions[pid] ~= nil then
self:ShowTooltip(self.Potions[pid].name, self.Potions[pid].desc, 240 + (idx - 1) * 44)
end
end)
ps:ConnectEvent(UITouchExitEvent, function() self:HideTooltip() end)
ps:ConnectEvent(UITouchDownEvent, function() self:OpenPotionMenu(idx) end)
end
end
local pmUse = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/PotionMenu/Use")
if pmUse ~= nil and pmUse.ButtonComponent ~= nil then
pmUse:ConnectEvent(ButtonClickEvent, function() self:UsePotion() end)
end
local pmToss = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/PotionMenu/Toss")
if pmToss ~= nil and pmToss.ButtonComponent ~= nil then
pmToss:ConnectEvent(ButtonClickEvent, function() self:TossPotion() end)
end
local pmClose = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/PotionMenu/Close")
if pmClose ~= nil and pmClose.ButtonComponent ~= nil then
pmClose:ConnectEvent(ButtonClickEvent, function() self:ClosePotionMenu() end)
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
local jcRelic = _EntityService:GetEntityByPath("/ui/DefaultGroup/JobChoiceHud/RelicButton")
if jcRelic ~= nil and jcRelic.ButtonComponent ~= nil then
jcRelic:ConnectEvent(ButtonClickEvent, function() self:PickJobReward("relic") end)
end
local jcJob = _EntityService:GetEntityByPath("/ui/DefaultGroup/JobChoiceHud/JobButton")
if jcJob ~= nil and jcJob.ButtonComponent ~= nil then
jcJob:ConnectEvent(ButtonClickEvent, function() self:PickJobReward("job") end)
end
for i = 1, 3 do
local slotIdx = i
local jb = _EntityService:GetEntityByPath("/ui/DefaultGroup/JobSelectHud/Job_slot" .. tostring(i))
if jb ~= nil and jb.ButtonComponent ~= nil then
jb:ConnectEvent(ButtonClickEvent, function()
if self.JobOpts ~= nil and self.JobOpts[slotIdx] ~= nil then
self:SetJob(self.JobOpts[slotIdx].id)
end
end)
end
end`),
method('StartPlayerTurn', `self.Turn = self.Turn + 1
self.Energy = self.MaxEnergy
self:ApplyRelics("turnStart")
self.PlayerBlock = 0
if self.ClayBlockNext > 0 then
self.PlayerBlock = self.PlayerBlock + self.ClayBlockNext
self.ClayBlockNext = 0
end
if self.PlayerPowers ~= nil then
for i = 1, #self.PlayerPowers do
local pc = self.Cards[self.PlayerPowers[i]]
if pc ~= nil then
if pc.powerEffect == "strengthPerTurn" then
self.PlayerStr = self.PlayerStr + pc.value
elseif pc.powerEffect == "energyPerTurn" then
self.Energy = self.Energy + pc.value
elseif pc.powerEffect == "blockPerTurn" then
self.PlayerBlock = self.PlayerBlock + pc.value
end
if pc.turnStartShiv ~= nil then
self:AddCardsToHand("Shiv", pc.turnStartShiv)
end
end
end
end
self:DrawCards(5)
self:RenderHand(true)
self:RenderCombat()`),
method('EndPlayerTurn', `if self.CombatOver == true or self.FxBusy == true or self.TurnBusy == true then
return
end
if self:IsDiscardSelecting() == true then
self:Toast("버릴 카드를 먼저 선택하세요")
return
end
local burn = 0
for bi = 1, #self.Hand do
\tlocal hc = self.Cards[self.Hand[bi]]
\tif hc ~= nil and hc.endTurnDamage ~= nil then burn = burn + hc.endTurnDamage end
end
if burn > 0 then
\tself.PlayerHp = self.PlayerHp - burn
\tif self.PlayerHp < 0 then self.PlayerHp = 0 end
\tself:ShowPlayerDmgPop(burn)
\tself:RenderCombat()
end
local kept = {}
for i = 1, #self.Hand do
\tlocal cardId = self.Hand[i]
\tlocal c = self.Cards[cardId]
\tif c ~= nil and c.retain == true then
\t\ttable.insert(kept, cardId)
\telse
\t\ttable.insert(self.DiscardPile, cardId)
\tend
end
self.Hand = kept
if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end
if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end
self:RenderHand(false)
self:RenderPiles()
self:EnemyTurn()`),
method('DrawCards', `local drawnSlots = {}
for i = 1, amount do
\tif #self.DrawPile <= 0 then
\t\tself:RecycleDiscardIntoDraw()
\tend
\tif #self.DrawPile <= 0 then
\t\tbreak
\tend
\tlocal cardId = table.remove(self.DrawPile)
\tif #self.Hand >= 10 then
\t\ttable.insert(self.DiscardPile, cardId)
\t\tself:TriggerSly(cardId)
\telse
\t\ttable.insert(self.Hand, cardId)
\t\tif #self.Hand <= 5 then
\t\t\ttable.insert(drawnSlots, #self.Hand)
\t\tend
\tend
end
self:RenderPiles()
if animate == true and #drawnSlots > 0 then
\tself:RenderHand(false)
\tlocal drawStart = Vector2(-590, 8)
\tfor i = 1, #drawnSlots do
\t\tlocal slot = drawnSlots[i]
\t\tself:AnimateCardFrom(slot, drawStart, Vector2(self:GetHandSlotX(slot), 0), 0.08 + i * 0.045)
\tend
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' },
]),
method('AddCardsToHand', `if self.Hand == nil then
self.Hand = {}
end
if self.DiscardPile == nil then
self.DiscardPile = {}
end
for i = 1, amount do
if #self.Hand >= 10 then
table.insert(self.DiscardPile, cardId)
else
table.insert(self.Hand, cardId)
end
end
self:RenderHand(false)
self:RenderPiles()`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
method('RecycleDiscardIntoDraw', `if self.DiscardPile == nil or #self.DiscardPile <= 0 then
\treturn
end
self.DrawPile = {}
for i = 1, #self.DiscardPile do
\tself.DrawPile[i] = self.DiscardPile[i]
end
self.DiscardPile = {}
self:Shuffle(self.DrawPile)`),
method('RenderPiles', `self:SetText("/ui/DefaultGroup/DeckHud/DrawPile/Count", self:FormatNumber(#self.DrawPile))
self:SetText("/ui/DefaultGroup/DeckHud/DiscardPile/Count", self:FormatNumber(#self.DiscardPile))
self:SetText("/ui/DefaultGroup/DeckHud/ExhaustPile/Count", self:FormatNumber(#(self.ExhaustPile or {})))
self:SetText("/ui/DefaultGroup/DeckHud/EnergyOrb/Value", string.format("%d", self.Energy) .. "/" .. string.format("%d", self.MaxEnergy))
local inspect = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud")
if inspect ~= nil and inspect.Enable == true and self.DeckInspectKind ~= "" then
self:OpenDeckInspect(self.DeckInspectKind)
end`),
];

229
tools/deck/cb/deckview.mjs Normal file
View File

@@ -0,0 +1,229 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const deckViewMethods = [
method('OpenDeckInspect', `self.DeckInspectKind = kind
if self.DeckAllOpen == true then
self.DeckAllOpen = false
local allHud = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud")
if allHud ~= nil then
allHud.Enable = false
end
end
local pile = {}
local title = ""
if kind == "discard" then
pile = self.DiscardPile or {}
title = "버린 덱"
elseif kind == "exhaust" then
pile = self.ExhaustPile or {}
title = "소멸 덱"
else
pile = self.DrawPile or {}
title = "뽑을 덱"
end
self:RenderDeckInspect(pile, title)
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud")
if hud ~= nil then
hud.Enable = true
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'kind' }]),
method('CloseDeckInspect', `self.DeckInspectKind = ""
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud")
if hud ~= nil then
hud.Enable = false
end`),
method('RenderDeckInspect', `local count = 0
if pile ~= nil then
count = #pile
end
local suffix = " (" .. tostring(count) .. ")"
if count > 60 then
suffix = suffix .. " - 60장까지 표시"
end
self:SetText("/ui/DefaultGroup/DeckInspectHud/Title", title .. suffix)
local empty = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud/Empty")
if empty ~= nil then
empty.Enable = count <= 0
end
for i = 1, 60 do
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud/Grid/Card" .. tostring(i))
if e ~= nil then
local cardId = nil
if pile ~= nil then
cardId = pile[i]
end
if cardId == nil then
e.Enable = false
else
e.Enable = true
self:ApplyInspectCardVisual(i, cardId)
end
end
end`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pile' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'title' },
]),
method('ApplyInspectCardVisual', `self:ApplyCardFace("/ui/DefaultGroup/DeckInspectHud/Grid/Card" .. tostring(slot), cardId)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('BindClassDeckTabs', `local warriorTab = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud/WarriorTab")
if warriorTab ~= nil and warriorTab.ButtonComponent ~= nil then
if self.WarriorDeckTabHandler ~= nil then
warriorTab:DisconnectEvent(ButtonClickEvent, self.WarriorDeckTabHandler)
self.WarriorDeckTabHandler = nil
end
self.WarriorDeckTabHandler = warriorTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("warrior") end)
end
local thiefTab = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud/ThiefTab")
if thiefTab ~= nil and thiefTab.ButtonComponent ~= nil then
if self.ThiefDeckTabHandler ~= nil then
thiefTab:DisconnectEvent(ButtonClickEvent, self.ThiefDeckTabHandler)
self.ThiefDeckTabHandler = nil
end
self.ThiefDeckTabHandler = thiefTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("bandit") end)
end
local mageTab = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud/MageTab")
if mageTab ~= nil and mageTab.ButtonComponent ~= nil then
if self.MageDeckTabHandler ~= nil then
mageTab:DisconnectEvent(ButtonClickEvent, self.MageDeckTabHandler)
self.MageDeckTabHandler = nil
end
self.MageDeckTabHandler = mageTab:ConnectEvent(ButtonClickEvent, function() self:SetClassDeckTab("magician") end)
end`),
method('OpenClassDeck', `self.CodexMode = false
self.ClassDeckMode = true
self.DeckAllOpen = true
self:SetClassDeckTab(className)
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud")
if hud ~= nil then
hud.Enable = true
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' }]),
method('SetClassDeckTab', `if self.ClassDeckMode ~= true then
return
end
self.ClassDeckCards = {}
self.ClassDeckTitle = "직업 덱"
if className ~= "warrior" and className ~= "magician" and className ~= "bandit" then
className = "bandit"
end
self.ClassDeckClass = className
local allowed = {}
if className == "warrior" then
allowed["warrior"] = true
allowed["fighter"] = true
allowed["page"] = true
allowed["spearman"] = true
self.ClassDeckTitle = "전사 전체 덱"
elseif className == "magician" then
allowed["magician"] = true
allowed["firepoison"] = true
allowed["icelightning"] = true
allowed["cleric"] = true
self.ClassDeckTitle = "마법사 전체 덱"
else
allowed["bandit"] = true
allowed["shiv"] = true
allowed["poisoner"] = true
allowed["trickster"] = true
self.ClassDeckTitle = "도적 전체 덱"
end
for id, c in pairs(self.Cards) do
if c ~= nil and c.curse ~= true and allowed[c.class] == true then
table.insert(self.ClassDeckCards, id)
end
end
table.sort(self.ClassDeckCards, function(a, b)
local ca = self.Cards[a]
local cb = self.Cards[b]
local na = a
local nb = b
if ca ~= nil and ca.name ~= nil then na = ca.name end
if cb ~= nil and cb.name ~= nil then nb = cb.name end
if na == nb then return a < b end
return na < nb
end)
self:RenderAllDeck()
self:RenderClassDeckTabs()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'className' }]),
method('RenderClassDeckTabs', `local tabs = {
{ path = "/ui/DefaultGroup/DeckAllHud/WarriorTab", cls = "warrior" },
{ path = "/ui/DefaultGroup/DeckAllHud/ThiefTab", cls = "bandit" },
{ path = "/ui/DefaultGroup/DeckAllHud/MageTab", cls = "magician" },
}
for i = 1, #tabs do
local e = _EntityService:GetEntityByPath(tabs[i].path)
if e ~= nil then
e.Enable = self.ClassDeckMode == true
if e.SpriteGUIRendererComponent ~= nil then
if self.ClassDeckClass == tabs[i].cls then
e.SpriteGUIRendererComponent.Color = Color(0.22, 0.28, 0.34, 1)
else
e.SpriteGUIRendererComponent.Color = Color(0.11, 0.13, 0.16, 1)
end
end
end
end`),
method('OpenAllDeck', `local inspectHud = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckInspectHud")
if inspectHud ~= nil then
inspectHud.Enable = false
end
self.DeckInspectKind = ""
self.ClassDeckMode = false
self.ClassDeckClass = ""
self:RenderClassDeckTabs()
self.DeckAllOpen = true
self:RenderAllDeck()
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud")
if hud ~= nil then
hud.Enable = true
end`),
method('CloseAllDeck', `self.DeckAllOpen = false
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud")
if hud ~= nil then
hud.Enable = false
end
if self.ClassDeckMode == true then
self.ClassDeckMode = false
self.ClassDeckCards = {}
self.ClassDeckTitle = ""
self.ClassDeckClass = ""
end
self:RenderClassDeckTabs()
if self.CodexMode == true then
self.CodexMode = false
self:ShowLobby()
end`),
method('RenderAllDeck', `local pile = self.RunDeck or {}
local title = "모든 덱"
if self.ClassDeckMode == true then
pile = self.ClassDeckCards or {}
title = self.ClassDeckTitle
elseif self.CodexMode == true then
pile = self.CodexCards or {}
title = "카드 도감"
end
local count = #pile
self:SetText("/ui/DefaultGroup/DeckAllHud/Title", title .. " (" .. tostring(count) .. ")")
self:RenderClassDeckTabs()
local empty = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud/Empty")
if empty ~= nil then
empty.Enable = count <= 0
end
for i = 1, 120 do
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckAllHud/Grid/Card" .. tostring(i))
if e ~= nil then
local cardId = pile[i]
if cardId == nil then
e.Enable = false
else
e.Enable = true
self:ApplyAllDeckCardVisual(i, cardId)
end
end
end`),
method('ApplyAllDeckCardVisual', `self:ApplyCardFace("/ui/DefaultGroup/DeckAllHud/Grid/Card" .. tostring(slot), cardId)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
];

401
tools/deck/cb/hand.mjs Normal file
View File

@@ -0,0 +1,401 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const handMethods = [
method('GetHandSlotX', `local n = 0
if self.Hand ~= nil then
n = #self.Hand
end
if n <= 0 then
return 0
end
local spacing = 175
if n > 8 then spacing = math.floor(1400 / n) end
local startX = -((n - 1) * spacing) / 2
return startX + (slot - 1) * spacing`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'number'),
method('RenderHand', `local n = #self.Hand
local spacing = 175
if n > 8 then spacing = math.floor(1400 / n) end
local startX = -((n - 1) * spacing) / 2
local drawStart = Vector2(-590, 8)
for i = 1, 10 do
\tlocal cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
\tif cardEntity ~= nil then
\t\tlocal cardId = self.Hand[i]
\t\tif cardId == nil then
\t\t\tcardEntity.Enable = false
\t\telse
\t\t\tcardEntity.Enable = true
\t\t\tif cardEntity.UITransformComponent ~= nil then cardEntity.UITransformComponent.UIScale = Vector3(1, 1, 1) end
\t\t\tself:ApplyCardVisual(i, cardId)
\t\t\tlocal tx = self:GetHandSlotX(i)
\t\t\tif animate == true then
\t\t\t\tself:AnimateCardFrom(i, drawStart, Vector2(tx, 0), 0.16 + i * 0.03)
\t\t\telse
\t\t\t\tif cardEntity.UITransformComponent ~= nil then cardEntity.UITransformComponent.anchoredPosition = Vector2(tx, 0) end
\t\t\tend
\t\tend
\tend
end
self:RenderPiles()`, [{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'animate' }]),
method('ApplyCardFace', `local c = self.Cards[cardId]
if c == nil then
c = { name = cardId, cost = 0, desc = "", kind = "Skill", class = "warrior", rarity = "normal" }
end
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
if e.UITransformComponent ~= nil then
e.UITransformComponent.UIScale = Vector3(1, 1, 1)
end
local frames = self.CardFrames[self.ClassToFrame[c.class] or "warrior"]
local ruid = nil
if frames ~= nil then
ruid = frames[c.rarity or "normal"]
end
if ruid ~= nil then
e.SpriteGUIRendererComponent.ImageRUID = ruid
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
end
end
self:SetText(base .. "/Cost", string.format("%d", c.cost))
self:SetText(base .. "/Name", c.name)
self:SetText(base .. "/Desc", c.desc)
local art = _EntityService:GetEntityByPath(base .. "/Art")
if art ~= nil then
if c.image ~= nil and c.image ~= "" then
art.Enable = true
if art.SpriteGUIRendererComponent ~= nil then
art.SpriteGUIRendererComponent.ImageRUID = c.image
end
else
art.Enable = false
end
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('SetCardHover', `local prefix = ""
local count = 0
local xs = {}
local baseY = 0
local hoverIndex = 0
local push = 110
if string.find(path, "/ui/DefaultGroup/CardHand/Card") == 1 then
if self.DragSlot ~= nil and self.DragSlot > 0 then
return
end
prefix = "/ui/DefaultGroup/CardHand/Card"
count = 0
if self.Hand ~= nil then count = #self.Hand end
for i = 1, count do
xs[i] = self:GetHandSlotX(i)
end
baseY = 0
hoverIndex = tonumber(string.match(path, "Card(%d+)")) or 0
elseif string.find(path, "/ui/DefaultGroup/RewardHud/Reward") == 1 then
prefix = "/ui/DefaultGroup/RewardHud/Reward"
count = 3
xs = { -300, 0, 300 }
baseY = 0
hoverIndex = tonumber(string.match(path, "Reward(%d+)")) or 0
elseif string.find(path, "/ui/DefaultGroup/ShopHud/Card") == 1 then
prefix = "/ui/DefaultGroup/ShopHud/Card"
count = 3
xs = { -300, 0, 300 }
baseY = 20
hoverIndex = tonumber(string.match(path, "Card(%d+)")) or 0
end
if count <= 0 then
return
end
if self.CardHoverTweenId ~= nil and self.CardHoverTweenId ~= 0 then
_TimerService:ClearTimer(self.CardHoverTweenId)
self.CardHoverTweenId = 0
end
local items = {}
for i = 1, count do
local e = _EntityService:GetEntityByPath(prefix .. tostring(i))
if e ~= nil and e.UITransformComponent ~= nil then
local tr = e.UITransformComponent
local tx = xs[i]
local ty = baseY
local sc = 1
if hover == true and hoverIndex > 0 then
if i == hoverIndex and e.Enable == true then
sc = 1.5
elseif i < hoverIndex then
tx = tx - push
elseif i > hoverIndex then
tx = tx + push
end
end
table.insert(items, { tr = tr, sx = tr.anchoredPosition.x, sy = tr.anchoredPosition.y, ss = tr.UIScale.x, tx = tx, ty = ty, ts = sc })
end
end
local elapsed = 0
local duration = 0.12
local eventId = 0
eventId = _TimerService:SetTimerRepeat(function()
elapsed = elapsed + 1 / 60
local t = math.min(elapsed / duration, 1)
local eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseOut, t)
for i = 1, #items do
local it = items[i]
local x = it.sx + (it.tx - it.sx) * eased
local y = it.sy + (it.ty - it.sy) * eased
local s = it.ss + (it.ts - it.ss) * eased
it.tr.anchoredPosition = Vector2(x, y)
it.tr.UIScale = Vector3(s, s, 1)
end
if t >= 1 then
_TimerService:ClearTimer(eventId)
if self.CardHoverTweenId == eventId then
self.CardHoverTweenId = 0
end
end
end, 1 / 60)
self.CardHoverTweenId = eventId`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hover' },
]),
method('ApplyCardVisual', `self:ApplyCardFace("/ui/DefaultGroup/CardHand/Card" .. tostring(slot), cardId)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('SetText', `local entity = _EntityService:GetEntityByPath(path)
if entity ~= nil and entity.TextComponent ~= nil then
\tentity.TextComponent.Text = value
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'value' },
]),
method('FormatNumber', `if value == nil then
return ""
end
local n = tonumber(value)
if n == nil then
return tostring(value)
end
if math.abs(n - math.floor(n)) < 0.00001 then
return string.format("%d", math.floor(n))
end
return tostring(n)`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'value' }], 0, 'string'),
method('AnimateCardFrom', `local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(slot))
if cardEntity == nil or cardEntity.UITransformComponent == nil then
\treturn
end
local tr = cardEntity.UITransformComponent
tr.anchoredPosition = fromPos
local elapsed = 0
local eventId = 0
eventId = _TimerService:SetTimerRepeat(function()
\telapsed = elapsed + 1 / 60
\tlocal t = math.min(elapsed / duration, 1)
\tlocal eased = _TweenLogic:Ease(0, 1, 1, EaseType.SineEaseOut, t)
\ttr.anchoredPosition = Vector2(fromPos.x + (toPos.x - fromPos.x) * eased, fromPos.y + (toPos.y - fromPos.y) * eased)
\tif t >= 1 then
\t\t_TimerService:ClearTimer(eventId)
\tend
end, 1 / 60)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'fromPos' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'toPos' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'duration' },
]),
method('AddCardBlock', `local amount = base or 0
if amount > 0 and self.PlayerDex ~= nil then
amount = amount + self.PlayerDex
end
if amount < 0 then
amount = 0
end
self.PlayerBlock = self.PlayerBlock + amount
return amount`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
method('CalcPlayerAttack', `local base2 = base
self.FightAttackCount = self.FightAttackCount + 1
if self.FightAttackCount == 1 and self:HasRelic("akabeko") then
base2 = base2 + 8
end
local dmg = base2 + self.PlayerStr
if self:HasRelic("penNib") and self.FightAttackCount % 10 == 0 then
dmg = dmg * 2
end
if self.PlayerWeak > 0 then
dmg = math.floor(dmg * 0.75)
end
if dmg > 0 and dmg < 5 and self:HasRelic("boot") then
dmg = 5
end
if dmg < 0 then
dmg = 0
end
return dmg`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'base' }], 0, 'number'),
method('ResolveCardEffects', `if c == nil then
return
end
if c.kind == "Attack" then
if c.damage ~= nil then
self:PlayerAttackMotion()
local total = 0
local hitN = c.hits or 1
for h = 1, hitN do
total = total + self:CalcPlayerAttack(c.damage)
end
if c.aoe == true then
self:PlayAoeFx(c.fx or c.image, total)
else
self:PlayAttackFx(self.TargetIndex, c.fx or c.image, total, c.pierce == true)
end
end
if c.block ~= nil then
self:AddCardBlock(c.block)
end
if free ~= true then
self:ApplyRelics("cardPlayed")
end
elseif c.kind == "Skill" then
if c.block ~= nil then
self:AddCardBlock(c.block)
end
elseif c.kind == "Power" then
if free ~= true then
table.insert(self.PlayerPowers, cardId)
end
end
if c.strength ~= nil then
self.PlayerStr = self.PlayerStr + c.strength
end
if c.dex ~= nil then
self.PlayerDex = self.PlayerDex + c.dex
end
if c.thorns ~= nil then
self.PlayerThorns = self.PlayerThorns + c.thorns
end
if c.selfVuln ~= nil then
self.PlayerVuln = self.PlayerVuln + c.selfVuln
end
if c.heal ~= nil then
self.PlayerHp = math.min(self.PlayerHp + c.heal, self.PlayerMaxHp)
end
if c.weak ~= nil or c.vuln ~= nil or c.poison ~= nil then
local tm = self.Monsters[self.TargetIndex]
if tm == nil or tm.alive ~= true then
for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then tm = self.Monsters[i]; self.TargetIndex = i; break end
end
end
if tm ~= nil and tm.alive == true then
if c.weak ~= nil then tm.weak = tm.weak + c.weak end
if c.poison ~= nil then tm.poison = (tm.poison or 0) + c.poison end
if c.vuln ~= nil then
tm.vuln = tm.vuln + c.vuln
if self:HasRelic("championBelt") then
tm.weak = tm.weak + 1
end
end
end
end
if c.draw ~= nil then
self:DrawCards(c.draw, true)
end
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
self:AddCardsToHand("Shiv", c.addShiv)
end`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'free' },
]),
method('TriggerSly', `local c = self.Cards[cardId]
if c == nil or c.sly ~= true then
return
end
self:Toast("교활 발동: " .. c.name)
self:ResolveCardEffects(cardId, c, true)`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' }]),
method('DiscardHandCard', `if self.Hand == nil then
return
end
local cardId = self.Hand[slot]
if cardId == nil then
return
end
table.remove(self.Hand, slot)
table.insert(self.DiscardPile, cardId)
if triggerSly == true then
self:TriggerSly(cardId)
end`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'triggerSly' },
]),
method('IsDiscardSelecting', `return self.DiscardSelectRemaining ~= nil and self.DiscardSelectRemaining > 0`, [], 0, 'boolean'),
method('UpdateDiscardPrompt', `local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/DiscardPrompt")
if e == nil then
return
end
if self:IsDiscardSelecting() == true then
local picked = self.DiscardSelectTotal - self.DiscardSelectRemaining
self:SetText("/ui/DefaultGroup/CombatHud/DiscardPrompt", "버릴 카드 선택 " .. self:FormatNumber(picked + 1) .. "/" .. self:FormatNumber(self.DiscardSelectTotal))
e.Enable = true
else
e.Enable = false
end`),
method('BeginDiscardSelection', `if c == nil or self.Hand == nil then
return false
end
local n = 0
if c.discardAll == true then
n = #self.Hand
elseif c.discard ~= nil then
n = math.min(c.discard, #self.Hand)
end
if n <= 0 then
return false
end
self.DiscardSelectRemaining = n
self.DiscardSelectTotal = n
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
if c.addShiv ~= nil then
self.DiscardPostShiv = c.addShiv
end
if c.addShivPerDiscard == true then
self.DiscardShivPerPick = 1
end
self:UpdateDiscardPrompt()
self:Toast("버릴 카드를 선택하세요")
return true`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'c' }], 0, 'boolean'),
method('FinishDiscardSelection', `self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
local shivCount = self.DiscardPostShiv or 0
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self:UpdateDiscardPrompt()
if shivCount > 0 then
self:AddCardsToHand("Shiv", shivCount)
end
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()
self:CheckCombatEnd()`),
method('SelectDiscardSlot', `if self:IsDiscardSelecting() ~= true then
return false
end
if self.Hand == nil or self.Hand[slot] == nil then
return true
end
local discarded = self.Hand[slot]
self:DiscardHandCard(slot, true)
if discarded ~= nil and self.DiscardShivPerPick ~= nil and self.DiscardShivPerPick > 0 then
self.DiscardPostShiv = (self.DiscardPostShiv or 0) + self.DiscardShivPerPick
end
self.DiscardSelectRemaining = self.DiscardSelectRemaining - 1
if self.DiscardSelectRemaining <= 0 or #self.Hand <= 0 then
self:FinishDiscardSelection()
else
self:UpdateDiscardPrompt()
self:RenderHand(false)
self:RenderPiles()
self:RenderCombat()
end
return true`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }], 0, 'boolean'),
];

213
tools/deck/cb/items.mjs Normal file
View File

@@ -0,0 +1,213 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const itemMethods = [
method('HasRelic', `if self.RunRelics == nil then
return false
end
for i = 1, #self.RunRelics do
if self.RunRelics[i] == id then
return true
end
end
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'),
method('ApplyRelics', `if self.RunRelics == nil then
return
end
for i = 1, #self.RunRelics do
local r = self.Relics[self.RunRelics[i]]
if r ~= nil and r.hook == hook then
if r.effect == "block" then
self.PlayerBlock = self.PlayerBlock + r.value
elseif r.effect == "energy" then
self.Energy = self.Energy + r.value
elseif r.effect == "strength" then
self.PlayerStr = self.PlayerStr + r.value
elseif r.effect == "draw" then
self:DrawCards(r.value)
self:RenderHand(false)
elseif r.effect == "heal" or r.effect == "healOnAttack" or r.effect == "healOnWin" then
self.PlayerHp = self.PlayerHp + r.value
if self.PlayerHp > self.PlayerMaxHp then
self.PlayerHp = self.PlayerMaxHp
end
elseif r.effect == "healIfLow" then
if self.PlayerHp * 2 <= self.PlayerMaxHp then
self.PlayerHp = self.PlayerHp + r.value
if self.PlayerHp > self.PlayerMaxHp then
self.PlayerHp = self.PlayerMaxHp
end
end
elseif r.effect == "gold" then
self.Gold = self.Gold + r.value
end
end
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hook' }]),
method('AddRelic', `if self.RunRelics == nil then
self.RunRelics = {}
end
table.insert(self.RunRelics, id)
local r = self.Relics[id]
if r ~= nil and r.hook == "passive" then
if r.effect == "potionSlots" then
self.PotionSlots = r.value
self:RenderPotions()
elseif r.effect == "maxHp" then
self.PlayerMaxHp = self.PlayerMaxHp + r.value
self.PlayerHp = self.PlayerHp + r.value
end
end
self:RenderRelics()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
method('PickNewRelic', `local pool = {}
for i = 1, #self.RelicPool do
if self:HasRelic(self.RelicPool[i]) == false then
table.insert(pool, self.RelicPool[i])
end
end
if #pool == 0 then
self.Gold = self.Gold + 25
self:Toast("유물을 모두 모았습니다! 메소 +25")
return ""
end
return pool[math.random(1, #pool)]`, [], 0, 'string'),
method('AddPotion', `if self.RunPotions == nil then
self.RunPotions = {}
end
if #self.RunPotions >= self.PotionSlots then
self:Toast("물약 슬롯이 가득 찼습니다")
return false
end
table.insert(self.RunPotions, pid)
self:RenderPotions()
return true`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'pid' }], 0, 'boolean'),
method('MaybeDropPotion', `if math.random() > ${POTIONS.dropChance} then
return
end
local keys = {}
for pid, _ in pairs(self.Potions) do
table.insert(keys, pid)
end
table.sort(keys)
local pid = keys[math.random(1, #keys)]
if self:AddPotion(pid) == true then
local p = self.Potions[pid]
self:Toast("물약 획득: " .. p.name)
end`),
method('RenderPotions', `for i = 1, 5 do
local base = "/ui/DefaultGroup/CombatHud/TopBar/PotionSlot" .. tostring(i)
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
local pid = nil
if self.RunPotions ~= nil then
pid = self.RunPotions[i]
end
if pid ~= nil and self.Potions[pid] ~= nil then
e.SpriteGUIRendererComponent.ImageRUID = self.Potions[pid].icon
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
elseif i > self.PotionSlots then
e.SpriteGUIRendererComponent.ImageRUID = ""
e.SpriteGUIRendererComponent.Color = Color(0.1, 0.1, 0.12, 0.85)
else
e.SpriteGUIRendererComponent.ImageRUID = ""
e.SpriteGUIRendererComponent.Color = Color(0.22, 0.25, 0.3, 0.9)
end
end
end`),
method('OpenPotionMenu', `if self.RunPotions == nil or self.RunPotions[slot] == nil then
return
end
self.PotionMenuSlot = slot
local pid = self.RunPotions[slot]
local p = self.Potions[pid]
if p ~= nil then
self:SetText("/ui/DefaultGroup/CombatHud/PotionMenu/Title", p.name .. " — " .. p.desc)
end
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PotionMenu", true)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('ClosePotionMenu', `self.PotionMenuSlot = 0
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PotionMenu", false)`),
method('UsePotion', `if self.PotionMenuSlot <= 0 then
return
end
if self.CombatOver == true or self.TurnBusy == true or self.FxBusy == true then
self:Toast("지금은 사용할 수 없습니다")
return
end
local combat = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud")
local hand = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand")
if combat == nil or combat.Enable ~= true or hand == nil or hand.Enable ~= true then
self:Toast("전투 중에만 사용할 수 있습니다")
return
end
local pid = self.RunPotions[self.PotionMenuSlot]
if pid == nil then
return
end
local p = self.Potions[pid]
if p == nil then
return
end
if p.effect == "heal" then
self.PlayerHp = math.min(self.PlayerHp + p.value, self.PlayerMaxHp)
elseif p.effect == "damage" then
self:DealDamageToTarget(p.value, false)
self:ShowDmgPop(self.TargetIndex, p.value)
elseif p.effect == "strength" then
self.PlayerStr = self.PlayerStr + p.value
elseif p.effect == "block" then
self.PlayerBlock = self.PlayerBlock + p.value
elseif p.effect == "energy" then
self.Energy = self.Energy + p.value
elseif p.effect == "weak" then
local tm = self.Monsters[self.TargetIndex]
if tm ~= nil and tm.alive == true then
tm.weak = tm.weak + p.value
end
end
table.remove(self.RunPotions, self.PotionMenuSlot)
self:Toast("물약 사용: " .. p.name)
self:ClosePotionMenu()
self:RenderPotions()
self:RenderPiles()
self:RenderCombat()
self:CheckCombatEnd()`),
method('TossPotion', `if self.PotionMenuSlot <= 0 then
return
end
local pid = self.RunPotions[self.PotionMenuSlot]
if pid ~= nil then
local p = self.Potions[pid]
table.remove(self.RunPotions, self.PotionMenuSlot)
if p ~= nil then
self:Toast("물약 버림: " .. p.name)
end
end
self:ClosePotionMenu()
self:RenderPotions()`),
method('RenderRelics', `local count = 0
if self.RunRelics ~= nil then
count = #self.RunRelics
end
for i = 1, 10 do
local base = "/ui/DefaultGroup/CombatHud/TopBar/RelicSlot" .. tostring(i)
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
local rid = nil
if self.RunRelics ~= nil then
rid = self.RunRelics[i]
end
if rid ~= nil and self.Relics[rid] ~= nil and (i < 10 or count <= 10) then
e.SpriteGUIRendererComponent.ImageRUID = self.Relics[rid].icon
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
else
e.SpriteGUIRendererComponent.ImageRUID = ""
e.SpriteGUIRendererComponent.Color = Color(0.15, 0.16, 0.2, 0.6)
end
end
end
local of = ""
if count > 10 then
of = "+" .. tostring(count - 9)
end
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/RelicOverflow", of)`),
];

79
tools/deck/cb/jobs.mjs Normal file
View File

@@ -0,0 +1,79 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const jobMethods = [
method('ShowJobChoice', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false)
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false)
self:SetEntityEnabled("/ui/DefaultGroup/JobChoiceHud", true)`),
method('PickJobReward', `self:SetEntityEnabled("/ui/DefaultGroup/JobChoiceHud", false)
if kind == "relic" then
local bid = self:PickNewRelic()
if bid ~= "" then
self:AddRelic(bid)
local br = self.Relics[bid]
if br ~= nil then
self:Toast("유물 획득: " .. br.name)
end
end
self:ContinueAfterBoss()
else
self:ShowJobSelect()
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'kind' }]),
method('ShowJobSelect', `local opts = self.Jobs[self.SelectedClass]
if opts == nil then
opts = self.Jobs["warrior"]
end
self.JobOpts = opts
for i = 1, 3 do
local base = "/ui/DefaultGroup/JobSelectHud/Job_slot" .. tostring(i)
local o = opts[i]
if o ~= nil then
self:SetEntityEnabled(base, true)
self:SetText(base .. "/Name", o.name)
self:SetText(base .. "/Desc", o.desc)
local sc = self.Cards[o.starter]
if sc ~= nil then
self:SetText(base .. "/Starter", "대표 카드: " .. sc.name)
end
else
self:SetEntityEnabled(base, false)
end
end
self:SetEntityEnabled("/ui/DefaultGroup/JobSelectHud", true)`),
method('JobLabel', `if self.PlayerJob ~= "" and self.Jobs ~= nil then
for cls, list in pairs(self.Jobs) do
for i = 1, #list do
if list[i].id == self.PlayerJob then
return list[i].name
end
end
end
end
if self.SelectedClass == "warrior" then
return "전사"
elseif self.SelectedClass == "bandit" then
return "도적"
elseif self.SelectedClass == "magician" then
return "마법사"
end
return "플레이어"`, [], 0, 'string'),
method('SetJob', `self.PlayerJob = jobId
local starter = ""
local opts = self.Jobs[self.SelectedClass] or {}
for i = 1, #opts do
if opts[i].id == jobId then
starter = opts[i].starter
end
end
if starter ~= "" then
table.insert(self.RunDeck, starter)
local sc = self.Cards[starter]
if sc ~= nil then
self:Toast("2차 전직: " .. self:JobLabel() .. "! 신규 카드 — " .. sc.name)
end
end
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
self:SetEntityEnabled("/ui/DefaultGroup/JobSelectHud", false)
self:ContinueAfterBoss()`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'jobId' }]),
];

230
tools/deck/cb/map.mjs Normal file
View File

@@ -0,0 +1,230 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const mapMethods = [
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
-- 부모 노드 타입 수집 (rest/shop/elite 는 부모와 같은 타입 연속 금지)
local parentTypes = {}
for pid, pn in pairs(self.MapNodes) do
if pn.row == r - 1 then
for i = 1, #pn.next do
if pn.next[i] == id then parentTypes[pn.type] = 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
local t = w[i][1]
if (t == "elite" or t == "rest" or t == "shop") and parentTypes[t] == 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
else
local node = self.MapNodes[self.CurrentNodeId]
if node == nil then
return false
end
list = node.next
end
for i = 1, #list do
if list[i] == id then
return true
end
end
return false`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }], 0, 'boolean'),
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 ruid = self.NodeIcons[node.type]
if ruid == nil then
ruid = self.NodeIcons["combat"]
end
if e.SpriteGUIRendererComponent ~= nil and ruid ~= nil then
e.SpriteGUIRendererComponent.ImageRUID = ruid
end
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(1, 0.82, 0.3, 1)
elseif visited == true then
e.SpriteGUIRendererComponent.Color = Color(0.5, 0.5, 0.55, 0.9)
elseif reachable == true then
e.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
else
e.SpriteGUIRendererComponent.Color = Color(0.68, 0.68, 0.72, 0.85)
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
d.SpriteGUIRendererComponent.Color = Color(0.5, 0.5, 0.55, 0.8)
end
end
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
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
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 = ""
self:StartCombat()
end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]),
];

308
tools/deck/cb/render.mjs Normal file
View File

@@ -0,0 +1,308 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const renderMethods = [
method('BuffsLabel', `local parts = {}
if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end
if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end
if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end
if poison ~= nil and poison > 0 then table.insert(parts, "독" .. tostring(poison)) end
return table.concat(parts, " ")`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'str' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'weak' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'vuln' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'poison' },
], 0, 'string'),
method('RenderCombat', `for i = 1, ${MAX_MONSTERS} do
local base = "/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(i)
local m = self.Monsters[i]
if m ~= nil and m.alive == true then
self:SetEntityEnabled(base, true)
self:SetText(base .. "/Name", m.name)
self:SetText(base .. "/Hp", string.format("%d", m.hp) .. "/" .. string.format("%d", m.maxHp))
local intent = m.intents[m.intentIdx]
local t = ""
if intent ~= nil then
if intent.kind == "Attack" then
local atk = intent.value + m.str
if m.weak > 0 then atk = math.floor(atk * 0.75) end
if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end
t = "공격 " .. tostring(atk)
elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value)
elseif intent.kind == "Debuff" then
if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여"
else t = "취약 " .. tostring(intent.value) .. " 부여" end
elseif intent.kind == "AddCard" then
t = "저주 카드 추가"
end
end
self:SetText(base .. "/Intent", t)
local dragActive = self.DragTargetIndex ~= nil and self.DragTargetIndex > 0
local shownTarget = self.TargetIndex
if dragActive == true then shownTarget = self.DragTargetIndex end
self:SetEntityEnabled(base .. "/TargetFrame", i == shownTarget)
self:SetEntityEnabled(base .. "/TargetMarker", i == shownTarget and dragActive)
self:SetEntityEnabled(base .. "/TargetMarker/Label", i == shownTarget and dragActive)
local intentEntity = _EntityService:GetEntityByPath(base .. "/Intent")
if intentEntity ~= nil and intentEntity.TextComponent ~= nil and intent ~= nil then
if intent.kind == "Attack" then
intentEntity.TextComponent.FontColor = Color(1, 0.45, 0.35, 1)
elseif intent.kind == "Debuff" then
intentEntity.TextComponent.FontColor = Color(0.8, 0.5, 1, 1)
elseif intent.kind == "AddCard" then
intentEntity.TextComponent.FontColor = Color(0.6, 0.85, 0.4, 1)
else
intentEntity.TextComponent.FontColor = Color(0.5, 0.75, 1, 1)
end
end
self:SetHpBar(base .. "/HpBarFill", m.hp, m.maxHp, ${HP_BAR_W})
self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0)
self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block))
self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln, m.poison or 0))
else
self:SetEntityEnabled(base, false)
end
end
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/HpText", string.format("%d", self.PlayerHp) .. "/" .. string.format("%d", self.PlayerMaxHp))
self:SetHpBar("/ui/DefaultGroup/CombatHud/PlayerPanel/HpBarFill", self.PlayerHp, self.PlayerMaxHp, 220)
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge", self.PlayerBlock > 0)
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/BlockBadge/Value", string.format("%d", self.PlayerBlock))
local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln, 0)
if self.PlayerDex ~= nil and self.PlayerDex > 0 then
if pb ~= "" then pb = pb .. " " end
pb = pb .. "민첩+" .. tostring(self.PlayerDex)
end
if self.PlayerThorns ~= nil and self.PlayerThorns > 0 then
if pb ~= "" then pb = pb .. " " end
pb = pb .. "가시" .. tostring(self.PlayerThorns)
end
if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then
local names = {}
for i = 1, #self.PlayerPowers do
local pc = self.Cards[self.PlayerPowers[i]]
if pc ~= nil then table.insert(names, pc.name) end
end
if pb ~= "" then pb = pb .. " · " end
pb = pb .. table.concat(names, " ")
end
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Buffs", pb)
self:RenderRun()`),
method('ShowDmgPop', `local slotKey = string.format("%d", math.floor(slot or 0))
local base = "/ui/DefaultGroup/CombatHud/DmgPop" .. slotKey
local pop = _EntityService:GetEntityByPath(base)
if pop == nil then
return
end
self.DmgPopSeq = (self.DmgPopSeq or 0) + 1
local popSeq = self.DmgPopSeq
self:SetText(base, "")
local damageDigitRuids = { ${DAMAGE_DIGIT_RUIDS.map(luaStr).join(', ')} }
local shown = tostring(math.max(0, math.floor(amount)))
if string.len(shown) > ${DAMAGE_POP_MAX_DIGITS} then
shown = string.sub(shown, 1, ${DAMAGE_POP_MAX_DIGITS})
end
local digits = {}
for i = 1, string.len(shown) do
table.insert(digits, tonumber(string.sub(shown, i, i)) or 0)
end
local totalW = #digits * ${DAMAGE_POP_DIGIT_W} + math.max(0, #digits - 1) * ${DAMAGE_POP_DIGIT_SPACING}
local startX = -totalW / 2 + ${DAMAGE_POP_DIGIT_W} / 2
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
self:SetEntityEnabled(base .. "/Digit" .. tostring(i), false)
end
for i = 1, ${DAMAGE_POP_MAX_DIGITS} do
local digitPath = base .. "/Digit" .. tostring(i)
local digitEntity = _EntityService:GetEntityByPath(digitPath)
if digitEntity ~= nil and digitEntity.SpriteGUIRendererComponent ~= nil then
if digits[i] ~= nil then
digitEntity.SpriteGUIRendererComponent.ImageRUID = damageDigitRuids[digits[i] + 1]
digitEntity.SpriteGUIRendererComponent.Color = Color(1, 1, 1, 1)
if digitEntity.UITransformComponent ~= nil then
digitEntity.UITransformComponent.anchoredPosition = Vector2(startX + (i - 1) * (${DAMAGE_POP_DIGIT_W} + ${DAMAGE_POP_DIGIT_SPACING}), 0)
end
self:SetEntityEnabled(digitPath, true)
else
self:SetEntityEnabled(digitPath, false)
end
end
end
local popPos = nil
local m = self.Monsters[slot]
if m ~= nil and m.entity ~= nil and isvalid(m.entity) and m.entity.TransformComponent ~= nil then
local wp = m.entity.TransformComponent.WorldPosition
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y + 0.45}))
popPos = _UILogic:ScreenToUIPosition(screen)
else
local slotEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/MonsterSlot" .. slotKey)
if slotEntity ~= nil and slotEntity.UITransformComponent ~= nil then
local sp = slotEntity.UITransformComponent.anchoredPosition
popPos = Vector2(sp.x, sp.y + 76)
end
end
if pop ~= nil and pop.UITransformComponent ~= nil then
if popPos ~= nil then
pop.UITransformComponent.anchoredPosition = popPos
else
pop.UITransformComponent.anchoredPosition = Vector2(0, 120)
end
end
self:SetEntityEnabled(base, true)
for i = 1, 6 do
_TimerService:SetTimerOnce(function()
if self.DmgPopSeq ~= popSeq then
return
end
local p = _EntityService:GetEntityByPath(base)
if p ~= nil and p.UITransformComponent ~= nil then
local cur = p.UITransformComponent.anchoredPosition
p.UITransformComponent.anchoredPosition = Vector2(cur.x, cur.y + 7)
end
end, 0.045 * i)
end
_TimerService:SetTimerOnce(function()
if self.DmgPopSeq ~= popSeq then
return
end
self:SetEntityEnabled(base, false)
end, 0.48)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
]),
method('ShowPlayerDmgPop', `local base = "/ui/DefaultGroup/CombatHud/PlayerPanel/DmgPop"
if amount > 0 then
self:SetText(base, "-" .. string.format("%d", amount))
else
self:SetText(base, "막음")
end
self:SetEntityEnabled(base, true)
_TimerService:SetTimerOnce(function() self:SetEntityEnabled(base, false) end, 0.6)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }]),
method('PlayerAttackMotion', `local lp = _UserService.LocalPlayer
if lp == nil then
return
end
if lp.StateComponent == nil then
return
end
pcall(function() lp.StateComponent:ChangeState("ATTACK") end)
_TimerService:SetTimerOnce(function()
if lp ~= nil and isvalid(lp) and lp.StateComponent ~= nil then
pcall(function() lp.StateComponent:ChangeState("IDLE") end)
end
end, 0.5)`),
method('PlayerHitMotion', `local lp = _UserService.LocalPlayer
if lp == nil then
return
end
if lp.StateComponent ~= nil then
pcall(function() lp.StateComponent:ChangeState("HIT") end)
end
local tr = lp.TransformComponent
if tr == nil then
return
end
local p = tr.Position
tr.Position = Vector3(p.x - 0.15, p.y, p.z)
_TimerService:SetTimerOnce(function()
if lp ~= nil and isvalid(lp) and lp.TransformComponent ~= nil then
lp.TransformComponent.Position = Vector3(p.x, p.y, p.z)
end
end, 0.15)`),
method('MonsterLunge', `local m = self.Monsters[idx]
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
return
end
if m.motionBusy == true then
return
end
m.motionBusy = true
local e = m.entity
local tr = e.TransformComponent
if tr == nil then
m.motionBusy = false
return
end
local p = tr.Position
tr.Position = Vector3(p.x - 0.35, p.y, p.z)
_TimerService:SetTimerOnce(function()
if isvalid(e) and e.TransformComponent ~= nil then
e.TransformComponent.Position = Vector3(p.x, p.y, p.z)
end
m.motionBusy = false
end, 0.18)`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'idx' }]),
method('MonsterHitMotion', `local m = self.Monsters[slot]
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then
return
end
local e = m.entity
if m.hitClip ~= nil and e.SpriteRendererComponent ~= nil then
e.SpriteRendererComponent.SpriteRUID = m.hitClip
_TimerService:SetTimerOnce(function()
if isvalid(e) and e.SpriteRendererComponent ~= nil and m.alive == true and m.standClip ~= nil then
e.SpriteRendererComponent.SpriteRUID = m.standClip
end
end, 0.5)
else
if m.motionBusy == true then
return
end
m.motionBusy = true
local tr = e.TransformComponent
if tr == nil then
m.motionBusy = false
return
end
local p = tr.Position
local seq = { 0.12, -0.12, 0 }
for i = 1, #seq do
local dx = seq[i]
_TimerService:SetTimerOnce(function()
if isvalid(e) and e.TransformComponent ~= nil then
e.TransformComponent.Position = Vector3(p.x + dx, p.y, p.z)
end
if i == #seq then
m.motionBusy = false
end
end, 0.06 * i)
end
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('SetHpBar', `local e = _EntityService:GetEntityByPath(path)
if e == nil or e.UITransformComponent == nil then
return
end
local ratio = 0
if maxHp > 0 then ratio = hp / maxHp end
if ratio < 0 then ratio = 0 end
local w = width * ratio
e.UITransformComponent.RectSize = Vector2(w, 14)`, [
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'path' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'hp' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'maxHp' },
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'width' },
]),
method('PositionMonsterSlot', `local m = self.Monsters[slot]
if m == nil or m.entity == nil or not isvalid(m.entity) then
return
end
local tr = m.entity.TransformComponent
if tr == nil then
return
end
local wp = tr.WorldPosition
local screen = _UILogic:WorldToScreenPosition(Vector2(wp.x, wp.y + ${HEAD_OFFSET_Y}))
local uipos = _UILogic:ScreenToUIPosition(screen)
local e = _EntityService:GetEntityByPath("/ui/DefaultGroup/CombatHud/MonsterSlot" .. tostring(slot))
if e ~= nil and e.UITransformComponent ~= nil then
e.UITransformComponent.anchoredPosition = uipos
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('SetTarget', `if self.Monsters[slot] ~= nil and self.Monsters[slot].alive == true then
self.TargetIndex = slot
self:RenderCombat()
end`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
method('RenderRun', `local floorText = "막 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength) .. " · " .. string.format("%d", self.Depth) .. "층"
if self.AscensionLevel > 0 then
floorText = floorText .. " · 승천" .. string.format("%d", self.AscensionLevel)
end
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Floor", floorText)
self:SetText("/ui/DefaultGroup/CombatHud/TopBar/Gold", "메소 " .. string.format("%d", self.Gold))`),
];

55
tools/deck/cb/reward.mjs Normal file
View File

@@ -0,0 +1,55 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const rewardMethods = [
method('CardPool', `local pool = {}
for id, c in pairs(self.Cards) do
if c.token ~= true and (c.class == self.SelectedClass or (self.PlayerJob ~= "" and c.class == self.PlayerJob)) then
table.insert(pool, id)
end
end
table.sort(pool)
return pool`, [], 0, 'any'),
method('OfferReward', `self:SetEntityEnabled("/ui/DefaultGroup/CardHand", false)
self:SetEntityEnabled("/ui/DefaultGroup/DeckHud", false)
local pool = self:CardPool()
local byRarity = {}
for _, id in ipairs(pool) do
local r = self.Cards[id].rarity or "normal"
if byRarity[r] == nil then byRarity[r] = {} end
table.insert(byRarity[r], id)
end
self.RewardChoices = {}
for i = 1, 3 do
local roll = math.random(1, 100)
local want = "normal"
if roll > 95 then want = "legend" elseif roll > 70 then want = "unique" end
local bucket = byRarity[want]
if bucket == nil or #bucket == 0 then bucket = pool end
self.RewardChoices[i] = bucket[math.random(1, #bucket)]
self:ApplyRewardVisual(i, self.RewardChoices[i])
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
hud.Enable = true
end`),
method('ApplyRewardVisual', `self:ApplyCardFace("/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot), cardId)`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
]),
method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
return
end
if slot ~= 0 and self.RewardChoices ~= nil then
local id = self.RewardChoices[slot]
if id ~= nil then
table.insert(self.RunDeck, id)
end
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
hud.Enable = false
end
self:ShowMap()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
];

208
tools/deck/cb/run.mjs Normal file
View File

@@ -0,0 +1,208 @@
import { method, RUN_LENGTH, GOLD_PER_WIN, CARD_PRICE, REST_HEAL, RELIC_PRICE, ACT_COUNT, ACT_MAPS, LOBBY_MAP, LOBBY_SPAWN } from '../lib/codeblock.mjs';
import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_ROWS, MAP_COLS, CHEST_CLOSED_RUID, CHEST_OPEN_RUID, NODEICONS, CHARS, CAM, RELICS, POTIONS, luaSoulShopTable, frameRuid, luaFramesTable, luaNodeIconsTable, luaCharsTable, luaRelicsTable, luaPotionsTable, luaIntentsArray, luaEnemiesTable, luaStr, luaJobsTable, luaCardsTable, luaDeckTable } from '../lib/data.mjs';
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const runMethods = [
method('StartRun', `if self.SelectedClass == "magician" then
self.PlayerMaxHp = ${CLASSES.magician.maxHp}
self.RunDeck = { ${CARDS.starterDecks.magician.map(luaStr).join(', ')} }
elseif self.SelectedClass == "bandit" then
self.PlayerMaxHp = ${CLASSES.bandit.maxHp}
self.RunDeck = { ${CARDS.starterDecks.bandit.map(luaStr).join(', ')} }
else
self.PlayerMaxHp = ${CLASSES.warrior.maxHp}
self.RunDeck = { ${CARDS.starterDecks.warrior.map(luaStr).join(', ')} }
end
self.PlayerMaxHp = self.PlayerMaxHp - self:AscStartHpPenalty()
self.PlayerHp = self.PlayerMaxHp
self.Gold = 0
self.Floor = 1
self.RunLength = ${ACT_COUNT}
self.RunActive = true
self.RunRelics = {}
self.RunPotions = {}
self.PotionSlots = ${POTIONS.baseSlots}
${luaPotionsTable(POTIONS.potions)}
${luaRelicsTable(RELICS.relics)}
self.RelicPool = { ${RELICS.relicPool.map(luaStr).join(', ')} }
${luaEnemiesTable(ENEMIES.enemies)}
self.CurrentNodeId = ""
self.CurrentEnemyId = ""
self.PlayerJob = ""
${luaJobsTable(JOBS)}
${luaFramesTable()}
${luaNodeIconsTable()}
${luaCharsTable()}
self:GenerateMap()
self:BindButtons()
self:AddRelic("${RELICS.startingRelic}")
self:ApplySoulUnlocks()
self:RenderPotions()
self:TeleportToActMap()
self:ShowMap()`),
method('KickCombatCamera', `local cam = nil
local lp = _UserService.LocalPlayer
if lp ~= nil then cam = lp.CameraComponent end
if cam == nil then cam = _CameraService:GetCurrentCameraComponent() end
if cam ~= nil then cam.ConfineCameraArea = false end
_TimerService:SetTimerOnce(function()
local cc = nil
local lp2 = _UserService.LocalPlayer
if lp2 ~= nil then cc = lp2.CameraComponent end
if cc == nil then cc = _CameraService:GetCurrentCameraComponent() end
if cc ~= nil then
cc.ZoomRatio = ${CAM.zoomRatio}
cc.CameraOffset = Vector2(${CAM.cameraOffsetX}, ${CAM.cameraOffsetY})
cc.ScreenOffset = Vector2(${CAM.screenOffsetX}, ${CAM.screenOffsetY})
cc.ConfineCameraArea = true
end
end, 0.2)`),
method('StartCombat', `self:ShowState("combat")
self:KickCombatCamera()
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/Result", false)
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/PotionMenu", false)
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/TooltipBox", false)
self:SetEntityEnabled("/ui/DefaultGroup/CombatHud/DiscardPrompt", false)
self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Name", self:JobLabel())
self.MaxEnergy = 3
self.Turn = 0
self.PlayerBlock = 0
self.PlayerStr = 0
self.PlayerDex = 0
self.PlayerThorns = 0
self.PlayerWeak = 0
self.PlayerVuln = 0
self.PlayerPowers = {}
self.FightAttackCount = 0
self.DmgPopSeq = 0
self.FirstHpLossDone = false
self.ClayBlockNext = 0
self.DiscardSelectRemaining = 0
self.DiscardSelectTotal = 0
self.DiscardPostShiv = 0
self.DiscardShivPerPick = 0
self.CombatOver = false
self.DiscardPile = {}
self.ExhaustPile = {}
self.Hand = {}
${luaCardsTable(CARDS.cards)}
self.DrawPile = {}
for i = 1, #self.RunDeck do
self.DrawPile[i] = self.RunDeck[i]
end
self:Shuffle(self.DrawPile)
self:BuildMonsters()
self:RenderCombat()
self:StartPlayerTurn()
self:ApplyRelics("combatStart")
self:RenderCombat()`),
method('RegisterMonster', `if self.Registered == nil then
self.Registered = {}
end
local g = group
if g == nil or g == "" then g = "combat" end
local mp = mapName
if mp == nil then mp = "" end
table.insert(self.Registered, { entity = monster, enemyId = enemyId, group = g, map = mp })`, [
{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'enemyId' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'group' },
{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'mapName' },
]),
method('BuildMonsters', `self.Monsters = {}
local g = "combat"
local node = self.MapNodes[self.CurrentNodeId]
if node ~= nil and node.type ~= nil then g = node.type end
local pmap = ""
local lp = _UserService.LocalPlayer
if lp ~= nil and lp.CurrentMapName ~= nil then pmap = lp.CurrentMapName end
local reg = self.Registered or {}
for i = 1, #reg do
if reg[i].entity ~= nil and isvalid(reg[i].entity) then
reg[i].entity:SetVisible(false)
end
end
local byGroup = {}
for i = 1, #reg do
local r = reg[i]
if r.entity ~= nil and isvalid(r.entity) and (r.map == nil or r.map == "" or pmap == "" or r.map == pmap) then
local gg = r.group
if gg == nil or gg == "" then gg = "combat" end
if byGroup[gg] == nil then byGroup[gg] = {} end
local x = 0
if r.entity.TransformComponent ~= nil then
x = r.entity.TransformComponent.WorldPosition.x
end
table.insert(byGroup[gg], { entity = r.entity, enemyId = r.enemyId, x = x })
end
end
-- 노드 타입별 랜덤 구성: 일반 1~3 / 엘리트 1+일반0~2 / 보스 1
local chosen = {}
local function takeFrom(key, k)
local src = byGroup[key] or {}
local pool = {}
for i = 1, #src do pool[i] = src[i] end
self:Shuffle(pool)
local taken = 0
for i = 1, #pool do
if taken >= k then break end
table.insert(chosen, pool[i])
taken = taken + 1
end
end
if g == "boss" then
takeFrom("boss", 1)
elseif g == "elite" then
takeFrom("elite", 1)
takeFrom("combat", math.random(0, 2))
else
takeFrom("combat", math.random(1, 3))
end
if #chosen == 0 then takeFrom(g, 1) end
if #chosen == 0 then takeFrom("combat", 1) end
table.sort(chosen, function(a, b) return a.x < b.x end)
local mult = 1 + (self.Floor - 1) * 0.45
if g == "elite" or g == "boss" then
mult = mult + self:AscEliteBonus()
end
local n = #chosen
if n > ${MAX_MONSTERS} then n = ${MAX_MONSTERS} end
for i = 1, n do
local item = chosen[i]
local e = self.Enemies[item.enemyId]
if e == nil then e = { name = item.enemyId, maxHp = 10, intents = { { kind = "Attack", value = 5 } } } end
local intents = {}
for k = 1, #e.intents do
local v = e.intents[k].value or 0
if e.intents[k].kind == "Attack" then
v = math.floor(v * mult * self:AscAtkMult())
elseif e.intents[k].kind ~= "Debuff" then
v = math.floor(v * mult)
end
intents[k] = { kind = e.intents[k].kind, value = v, effect = e.intents[k].effect, card = e.intents[k].card, count = e.intents[k].count }
end
local maxHp = math.floor(e.maxHp * mult * self:AscHpMult())
local hitClip = nil
local standClip = nil
if item.entity.StateAnimationComponent ~= nil then
pcall(function()
hitClip = item.entity.StateAnimationComponent.ActionSheet["hit"]
standClip = item.entity.StateAnimationComponent.ActionSheet["stand"]
end)
end
local startIdx = 1
if #intents > 0 then startIdx = math.random(1, #intents) end
self.Monsters[i] = { entity = item.entity, enemyId = item.enemyId, name = e.name,
hp = maxHp, maxHp = maxHp, block = 0, str = 0, weak = 0, vuln = 0, poison = 0,
hitClip = hitClip, standClip = standClip, motionBusy = false,
intents = intents, intentIdx = startIdx, alive = true, slot = i }
self:ReviveMonsterEntity(item.entity)
self:PositionMonsterSlot(i)
end
self.TargetIndex = 1`),
method('ReviveMonsterEntity', `if monster == nil or not isvalid(monster) then
return
end
monster:SetEnable(true)
monster:SetVisible(true)`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'monster' }]),
];

Some files were not shown because too many files have changed in this diff Show More