claude-ai-office 0.31.0 → 0.34.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.en.md +14 -6
  2. package/README.md +36 -10
  3. package/docs/ARCHITECTURE.md +6 -2
  4. package/package.json +1 -1
  5. package/public/assets/characters/acc-glasses.webp +0 -0
  6. package/public/assets/characters/acc-hearing.webp +0 -0
  7. package/public/assets/characters/acc-mask.webp +0 -0
  8. package/public/assets/characters/acc-sunglasses.webp +0 -0
  9. package/public/assets/characters/female-a.mask.webp +0 -0
  10. package/public/assets/characters/female-a.webp +0 -0
  11. package/public/assets/characters/female-b.mask.webp +0 -0
  12. package/public/assets/characters/female-b.webp +0 -0
  13. package/public/assets/characters/female-c.mask.webp +0 -0
  14. package/public/assets/characters/female-c.webp +0 -0
  15. package/public/assets/characters/female-d.mask.webp +0 -0
  16. package/public/assets/characters/female-d.webp +0 -0
  17. package/public/assets/characters/female-e.mask.webp +0 -0
  18. package/public/assets/characters/female-e.webp +0 -0
  19. package/public/assets/characters/female-f.mask.webp +0 -0
  20. package/public/assets/characters/female-f.webp +0 -0
  21. package/public/assets/characters/male-a.mask.webp +0 -0
  22. package/public/assets/characters/male-a.webp +0 -0
  23. package/public/assets/characters/male-b.mask.webp +0 -0
  24. package/public/assets/characters/male-b.webp +0 -0
  25. package/public/assets/characters/male-c.mask.webp +0 -0
  26. package/public/assets/characters/male-c.webp +0 -0
  27. package/public/assets/characters/male-d.mask.webp +0 -0
  28. package/public/assets/characters/male-d.webp +0 -0
  29. package/public/assets/characters/male-e.mask.webp +0 -0
  30. package/public/assets/characters/male-e.webp +0 -0
  31. package/public/assets/characters/male-f.mask.webp +0 -0
  32. package/public/assets/characters/male-f.webp +0 -0
  33. package/public/index.html +1 -1
  34. package/public/js/avatarStore.js +20 -17
  35. package/public/js/customCharacters.js +133 -0
  36. package/public/js/customize.js +148 -64
  37. package/public/js/i18n.js +16 -3
  38. package/public/js/scene/actors.js +28 -33
  39. package/public/js/scene/characters.js +309 -0
  40. package/public/style.css +34 -12
  41. package/scripts/bake-characters/bake.js +79 -0
  42. package/scripts/bake-characters/render.html +243 -0
  43. package/server.js +1 -0
  44. package/public/js/scene/avatar.js +0 -294
package/README.en.md CHANGED
@@ -138,14 +138,22 @@ Shortcuts work when no input field has focus.
138
138
 
139
139
  ## Character looks and names
140
140
 
141
- ![Customizer](docs/screenshots/fe-30-customize.jpg)
141
+ ![Customizer](docs/screenshots/fe-56-customize.jpg)
142
142
 
143
- ⚙️ Settings → **Character looks** customizes four targets separately: **me / default Claude / default subagent / the Claude of the session you are viewing**.
143
+ Characters are the 12 [Kenney Mini Characters](https://kenney.nl/assets/mini-characters) (CC0), baked at the office's isometric angle. They walk, sit in rest rooms, type while editing or running commands, and nod with a `!` when waiting for you. Subagents wear a yellow hard hat.
144
144
 
145
- - **10 presets**: Developer · Suit · Researcher · Casual · Artist · Wizard · King · Robot · Cat · Bear. Pick one, then tweak the parts.
146
- - **4 body types**: human · robot · cat · bear. The skin (fur, metal) palette follows the body.
147
- - **Parts**: skin, hair (12 styles + color), top (8 styles + color; `auto` uses the session color), bottom (pants · shorts · skirt), shoes, glasses (round · square · sunglasses · goggles · monocle), hats (cap · beanie · beret · headband · bucket hat · top hat · wizard · crown …), extras (headphones · scarf · badge · earring · bow tie · beard · mask …).
148
- - 🎲 random, ↺ reset, 🗑 delete the saved look. Saved in this browser only.
145
+ ⚙️ Settings → **Character looks** picks one of the 12 for four targets separately: **me / default Claude / default subagent / the Claude of the session you are viewing**. Claude targets default to **Auto** (a different character per session, the same in every browser). 🎲 random (a whole character or a mix of parts), ↺ reset, 🗑 delete the saved choice. Saved in this browser only.
146
+
147
+ **Mix parts**: the parts rows in the same window change a character piece by piece — any of the 12 **heads** on any of the 12 **bodies**, **hair / top / bottom colors** (original, a palette, or any color from the picker; the original shading pattern is kept), and an **accessory** (glasses, sunglasses, mask, hearing aid from the Kenney pack). Walking, sitting, typing, nodding and the subagent hard hat all work with mixes.
148
+
149
+ ![Mix parts](docs/screenshots/fe-57-customize.jpg)
150
+
151
+ **My characters (your own pictures)**: click **+ Upload PNG** or drop a file anywhere on the customizer to use a picture with a transparent background.
152
+
153
+ - The transparent margin is trimmed and the long side scaled to 256 px, then stored in this browser's localStorage (several pictures, up to 24 / about 2.5 MB). Hover a tile and press **×** twice to delete it; targets using it go back to their default.
154
+ - Any target can use one. **Auto** never picks an uploaded picture.
155
+ - It is a single still image, so the whole body moves: a bounce and slight tilt per step, lower when sitting, a small jitter while typing, a bow when nodding. It flips with the walking direction, and a subagent's hard hat sits on the top of the head (found from the alpha channel).
156
+ - Non-images, files over 10 MB or 8192 px, and full storage show a notice in the customizer. A picture without transparency still works but shows as a rectangle.
149
157
 
150
158
  **Names per session**: every session's character gets an automatic nickname (Alex, Sam, Max, Leo, Mia, Zoe, …). Rename it in the "Character" row of the session card or in the customizer (🎲 draws another nickname; clearing the field goes back to automatic). The log and tooltips use that name. The server still calls it `Claude`; only the screen changes.
151
159
 
package/README.md CHANGED
@@ -138,16 +138,40 @@ node bin/ai-office.js # 또는 npm run install-hooks && npm start
138
138
  | `Esc` | 단축키 패널 · 외형 모달 · 설정 메뉴 · 전체 보기 닫기 → 검색창 비우기 → 따라가기 해제 (순서대로 하나씩) |
139
139
  | `?` | 단축키 패널 열기/닫기 |
140
140
 
141
- ## 캐릭터 외형과 이름
141
+ ## 캐릭터와 이름
142
142
 
143
- ![캐릭터 외형 모달](docs/screenshots/fe-30-customize.jpg)
143
+ ![캐릭터 12명과 동작](docs/screenshots/fe-54-characters.jpg)
144
144
 
145
- ⚙️ 설정 → **캐릭터 외형** 에서 **나 / Claude 기본 / 서브에이전트 기본 / 지금 보는 세션의 Claude** 네 대상을 따로 꾸밀 수 있습니다.
145
+ 캐릭터는 [Kenney Mini Characters](https://kenney.nl/assets/mini-characters)(CC0) 12명을 사무실과 같은 각도(2:1 아이소메트릭)로 구운 스프라이트입니다. 상태에 따라 동작이 바뀝니다: 이동 중 **걷기**, 휴식 방에서 **앉기**, 편집·실행 중 **타이핑**, 답을 기다리면 **끄덕임** + 머리 위 느낌표. 서브에이전트는 노란 **안전모**를 씁니다.
146
146
 
147
- - **프리셋** 10종: 개발자 · 정장 · 연구원 · 캐주얼 · 예술가 · 마법사 · 임금님 · 로봇 · 고양이 · 곰. 고른 뒤 파츠를 더 바꿀 수 있다.
148
- - **몸 종류** 4가지: 사람 · 로봇 · 고양이 · 곰. 몸에 따라 피부(털·금속) 팔레트가 바뀐다.
149
- - **파츠**: 피부색, 머리(12가지 스타일 + 색), 상의(8종 + 색, `auto` 는 세션 색), 하의(바지·반바지·치마), 신발, 안경(둥근·네모·선글라스·고글·외알), 모자(캡·비니·베레모·머리띠·버킷햇·실크햇·마법사·왕관…), 소품(헤드폰·스카프·사원증·귀걸이·나비넥타이·수염·마스크…).
150
- - 🎲 랜덤, ↺ 초기화, 🗑 저장 삭제. 저장은 이 브라우저에만 남는다.
147
+ ![캐릭터 고르기 모달](docs/screenshots/fe-54-customize.jpg)
148
+
149
+ ⚙️ 설정 → **캐릭터 외형** 에서 **나 / Claude 기본 / 서브에이전트 기본 / 지금 보는 층의 Claude** 네 대상마다 12명 중 한 명을 고릅니다.
150
+
151
+ - Claude 계열은 기본이 **자동**: 세션마다 다른 캐릭터가 배정되고(세션 id 해시), 어느 브라우저에서도 같은 세션은 같은 캐릭터입니다.
152
+ - 🎲 랜덤(통째 캐릭터 또는 파트 조합), ↺ 초기화, 🗑 저장 삭제. 저장은 이 브라우저에만 남는다.
153
+
154
+ **파트 바꾸기**: 같은 창의 파트 줄에서 캐릭터를 부위별로 고칩니다.
155
+
156
+ ![파트 바꾸기 — 고르기 창](docs/screenshots/fe-57-customize.jpg)
157
+
158
+ - **머리 · 몸**: 12명 중 아무 머리와 아무 몸을 섞는다 (예: female-b 의 머리 + male-d 의 정장).
159
+ - **머리카락 · 상의 · 하의 색**: 원래 색, 팔레트, 또는 색 고르개로 아무 색. 원래 무늬의 밝고 어두움은 그대로 남는다 (넥타이·칼라처럼 다른 색 부분은 원래 색).
160
+ - **소품**: 안경 · 선글라스 · 마스크 · 보청기 (Kenney 팩에 들어 있는 모델).
161
+ - 걷기·앉기·타이핑·끄덕임과 서브에이전트 안전모도 조합 그대로 움직입니다.
162
+
163
+ ![파트 조합 × 동작 — 맨 오른쪽은 서브에이전트 안전모](docs/screenshots/fe-57-parts.jpg)
164
+
165
+ **내 캐릭터 (직접 올린 그림)**: 같은 창의 **+ PNG 올리기** 타일을 누르거나 그 위(창 어디든)에 파일을 끌어다 놓으면, 배경이 투명한 그림을 캐릭터로 씁니다.
166
+
167
+ ![PNG 올리기 — 고르기 창](docs/screenshots/fe-56-customize.jpg)
168
+
169
+ - 올린 그림은 투명 여백을 잘라내고 긴 변을 256px 로 줄여 이 브라우저 localStorage 에 저장됩니다(여러 장, 최대 24장·합계 약 2.5MB). 타일에 마우스를 올려 **×** 를 두 번 누르면 지워지고, 그 그림을 쓰던 대상은 기본값으로 돌아갑니다.
170
+ - 네 대상 어디에나 쓸 수 있습니다. **자동**은 Kenney 12명 중에서만 고르고 올린 그림은 섞지 않습니다.
171
+ - 정지 그림 한 장이라 동작은 몸 전체로 보여 줍니다: 걷기는 걸음마다 통통 튀며 살짝 기울고, 앉으면 낮아지고, 타이핑은 잔떨림, 끄덕임은 숙였다 펴기. 진행 방향에 따라 좌우가 뒤집히고, 서브에이전트면 그림의 머리 꼭대기(알파로 찾음)에 안전모가 얹힙니다.
172
+ - 이미지가 아니거나(파일 10MB·한 변 8192px 초과 포함) 저장 공간이 모자라면 창 안에 안내가 나옵니다. 배경이 투명하지 않은 그림도 쓸 수는 있지만 사각형 그대로 보입니다.
173
+
174
+ ![사무실의 커스텀 캐릭터 — 서브에이전트는 안전모](docs/screenshots/fe-56-subagent-hardhat.jpg)
151
175
 
152
176
  **세션별 이름**: 세션마다 캐릭터에 자동 별명(Alex, Sam, Max, Leo, Mia, Zoe, …)이 붙고, 우측 세션 카드의 "캐릭터" 행이나 외형 모달의 이름 행에서 직접 이름을 지을 수 있습니다(🎲 로 다른 별명 뽑기, 비우면 자동 별명으로). 활동 로그와 툴팁도 그 이름으로 표시됩니다. 서버가 보는 이름은 그대로 `Claude` 이고 화면에서만 바뀝니다.
153
177
 
@@ -182,8 +206,8 @@ Claude Code 세션 ─hooks─▶ hooks/emit.js ─POST /hook─▶ server.js
182
206
  - `lib/normalize.js` 훅 payload 축소 + 도구 → 방/말풍선 해석
183
207
  - `lib/state.js` 층/세션/캐릭터 상태 머신, `lib/project.js` cwd → git 저장소·브랜치·worktree
184
208
  - `lib/journal.js` 이벤트 JSONL 기록(`~/.ai-office/events.jsonl`) + 기동 시 재생, 주기 압축
185
- - `public/js/scene/*` 아이소메트릭 맵·가구·캐릭터(외형 `avatar.js`)·A*·카메라·시간대 조명(`daylight.js`)·층 스타일(`floorStyle.js`)
186
- - `public/js/overview.js` 전체 보기, `customize.js` 외형 모달, `names.js` 세션별 이름, `panels.js` 좌/우 패널
209
+ - `public/js/scene/*` 아이소메트릭 맵·가구·캐릭터(스프라이트 `characters.js`, 시트는 `public/assets/characters/`)·A*·카메라·시간대 조명(`daylight.js`)·층 스타일(`floorStyle.js`)
210
+ - `public/js/overview.js` 전체 보기, `customize.js` 캐릭터 고르기 모달, `names.js` 세션별 이름, `panels.js` 좌/우 패널
187
211
  - `bin/ai-office.js` CLI, `scripts/install-hooks.js` 훅 등록/제거, `scripts/demo.js` 데모
188
212
 
189
213
  외부 의존성 0, 번들러 없음. 자세한 설계는 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
@@ -191,7 +215,7 @@ Claude Code 세션 ─hooks─▶ hooks/emit.js ─POST /hook─▶ server.js
191
215
  ## 개발
192
216
 
193
217
  ```bash
194
- npm test # node --test (상태 머신 · 경로 탐색 · 외형 · 이름 · CLI …)
218
+ npm test # node --test (상태 머신 · 경로 탐색 · 캐릭터 · 이름 · CLI …)
195
219
  AI_OFFICE_PORT=4799 npm start # 다른 포트로 띄우기 (훅도 같은 포트로 등록해야 보임)
196
220
  npm run demo # 서버가 떠 있는 상태에서 가짜 세션 흘려 넣기 (DEMO_SPEED=3 으로 빠르게)
197
221
  ```
@@ -205,4 +229,6 @@ npm run bump:minor # 0.19.0 → 0.20.0
205
229
 
206
230
  릴리스: `git tag vX.Y.Z && git push origin vX.Y.Z` 를 올리면 GitHub Actions 가 맥·Windows 앱을 빌드해 Release 에 올리고(updater 용 `latest.json` 포함), Windows 러너에서 설치 → 실행 → 훅까지 확인합니다. 테스트는 push·PR 마다 Ubuntu·Windows·macOS 에서 돕니다.
207
231
 
232
+ 캐릭터 스프라이트 다시 굽기: [Kenney Mini Characters](https://kenney.nl/assets/mini-characters) 팩을 받아 풀고 `node scripts/bake-characters/bake.js <팩 폴더>` (헤드리스 크롬 + three.js CDN, 결과는 `public/assets/characters/*.webp`). 캐릭터마다 색 시트(머리 레이어 + 몸통 레이어)와 마스크 시트(머리카락·상의·하의 음영), 소품마다 시트 하나를 굽는다. 시트 구성(프레임 크기·동작 행·레이어)과 소품 위치는 `characters.js` 의 `SHEET`·`ACCESSORIES` 에서 정한다.
233
+
208
234
  작업 흐름: GitHub 이슈를 `[FE-30]`, `[BE-13]`, `[APP-03]` 처럼 FE/BE/APP 으로 나눠 만들고, `main` 에서 `feature/FE-30` 식 브랜치를 분리해 작업한 뒤 결과·테스트·캡처를 이슈 댓글로 남긴다.
@@ -168,6 +168,7 @@ public/
168
168
  index.html 3단 레이아웃 (좌 층별 안내 / 중앙 canvas / 우 사람들·로그), 상단 헤더(층·세션 제목·브랜치·LIVE)
169
169
  style.css 라이트/다크 테마 토큰, 패널 카드
170
170
  fonts/ 서브셋 웹폰트 (scripts/subset-fonts.sh)
171
+ assets/characters/ 캐릭터 스프라이트 webp: 12명 색 시트(머리·몸통 레이어) + 마스크 시트(머리카락·상의·하의 음영) + 소품 4장 (scripts/bake-characters 로 굽는다)
171
172
  js/
172
173
  main.js SSE 연결·재접속, store 갱신, 알림(소리·브라우저 알림), 설정, 키보드(1~9 층 이동)
173
174
  store.js floors/sessions/log 미러, 선택된 층/세션, 층 접기(auto/all/none), subscribe()
@@ -175,7 +176,8 @@ public/
175
176
  overview.js 전체 보기: 층을 건물처럼 쌓아 미니 사무실 + 캐릭터 점
176
177
  i18n.js 언어: 한국어 원문, 영어는 사전 → 패턴 규칙 → 구절 치환. 사용자 요청·제목은 번역하지 않는다
177
178
  names.js 세션별 캐릭터 별명 (localStorage)
178
- customize.js, avatarStore.js 캐릭터 외형 커스터마이즈 (몸 종류·파츠·색·악세사리)
179
+ customize.js, avatarStore.js 캐릭터 고르기 (대상별 12명 중 하나 / 자동 / 올린 그림)
180
+ customCharacters.js 커스텀 캐릭터: PNG 투명 여백 자르기·256px 축소, dataURL 을 localStorage 에 (FE-56)
179
181
  floorStylePicker.js 층 🎨 → 인테리어 프리셋 선택 (서버에 저장)
180
182
  tooltip.js, texttip.js 캐릭터 툴팁, 잘린 글자 툴팁
181
183
  scene/
@@ -185,7 +187,7 @@ public/
185
187
  pathfind.js A* (4방향), 벽 edge 와 solid 가구 회피
186
188
  actors.js Agent → Actor(위치, 경로, 걷기 애니, 말풍선, 슬롯 점유/해제, 휴식 방 안 배회)
187
189
  spots.js 휴식 방 명소 (레이아웃이 채우는 live 객체)
188
- avatar.js 치비 캐릭터 그리기 (사람·로봇·고양이·곰)
190
+ characters.js 캐릭터 스프라이트 (Kenney Mini Characters, CC0) 동작 프레임·안전모, 파트 조합('mix:...') 레이어·색 입히기·소품, 커스텀 그림('custom:<id>') 그리기
189
191
  render.js 바닥 → 3D AABB 깊이 정렬된 가구/벽/캐릭터 → 방 이름표·말풍선
190
192
  sprites.js 가구·벽을 코드로 그리는 함수들 (외부 이미지 없음)
191
193
  camera.js 팬·줌·맵 맞춤·클릭 판정, 선택 세션 따라가기
@@ -234,6 +236,8 @@ public/
234
236
  - **그림 자산 없음**: 가구·캐릭터를 canvas 코드로 그린다 (sprites.js · avatar.js).
235
237
  - **재생 가능한 저널**: 상태를 직접 저장하지 않고 원본 이벤트를 남겨 재생한다. 상태 머신이 바뀌어도 저널은 그대로 쓸 수 있다.
236
238
  - **뷰어 설정은 브라우저에**: 별명·외형·언어·테마는 localStorage. 층 인테리어처럼 모두가 같이 봐야 하는 것만 서버에 둔다.
239
+ - **커스텀 캐릭터도 브라우저에 (FE-56)**: 올린 PNG 는 서버로 보내지 않는다. 투명 여백을 자르고 긴 변 256px 로 줄인 dataURL 을 `aiOffice.customCharacters.v1` 에 두고(최대 24장·2.5MB, 실패하면 되돌림), 고른 값은 `custom:<id>` 로 `aiOffice.characters.v1` 에 섞인다. 키를 나눠 그림 저장이 실패해도 고르기 설정은 남는다. '자동' 해시는 Kenney 12명만 쓴다. 정지 그림 한 장이라 동작은 몸 전체의 오프셋·기울기·눌림으로 보이고, 안전모 위치는 스프라이트와 같은 알파 스캔(`scanHead`)으로 찾는다.
240
+ - **파트 조합은 굽고 겹친다 (FE-57)**: Kenney 12명은 뼈대가 같고 모델이 머리·몸통 두 덩어리라, 굽기에서 머리 레이어(몸통은 깊이만 써서 팔이 앞에 오면 머리가 잘린다)와 몸통 레이어를 따로 굽는다. 모두 같은 배율(가장 큰 캐릭터 기준)·같은 앉기 높이라 아무 머리나 아무 몸 위에 겹치면 맞는다. 색 바꾸기는 팔레트(8열×4띠) 칸으로 부위를 고른다: 피부 = 머리와 팔에 함께 쓰인 칸, 머리카락 = 머리의 그다음 넓은 칸(얼굴에 붙은 납작한 눈·입은 뺌), 상의·하의 = 몸통·팔 / 다리(신발 위)에서 가장 넓은 칸. 그 부위를 흰 알베도 × 상대 밝기로 다시 그린 음영을 마스크 시트에 두고, 그릴 때 선형 색공간에서 음영 × 새 색으로 칠한 레이어를 캐시한다(최근 16개). 소품은 원래 안경이 없는 male-f 의 머리 뼈에 붙여 구운 시트 하나를 모든 머리에 얹는다. 값은 문자열 `mix:<머리>.<몸>.<머리카락>.<상의>.<하의>.<소품>` 이라 기존 저장·정규화·대상별 설정을 그대로 탄다.
237
241
  - **범위**: Claude Code 훅만 받는다. claude.ai 채팅·클라우드 세션은 훅이 없어 다루지 않는다.
238
242
 
239
243
  ## 9. 보안 (BE-21)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-ai-office",
3
- "version": "0.31.0",
3
+ "version": "0.34.2",
4
4
  "description": "Claude Code 세션 활동을 아이소메트릭 사무실로 실시간 시각화",
5
5
  "keywords": [
6
6
  "claude",
package/public/index.html CHANGED
@@ -40,7 +40,7 @@
40
40
  <div class="settings-row"><span class="settings-label">생동감</span><button id="ambient-btn" class="tool" title="쉬는 캐릭터가 휴게실·카페·테라스 안을 옮겨 다니거나 산책하는 자율 행동">✨ 꺼짐</button></div>
41
41
  <div class="settings-row"><span class="settings-label">테마 <kbd>T</kbd></span><button id="theme-btn" class="tool" title="다크 모드 전환">🌙 라이트</button></div>
42
42
  <div class="settings-row"><span class="settings-label">데모 <kbd>D</kbd></span><button id="demo-btn" class="tool" title="가짜 세션 4개로 사무실을 북적이게 (실제 세션과 별도 층, '(데모)' 표시)">🎬 꺼짐</button></div>
43
- <div class="settings-row"><span class="settings-label">캐릭터 외형</span><button id="customize-btn" class="tool" title="나 / Claude / 서브에이전트 캐릭터 외형 고르기 — 프리셋·몸 종류(사람·로봇·고양이·곰)·파츠, 지금 보는 세션의 캐릭터 이름">👤 고르기…</button></div>
43
+ <div class="settings-row"><span class="settings-label">캐릭터 외형</span><button id="customize-btn" class="tool" title="나 / Claude / 서브에이전트 캐릭터 고르기 — 12명 중 하나 또는 머리·몸·색·소품 조합, 세션마다 자동 또는 직접 올린 PNG, 지금 보는 세션의 캐릭터 이름">👤 고르기…</button></div>
44
44
  <div class="settings-row"><span class="settings-label">시간대 조명</span><select id="daylight-select" class="tool" title="실제 시각에 따라 창문·테라스·실내 빛이 바뀝니다 (아침 06~10 · 낮 10~17 · 저녁 17~20 · 밤 20~06). 미리보기로 시간대를 고정할 수도 있어요"><option value="auto">🕒 자동 (실제 시각)</option><option value="off">끔</option><option value="morning">🌅 아침</option><option value="day">☀️ 낮</option><option value="evening">🌇 저녁</option><option value="night">🌙 밤</option></select></div>
45
45
  <div class="settings-row"><span class="settings-label">층 인테리어</span><button id="floor-style-btn" class="tool" title="지금 보는 층의 인테리어 프리셋 (층별 안내의 🎨 와 같음) — 색과 사무실 구조가 바뀝니다">🎨 고르기…</button></div>
46
46
  <div class="settings-row"><span class="settings-label">언어</span><select id="lang-select" class="tool" title="화면 언어"><option value="ko">한국어</option><option value="en">English</option></select></div>
@@ -1,8 +1,9 @@
1
- // 외형 설정 저장소. 뷰어 설정이므로 localStorage 에 둔다.
2
- // 키: me | claude | sub | floor:<floorId>
3
- import { DEFAULTS, normalizeAvatar } from './scene/avatar.js';
1
+ // 캐릭터 고르기 저장소. 뷰어 설정이므로 localStorage 에 둔다.
2
+ // 키: me | claude | sub | floor:<floorId> 값: 캐릭터 이름(characters.js 의 CHARACTERS), 'mix:...'(파트 조합), 'custom:<id>'(올린 그림) 또는 'auto'(세션마다 다르게)
3
+ import { DEFAULTS, isCustomCharacter, normalizeCharacter, resolveCharacter } from './scene/characters.js';
4
+ import { createCustomCharacters } from './customCharacters.js';
4
5
 
5
- const KEY = 'aiOffice.avatars.v2';
6
+ const KEY = 'aiOffice.characters.v1';
6
7
 
7
8
  function load() {
8
9
  try {
@@ -13,35 +14,37 @@ function load() {
13
14
  } catch { return {}; }
14
15
  }
15
16
 
16
- export function createAvatarStore() {
17
+ export function createAvatarStore(customs = createCustomCharacters()) {
17
18
  let data = load();
18
19
  const listeners = new Set();
19
- const cache = new Map();
20
+ customs.subscribe(() => { for (const fn of listeners) fn(); });
20
21
 
21
22
  function save() {
22
23
  try { localStorage.setItem(KEY, JSON.stringify(data)); } catch {}
23
- cache.clear();
24
24
  for (const fn of listeners) fn();
25
25
  }
26
26
 
27
27
  function baseFor(key) { return key === 'me' ? DEFAULTS.me : key === 'sub' ? DEFAULTS.sub : DEFAULTS.claude; }
28
28
 
29
- /** 저장된 값이 없으면 기본값 */
29
+ /** 저장된 값이 없으면 기본값. 지운 커스텀 그림을 가리키면 기본값. 나는 'auto' 가 없다 */
30
30
  function get(key) {
31
- if (!cache.has(key)) cache.set(key, normalizeAvatar(data[key], baseFor(key)));
32
- return cache.get(key);
31
+ let v = normalizeCharacter(data[key], baseFor(key));
32
+ if (isCustomCharacter(v) && !customs.has(v)) v = baseFor(key);
33
+ return key === 'me' && v === 'auto' ? DEFAULTS.me : v;
33
34
  }
34
- function has(key) { return !!data[key]; }
35
- function set(key, spec) { data[key] = normalizeAvatar(spec, baseFor(key)); save(); }
35
+ function has(key) { return key in data; }
36
+ function set(key, value) { data[key] = normalizeCharacter(value, baseFor(key)); save(); }
36
37
  function reset(key) { delete data[key]; save(); }
37
38
 
38
- /** 캐릭터에 쓸 외형: 층별 Claude 설정 > Claude 기본 */
39
+ /** 캐릭터에 쓸 이름: 층별 Claude 설정 > Claude 기본. 'auto' 는 캐릭터 키 해시로 */
39
40
  function forActor(a, floorId) {
40
41
  if (a.user) return get('me');
41
- if (a.kind === 'sub') return get('sub');
42
- if (floorId && data[`floor:${floorId}`]) return get(`floor:${floorId}`);
43
- return get('claude');
42
+ let v;
43
+ if (a.kind === 'sub') v = get('sub');
44
+ else if (floorId && has(`floor:${floorId}`)) v = get(`floor:${floorId}`);
45
+ else v = get('claude');
46
+ return resolveCharacter(v, a.key);
44
47
  }
45
48
 
46
- return { get, has, set, reset, forActor, baseFor, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); } };
49
+ return { get, has, set, reset, forActor, baseFor, customs, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); } };
47
50
  }
@@ -0,0 +1,133 @@
1
+ // 커스텀 캐릭터 (FE-56): 사용자가 올린 배경 없는 그림. 뷰어 설정이므로 localStorage 에 dataURL 로 둔다.
2
+ // 키: aiOffice.customCharacters.v1 = [{ id, name, src, w, h, at }] 캐릭터 값은 'custom:<id>' (characters.js)
3
+ // 고른 캐릭터(aiOffice.characters.v1)와 키를 나눠, 그림이 커서 저장에 실패해도 고르기 설정은 남는다.
4
+ import { setCustomCharacters } from './scene/characters.js';
5
+
6
+ const KEY = 'aiOffice.customCharacters.v1';
7
+ export const LIMITS = {
8
+ fileBytes: 10 * 1024 * 1024, // 올리는 파일
9
+ pixels: 8192, // 원본 한 변
10
+ side: 256, // 저장할 그림의 긴 변
11
+ srcChars: 400 * 1024, // 한 장의 dataURL 길이
12
+ totalChars: 2.5 * 1024 * 1024, // 전부 합친 길이 (localStorage 는 보통 5MB 안팎)
13
+ count: 24,
14
+ };
15
+
16
+ /** 오류 코드 → 안내 문구 (고르기 창에 그대로 보인다) */
17
+ export const MESSAGES = {
18
+ 'not-image': '이미지 파일이 아닙니다 — 배경이 투명한 PNG 를 올려 주세요',
19
+ 'too-big': '파일이 너무 큽니다 — 10MB 이하로 올려 주세요',
20
+ 'too-large': '그림이 너무 큽니다 — 한 변 8192px 이하로 올려 주세요',
21
+ 'decode': '이미지를 읽지 못했습니다 — 다른 파일로 해 보세요',
22
+ 'empty': '그림이 전부 투명합니다',
23
+ 'too-many': '커스텀 캐릭터가 너무 많습니다 — 안 쓰는 것을 지워 주세요',
24
+ 'quota': '저장 공간이 부족합니다 — 안 쓰는 커스텀 캐릭터를 지워 주세요',
25
+ 'opaque': '배경이 투명하지 않은 그림이라 사각형 그대로 보입니다',
26
+ 'added': '추가했습니다 — 저장을 누르면 이 대상에 적용됩니다',
27
+ };
28
+
29
+ function load() {
30
+ try {
31
+ const list = JSON.parse(localStorage.getItem(KEY) || '[]');
32
+ return Array.isArray(list) ? list.filter((c) => c && /^[a-z0-9]{1,32}$/.test(c.id) && typeof c.src === 'string' && c.src.startsWith('data:image/')) : [];
33
+ } catch { return []; }
34
+ }
35
+
36
+ const sizeOf = (list) => list.reduce((n, c) => n + c.src.length, 0);
37
+ const newId = () => (Date.now().toString(36) + Math.random().toString(36).slice(2, 6)).slice(-10);
38
+
39
+ export function createCustomCharacters() {
40
+ let list = load();
41
+ const listeners = new Set();
42
+ setCustomCharacters(list);
43
+
44
+ function changed() { setCustomCharacters(list); for (const fn of listeners) fn(); }
45
+ /** 저장. 공간이 모자라면(QuotaExceeded) 이전 목록으로 되돌리고 'quota' */
46
+ function save(next) {
47
+ try { localStorage.setItem(KEY, JSON.stringify(next)); } catch { return 'quota'; }
48
+ list = next; changed();
49
+ return null;
50
+ }
51
+
52
+ /** 다듬은 그림 { src, w, h, name } 을 더한다 → { value: 'custom:<id>' } 또는 { error } */
53
+ function add(img) {
54
+ if (!img || typeof img.src !== 'string' || !img.src.startsWith('data:image/')) return { error: 'decode' };
55
+ if (list.length >= LIMITS.count) return { error: 'too-many' };
56
+ if (img.src.length > LIMITS.srcChars || sizeOf(list) + img.src.length > LIMITS.totalChars) return { error: 'quota' };
57
+ const c = { id: newId(), name: String(img.name || '').slice(0, 40), src: img.src, w: img.w | 0, h: img.h | 0, at: Date.now() };
58
+ const error = save([...list, c]);
59
+ return error ? { error } : { value: `custom:${c.id}` };
60
+ }
61
+ function remove(value) {
62
+ const id = String(value).replace(/^custom:/, '');
63
+ if (!list.some((c) => c.id === id)) return;
64
+ save(list.filter((c) => c.id !== id));
65
+ }
66
+ const has = (value) => list.some((c) => `custom:${c.id}` === value);
67
+
68
+ return {
69
+ has, add, remove,
70
+ list: () => list.map((c) => ({ ...c, value: `custom:${c.id}` })),
71
+ bytes: () => sizeOf(list),
72
+ subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
73
+ };
74
+ }
75
+
76
+ // ---------- 그림 다듬기 ----------
77
+ /** 알파가 min 이상인 픽셀의 경계 상자. 전부 투명하면 null. opaque: 투명한 픽셀이 하나도 없다 */
78
+ export function alphaBounds(data, w, h, min = 16) {
79
+ let x0 = w, y0 = h, x1 = -1, y1 = -1, clear = false;
80
+ for (let y = 0; y < h; y++) {
81
+ for (let x = 0; x < w; x++) {
82
+ const a = data[(y * w + x) * 4 + 3];
83
+ if (a < 250) clear = true;
84
+ if (a < min) continue;
85
+ if (x < x0) x0 = x;
86
+ if (x > x1) x1 = x;
87
+ if (y < y0) y0 = y;
88
+ if (y > y1) y1 = y;
89
+ }
90
+ }
91
+ if (x1 < 0) return null;
92
+ return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1, opaque: !clear };
93
+ }
94
+
95
+ /** 긴 변이 side 를 넘으면 줄인 크기 */
96
+ export function fitSize(w, h, side = LIMITS.side) {
97
+ const k = Math.min(1, side / Math.max(w, h));
98
+ return { w: Math.max(1, Math.round(w * k)), h: Math.max(1, Math.round(h * k)) };
99
+ }
100
+
101
+ /**
102
+ * 파일 → { src(dataURL), w, h, name, opaque } 또는 { error }. 브라우저에서만.
103
+ * 투명 여백을 잘라내고 긴 변을 LIMITS.side 로 줄인다. PNG 가 크면 WebP 와 비교해 작은 쪽
104
+ */
105
+ export async function prepareImage(file) {
106
+ if (!file || !/^image\//.test(file.type || '')) return { error: 'not-image' };
107
+ if (file.size > LIMITS.fileBytes) return { error: 'too-big' };
108
+ let bmp;
109
+ try { bmp = await createImageBitmap(file); } catch { return { error: 'decode' }; }
110
+ try {
111
+ const { width: W, height: H } = bmp;
112
+ if (!W || !H) return { error: 'decode' };
113
+ if (W > LIMITS.pixels || H > LIMITS.pixels) return { error: 'too-large' };
114
+ const src = document.createElement('canvas');
115
+ src.width = W; src.height = H;
116
+ const sx = src.getContext('2d', { willReadFrequently: true });
117
+ sx.drawImage(bmp, 0, 0);
118
+ const box = alphaBounds(sx.getImageData(0, 0, W, H).data, W, H);
119
+ if (!box) return { error: 'empty' };
120
+ const out = document.createElement('canvas');
121
+ const size = fitSize(box.w, box.h);
122
+ out.width = size.w; out.height = size.h;
123
+ const ox = out.getContext('2d');
124
+ ox.imageSmoothingEnabled = true; ox.imageSmoothingQuality = 'high';
125
+ ox.drawImage(src, box.x, box.y, box.w, box.h, 0, 0, size.w, size.h);
126
+ let url = out.toDataURL('image/png');
127
+ if (url.length > 120 * 1024) {
128
+ const webp = out.toDataURL('image/webp', 0.92);
129
+ if (webp.startsWith('data:image/webp') && webp.length < url.length) url = webp;
130
+ }
131
+ return { src: url, w: size.w, h: size.h, name: String(file.name || '').replace(/\.[^.]+$/, ''), opaque: box.opaque };
132
+ } finally { bmp.close?.(); }
133
+ }