diff --git a/README.md b/README.md
index c9b8cfe..ef8ae3f 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
-
+
Codigdex
- A pixel-art coding-education game — beat bug monsters, pass a capture quiz, and register graded cards in your own Pokédex-style code dex.
+ 버그 몬스터를 물리치고, 캡처 퀴즈를 통과해 나만의 코딩 도감을 완성하는 픽셀 아트 교육 게임
@@ -16,37 +16,57 @@
----
+## Codigdex란?
-## 🎮 What is Codigdex?
+Codigdex는 코딩 개념을 읽는 데서 끝나지 않습니다. 코드 배틀에서 버그 몬스터를 쓰러뜨리고 짧은 캡처 퀴즈로 이해도를 확인하면, 결과에 따라 브론즈·실버·골드 카드가 개인 도감에 등록됩니다. 낮은 등급의 카드도 다시 도전해 골드로 성장시킬 수 있습니다.
-Codigdex doesn't stop at *teaching* a coding concept — you have to prove you understood it. Defeat a bug monster in a code battle, pass its short capture quiz, and it's registered as a bronze/silver/gold card in your personal **Codigdex**. Filling out the dex *is* the game.
+플레이어는 서버 구름 위 픽셀 마을 `Codeville`의 주니어 개발자로 시작합니다. 튜토리얼과 Git·Linux 공통 과정을 마친 뒤 원하는 직업으로 전직하고, 직업별 상세 지도에서 기술 지역을 돌며 도감을 확장합니다.
-You play a junior coder in Codeville, a pixel town floating on a server cloud, hunting down bug monsters concept by concept. Miss a perfect score? Bronze and silver cards can always be re-caught for gold later — the game rewards retrying, not punishing misses.
+
+
+
+
+## 게임 진행 구조
+
+```text
+튜토리얼
+ → 주니어 공통 Path (Git → Linux)
+ → 1차 전직 선택
+ → 직업별 상세 지도
+ → 기술 지역 → 코드 배틀 → 캡처 퀴즈 → 도감 등록
+ → ??? (두 직업 Path 완성 후 열리는 2차 전직)
+```
+
+현재 직업 Path는 웹 프론트엔드, 백엔드, DevOps, 데이터 엔지니어, 데이터 분석가로 구성됩니다. 전직 후에는 공통 과정의 루피 대신 각 직업의 선배 NPC가 길을 안내합니다. 향후 풀스택 엔지니어, ML Developer, 플랫폼 엔지니어/SRE 같은 2차 전직은 처음부터 `???`로 보이되, 관련된 두 1차 직업 도감을 완성해야 정체와 조건이 드러납니다.
-
+
-Built with **Next.js + Phaser.js**, deployed on **Vercel**. See [docs/GAME_DESIGN.md](docs/GAME_DESIGN.md) for the full design doc — core loop, curriculum roadmap, card/dex system, and an example playthrough of CH.01.
+## 현재 구현
+
+- 튜토리얼, CH.01 Git, CH.02 Linux의 퀘스트 → 배틀 → 퀴즈 → 포획 루프
+- 챕터마다 Lv.1~Lv.5로 이어지는 몬스터와 순차 해금
+- 정답률에 따른 브론즈·실버·골드 카드와 골드 재도전
+- 완성도와 챕터 마스터 배지를 보여 주는 Codigdex 화면
+- 5개 1차 직업 선택, 직업별 월페이퍼 지도와 기술 지역 표시
+- 전직에 따라 달라지는 직업별 가이드 NPC
+- `???`로 미리 보이는 2차 전직 슬롯
+- 아직 배틀이 없는 전문 기술 몬스터를 `???` 도감 슬롯으로 미리 표시
+- 브라우저 로컬 저장 v2와 기존 v1 저장 데이터 자동 마이그레이션
+- Phaser와 분리된 `lib/domain` 규칙, Vitest 단위·통합 테스트
-## ✨ Features
+직업별 전문 챕터의 몬스터 아트와 지도는 준비되어 있으며, 각 지역의 배틀·퀴즈 콘텐츠는 순차 연결 중입니다. 상세 커리큘럼과 2차 전직 해금 원칙은 [직업 전직 Path 설계](docs/CAREER_PATH_DESIGN.md)를 참고하세요.
-**🐛 Game**
-- ✅ Quest → code battle → capture quiz → dex registration → reward loop, fully playable end-to-end (tutorial, "반복문의 숲")
-- ✅ Block-ordering code battle minigame
-- ✅ Bronze / silver / gold grading from capture-quiz accuracy, with gold-upgrade re-challenges
-- ✅ Codigdex dex screen with completion % and a chapter-master badge
-- ✅ Framework-free `lib/domain` game logic, unit + integration tested with Vitest
+## 기술 스택
-**🤖 Repo automation**
-- ✅ Push a `feat/` branch → Linear issue + mirrored GitHub issue + Draft PR into `develop`, auto-created — no manual issue pairing
-- ✅ Every PR validated against the Linear/GitHub issue-pair policy before it can merge
-- ✅ Claude (and optionally Codex) auto-reviews every PR
-- ✅ Slack notification on every merge to `develop`/`main`
-- ✅ Lint, test, and build run in CI on every PR touching `web/`
+- Next.js 16, React 19
+- Phaser 4
+- TypeScript
+- Vitest
+- Vercel
-## 🚀 Quick start
+## 시작하기
```bash
cd web
@@ -54,53 +74,61 @@ npm install
npm run dev
```
-Open [http://localhost:3000](http://localhost:3000). Other useful scripts (run from `web/`): `npm run test`, `npm run lint`, `npm run build`.
+브라우저에서 [http://localhost:3001](http://localhost:3001)을 엽니다.
-## 📖 Documentation
-
-- [docs/GAME_DESIGN.md](docs/GAME_DESIGN.md) — game design doc (concept, core loop, curriculum roadmap, playthrough example)
-- [AGENTS.md](AGENTS.md) — project purpose + the PR/issue policy that gates every merge (source of truth for both human and agent contributors)
-- [docs/kor/GIT_WORKFLOW.md](docs/kor/GIT_WORKFLOW.md) / [docs/eng/GIT_WORKFLOW.md](docs/eng/GIT_WORKFLOW.md) — human-readable branch/PR/Linear policy
+```bash
+npm run test
+npm run lint
+npm run build
+```
-## 🧑💻 Contributing
+## 프로젝트 구조
+
+```text
+web/
+├─ app/ Next.js 진입점
+├─ lib/domain/ 챕터, 도감, 직업과 진로 규칙
+├─ lib/phaser/scenes/ Phaser 화면
+├─ lib/phaser/worldMap/ 월드맵, 상세 진로 지도와 상호작용
+├─ lib/phaser/save/ 버전별 저장 스키마와 마이그레이션
+├─ public/assets/
+│ ├─ careers/ 직업 초상과 전직 후 NPC
+│ ├─ monsters/ 챕터별 도감 몬스터
+│ ├─ npcs/ 공통 과정 NPC
+│ └─ wallpapers/career-paths/ 직업별 상세 지도
+└─ test/ 도메인·Phaser 테스트
+```
-PRs follow `feat/` → Draft PR into `develop` → `develop` → `main`, gated by a paired Linear (`COD-n`) + GitHub (`#n`) issue — see [AGENTS.md](AGENTS.md#pr--issue-policy) for the full flow and manual fallback. Please also read [CONTRIBUTING.md](CONTRIBUTING.md), [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md), and [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
+저장 데이터는 `progress`, `player`, `ui` 영역을 분리합니다. 도감 카드에는 몬스터 ID와 포획 시각만 저장하고 표시 정보는 현재 챕터 정의에서 다시 구성하므로, 콘텐츠가 바뀌어도 오래된 저장 데이터가 화면을 오염시키지 않습니다. 미래 몬스터의 `???` 슬롯은 현재 도감 완성률에서 제외하며, 2차 전직 해금 여부 역시 별도 플래그가 아니라 포획 기록에서 계산하도록 확장할 예정입니다.
-
-🧰 Setup checklist for a new repo made from this template
+## 문서
-This repo is built on codingnanyong's standard repo template: Linear/GitHub-issue-gated PR flow, Claude + Codex PR review, Slack merge notifications, and the usual community-health files, all pre-wired. The checklist below is what a *new* repo spun up from this template still needs — kept here for reference.
+- [게임 디자인](docs/GAME_DESIGN.md)
+- [직업 전직 Path 설계](docs/CAREER_PATH_DESIGN.md)
+- [프로젝트 및 PR 정책](AGENTS.md)
+- [Git 작업 흐름 (한국어)](docs/kor/GIT_WORKFLOW.md) / [Git workflow (English)](docs/eng/GIT_WORKFLOW.md)
+- [기여 가이드](CONTRIBUTING.md)
-Everything below is a **one-time, per-repo** step — things only a human can do or decide (create accounts/keys, name the project, click "Install"). Once done, day-to-day PR/issue/Slack work is fully automated; nobody touches these again unless a key rotates or the project is renamed.
+## 기여
-### A. What stays automated after setup (no action needed once wired up)
+기능 브랜치는 `feat/` 형식을 사용하고 `develop`으로 Draft PR을 엽니다. 저장소 자동화가 Linear `COD-n` 이슈와 GitHub 미러 이슈를 생성·연결하며, 모든 PR은 해당 이슈 쌍과 CI 검증을 통과해야 합니다. 자세한 흐름과 수동 복구 절차는 [AGENTS.md의 PR 정책](AGENTS.md#pr--issue-policy)을 확인하세요.
-- Opening a `feat/` branch → Linear issue created/reused, GitHub mirror issue created/reused, Draft PR opened into `develop` — all handled by `prepare-feature-pr.yml`.
-- Every PR event → branch/title/body/issue-pair validated by `pr-policy.yml`; nothing to fill in by hand.
-- Merge into `develop` → mirrored GitHub issue auto-closed by `pr-policy.yml`, which auto-transitions the Linear issue to Done via Linear's own GitHub integration.
-- Every PR → reviewed by `claude-review.yml` (and Codex, if installed).
-- Merge into `develop`/`main` → summary posted to Slack by `notify-slack-on-merge.yml`.
+
+새 저장소 자동화 설정 체크리스트
-### B. What you must newly do or provide for *this* repo
+이 프로젝트를 템플릿으로 사용하는 새 저장소에서는 다음 항목을 한 번만 설정합니다.
-1. **Rename things**: update this README, `AGENTS.md`'s "Project purpose" section, and the license year/holder if needed.
-2. **Create `develop` branch**: `git checkout -b develop && git push -u origin develop`, then set `develop` as the default branch in repo Settings if that's your convention (or keep `main` default and just target `develop` for feature PRs).
-3. **Install the Claude GitHub App**: https://github.com/apps/claude → select this repo.
-4. **(Optional) Install a Codex review app** (e.g. ChatGPT Codex Connector) via https://github.com/settings/installations if you want a second automated reviewer.
-5. **Add repo secrets** (Settings → Secrets and variables → Actions → Secrets) — these are credentials only you can issue:
- - `CLAUDE_CODE_OAUTH_TOKEN` (run `claude setup-token` locally if you have a Claude subscription) or `ANTHROPIC_API_KEY`
- - `SLACK_WEBHOOK_URL` (Slack app → Incoming Webhooks, pick your notifications channel)
- - `LINEAR_API_KEY` (Linear → Settings → API → Create key)
- - `GH_PAT` — a fine-grained PAT (Contents:read, Issues:write, Pull requests:write on this repo), **not** the default `GITHUB_TOKEN`. `prepare-feature-pr.yml` uses it to create the draft PR; PRs created with `GITHUB_TOKEN` don't retrigger `pr-policy.yml` (GitHub's anti-recursion rule), so the PR would stay unchecked.
-6. **Add repo variables** (Settings → Secrets and variables → Actions → Variables) — the Linear project this repo's issues live in, since that's different per repo:
- - `LINEAR_PROJECT_SLUG` — from the Linear project's "Copy link" (the last URL segment)
- - `LINEAR_PROJECT_NAME` — the project's display name, used as a fallback lookup if the slug ever changes
-7. **Branch protection** (optional but recommended): require the `validate-flow` and `review` checks to pass before merging into `develop`/`main`.
+1. `develop` 브랜치를 만들고 기본 PR 대상으로 사용합니다.
+2. Claude GitHub App을 설치합니다.
+3. Actions secrets에 `CLAUDE_CODE_OAUTH_TOKEN`(또는 `ANTHROPIC_API_KEY`), `SLACK_WEBHOOK_URL`, `LINEAR_API_KEY`, `GH_PAT`을 등록합니다.
+4. `GH_PAT`은 해당 저장소의 Contents 읽기, Issues 쓰기, Pull requests 쓰기 권한을 가진 fine-grained PAT를 사용합니다.
+5. Actions variables에 `LINEAR_PROJECT_SLUG`, `LINEAR_PROJECT_NAME`을 등록합니다.
+6. `develop`과 `main`의 브랜치 보호 규칙에서 `validate-flow`, `review` 검사를 요구하도록 설정합니다.
-Steps 5–6 are the only inputs the automation actually needs; everything after that (A above) runs itself. For the full day-to-day procedure and manual fallback if a secret expires, see [AGENTS.md](AGENTS.md#pr--issue-policy) or [CONTRIBUTING.md](CONTRIBUTING.md).
+설정 후에는 `feat/` push 시 Linear/GitHub 이슈 쌍과 Draft PR 생성, PR 정책 검사, 리뷰, merge 알림이 자동으로 실행됩니다.
-## 📄 License
+## 라이선스
[MIT](LICENSE) © codingnanyong
diff --git a/docs/CAREER_PATH_DESIGN.md b/docs/CAREER_PATH_DESIGN.md
index 88fb8f6..a220a0f 100644
--- a/docs/CAREER_PATH_DESIGN.md
+++ b/docs/CAREER_PATH_DESIGN.md
@@ -64,6 +64,18 @@ flowchart LR
| 데이터 엔지니어 | Python·SQL → 데이터 모델링 → 데이터 파이프라인 → Docker → 워크플로 오케스트레이션 |
| 데이터 분석가 | SQL → 기초 통계 → 데이터 시각화 → BI 도구 → 분석용 Python |
+### 맵의 깊이와 이동
+
+Path 화면과 플레이 지역은 같은 화면이 아니다. 상위 Path는 공통 과정과 전직 관계를 보여 주는 진로도이고, 1차 직업 카드를 선택하면 해당 직업의 **상세 지도**로 이동한다. 상세 지도 위의 기술 거점(HTML/CSS, Docker, SQL 등)을 선택하면 몬스터를 포획하는 개별 챕터 지역으로 들어간다.
+
+```text
+진로 Path → 직업별 상세 지도 → 기술 챕터 지역 → 배틀/포획
+```
+
+직업별 월페이퍼에는 이동 경로와 거점이 그려져 있고, 게임 UI가 그 위에 선택 가능한 번호·기술명을 겹쳐 표시한다. 따라서 새 챕터를 추가할 때는 상위 Path를 다시 그리는 대신 상세 지도 데이터에 목적지를 추가하고, 그 목적지를 챕터 지역과 연결한다.
+
+전직 전 공통 과정은 버그 연구원 루피가 안내한다. 전직 후 상세 지도에서는 직업 초상을 활용한 직업별 선배 NPC가 안내를 이어받는다. 직업을 바꾸면 저장된 1차 직업과 함께 지도·NPC가 즉시 교체된다.
+
## 직업 사이의 공유 기술
직업 Path는 완전히 분리된 다섯 개의 선이 아니다. 같은 기술을 서로 다른 맥락에서 만나는 교차형 트리다.
@@ -86,13 +98,25 @@ flowchart LR
|---|---|---|
| 플랫폼 엔지니어 / SRE | DevOps + 백엔드 | 서비스 배포 파이프라인, 관찰 가능성, 장애 대응 |
| MLOps · 데이터 플랫폼 엔지니어 | DevOps + 데이터 엔지니어 | 파이프라인 컨테이너화, 스케줄링, 모니터링 |
+| ML Developer | 백엔드 + 데이터 엔지니어 | 모델 서빙, 추론 API, 데이터·모델 파이프라인 |
| 풀스택 엔지니어 | 웹 프론트엔드 + 백엔드 | 화면과 API 연결, 인증, 배포 |
| 분석 엔지니어 | 데이터 엔지니어 + 데이터 분석가 | 데이터 모델링, 변환, 지표 정의 |
- **조합은 실제 직무명이 있는 것만 둔다.** 1차 직업 5개의 모든 조합(10가지)을 만들면 콘텐츠만 늘고 직업의 의미가 흐려진다.
- **도감식 실루엣으로 공개한다.** 경로 맵에 `???` 실루엣으로 존재만 보여 주고, 첫 직업 경로를 완료하면 해금 조건 힌트를 공개한다. 완전히 숨기면 존재를 몰라 동기가 생기지 않는다.
+- **미래 몬스터도 도감에 자리를 잡는다.** 아직 배틀과 퀴즈가 없는 전문 챕터는 번호와 `???`만 표시한다. 출시 전 슬롯은 현재 도감의 전체 수·완성률 계산에서 제외한다.
- **칭호만 주지 않는다.** 두 분야를 잇는 5단계 통합 챕터, 전용 초상 아트, 도감 배지를 함께 제공한다.
-- **해금 조건은 포획 기록에서 계산한다.** 직업 경로 완료를 따로 저장하지 않으므로 2차 직업을 추가해도 저장 데이터를 바꿀 필요가 없다.
+- **해금 조건은 포획 기록에서 계산한다.** 직업 경로 완료 여부를 별도 플래그로 저장하지 않는다. 저장 v2에는 향후 선택된 2차 직업을 기록할 자리만 두고, 실제 해금 여부는 언제나 도감에서 다시 계산한다.
+
+## 저장 구조
+
+브라우저 저장은 변경이 잦은 화면 상태를 한 객체에 평평하게 쌓지 않고 `progress`, `player`, `ui`로 나눈다.
+
+- `progress`: 몬스터 ID와 포획 시각. 카드 표시 정보와 Path 완료 여부는 현재 콘텐츠 정의에서 다시 계산한다.
+- `player`: 선택한 1차 직업과 향후 선택할 2차 직업.
+- `ui`: 튜토리얼 안내 확인처럼 진행과 직접 관련 없는 화면 상태.
+
+기존 `codigdex:save:v1`은 첫 로드 때 v2 구조로 복사한다. 구버전 슬롯은 마이그레이션 안전망으로 유지하며, 새 게임 초기화 시 두 슬롯을 함께 정리한다.
직업 제작 순서(DevOps → 백엔드 → 데이터 엔지니어 → 웹 프론트엔드 → 데이터 분석가)를 따르면 플랫폼 엔지니어가 가장 먼저(백엔드 경로 완성 시) 열리고, 풀스택 엔지니어는 웹 프론트엔드 경로가 완성된 뒤 열린다.
diff --git a/web/lib/domain/dex/catalog.ts b/web/lib/domain/dex/catalog.ts
new file mode 100644
index 0000000..397681d
--- /dev/null
+++ b/web/lib/domain/dex/catalog.ts
@@ -0,0 +1,38 @@
+import { DEX_MONSTERS } from "@/lib/domain/chapters";
+import { TECHNOLOGY_SPECIMENS } from "@/lib/domain/technologySpecimens";
+import type { MonsterDefinition } from "@/lib/domain/chapters/types";
+
+export interface ReleasedDexSlot {
+ kind: "released";
+ dexNumber: string;
+ monster: MonsterDefinition;
+}
+
+export interface PlannedDexSlot {
+ kind: "planned";
+ dexNumber: string;
+ /** Internal routing id; the UI deliberately hides the technology and monster. */
+ technologyId: string;
+}
+
+export type DexCatalogSlot = ReleasedDexSlot | PlannedDexSlot;
+
+const firstPlannedNumber = DEX_MONSTERS.length + 1;
+
+export const PLANNED_DEX_SLOTS: readonly PlannedDexSlot[] = Object.values(TECHNOLOGY_SPECIMENS)
+ .filter((specimen) => specimen.role === "future")
+ .map((specimen, index) => ({
+ kind: "planned",
+ dexNumber: String(firstPlannedNumber + index).padStart(3, "0"),
+ technologyId: specimen.id,
+ }));
+
+/** Released entries count toward completion; planned ??? slots are preview-only. */
+export const DEX_CATALOG: readonly DexCatalogSlot[] = [
+ ...DEX_MONSTERS.map((monster) => ({
+ kind: "released" as const,
+ dexNumber: monster.dexNumber,
+ monster,
+ })),
+ ...PLANNED_DEX_SLOTS,
+];
diff --git a/web/lib/domain/player/jobs.ts b/web/lib/domain/player/jobs.ts
index caeb02f..7df771d 100644
--- a/web/lib/domain/player/jobs.ts
+++ b/web/lib/domain/player/jobs.ts
@@ -1,4 +1,10 @@
export type JobId = "frontend" | "backend" | "devops" | "data-engineer" | "data-analyst";
+export type SecondaryJobId =
+ | "fullstack-engineer"
+ | "platform-engineer"
+ | "ml-developer"
+ | "mlops-engineer"
+ | "analytics-engineer";
export interface JobOption {
id: JobId | "junior";
@@ -6,24 +12,32 @@ export interface JobOption {
tagline: string;
textureKey?: string;
assetPath?: string;
+ guideName: string;
}
-// Every player starts here. Real job selection only makes sense once the
-// common path (Git, terminal/Linux, ...) actually exists to walk through first —
-// until then everyone is just this.
+export interface SecondaryJobOption {
+ id: SecondaryJobId;
+ /** Kept out of the UI until the unlock condition is met. */
+ name: string;
+ requires: readonly [JobId, JobId];
+}
+
+// Every player starts here. A primary job is selected after the common path.
export const DEFAULT_JOB: JobOption = {
id: "junior",
name: "주니어 개발자",
- tagline: "이제 막 첫걸음을 뗀 개발자",
+ tagline: "이제 막 첫 모험을 떠난 개발자",
+ guideName: "버그 연구원 루피",
};
-export const JOB_OPTIONS: JobOption[] = [
+export const JOB_OPTIONS: readonly JobOption[] = [
{
id: "frontend",
name: "웹 프론트엔드 개발자",
tagline: "화면을 그리는 마법사",
textureKey: "career-frontend",
assetPath: "/assets/careers/frontend-developer.png",
+ guideName: "프론트엔드 선배",
},
{
id: "backend",
@@ -31,6 +45,7 @@ export const JOB_OPTIONS: JobOption[] = [
tagline: "데이터를 지키는 수호자",
textureKey: "career-backend",
assetPath: "/assets/careers/backend-developer.png",
+ guideName: "백엔드 선배",
},
{
id: "devops",
@@ -38,6 +53,7 @@ export const JOB_OPTIONS: JobOption[] = [
tagline: "배포 흐름을 지키는 자동화 장인",
textureKey: "career-devops",
assetPath: "/assets/careers/devops-engineer.png",
+ guideName: "DevOps 선배",
},
{
id: "data-engineer",
@@ -45,6 +61,7 @@ export const JOB_OPTIONS: JobOption[] = [
tagline: "데이터의 길을 만드는 설계자",
textureKey: "career-data-engineer",
assetPath: "/assets/careers/data-engineer.png",
+ guideName: "데이터 엔지니어 선배",
},
{
id: "data-analyst",
@@ -52,10 +69,21 @@ export const JOB_OPTIONS: JobOption[] = [
tagline: "패턴을 읽는 관찰자",
textureKey: "career-data-analyst",
assetPath: "/assets/careers/data-analyst.png",
+ guideName: "데이터 분석가 선배",
},
];
+/** Future tier-two jobs are present in the model before their content ships. */
+export const SECONDARY_JOB_OPTIONS: readonly SecondaryJobOption[] = [
+ { id: "fullstack-engineer", name: "풀스택 엔지니어", requires: ["frontend", "backend"] },
+ { id: "platform-engineer", name: "플랫폼 엔지니어 / SRE", requires: ["backend", "devops"] },
+ { id: "ml-developer", name: "ML Developer", requires: ["backend", "data-engineer"] },
+ { id: "mlops-engineer", name: "MLOps 엔지니어", requires: ["devops", "data-engineer"] },
+ { id: "analytics-engineer", name: "분석 엔지니어", requires: ["data-engineer", "data-analyst"] },
+];
+
export const JOB_REGISTRY_KEY = "selectedJob";
+export const SECONDARY_JOB_REGISTRY_KEY = "selectedSecondaryJob";
export function findJob(id: string | undefined): JobOption {
if (!id) return DEFAULT_JOB;
@@ -63,3 +91,11 @@ export function findJob(id: string | undefined): JobOption {
if (id === "data") return JOB_OPTIONS.find((job) => job.id === "data-analyst")!;
return JOB_OPTIONS.find((job) => job.id === id) ?? DEFAULT_JOB;
}
+
+export function secondaryJobsFor(jobId: JobId): readonly SecondaryJobOption[] {
+ return SECONDARY_JOB_OPTIONS.filter((job) => job.requires.includes(jobId));
+}
+
+export function findSecondaryJob(id: string | null | undefined): SecondaryJobOption | undefined {
+ return SECONDARY_JOB_OPTIONS.find((job) => job.id === id);
+}
diff --git a/web/lib/phaser/config.ts b/web/lib/phaser/config.ts
index 50e42f6..6f2327c 100644
--- a/web/lib/phaser/config.ts
+++ b/web/lib/phaser/config.ts
@@ -6,6 +6,7 @@ import { WorldMapScene } from "./scenes/WorldMapScene";
import { CodeBattleScene } from "./scenes/CodeBattleScene";
import { CaptureQuizScene } from "./scenes/CaptureQuizScene";
import { CodigdexScene } from "./scenes/CodigdexScene";
+import { CareerRegionScene } from "./scenes/CareerRegionScene";
import { initializeRegistryPersistence } from "./registryAdapter";
export function createGameConfig(
@@ -31,13 +32,10 @@ export function createGameConfig(
},
scene: [
IntroScene,
- // JobSelectScene has no entry point yet — nothing calls
- // scene.start("job-select") until PathMapScene's common-dex nodes
- // have real content to finish. Registered here so it's ready to
- // wire in once that content exists.
JobSelectScene,
PathMapScene,
WorldMapScene,
+ CareerRegionScene,
CodeBattleScene,
CaptureQuizScene,
CodigdexScene,
diff --git a/web/lib/phaser/dex/entry.ts b/web/lib/phaser/dex/entry.ts
index ca62afd..bc084a1 100644
--- a/web/lib/phaser/dex/entry.ts
+++ b/web/lib/phaser/dex/entry.ts
@@ -1,15 +1,23 @@
-import { DEX_MONSTERS } from "@/lib/domain/chapters";
+import { DEX_CATALOG } from "@/lib/domain/dex/catalog";
import type { CapturedCard, MonsterDefinition } from "@/lib/domain/chapters/types";
/** One dex row: the monster, and its card if the player has captured it. */
export interface DexEntry {
- monster: MonsterDefinition;
+ dexNumber: string;
+ monster?: MonsterDefinition;
card?: CapturedCard;
+ planned: boolean;
}
export function buildDexEntries(cards: readonly CapturedCard[]): DexEntry[] {
- return DEX_MONSTERS.map((monster) => ({
- monster,
- card: cards.find((card) => card.id === monster.id),
- }));
+ return DEX_CATALOG.map((slot) =>
+ slot.kind === "released"
+ ? {
+ dexNumber: slot.dexNumber,
+ monster: slot.monster,
+ card: cards.find((card) => card.id === slot.monster.id),
+ planned: false,
+ }
+ : { dexNumber: slot.dexNumber, planned: true }
+ );
}
diff --git a/web/lib/phaser/dex/entryList.ts b/web/lib/phaser/dex/entryList.ts
index 1e0185e..445d36f 100644
--- a/web/lib/phaser/dex/entryList.ts
+++ b/web/lib/phaser/dex/entryList.ts
@@ -41,8 +41,10 @@ export class EntryList {
const centerX = (options.left + options.right) / 2;
const statsY = options.top + VISIBLE_ROWS * ROW_STEP + 10;
const captured = entries.filter((entry) => entry.card).length;
+ const released = entries.filter((entry) => !entry.planned).length;
+ const planned = entries.length - released;
scene.add
- .text(centerX, statsY, `등록 ${captured} · 전체 ${entries.length}`, {
+ .text(centerX, statsY, `등록 ${captured}/${released} · 미발견 ${planned}`, {
...pixelText("body"),
color: PALETTE_HEX.sand,
})
@@ -111,13 +113,13 @@ export class EntryList {
row.on("pointerup", () => options.onPick(index));
const ball = scene.add.graphics({ x: options.left + 22, y: rowY });
- ball.fillStyle(entry.card ? PALETTE.amber : PALETTE.mutedBrown, 1);
+ ball.fillStyle(entry.card ? PALETTE.amber : entry.planned ? PALETTE.nightBrown : PALETTE.mutedBrown, 1);
ball.fillCircle(0, 0, 7);
ball.lineStyle(2, PALETTE.ink, 1);
ball.strokeCircle(0, 0, 7);
const label = scene.add
- .text(options.left + 42, rowY, `No.${entry.monster.dexNumber} ${entry.card ? entry.card.name : "???"}`, {
+ .text(options.left + 42, rowY, `No.${entry.dexNumber} ${entry.card ? entry.card.name : "???"}`, {
...pixelText("body"),
color: entry.card ? PALETTE_HEX.cream : PALETTE_HEX.mutedBrown,
})
diff --git a/web/lib/phaser/dex/previewPane.ts b/web/lib/phaser/dex/previewPane.ts
index 7e406d3..7e0868b 100644
--- a/web/lib/phaser/dex/previewPane.ts
+++ b/web/lib/phaser/dex/previewPane.ts
@@ -21,7 +21,7 @@ export class PreviewPane {
}
/** Rebuilds the pane for `entry`, replacing whatever it showed before. */
- show({ monster, card }: DexEntry) {
+ show({ dexNumber, monster, card, planned }: DexEntry) {
this.group?.destroy(true);
const { scene, centerX } = this;
const previewTop = this.top + 6;
@@ -34,7 +34,7 @@ export class PreviewPane {
.setStrokeStyle(2, PALETTE.mutedBrown),
];
- if (card) {
+ if (card && monster) {
const fit = fitTexture(scene, monster.textureKey, PREVIEW_SIZE - 16, PREVIEW_SIZE - 16);
items.push(scene.add.image(centerX, previewCenterY, monster.textureKey).setScale(fit.scale));
} else {
@@ -52,14 +52,14 @@ export class PreviewPane {
cursor += line.height + gap;
};
- addLine(`No. ${monster.dexNumber}`, { ...pixelText("body"), color: PALETTE_HEX.sand }, 4);
+ addLine(`No. ${dexNumber}`, { ...pixelText("body"), color: PALETTE_HEX.sand }, 4);
addLine(card ? card.name : "???", { ...pixelText("subtitle"), color: PALETTE_HEX.cream, fontStyle: "bold" }, 6);
if (card) {
addLine(card.classification, { ...pixelText("body"), color: PALETTE_HEX.amber }, 6);
addLine(card.trait, { ...pixelText("body"), color: PALETTE_HEX.sand, align: "center", wordWrap: { width: infoWidth } }, 0);
} else {
addLine(
- "아직 관찰되지 않았습니다",
+ planned ? "아직 발견되지 않은 지역입니다" : "아직 관찰되지 않았습니다",
{ ...pixelText("body"), color: PALETTE_HEX.mutedBrown, align: "center", wordWrap: { width: infoWidth } },
0
);
diff --git a/web/lib/phaser/pathMap/nodes.ts b/web/lib/phaser/pathMap/nodes.ts
index 137bb3c..95d32e5 100644
--- a/web/lib/phaser/pathMap/nodes.ts
+++ b/web/lib/phaser/pathMap/nodes.ts
@@ -106,6 +106,29 @@ export function drawPromotionNode(
makePressable(card, new Phaser.Geom.Rectangle(-half, -half, PROMOTION_SIZE, PROMOTION_SIZE), 1.04, onSelect);
}
+/** A locked silhouette for a future tier-two job; its real name stays hidden. */
+export function drawMysteryCareerNode(
+ scene: Phaser.Scene,
+ x: number,
+ y: number,
+ onSelect: () => void
+) {
+ const width = 104;
+ const height = 54;
+ const panel = scene.add
+ .rectangle(0, 0, width, height, PALETTE.nightBrown, 0.96)
+ .setStrokeStyle(2, PALETTE.mutedBrown);
+ const eyebrow = scene.add
+ .text(0, -13, "2차 전직", { ...pixelText("caption"), color: PALETTE_HEX.sand })
+ .setOrigin(0.5);
+ const label = scene.add
+ .text(0, 11, "◆ ???", { ...pixelText("body"), color: PALETTE_HEX.cream })
+ .setOrigin(0.5);
+ const card = scene.add.container(x, y, [panel, eyebrow, label]);
+ makePressable(card, new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height), 1.035, onSelect);
+ return card;
+}
+
/**
* Specimen art has to be transparent to its edges for this to read as a
* portrait in a ring rather than a square on a disc — the sprite is drawn at
diff --git a/web/lib/phaser/registryAdapter.ts b/web/lib/phaser/registryAdapter.ts
index fbf0179..456d320 100644
--- a/web/lib/phaser/registryAdapter.ts
+++ b/web/lib/phaser/registryAdapter.ts
@@ -1,23 +1,18 @@
import Phaser from "phaser";
import { DexState, EMPTY_DEX_STATE } from "@/lib/domain/dex/capture";
import { DEX_MONSTERS } from "@/lib/domain/chapters";
-import { findJob, JOB_REGISTRY_KEY } from "@/lib/domain/player/jobs";
+import {
+ findJob,
+ findSecondaryJob,
+ JOB_REGISTRY_KEY,
+ SECONDARY_JOB_REGISTRY_KEY,
+} from "@/lib/domain/player/jobs";
+import { createSave, parseSave } from "./save/schema";
const CARDS_KEY = "cards";
export const TUTORIAL_ONBOARDING_SEEN_KEY = "tutorialOnboardingSeen";
-export const SAVE_STORAGE_KEY = "codigdex:save:v1";
-
-interface StoredCapture {
- id: string;
- capturedAt: string;
-}
-
-interface StoredGameState {
- version: 1;
- captures: StoredCapture[];
- selectedJob: string;
- tutorialOnboardingSeen: boolean;
-}
+export const SAVE_STORAGE_KEY = "codigdex:save:v2";
+export const LEGACY_SAVE_STORAGE_KEY = "codigdex:save:v1";
function browserStorage(): Storage | undefined {
if (typeof window === "undefined") return undefined;
@@ -32,16 +27,14 @@ function browserStorage(): Storage | undefined {
export function hydrateRegistry(registry: Phaser.Data.DataManager, storage = browserStorage()) {
if (!storage) return;
try {
- const raw = storage.getItem(SAVE_STORAGE_KEY);
- if (!raw) return;
- const save = JSON.parse(raw) as Partial;
- if (save.version !== 1 || !Array.isArray(save.captures)) return;
+ const currentRaw = storage.getItem(SAVE_STORAGE_KEY);
+ const legacyRaw = storage.getItem(LEGACY_SAVE_STORAGE_KEY);
+ const currentSave = currentRaw ? parseSave(currentRaw) : undefined;
+ const save = currentSave ?? (legacyRaw ? parseSave(legacyRaw) : undefined);
+ if (!save) return;
const capturedAtById = new Map(
- save.captures
- .filter((entry): entry is StoredCapture =>
- Boolean(entry && typeof entry.id === "string" && typeof entry.capturedAt === "string")
- )
+ save.progress.captures
.map((entry) => [entry.id, entry.capturedAt])
);
const cards = DEX_MONSTERS.filter((monster) => capturedAtById.has(monster.id)).map((monster) => ({
@@ -56,8 +49,12 @@ export function hydrateRegistry(registry: Phaser.Data.DataManager, storage = bro
}));
registry.set(CARDS_KEY, cards);
- registry.set(JOB_REGISTRY_KEY, findJob(save.selectedJob).id);
- registry.set(TUTORIAL_ONBOARDING_SEEN_KEY, save.tutorialOnboardingSeen === true);
+ registry.set(JOB_REGISTRY_KEY, findJob(save.player.primaryJobId).id);
+ registry.set(SECONDARY_JOB_REGISTRY_KEY, findSecondaryJob(save.player.secondaryJobId)?.id ?? null);
+ registry.set(TUTORIAL_ONBOARDING_SEEN_KEY, save.ui.tutorialOnboardingSeen);
+
+ // Copy a valid legacy save into the current slot without deleting the fallback.
+ if (!currentSave) persistRegistry(registry, storage);
} catch {
// A malformed or unavailable save must never prevent the game from booting.
}
@@ -66,12 +63,13 @@ export function hydrateRegistry(registry: Phaser.Data.DataManager, storage = bro
export function persistRegistry(registry: Phaser.Data.DataManager, storage = browserStorage()) {
if (!storage) return;
const state = readDexState(registry);
- const save: StoredGameState = {
- version: 1,
+ const save = createSave({
captures: state.cards.map(({ id, capturedAt }) => ({ id, capturedAt })),
- selectedJob: findJob(registry.get(JOB_REGISTRY_KEY) as string | undefined).id,
+ primaryJobId: findJob(registry.get(JOB_REGISTRY_KEY) as string | undefined).id,
+ secondaryJobId:
+ findSecondaryJob(registry.get(SECONDARY_JOB_REGISTRY_KEY) as string | null | undefined)?.id ?? null,
tutorialOnboardingSeen: registry.get(TUTORIAL_ONBOARDING_SEEN_KEY) === true,
- };
+ });
try {
storage.setItem(SAVE_STORAGE_KEY, JSON.stringify(save));
} catch {
@@ -95,11 +93,13 @@ export function hasSavedProgress(registry: Phaser.Data.DataManager): boolean {
export function resetGameProgress(registry: Phaser.Data.DataManager, storage = browserStorage()) {
try {
storage?.removeItem(SAVE_STORAGE_KEY);
+ storage?.removeItem(LEGACY_SAVE_STORAGE_KEY);
} catch {
// Registry reset still works when browser storage is unavailable.
}
registry.set(CARDS_KEY, EMPTY_DEX_STATE.cards);
registry.set(JOB_REGISTRY_KEY, "junior");
+ registry.set(SECONDARY_JOB_REGISTRY_KEY, null);
registry.set(TUTORIAL_ONBOARDING_SEEN_KEY, false);
persistRegistry(registry, storage);
}
diff --git a/web/lib/phaser/save/schema.ts b/web/lib/phaser/save/schema.ts
new file mode 100644
index 0000000..9a54000
--- /dev/null
+++ b/web/lib/phaser/save/schema.ts
@@ -0,0 +1,90 @@
+export interface StoredCapture {
+ id: string;
+ capturedAt: string;
+}
+
+interface StoredGameStateV1 {
+ version: 1;
+ captures: StoredCapture[];
+ selectedJob?: string;
+ tutorialOnboardingSeen?: boolean;
+}
+
+export interface StoredGameStateV2 {
+ version: 2;
+ progress: {
+ captures: StoredCapture[];
+ };
+ player: {
+ primaryJobId: string;
+ secondaryJobId: string | null;
+ };
+ ui: {
+ tutorialOnboardingSeen: boolean;
+ };
+}
+
+export interface SaveSnapshot {
+ captures: StoredCapture[];
+ primaryJobId: string;
+ secondaryJobId: string | null;
+ tutorialOnboardingSeen: boolean;
+}
+
+export function createSave(snapshot: SaveSnapshot): StoredGameStateV2 {
+ return {
+ version: 2,
+ progress: { captures: snapshot.captures },
+ player: {
+ primaryJobId: snapshot.primaryJobId,
+ secondaryJobId: snapshot.secondaryJobId,
+ },
+ ui: { tutorialOnboardingSeen: snapshot.tutorialOnboardingSeen },
+ };
+}
+
+/** Accepts both the current save and the original flat v1 shape. */
+export function parseSave(raw: string): StoredGameStateV2 | undefined {
+ let value: unknown;
+ try {
+ value = JSON.parse(raw);
+ } catch {
+ return undefined;
+ }
+ if (!isRecord(value)) return undefined;
+
+ if (value.version === 1) return migrateV1(value);
+ if (value.version !== 2 || !isRecord(value.progress) || !isRecord(value.player) || !isRecord(value.ui)) {
+ return undefined;
+ }
+ if (!Array.isArray(value.progress.captures)) return undefined;
+
+ return createSave({
+ captures: validCaptures(value.progress.captures),
+ primaryJobId: typeof value.player.primaryJobId === "string" ? value.player.primaryJobId : "junior",
+ secondaryJobId: typeof value.player.secondaryJobId === "string" ? value.player.secondaryJobId : null,
+ tutorialOnboardingSeen: value.ui.tutorialOnboardingSeen === true,
+ });
+}
+
+function migrateV1(value: Record): StoredGameStateV2 | undefined {
+ if (!Array.isArray(value.captures)) return undefined;
+ const old = value as unknown as StoredGameStateV1;
+ return createSave({
+ captures: validCaptures(old.captures),
+ primaryJobId: typeof old.selectedJob === "string" ? old.selectedJob : "junior",
+ secondaryJobId: null,
+ tutorialOnboardingSeen: old.tutorialOnboardingSeen === true,
+ });
+}
+
+function validCaptures(value: unknown[]): StoredCapture[] {
+ return value.filter(
+ (entry): entry is StoredCapture =>
+ isRecord(entry) && typeof entry.id === "string" && typeof entry.capturedAt === "string"
+ );
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
diff --git a/web/lib/phaser/scenes/CareerRegionScene.ts b/web/lib/phaser/scenes/CareerRegionScene.ts
new file mode 100644
index 0000000..69d89c7
--- /dev/null
+++ b/web/lib/phaser/scenes/CareerRegionScene.ts
@@ -0,0 +1,98 @@
+import Phaser from "phaser";
+import { findJob, type JobId } from "@/lib/domain/player/jobs";
+import { PALETTE, PALETTE_HEX } from "../palette";
+import { pixelText } from "../pixelFont";
+import { applyPixelFontToScene, createButton, drawOrnateFrame } from "../ui";
+import { careerPathFor, type CareerRegion } from "../worldMap/careerPaths";
+
+interface CareerRegionData {
+ careerId: JobId;
+ regionId: string;
+}
+
+/** A zoomed-in destination between the career atlas and a future chapter battle. */
+export class CareerRegionScene extends Phaser.Scene {
+ private careerId: JobId = "frontend";
+ private regionId = "html-css";
+
+ constructor() {
+ super("career-region");
+ }
+
+ init(data?: CareerRegionData) {
+ if (data?.careerId) this.careerId = data.careerId;
+ if (data?.regionId) this.regionId = data.regionId;
+ }
+
+ preload() {
+ const path = careerPathFor(this.careerId);
+ const job = findJob(this.careerId);
+ this.load.image(path.textureKey, path.assetPath);
+ this.load.image(job.textureKey!, job.assetPath!);
+ }
+
+ create() {
+ const { width, height } = this.scale;
+ const path = careerPathFor(this.careerId);
+ const job = findJob(this.careerId);
+ const region = path.regions.find((candidate) => candidate.id === this.regionId) ?? path.regions[0];
+ const zoom = 1.42;
+
+ // Pull the selected pad toward the center without exposing empty canvas at map edges.
+ const scaledWidth = width * zoom;
+ const scaledHeight = height * zoom;
+ const backdropX = Phaser.Math.Clamp(
+ width / 2 + (width / 2 - region.x) * zoom,
+ width - scaledWidth / 2,
+ scaledWidth / 2
+ );
+ const backdropY = Phaser.Math.Clamp(
+ height / 2 + (height / 2 - region.y) * zoom,
+ height - scaledHeight / 2,
+ scaledHeight / 2
+ );
+ this.add.image(backdropX, backdropY, path.textureKey).setDisplaySize(width * zoom, height * zoom);
+ this.add.rectangle(width / 2, height / 2, width, height, PALETTE.nightBrown, 0.2);
+
+ drawOrnateFrame(this, width / 2, 48, 430, 66, { fillAlpha: 0.96, radius: 12 });
+ this.add
+ .text(width / 2, 35, `${region.label} · 상세 지역`, {
+ ...pixelText("subtitle"),
+ color: PALETTE_HEX.maroon,
+ })
+ .setOrigin(0.5);
+ this.add
+ .text(width / 2, 60, `${job.name} 도감 수집지`, {
+ ...pixelText("caption"),
+ color: PALETTE_HEX.mutedBrown,
+ })
+ .setOrigin(0.5);
+
+ this.drawGuide(job.textureKey!, job.guideName, region);
+ createButton(this, 88, height - 28, 136, 34, "상세 지도", () => this.scene.start("world-map"));
+ createButton(this, width - 88, height - 28, 136, 34, "Path 보기", () =>
+ this.scene.start("path-map", { careerId: this.careerId })
+ );
+ applyPixelFontToScene(this);
+ }
+
+ private drawGuide(textureKey: string, guideName: string, region: CareerRegion) {
+ const { width, height } = this.scale;
+ drawOrnateFrame(this, width / 2, height - 112, 590, 118, { fillAlpha: 0.96, radius: 14 });
+ this.add.image(width / 2 - 230, height - 112, textureKey).setDisplaySize(102, 102);
+ this.add.text(width / 2 - 165, height - 148, guideName, {
+ ...pixelText("body"),
+ color: PALETTE_HEX.maroon,
+ });
+ this.add.text(
+ width / 2 - 165,
+ height - 120,
+ `${region.label} 몬스터의 도감·배틀·퀴즈 콘텐츠를 연결 중이에요.\n지역 입구와 돌아가는 길은 먼저 열어 두었어요.`,
+ {
+ ...pixelText("body"),
+ color: PALETTE_HEX.ink,
+ lineSpacing: 5,
+ }
+ );
+ }
+}
diff --git a/web/lib/phaser/scenes/CodigdexScene.ts b/web/lib/phaser/scenes/CodigdexScene.ts
index 68d72e1..aedb14f 100644
--- a/web/lib/phaser/scenes/CodigdexScene.ts
+++ b/web/lib/phaser/scenes/CodigdexScene.ts
@@ -80,7 +80,7 @@ export class CodigdexScene extends Phaser.Scene {
private pick(index: number) {
this.select(index);
const { monster, card } = this.entries[index];
- if (card) this.detail.open(monster, card);
+ if (monster && card) this.detail.open(monster, card);
}
private close() {
diff --git a/web/lib/phaser/scenes/JobSelectScene.ts b/web/lib/phaser/scenes/JobSelectScene.ts
index 5d2a90e..ccdc7ea 100644
--- a/web/lib/phaser/scenes/JobSelectScene.ts
+++ b/web/lib/phaser/scenes/JobSelectScene.ts
@@ -1,17 +1,29 @@
import Phaser from "phaser";
-import { PALETTE, PALETTE_HEX } from "../palette";
-import { applyPixelFontToScene, createButton, drawOrnateFrame } from "../ui";
-import { pixelText } from "../pixelFont";
-import { JOB_OPTIONS, JOB_REGISTRY_KEY } from "@/lib/domain/player/jobs";
import { isCommonPathComplete } from "@/lib/domain/chapters";
import { capturedIds } from "@/lib/domain/dex/capture";
+import {
+ findJob,
+ JOB_OPTIONS,
+ JOB_REGISTRY_KEY,
+ SECONDARY_JOB_OPTIONS,
+ type JobId,
+ type JobOption,
+} from "@/lib/domain/player/jobs";
+import { PALETTE, PALETTE_HEX } from "../palette";
+import { pixelText } from "../pixelFont";
import { readDexState } from "../registryAdapter";
+import { applyPixelFontToScene, createButton, drawOrnateFrame, showToast } from "../ui";
-const INK = PALETTE_HEX.ink;
-const CARD_WIDTH = 174;
-const CARD_HEIGHT = 166;
+const PRIMARY_X = 238;
+const SECONDARY_X = 730;
+const PRIMARY_WIDTH = 286;
+const SECONDARY_WIDTH = 222;
+const ROW_Y = [106, 184, 262, 340, 418] as const;
+/** Shows selectable primary jobs and the locked tier-two paths they can lead to. */
export class JobSelectScene extends Phaser.Scene {
+ private toast?: Phaser.GameObjects.Text;
+
constructor() {
super("job-select");
}
@@ -22,62 +34,135 @@ export class JobSelectScene extends Phaser.Scene {
create() {
const { width, height } = this.scale;
-
- this.add.rectangle(width / 2, height / 2, width, height, PALETTE.cream, 1);
+ this.toast = undefined;
+ this.add.rectangle(width / 2, height / 2, width, height, PALETTE.nightBrown, 1);
this.add
- .text(width / 2, 42, "어떤 개발자로 성장할까요?", {
+ .text(width / 2, 28, "CAREER PATH · 전직 계보", {
...pixelText("subtitle"),
- color: INK,
+ color: PALETTE_HEX.cream,
+ })
+ .setOrigin(0.5);
+ this.add
+ .text(PRIMARY_X, 63, "1차 전직 · 선택 가능", {
+ ...pixelText("body"),
+ color: PALETTE_HEX.amber,
+ })
+ .setOrigin(0.5);
+ this.add
+ .text(SECONDARY_X, 63, "2차 전직 · PATH PREVIEW", {
+ ...pixelText("body"),
+ color: PALETTE_HEX.mutedBrown,
})
.setOrigin(0.5);
- JOB_OPTIONS.forEach((job, index) => {
- const topRow = index < 3;
- const rowIndex = topRow ? index : index - 3;
- const cx = topRow ? 300 + rowIndex * 180 : 390 + rowIndex * 180;
- const cy = topRow ? 178 : 360;
- const frame = drawOrnateFrame(this, cx, cy, CARD_WIDTH, CARD_HEIGHT, { radius: 14 });
+ this.drawPromotionPaths();
+ const selected = findJob(this.registry.get(JOB_REGISTRY_KEY) as string | undefined).id;
+ JOB_OPTIONS.forEach((job, index) => this.drawPrimaryJob(job, ROW_Y[index], selected === job.id));
+ SECONDARY_JOB_OPTIONS.forEach((_job, index) => this.drawSecondaryMystery(index, ROW_Y[index]));
- this.add.image(cx, cy - 36, job.textureKey!).setDisplaySize(74, 74);
+ createButton(this, width / 2, height - 28, 140, 32, "돌아가기", () => this.scene.start("world-map"));
+ applyPixelFontToScene(this);
+ }
- this.add
- .text(cx, cy + 25, job.name, {
- ...pixelText("body"),
- color: INK,
- align: "center",
- wordWrap: { width: CARD_WIDTH - 20 },
- })
- .setOrigin(0.5);
+ private drawPromotionPaths() {
+ const indexByPrimary = new Map(JOB_OPTIONS.map((job, index) => [job.id, index]));
+ const lines = this.add.graphics();
+ SECONDARY_JOB_OPTIONS.forEach((secondary, secondaryIndex) => {
+ secondary.requires.forEach((primaryId, branchIndex) => {
+ const primaryIndex = indexByPrimary.get(primaryId)!;
+ const startY = ROW_Y[primaryIndex];
+ const endY = ROW_Y[secondaryIndex];
+ const elbowX = 470 + branchIndex * 28;
+ const stroke = () => {
+ lines.beginPath();
+ lines.moveTo(PRIMARY_X + PRIMARY_WIDTH / 2, startY);
+ lines.lineTo(elbowX, startY);
+ lines.lineTo(elbowX, endY);
+ lines.lineTo(SECONDARY_X - SECONDARY_WIDTH / 2, endY);
+ lines.strokePath();
+ };
+ lines.lineStyle(5, PALETTE.ink, 1);
+ stroke();
+ lines.lineStyle(2, PALETTE.mutedBrown, 0.68);
+ stroke();
+ });
+ });
+ }
+
+ private drawPrimaryJob(job: JobOption, y: number, selected: boolean) {
+ const frame = drawOrnateFrame(this, PRIMARY_X, y, PRIMARY_WIDTH, 62, {
+ fill: selected ? PALETTE.sand : PALETTE.cream,
+ radius: 10,
+ });
+ this.add.image(PRIMARY_X - 108, y, job.textureKey!).setDisplaySize(54, 54);
+ this.add
+ .text(PRIMARY_X - 70, y - 11, job.name, {
+ ...pixelText("body"),
+ color: PALETTE_HEX.ink,
+ })
+ .setOrigin(0, 0.5);
+ this.add
+ .text(PRIMARY_X - 70, y + 12, job.tagline, {
+ ...pixelText("caption"),
+ color: PALETTE_HEX.mutedBrown,
+ })
+ .setOrigin(0, 0.5);
+ if (selected) {
this.add
- .text(cx, cy + 58, job.tagline, {
+ .text(PRIMARY_X + 111, y, "현재", {
...pixelText("caption"),
- color: PALETTE_HEX.mutedBrown,
- align: "center",
- wordWrap: { width: CARD_WIDTH - 18 },
+ color: PALETTE_HEX.maroon,
})
.setOrigin(0.5);
+ }
- const hitArea = this.add
- .rectangle(cx, cy, CARD_WIDTH, CARD_HEIGHT, 0xffffff, 0)
- .setInteractive({ useHandCursor: true });
+ const hitArea = this.add
+ .rectangle(PRIMARY_X, y, PRIMARY_WIDTH, 62, 0xffffff, 0)
+ .setInteractive({ useHandCursor: true });
+ hitArea.on("pointerover", () => frame.setAlpha(0.82));
+ hitArea.on("pointerout", () => frame.setAlpha(1));
+ hitArea.on("pointerup", () => this.selectJob(job.id));
+ }
- hitArea.on("pointerover", () => frame.setAlpha(0.82));
- hitArea.on("pointerout", () => frame.setAlpha(1));
- hitArea.on("pointerup", () => this.selectJob(job.id));
+ private drawSecondaryMystery(index: number, y: number) {
+ const frame = drawOrnateFrame(this, SECONDARY_X, y, SECONDARY_WIDTH, 52, {
+ fill: PALETTE.wood,
+ fillAlpha: 0.94,
+ radius: 9,
});
+ this.add
+ .text(SECONDARY_X - 82, y, `Ⅱ-${index + 1}`, {
+ ...pixelText("caption"),
+ color: PALETTE_HEX.sand,
+ })
+ .setOrigin(0, 0.5);
+ this.add
+ .text(SECONDARY_X + 12, y, "◆ ???", {
+ ...pixelText("subtitle"),
+ color: PALETTE_HEX.cream,
+ })
+ .setOrigin(0.5);
- createButton(this, width / 2, height - 28, 140, 32, "돌아가기", () => this.scene.start("world-map"));
-
- applyPixelFontToScene(this);
+ const hitArea = this.add
+ .rectangle(SECONDARY_X, y, SECONDARY_WIDTH, 52, 0xffffff, 0)
+ .setInteractive({ useHandCursor: true });
+ hitArea.on("pointerover", () => frame.setAlpha(0.78));
+ hitArea.on("pointerout", () => frame.setAlpha(1));
+ hitArea.on("pointerup", () => {
+ this.toast = showToast(
+ this,
+ "??? · 연결된 두 1차 직업 도감을 완성하면 정체가 드러나요.",
+ this.toast
+ );
+ });
}
private selectJob(jobId: string) {
const commonPathComplete = isCommonPathComplete(capturedIds(readDexState(this.registry)));
// Before Git and Linux are cleared, choosing a card only previews its path.
- // The actual job title changes when the player returns here for promotion.
if (commonPathComplete) this.registry.set(JOB_REGISTRY_KEY, jobId);
- this.scene.start("path-map", { careerId: jobId });
+ this.scene.start("path-map", { careerId: jobId as JobId });
}
}
diff --git a/web/lib/phaser/scenes/PathMapScene.ts b/web/lib/phaser/scenes/PathMapScene.ts
index 92c12e9..23be30c 100644
--- a/web/lib/phaser/scenes/PathMapScene.ts
+++ b/web/lib/phaser/scenes/PathMapScene.ts
@@ -10,12 +10,13 @@ import { GIT_CHAPTER } from "@/lib/domain/chapters/git";
import { LINUX_CHAPTER } from "@/lib/domain/chapters/linux";
import type { ChapterId } from "@/lib/domain/chapters/types";
import { capturedIds } from "@/lib/domain/dex/capture";
-import { findJob, JOB_REGISTRY_KEY, type JobId } from "@/lib/domain/player/jobs";
+import { findJob, JOB_REGISTRY_KEY, secondaryJobsFor, type JobId } from "@/lib/domain/player/jobs";
import { COMMON_TECHNOLOGY_SPECIMENS } from "@/lib/domain/technologySpecimens";
import { preloadMonsterArt } from "../monsterArt";
+import { PALETTE } from "../palette";
import { drawConnections } from "../pathMap/connections";
import { CAREER_NODES, CAREER_PORTRAITS, COMMON_NODES, PROMOTION_NODE, type PathNode } from "../pathMap/layout";
-import { drawPathNode, drawPromotionNode } from "../pathMap/nodes";
+import { drawMysteryCareerNode, drawPathNode, drawPromotionNode } from "../pathMap/nodes";
import { StagePanel } from "../pathMap/stagePanel";
import { drawHeader, drawMapSurface, drawSectionLabels } from "../pathMap/surface";
import { readDexState } from "../registryAdapter";
@@ -79,6 +80,8 @@ export class PathMapScene extends Phaser.Scene {
}
}
+ if (this.selectedCareerId) this.drawSecondaryCareerMysteries(this.selectedCareerId);
+
createButton(this, width / 2, height - 27, 140, 32, "돌아가기", () => this.scene.start("world-map"));
createButton(this, 92, height - 27, 140, 32, "직업 변경", () => this.scene.start("job-select"));
if (this.captured.size > 0) {
@@ -89,6 +92,32 @@ export class PathMapScene extends Phaser.Scene {
this.openFocusedChapter();
}
+ private drawSecondaryCareerMysteries(careerId: JobId) {
+ const candidates = secondaryJobsFor(careerId);
+ const centerX = 790;
+ const y = 400;
+ const spacing = 115;
+ const startX = centerX - ((candidates.length - 1) * spacing) / 2;
+ const selectedNode = CAREER_NODES.find((node) => node.id === careerId)!;
+
+ const lines = this.add.graphics();
+ lines.lineStyle(5, PALETTE.ink, 0.8);
+ lines.lineBetween(selectedNode.x, 299, selectedNode.x, 350);
+ lines.lineStyle(2, PALETTE.mutedBrown, 0.75);
+ lines.lineBetween(selectedNode.x, 299, selectedNode.x, 350);
+
+ candidates.forEach((_candidate, index) => {
+ const x = startX + index * spacing;
+ lines.lineStyle(5, PALETTE.ink, 0.8);
+ lines.lineBetween(selectedNode.x, 350, x, y - 27);
+ lines.lineStyle(2, PALETTE.mutedBrown, 0.75);
+ lines.lineBetween(selectedNode.x, 350, x, y - 27);
+ drawMysteryCareerNode(this, x, y, () =>
+ this.notify("??? · 연관된 두 1차 직업 도감을 완성하면 정체가 드러나요.")
+ );
+ });
+ }
+
private statusOf(node: PathNode): ChapterStatus {
if (node.chapterId) return chapterStatus(getChapter(node.chapterId), this.captured);
if (node.kind === "career" && node.id === this.selectedCareerId && isCommonPathComplete(this.captured)) {
@@ -131,11 +160,11 @@ export class PathMapScene extends Phaser.Scene {
} else if (node.kind === "promotion") {
this.scene.start("job-select");
} else {
- this.notify(
- isCommonPathComplete(this.captured)
- ? `${node.label} 전직 완료! 전문 챕터는 준비 중이에요.`
- : `${node.label} 전직에는 Git과 Linux 클리어가 필요해요.`
- );
+ if (isCommonPathComplete(this.captured) && node.id === this.selectedCareerId) {
+ this.scene.start("world-map");
+ } else {
+ this.notify(`${node.label} 전직에는 Git과 Linux 클리어가 필요해요.`);
+ }
}
}
diff --git a/web/lib/phaser/scenes/WorldMapScene.ts b/web/lib/phaser/scenes/WorldMapScene.ts
index 09b46f6..00c0db9 100644
--- a/web/lib/phaser/scenes/WorldMapScene.ts
+++ b/web/lib/phaser/scenes/WorldMapScene.ts
@@ -17,7 +17,9 @@ import {
readDexState,
TUTORIAL_ONBOARDING_SEEN_KEY,
} from "../registryAdapter";
-import { applyPixelFontToScene, createButton, drawOrnateFrame } from "../ui";
+import { applyPixelFontToScene, createButton, drawOrnateFrame, showToast } from "../ui";
+import { drawCareerAtlas } from "../worldMap/careerAtlas";
+import { careerPathFor } from "../worldMap/careerPaths";
import { showGuideHint } from "../worldMap/guideHint";
import { OnboardingDialog } from "../worldMap/onboarding";
import { showQuestDialog } from "../worldMap/questDialog";
@@ -34,6 +36,8 @@ export class WorldMapScene extends Phaser.Scene {
private activeChapter?: ChapterDefinition;
private activeMonster?: MonsterDefinition;
private captured: ReadonlySet = new Set();
+ private selectedCareerId?: JobId;
+ private toast?: Phaser.GameObjects.Text;
constructor() {
super("world-map");
@@ -41,9 +45,12 @@ export class WorldMapScene extends Phaser.Scene {
preload() {
// The registry is hydrated at boot, so only this visit's backdrop needs downloading.
- const { backdrop } = this.resolveProgress();
+ const { backdrop, selectedJob } = this.resolveProgress();
this.load.image(backdrop.textureKey, backdrop.assetPath);
this.load.image("npc-lupi-guide", "/assets/npcs/lupi-guide-v1.png");
+ if (selectedJob.textureKey && selectedJob.assetPath) {
+ this.load.image(selectedJob.textureKey, selectedJob.assetPath);
+ }
preloadMonsterArt(this, CHAPTERS.flatMap((chapter) => chapter.stages));
}
@@ -65,18 +72,25 @@ export class WorldMapScene extends Phaser.Scene {
this.guideHint = undefined;
this.activeChapter = undefined;
this.activeMonster = undefined;
+ this.selectedCareerId = undefined;
+ this.toast = undefined;
const { captured, storedJob, selectedJob, backdrop } = this.resolveProgress();
this.captured = captured;
this.activeChapter = selectActiveChapter(this.captured);
this.activeMonster = this.activeChapter?.stages[currentStageIndex(this.activeChapter, this.captured)];
+ this.selectedCareerId = selectedJob.id === "junior" ? undefined : (selectedJob.id as JobId);
if (storedJob.id !== selectedJob.id) this.registry.set(JOB_REGISTRY_KEY, selectedJob.id);
this.add.image(width / 2, height / 2, backdrop.textureKey).setDisplaySize(width, height);
if (backdrop.ambience) playAmbience(this, backdrop.ambience);
this.hud = this.createHud(backdrop);
- if (this.activeChapter && this.activeMonster) this.createQuestActors();
+ if (this.activeChapter && this.activeMonster) {
+ this.createQuestActors();
+ } else if (this.selectedCareerId) {
+ this.createCareerAtlas(this.selectedCareerId);
+ }
applyPixelFontToScene(this);
if (!this.isTutorialCaptured() && this.registry.get(TUTORIAL_ONBOARDING_SEEN_KEY) !== true) {
@@ -108,6 +122,26 @@ export class WorldMapScene extends Phaser.Scene {
this.guideHint = undefined;
this.activeChapter = undefined;
this.activeMonster = undefined;
+ this.selectedCareerId = undefined;
+ this.toast = undefined;
+ });
+ }
+
+ private createCareerAtlas(careerId: JobId) {
+ const job = findJob(careerId);
+ drawCareerAtlas(this, {
+ job,
+ path: careerPathFor(careerId),
+ onRegion: (region) => {
+ this.scene.start("career-region", { careerId, regionId: region.id });
+ },
+ onMystery: () => {
+ this.toast = showToast(
+ this,
+ "??? · 다른 1차 직업 경로까지 완성하면 정체가 드러나요.",
+ this.toast
+ );
+ },
});
}
diff --git a/web/lib/phaser/worldMap/careerAtlas.ts b/web/lib/phaser/worldMap/careerAtlas.ts
new file mode 100644
index 0000000..b5f2067
--- /dev/null
+++ b/web/lib/phaser/worldMap/careerAtlas.ts
@@ -0,0 +1,128 @@
+import type Phaser from "phaser";
+import type { CareerPathDefinition, CareerRegion } from "./careerPaths";
+import type { JobOption } from "@/lib/domain/player/jobs";
+import { PALETTE, PALETTE_HEX } from "../palette";
+import { pixelText } from "../pixelFont";
+import { drawOrnateFrame } from "../ui";
+
+interface CareerAtlasOptions {
+ job: JobOption;
+ path: CareerPathDefinition;
+ onRegion: (region: CareerRegion) => void;
+ onMystery: () => void;
+}
+
+/** Turns a painted career wallpaper into a navigable atlas. */
+export function drawCareerAtlas(scene: Phaser.Scene, options: CareerAtlasOptions) {
+ options.path.regions.forEach((region, index) => drawRegion(scene, region, index + 1, options.onRegion));
+
+ const guideFrame = drawOrnateFrame(scene, 132, 111, 224, 78, { fillAlpha: 0.94, radius: 10 }).setDepth(5);
+ const guide = scene.add
+ .image(70, 111, options.job.textureKey!)
+ .setDisplaySize(72, 72)
+ .setDepth(6);
+ const guideName = scene.add
+ .text(110, 91, options.job.guideName, {
+ ...pixelText("caption"),
+ color: PALETTE_HEX.maroon,
+ })
+ .setDepth(6);
+ const guideLine = scene.add
+ .text(110, 111, "빛나는 지역을 눌러\n도감 수집지를 확인하세요.", {
+ ...pixelText("caption"),
+ color: PALETTE_HEX.ink,
+ lineSpacing: 3,
+ })
+ .setDepth(6);
+
+ const mysteryFrame = drawOrnateFrame(scene, 858, 466, 168, 76, {
+ fill: PALETTE.nightBrown,
+ fillAlpha: 0.92,
+ radius: 10,
+ }).setDepth(5);
+ const mysteryTitle = scene.add
+ .text(858, 451, "2차 전직", { ...pixelText("caption"), color: PALETTE_HEX.sand })
+ .setOrigin(0.5)
+ .setDepth(6);
+ const mystery = scene.add
+ .text(858, 477, "◆ ???", { ...pixelText("subtitle"), color: PALETTE_HEX.cream })
+ .setOrigin(0.5)
+ .setDepth(6);
+ const mysteryHitArea = scene.add
+ .rectangle(858, 466, 168, 76, 0xffffff, 0)
+ .setInteractive({ useHandCursor: true })
+ .setDepth(7);
+ mysteryHitArea.on("pointerover", () => mysteryFrame.setAlpha(0.82));
+ mysteryHitArea.on("pointerout", () => mysteryFrame.setAlpha(1));
+ mysteryHitArea.on("pointerup", options.onMystery);
+
+ return scene.add.container(0, 0, [guideFrame, guide, guideName, guideLine, mysteryFrame, mysteryTitle, mystery, mysteryHitArea]);
+}
+
+function drawRegion(
+ scene: Phaser.Scene,
+ region: CareerRegion,
+ order: number,
+ onSelect: (region: CareerRegion) => void
+) {
+ const radius = region.radius;
+ const landmark = region.landmark;
+ const landmarkHitArea = landmark
+ ? scene.add
+ .rectangle(landmark.x, landmark.y, landmark.width, landmark.height, 0xffffff, 0.001)
+ .setStrokeStyle(3, PALETTE.amber, 0)
+ .setInteractive({ useHandCursor: true })
+ .setDepth(3)
+ : undefined;
+ // The painted disc remains visible. This transparent circle makes the
+ // whole disc clickable, while the ring only signals interactivity.
+ const hitArea = scene.add
+ .circle(region.x, region.y, radius + 7, 0xffffff, 0.001)
+ .setInteractive({ useHandCursor: true })
+ .setDepth(5);
+ const ring = scene.add
+ .circle(region.x, region.y, radius, PALETTE.cream, 0)
+ .setStrokeStyle(3, PALETTE.amber, 0.95)
+ .setDepth(4);
+ const number = scene.add
+ .text(region.x, region.y, String(order), {
+ ...pixelText("caption"),
+ color: PALETTE_HEX.cream,
+ backgroundColor: "#6f2639dd",
+ padding: { x: 4, y: 2 },
+ })
+ .setOrigin(0.5)
+ .setDepth(4);
+ const label = scene.add
+ .text(region.x, region.y - 29, region.label, {
+ ...pixelText("caption"),
+ color: PALETTE_HEX.cream,
+ backgroundColor: "#2a1d14df",
+ padding: { x: 6, y: 3 },
+ })
+ .setOrigin(0.5)
+ .setDepth(5);
+
+ const activate = () => {
+ ring.setScale(1.12).setStrokeStyle(4, PALETTE.cream, 1);
+ landmarkHitArea?.setStrokeStyle(3, PALETTE.cream, 0.95);
+ label.setScale(1.04);
+ };
+ const deactivate = () => {
+ ring.setScale(1).setStrokeStyle(3, PALETTE.amber, 0.95);
+ landmarkHitArea?.setStrokeStyle(3, PALETTE.amber, 0);
+ label.setScale(1);
+ };
+ const select = () => onSelect(region);
+
+ const entryTargets: Phaser.GameObjects.GameObject[] = [hitArea];
+ if (landmarkHitArea) entryTargets.push(landmarkHitArea);
+ entryTargets.forEach((entry) => {
+ entry.on("pointerover", activate);
+ entry.on("pointerout", deactivate);
+ entry.on("pointerup", select);
+ });
+ const objects: Phaser.GameObjects.GameObject[] = [ring, number, label, hitArea];
+ if (landmarkHitArea) objects.unshift(landmarkHitArea);
+ return scene.add.container(0, 0, objects);
+}
diff --git a/web/lib/phaser/worldMap/careerPaths.ts b/web/lib/phaser/worldMap/careerPaths.ts
new file mode 100644
index 0000000..4b3f264
--- /dev/null
+++ b/web/lib/phaser/worldMap/careerPaths.ts
@@ -0,0 +1,107 @@
+import type { JobId } from "@/lib/domain/player/jobs";
+
+export interface CareerRegion {
+ id: string;
+ label: string;
+ /** Center and click radius of the painted entry disc. */
+ x: number;
+ y: number;
+ radius: number;
+ /** The illustrated destination connected to the disc is an entry too. */
+ landmark: { x: number; y: number; width: number; height: number };
+}
+
+export interface CareerPathDefinition {
+ jobId: JobId;
+ textureKey: string;
+ assetPath: string;
+ title: string;
+ regions: readonly CareerRegion[];
+}
+
+type Point = readonly [x: number, y: number];
+type Bounds = readonly [x: number, y: number, width: number, height: number];
+
+const region = (id: string, label: string, [x, y]: Point, [lx, ly, width, height]: Bounds): CareerRegion => ({
+ id,
+ label,
+ x,
+ y,
+ radius: 25,
+ landmark: { x: lx, y: ly, width, height },
+});
+
+export const CAREER_PATHS: Record = {
+ frontend: {
+ jobId: "frontend",
+ textureKey: "world-career-frontend",
+ assetPath: "/assets/wallpapers/career-paths/frontend-path-map-v1.png",
+ title: "웹 프론트엔드 개발자 경로",
+ regions: [
+ region("html-css", "HTML/CSS", [164, 384], [160, 365, 126, 108]),
+ region("javascript", "JavaScript", [339, 265], [390, 202, 118, 100]),
+ region("http-api", "브라우저 · HTTP", [487, 384], [488, 361, 142, 106]),
+ region("react", "React", [691, 305], [692, 257, 142, 116]),
+ region("frontend-testing", "프론트엔드 테스트", [839, 119], [876, 79, 138, 116]),
+ ],
+ },
+ backend: {
+ jobId: "backend",
+ textureKey: "world-career-backend",
+ assetPath: "/assets/wallpapers/career-paths/backend-path-map-v1.png",
+ title: "백엔드 개발자 경로",
+ regions: [
+ region("http-api", "HTTP/API", [109, 416], [111, 393, 122, 104]),
+ region("server-framework", "서버 프레임워크", [262, 195], [151, 197, 180, 154]),
+ region("sql", "데이터베이스 · SQL", [447, 205], [448, 174, 126, 116]),
+ region("security-auth", "인증 · 보안", [605, 145], [606, 111, 132, 112]),
+ region("network", "네트워크", [750, 230], [750, 198, 148, 122]),
+ region("docker", "Docker", [810, 353], [849, 360, 170, 118]),
+ ],
+ },
+ devops: {
+ jobId: "devops",
+ textureKey: "world-career-devops",
+ assetPath: "/assets/wallpapers/career-paths/devops-path-map-v1.png",
+ title: "DevOps 엔지니어 경로",
+ regions: [
+ region("network", "네트워크", [80, 435], [79, 405, 132, 126]),
+ region("docker", "Docker", [220, 379], [211, 346, 158, 108]),
+ region("cicd", "CI/CD", [395, 315], [382, 278, 168, 126]),
+ region("kubernetes", "Kubernetes", [520, 225], [521, 181, 148, 126]),
+ region("cloud-iac", "Cloud · IaC", [646, 369], [646, 333, 170, 122]),
+ region("monitoring", "모니터링", [740, 205], [741, 163, 136, 124]),
+ ],
+ },
+ "data-engineer": {
+ jobId: "data-engineer",
+ textureKey: "world-career-data-engineer",
+ assetPath: "/assets/wallpapers/career-paths/data-engineer-path-map-v1.png",
+ title: "데이터 엔지니어 경로",
+ regions: [
+ region("python", "Python", [114, 400], [113, 369, 132, 122]),
+ region("sql", "SQL · 데이터 모델링", [229, 203], [252, 169, 176, 126]),
+ region("data-pipeline", "데이터 파이프라인", [455, 369], [506, 341, 184, 128]),
+ region("docker", "Docker", [709, 157], [709, 125, 142, 124]),
+ region("orchestration", "오케스트레이션", [785, 284], [791, 246, 160, 118]),
+ region("monitoring", "모니터링", [858, 74], [861, 52, 140, 96]),
+ ],
+ },
+ "data-analyst": {
+ jobId: "data-analyst",
+ textureKey: "world-career-data-analyst",
+ assetPath: "/assets/wallpapers/career-paths/data-analyst-path-map-v1.png",
+ title: "데이터 분석가 경로",
+ regions: [
+ region("sql", "SQL", [272, 412], [122, 376, 104, 106]),
+ region("statistics", "기초 통계", [431, 326], [363, 313, 166, 118]),
+ region("visualization", "데이터 시각화", [580, 241], [550, 184, 142, 110]),
+ region("bi-tools", "BI 도구", [747, 183], [830, 98, 174, 140]),
+ region("python", "분석용 Python", [811, 282], [852, 300, 156, 96]),
+ ],
+ },
+};
+
+export function careerPathFor(jobId: JobId): CareerPathDefinition {
+ return CAREER_PATHS[jobId];
+}
diff --git a/web/lib/phaser/worldMap/progression.ts b/web/lib/phaser/worldMap/progression.ts
index d8d4a71..3cf8360 100644
--- a/web/lib/phaser/worldMap/progression.ts
+++ b/web/lib/phaser/worldMap/progression.ts
@@ -3,6 +3,7 @@ import { GIT_CHAPTER } from "@/lib/domain/chapters/git";
import { LINUX_CHAPTER } from "@/lib/domain/chapters/linux";
import { TUTORIAL_CHAPTER } from "@/lib/domain/chapters/tutorial";
import type { AmbienceId, ChapterDefinition } from "@/lib/domain/chapters/types";
+import { CAREER_PATHS } from "./careerPaths";
import type { JobId } from "@/lib/domain/player/jobs";
export interface WorldBackdrop {
@@ -33,33 +34,7 @@ const LINUX_BACKDROP: WorldBackdrop = {
ambience: "linux-cave",
};
-const CAREER_BACKDROPS: Record = {
- frontend: {
- textureKey: "world-career-frontend",
- assetPath: "/assets/wallpapers/career-paths/frontend-path-map-v1.png",
- title: "웹 프론트엔드 개발자 경로",
- },
- backend: {
- textureKey: "world-career-backend",
- assetPath: "/assets/wallpapers/career-paths/backend-path-map-v1.png",
- title: "백엔드 개발자 경로",
- },
- devops: {
- textureKey: "world-career-devops",
- assetPath: "/assets/wallpapers/career-paths/devops-path-map-v1.png",
- title: "DevOps 엔지니어 경로",
- },
- "data-engineer": {
- textureKey: "world-career-data-engineer",
- assetPath: "/assets/wallpapers/career-paths/data-engineer-path-map-v1.png",
- title: "데이터 엔지니어 경로",
- },
- "data-analyst": {
- textureKey: "world-career-data-analyst",
- assetPath: "/assets/wallpapers/career-paths/data-analyst-path-map-v1.png",
- title: "데이터 분석가 경로",
- },
-};
+const CAREER_BACKDROPS: Record = CAREER_PATHS;
export const WORLD_BACKDROPS = [TUTORIAL_BACKDROP, GIT_BACKDROP, LINUX_BACKDROP, ...Object.values(CAREER_BACKDROPS)];
diff --git a/web/test/domain/assets.test.ts b/web/test/domain/assets.test.ts
index 973fdf6..28e8c66 100644
--- a/web/test/domain/assets.test.ts
+++ b/web/test/domain/assets.test.ts
@@ -3,6 +3,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { CHAPTERS } from "@/lib/domain/chapters";
import { TECHNOLOGY_SPECIMENS } from "@/lib/domain/technologySpecimens";
+import { JOB_OPTIONS } from "@/lib/domain/player/jobs";
import { WORLD_BACKDROPS } from "@/lib/phaser/worldMap/progression";
const PUBLIC_DIR = path.resolve(__dirname, "../../public");
@@ -14,6 +15,7 @@ const PUBLIC_DIR = path.resolve(__dirname, "../../public");
*/
const referencedAssets = [
"/assets/npcs/lupi-guide-v1.png",
+ ...JOB_OPTIONS.flatMap((job) => job.assetPath ? [job.assetPath] : []),
...Object.values(TECHNOLOGY_SPECIMENS).map((specimen) => specimen.assetPath),
...WORLD_BACKDROPS.map((backdrop) => backdrop.assetPath),
...CHAPTERS.flatMap((chapter) => [
diff --git a/web/test/domain/careerPaths.test.ts b/web/test/domain/careerPaths.test.ts
new file mode 100644
index 0000000..0fa7e1a
--- /dev/null
+++ b/web/test/domain/careerPaths.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { CAREER_PATHS, careerPathFor } from "@/lib/phaser/worldMap/careerPaths";
+import { JOB_OPTIONS, SECONDARY_JOB_OPTIONS, secondaryJobsFor, type JobId } from "@/lib/domain/player/jobs";
+
+describe("career paths", () => {
+ it("gives every primary job a detailed map with several dex destinations", () => {
+ const primaryIds = JOB_OPTIONS.map((job) => job.id) as JobId[];
+ expect(Object.keys(CAREER_PATHS).sort()).toEqual([...primaryIds].sort());
+
+ primaryIds.forEach((jobId) => {
+ const path = careerPathFor(jobId);
+ expect(path.regions.length).toBeGreaterThanOrEqual(5);
+ expect(path.regions.every(({ x, y }) => x > 0 && x < 960 && y > 50 && y < 540)).toBe(true);
+ expect(path.regions.every(({ landmark }) => landmark !== undefined)).toBe(true);
+ expect(
+ path.regions.every(({ landmark }) =>
+ landmark
+ ? landmark.x - landmark.width / 2 >= 0 &&
+ landmark.x + landmark.width / 2 <= 960 &&
+ landmark.y - landmark.height / 2 >= 0 &&
+ landmark.y + landmark.height / 2 <= 540
+ : true
+ )
+ ).toBe(true);
+ });
+ });
+
+ it("keeps future tier-two identities modeled while the UI can render them as mysteries", () => {
+ expect(SECONDARY_JOB_OPTIONS.map((job) => job.id)).toContain("fullstack-engineer");
+ expect(SECONDARY_JOB_OPTIONS.map((job) => job.id)).toContain("ml-developer");
+ expect(secondaryJobsFor("backend").length).toBeGreaterThan(1);
+ });
+});
diff --git a/web/test/domain/dex/catalog.test.ts b/web/test/domain/dex/catalog.test.ts
new file mode 100644
index 0000000..debe1b0
--- /dev/null
+++ b/web/test/domain/dex/catalog.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { DEX_MONSTERS } from "@/lib/domain/chapters";
+import { DEX_CATALOG, PLANNED_DEX_SLOTS } from "@/lib/domain/dex/catalog";
+import { buildDexEntries } from "@/lib/phaser/dex/entry";
+
+describe("dex catalog roadmap", () => {
+ it("appends numbered ??? slots after released monsters", () => {
+ expect(PLANNED_DEX_SLOTS.length).toBeGreaterThan(0);
+ expect(DEX_CATALOG.slice(0, DEX_MONSTERS.length).every((slot) => slot.kind === "released")).toBe(true);
+ expect(PLANNED_DEX_SLOTS[0].dexNumber).toBe("012");
+ });
+
+ it("keeps planned slots separate from capturable monster definitions", () => {
+ const entries = buildDexEntries([]);
+ const planned = entries.filter((entry) => entry.planned);
+ expect(planned).toHaveLength(PLANNED_DEX_SLOTS.length);
+ expect(planned.every((entry) => !entry.monster && !entry.card)).toBe(true);
+ expect(DEX_MONSTERS).toHaveLength(11);
+ });
+});
diff --git a/web/test/phaser/registryAdapter.test.ts b/web/test/phaser/registryAdapter.test.ts
index 18fbd52..2be254b 100644
--- a/web/test/phaser/registryAdapter.test.ts
+++ b/web/test/phaser/registryAdapter.test.ts
@@ -1,10 +1,11 @@
import type Phaser from "phaser";
import { describe, expect, it, vi } from "vitest";
import { DEX_MONSTERS } from "@/lib/domain/chapters";
-import { JOB_REGISTRY_KEY } from "@/lib/domain/player/jobs";
+import { JOB_REGISTRY_KEY, SECONDARY_JOB_REGISTRY_KEY } from "@/lib/domain/player/jobs";
import {
hasSavedProgress,
hydrateRegistry,
+ LEGACY_SAVE_STORAGE_KEY,
persistRegistry,
readDexState,
resetGameProgress,
@@ -109,6 +110,27 @@ describe("hydrateRegistry", () => {
expect(unknown.get(TUTORIAL_ONBOARDING_SEEN_KEY)).toBe(false);
});
+ it("migrates the legacy storage slot into the nested v2 save", () => {
+ const storage = memoryStorage({
+ [LEGACY_SAVE_STORAGE_KEY]: JSON.stringify({
+ version: 1,
+ captures: [{ id: first.id, capturedAt: "2026-09-01T00:00:00.000Z" }],
+ selectedJob: "frontend",
+ tutorialOnboardingSeen: true,
+ }),
+ });
+ const registry = fakeRegistry();
+
+ hydrateRegistry(registry, storage);
+
+ expect(JSON.parse(storage.getItem(SAVE_STORAGE_KEY)!)).toEqual({
+ version: 2,
+ progress: { captures: [{ id: first.id, capturedAt: "2026-09-01T00:00:00.000Z" }] },
+ player: { primaryJobId: "frontend", secondaryJobId: null },
+ ui: { tutorialOnboardingSeen: true },
+ });
+ });
+
it.each([
["malformed JSON", memoryStorage({ [SAVE_STORAGE_KEY]: "{not json" })],
["an unknown save version", savedGame({ version: 2, captures: [] })],
@@ -146,10 +168,10 @@ describe("persistRegistry", () => {
persistRegistry(source, storage);
expect(JSON.parse(storage.getItem(SAVE_STORAGE_KEY)!)).toEqual({
- version: 1,
- captures: [{ id: first.id, capturedAt: "2026-09-01T00:00:00.000Z" }],
- selectedJob: "backend",
- tutorialOnboardingSeen: true,
+ version: 2,
+ progress: { captures: [{ id: first.id, capturedAt: "2026-09-01T00:00:00.000Z" }] },
+ player: { primaryJobId: "backend", secondaryJobId: null },
+ ui: { tutorialOnboardingSeen: true },
});
const restored = fakeRegistry();
@@ -179,13 +201,14 @@ describe("resetGameProgress", () => {
expect(readDexState(registry).cards).toEqual([]);
expect(registry.get(JOB_REGISTRY_KEY)).toBe("junior");
+ expect(registry.get(SECONDARY_JOB_REGISTRY_KEY)).toBeNull();
expect(registry.get(TUTORIAL_ONBOARDING_SEEN_KEY)).toBe(false);
expect(hasSavedProgress(registry)).toBe(false);
expect(JSON.parse(storage.getItem(SAVE_STORAGE_KEY)!)).toEqual({
- version: 1,
- captures: [],
- selectedJob: "junior",
- tutorialOnboardingSeen: false,
+ version: 2,
+ progress: { captures: [] },
+ player: { primaryJobId: "junior", secondaryJobId: null },
+ ui: { tutorialOnboardingSeen: false },
});
});