유니: 일하는 방식대로 진화하는 유니콘 펫. 입력창 위 정보 줄, 위험 명령어 경보, 휴식·숨쉬기 알림

Claude Code를 한국어로 편하게 쓰기 위한 mod 모음이에요. 일하는 방식대로 자라는 유니콘 펫, 표·코드·mermaid를 읽기 쉽게 그리는 한글 화면, 입력창 꾸미기가 들어 있어요.

| mod | 하는 일 | 명령어 |
|---|---|---|
유니 yuni | 옆 패널에서 돌아다니는 유니콘 펫. 입력창 위 정보 줄(컨텍스트·사용량·휴식), 위험한 명령어 경보, 휴식·숨쉬기 알림 | /yuni |
한글 화면 hangul-view | 표·코드·mermaid·제목을 읽기 쉽게 그리고, 도구 줄·스피너·힌트 줄을 한글로 바꿔요 | /hangul |
입력창 꾸미기 md-prompt | 입력하는 동안 코드 블록·굵게·목록·표에 색을 입혀요. 글자는 바꾸지 않아요 | /md-prompt |
셋 다 따로 설치할 수 있어요. 필요한 것만 골라 쓰세요.
Claude Code 2.1.287 이상이 필요해요. 터미널에서 Claude Code를 열고 프롬프트에 입력하세요.
/plugin install yuni --marketplace RJ-Stony/claude-hangul-mods
/plugin install hangul-view --marketplace RJ-Stony/claude-hangul-mods
/plugin install md-prompt --marketplace RJ-Stony/claude-hangul-mods
처음엔 마켓플레이스를 추가할지 물어봐요. y를 누르고, 설치 범위는 맨 위의 user(모든 프로젝트)를 고르면 돼요.
안 먹어요. 세 mod 모두 화면만 그리고, Claude에게 가는 요청에는 아무것도 붙이지 않아요. 명령어를 직접 실행했을 때 남는 한 줄 결과만 대화에 들어가요.
한글 화면에는 "표·mermaid가 그려진다"는 메모를 Claude에게 붙이는 옵션이 있어요(프롬프트마다 약 190토큰). 이 옵션은 기본으로 꺼져 있어요.
알에서 시작해서, 당신이 어떻게 일하느냐에 따라 다른 유니콘으로 자라요.
| 단계 | 조건 |
|---|---|
| 🥚 알 | 처음 시작. 답변 20번이면 금이 가요 |
| 🐴 아기 망아지 | 답변 50번에 부화. 이때 갈래가 정해져요 |
| 🦄 청년 유니콘 | 답변 200번 |
| 🪽 알리콘 | 답변 500번. 날개가 생겨요 |
rm -rf, 강제 push, reset --hard, DROP TABLE 같은 명령어가 나오면 뿔이 빨갛게 깜빡이고, 이미 허락된 상태여도 한 번 더 확인을 받아요.입력창 위 정보 줄
🦄 유니 청년 유니콘 Lv.6 │ 코드를 고치고 있어요 ✏️ │ 🧠 42% ⏳ 5시간 31% · 주간 12% ☕ 18분 🥚 2
명령어: /yuni 뒤에 숨 · 쉬기 · 오늘 · 이름 <이름> · 알림 <분> · 정보줄 켜기/끄기
패널이 저절로 열리려면 터미널이 144칸 이상이어야 해요. 좁으면 /yuni로 직접 여세요.
⧉ 복사 버튼.⎿ 명령어 2번 실행 · 파일 3개 읽음처럼 문장 아래에 흐리게 붙여요. 긴 명령어는 짧게 잘라요./hangul theme nord. 세부 옵션은 /config에서 한글 설명과 함께 바꿀 수 있어요.mod는 내 권한으로 실행돼요. 그래서 이 저장소의 mod는 필요한 것만 써요.
| mod | 쓰는 것 | 안 쓰는 것 |
|---|---|---|
| 유니 | 화면, 타이머, 펫 기록 저장, 컨텍스트·사용량 숫자 읽기 | 파일, 네트워크, 프로그램 실행 |
| 한글 화면 | 화면, 클립보드(복사 버튼), /hangul copy할 때 마지막 답변 읽기, 테마 설정 저장, 터미널 종류를 알려주는 환경 변수 읽기 | 파일, 네트워크, 프로그램 실행 |
| 입력창 꾸미기 | 입력창 색칠, 켜기/끄기 설정 저장 | 파일, 네트워크, 프로그램 실행 |
설치 전에 claude plugin validate <폴더>로 직접 확인할 수 있어요.
LICENSE에 그대로 있어요. 이 저장소의 나머지(유니 등)는 MIT예요.hooks/register.tsx 774 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { DayLog, Pet, View } from '../types'
5import { PET_W, RAINBOW, hornTip, paint, petTop, toCells, toSvg } from './scene'
6import type { Eyes } from './scene'
7import { BRANCHES, COATS, CRACK_AT, STAGES, stageOf } from './sprites'
8import type { Branch } from './sprites'
9
10const PANE = 'yuni'
11const ROWS = 11
12const TICK_MS = 100
13const SLEEP_AFTER_MS = 5 * 60_000
14
15const NEW_DAY = (day: string, now: number): DayLog => ({ day, turns: 0, tools: 0, files: [], breaks: 0, guards: 0, firstAt: now })
16const NEW_PET: Pet = {
17 name: '유니',
18 xp: 0,
19 happy: 70,
20 full: 70,
21 turns: 0,
22 branch: '알',
23 coat: 0,
24 counts: { edit: 0, search: 0, bash: 0 },
25 guards: 0,
26 breaks: 0,
27 streak: 1,
28 lastDay: '',
29 breakMin: 50,
30 band: true,
31 today: NEW_DAY('', 0),
32}
33const NEW_VIEW: View = {
34 line: '함께 있을게요',
35 bubble: '',
36 mode: 'normal',
37 breath: '',
38 startedAt: 0,
39 nextBreakAt: 0,
40 friends: [],
41 context: null,
42 fiveHour: null,
43 week: null,
44 cost: null,
45}
46
47const pet = atom({ plugin: 'yuni', key: 'pet' } as const, NEW_PET)
48const view = atom({ plugin: 'yuni', key: 'view' } as const, NEW_VIEW)
49
50// ── 한글 문구 ─────────────────────────────────────────────
51
52// 지금 하는 일을 한 줄로: 정보 줄과 패널에 쓴다
53const TOOL_KO: Record<string, string> = {
54 Bash: '명령어를 실행하고 있어요 ⚙️',
55 PowerShell: '명령어를 실행하고 있어요 ⚙️',
56 Read: '파일을 읽고 있어요 📖',
57 Edit: '코드를 고치고 있어요 ✏️',
58 MultiEdit: '코드를 고치고 있어요 ✏️',
59 Write: '파일을 쓰고 있어요 ✏️',
60 NotebookEdit: '노트북을 고치고 있어요 ✏️',
61 Grep: '내용을 찾아보고 있어요 🔍',
62 Glob: '파일을 찾아보고 있어요 🔍',
63 WebSearch: '인터넷에서 찾아보고 있어요 🌐',
64 WebFetch: '웹 페이지를 읽고 있어요 🌐',
65 Skill: '스킬을 꺼내 쓰고 있어요 🧰',
66 Agent: '도우미에게 일을 맡겼어요 🤝',
67 TodoWrite: '할 일을 정리하고 있어요 📝',
68}
69
70const CHEERS = [
71 '수고했어요! 오늘도 반짝반짝 ✨',
72 '하나 끝냈어요! 물 한 모금 어때요? 💧',
73 '잘하고 있어요. 천천히 가요 🌿',
74 '유니가 뿔로 박수 쳐요 🦄',
75 '끝! 어깨 한 번 쭉 펴볼까요?',
76]
77const OOPS = ['앗, 실패했어요. 괜찮아요, 다시 하면 돼요 🍀', '에러도 배우는 과정이에요', '토닥토닥… 금방 고칠 거예요']
78const STRETCH = [
79 '목을 천천히 좌우로 돌려요 🙆',
80 '어깨를 귀까지 올렸다가 툭 떨어뜨려요',
81 '먼 곳을 20초 바라봐요 (눈 휴식) 👀',
82 '일어나서 물 한 잔 마셔요 💧',
83 '손목을 돌리고 손가락을 쫙 펴요 ✋',
84 '허리를 펴고 숨을 크게 쉬어요 🌬️',
85]
86const PETTED = ['히힝, 좋아요 💕', '갈기 쓰다듬어 줘서 고마워요', '더 쓰다듬어 줘요!', '기분 최고예요 🌸']
87const FED = ['냠냠! 각설탕 맛있어요 🍬', '당근 최고예요 🥕', '오물오물… 🍎']
88const BREATH = ['들이마셔요', '잠깐 멈춰요', '내쉬어요', '잠깐 멈춰요']
89
90// 되돌리기 어려운 명령어: 뿔이 빨갛게 빛나고 한 번 더 확인을 받는다
91const DANGER: [RegExp, string][] = [
92 [/\brm\s+-[a-zA-Z]*(rf|fr)\b|\brm\s+-[a-zA-Z]*r[a-zA-Z]*\s+-[a-zA-Z]*f/, '파일을 통째로 지우는 rm -rf'],
93 [/\bgit\s+push\b[^\n]*(--force\b|--force-with-lease|\s-f\b)/, '원격 기록을 덮어쓰는 강제 push'],
94 [/\bgit\s+reset\s+--hard\b/, '작업한 내용을 버리는 reset --hard'],
95 [/\bgit\s+clean\s+-[a-zA-Z]*f/, '추적 안 되는 파일을 지우는 git clean'],
96 [/\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/, '수정 사항을 되돌리는 checkout/restore .'],
97 [/\bgit\s+branch\s+-D\b/, '브랜치를 강제로 지우는 branch -D'],
98 [/\bdrop\s+(table|database|schema)\b/i, '데이터베이스를 지우는 DROP'],
99 [/\btruncate\s+table\b/i, '테이블을 비우는 TRUNCATE'],
100 [/\bRemove-Item\b[^\n]*-Recurse/i, '폴더를 통째로 지우는 Remove-Item -Recurse'],
101 [/\b(mkfs|format\s+[a-z]:)/i, '디스크를 포맷하는 명령어'],
102 [/--no-verify\b/, '검사를 건너뛰는 --no-verify'],
103]
104
105const RANKS: [number, string][] = [
106 [0, '견습 지킴이'],
107 [3, '꼬마 수호자'],
108 [10, '무지개 기사'],
109 [30, '별빛 수호대장'],
110 [100, '전설의 수호자'],
111]
112
113// ── 작은 도우미 ─────────────────────────────────────────────
114
115const pick = (list: string[]) => list[Math.floor(Math.random() * list.length)] ?? ''
116const clamp = (n: number) => Math.max(0, Math.min(100, n))
117export const levelOf = (xp: number) => 1 + Math.floor(Math.sqrt(xp / 20))
118export const xpFor = (level: number) => (level - 1) ** 2 * 20
119const dayOf = (ms: number) => new Date(ms).toLocaleDateString('sv')
120const rankOf = (guards: number) => RANKS.filter(([n]) => guards >= n).pop()?.[1] ?? '견습 지킴이'
121const argOf = (input: unknown, key: string) => {
122 const v = key && input && typeof input === 'object' ? (input as Record<string, unknown>)[key] : undefined
123 return typeof v === 'string' ? v : ''
124}
125const short = (s: string, n = 60) => {
126 const one = s.replace(/\s+/g, ' ').trim()
127 return one.length > n ? one.slice(0, n - 1) + '…' : one
128}
129const tail = (path: string) => path.split(/[\\/]/).slice(-2).join('/')
130const hhmm = (ms: number) => {
131 const d = new Date(ms)
132 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
133}
134export const dangerOf = (command: string) => DANGER.find(([re]) => re.test(command))?.[1]
135
136export function branchFrom(counts: Pet['counts']): Branch {
137 const { edit, search, bash } = counts
138 if (edit >= search && edit >= bash) return '불꽃'
139 if (bash >= search) return '강철'
140 return '바람'
141}
142
143function withToday(p: Pet, now: number): Pet {
144 const day = dayOf(now)
145 return p.today.day === day ? p : { ...p, today: NEW_DAY(day, now) }
146}
147
148// ── 애니메이션: 리로드되면 새로 시작해도 괜찮은 값들 ──────────────
149
150type Walker = { x: number; target: number; y: number; foal: boolean }
151const anim = {
152 columns: 0,
153 tick: 0,
154 x: 4,
155 target: 4,
156 facingLeft: false,
157 lastActive: Date.now(),
158 inflight: 0,
159 mood: 'idle' as 'idle' | 'happy' | 'sad' | 'eat',
160 moodUntil: 0,
161 alertUntil: 0,
162 rainbowUntil: 0,
163 hearts: [] as { x: number; y: number }[],
164 sparkles: [] as { x: number; y: number; vx: number; vy: number; color: number; life: number }[],
165 friends: new Map<string, Walker>(),
166 breatheFrom: -1,
167 hour: new Date().getHours(),
168}
169const timers: Timer[] = []
170let lastBreakAt = Date.now()
171let snoozeUntil = 0
172let lastNightNag = 0
173let warnedContext = false
174
175const asleep = () => anim.inflight === 0 && anim.breatheFrom < 0 && Date.now() - anim.lastActive > SLEEP_AFTER_MS
176
177function wake() {
178 anim.lastActive = Date.now()
179}
180
181function feel(mood: typeof anim.mood, ms: number) {
182 anim.mood = mood
183 anim.moodUntil = anim.tick + ms / TICK_MS
184}
185
186function sparkle(p: Pet, v: View) {
187 const tip = hornTip({ ...looks(p, v), petX: anim.x, facingLeft: anim.facingLeft, rows: ROWS })
188 for (let i = 0; i < 12; i++) {
189 const a = (i / 12) * Math.PI * 2
190 anim.sparkles.push({
191 x: tip.x,
192 y: tip.y,
193 vx: Math.cos(a) * 0.7,
194 vy: Math.sin(a) * 0.5 - 0.2,
195 color: RAINBOW[i % RAINBOW.length] ?? 0xffffff,
196 life: 14,
197 })
198 }
199}
200
201function looks(p: Pet, v: View) {
202 return {
203 stage: stageOf(p.turns),
204 hiding: (v.fiveHour ?? 0) >= 95 || (v.week ?? 0) >= 95,
205 lying: (v.context ?? 0) >= 85,
206 }
207}
208
209function frame(p: Pet, v: View) {
210 const t = anim.tick
211 const mood = t < anim.moodUntil ? anim.mood : 'idle'
212 const walking = Math.abs(anim.x - anim.target) >= 1
213 const tired = (v.fiveHour ?? 0) >= 80
214 const sleeping = asleep()
215 const eyes: Eyes = sleeping
216 ? 'closed'
217 : mood === 'happy' || mood === 'eat'
218 ? 'happy'
219 : tired
220 ? 'sleepy'
221 : t % 45 < 2
222 ? 'closed'
223 : 'open'
224 const bob = sleeping
225 ? 0
226 : mood === 'happy'
227 ? -Math.round(Math.abs(Math.sin(t * 0.4)) * 3)
228 : walking
229 ? -((t >> 1) % 2)
230 : -((t >> 3) % 2)
231 let ring = 0
232 if (anim.breatheFrom >= 0) {
233 // 4초 들숨, 4초 멈춤, 4초 날숨, 4초 멈춤
234 const k = (((t - anim.breatheFrom) * TICK_MS) / 1000) % 16
235 ring = 9 + (k < 4 ? k / 4 : k < 8 ? 1 : k < 12 ? 1 - (k - 8) / 4 : 0) * 5
236 }
237 const rainbowLeft = anim.rainbowUntil - t
238 return paint({
239 columns: anim.columns,
240 rows: ROWS,
241 tick: t,
242 hour: anim.hour,
243 petX: anim.x,
244 bob,
245 facingLeft: anim.facingLeft,
246 ...looks(p, v),
247 cracked: p.turns >= CRACK_AT,
248 branch: p.branch,
249 coat: p.coat,
250 eyes,
251 tear: mood === 'sad',
252 alert: t < anim.alertUntil,
253 ring,
254 rainbow: rainbowLeft > 0 ? Math.min(1, rainbowLeft / 20) : 0,
255 hearts: anim.hearts,
256 sparkles: anim.sparkles,
257 friends: [...anim.friends.values()].map(f => ({ x: f.x, y: f.y, foal: f.foal })),
258 zzz: sleeping,
259 })
260}
261
262function step(v: View) {
263 anim.tick += 1
264 const maxX = Math.max(0, anim.columns - PET_W)
265 const still = asleep() || (v.context ?? 0) >= 85 || (v.fiveHour ?? 0) >= 95 || (v.week ?? 0) >= 95
266 if (anim.breatheFrom >= 0) anim.target = Math.round(maxX / 2)
267 else if (Math.abs(anim.x - anim.target) < 1 && Math.random() < 0.02) anim.target = Math.round(Math.random() * maxX)
268 if (!still && Math.abs(anim.x - anim.target) >= 1) {
269 anim.facingLeft = anim.target < anim.x
270 anim.x += (anim.facingLeft ? -1 : 1) * (anim.inflight > 0 ? 0.9 : 0.4)
271 }
272 anim.x = Math.max(0, Math.min(maxX, anim.x))
273 anim.hearts = anim.hearts.map(h => ({ x: h.x + (Math.random() - 0.5), y: h.y - 0.25 })).filter(h => h.y > -4)
274 anim.sparkles = anim.sparkles
275 .map(s => ({ ...s, x: s.x + s.vx, y: s.y + s.vy, vy: s.vy + 0.03, life: s.life - 1 }))
276 .filter(s => s.life > 0)
277
278 // 순찰대: 새 친구는 왼쪽에서 알로 굴러오고, 일이 끝난 친구는 아기 유니콘이 되어 날아간다
279 const groundY = ROWS * 2 - 3 - 7
280 const live = new Set(v.friends.map(f => f.id))
281 for (const f of v.friends)
282 if (!anim.friends.has(f.id))
283 anim.friends.set(f.id, { x: -6, target: 2 + Math.random() * Math.max(1, anim.columns - 10), y: groundY, foal: false })
284 for (const [id, f] of anim.friends) {
285 if (!live.has(id)) f.foal = true
286 if (f.foal) {
287 f.y -= 0.35
288 f.x += 0.3
289 if (f.y < -10) anim.friends.delete(id)
290 } else if (Math.abs(f.x - f.target) >= 1) f.x += f.target > f.x ? 0.6 : -0.6
291 else if (Math.random() < 0.01) f.target = 2 + Math.random() * Math.max(1, anim.columns - 10)
292 }
293}
294
295// ── 상태 바꾸기 ─────────────────────────────────────────────
296
297async function changePet($: EngineInterface, fn: (p: Pet) => Pet) {
298 const before = await read($, pet)
299 const now = await $.clock.now()
300 const next = await update($, pet, p => fn(withToday(p, now)))
301 const lvBefore = levelOf(before.xp)
302 const lvAfter = levelOf(next.xp)
303 const stBefore = stageOf(before.turns)
304 const stAfter = stageOf(next.turns)
305
306 if (before.turns < CRACK_AT && next.turns >= CRACK_AT) {
307 feel('happy', 3000)
308 $.ui.toast(`🥚 ${next.name}의 알에 금이 갔어요! 답변 ${STAGES[1].turns - next.turns}번 더 하면 부화해요`)
309 }
310 if (stAfter > stBefore) {
311 anim.rainbowUntil = anim.tick + 80
312 feel('happy', 4000)
313 if (stBefore === 0) {
314 const branch = branchFrom(next.counts)
315 const coat = Math.floor(Math.random() * COATS.length)
316 const hatched = await update($, pet, p => ({ ...p, branch, coat }))
317 await $.store.set('pet', hatched)
318 $.ui.toast(`🦄 부화했어요! ${branch} 유니콘이에요 (${COATS[coat]?.name} 털). ${BRANCHES[branch].about}`)
319 await say($, `안녕하세요! 저는 ${branch} 유니콘 ${next.name}예요 🌈`)
320 return
321 }
322 $.ui.toast(`🌈 ${next.name}가 ${STAGES[stAfter]?.name}(으)로 진화했어요!`)
323 await say($, `${STAGES[stAfter]?.name}이 됐어요! 고마워요 ✨`)
324 } else if (lvAfter > lvBefore) {
325 anim.rainbowUntil = anim.tick + 40
326 $.ui.toast(`⭐ ${next.name} 레벨 ${lvAfter}!`)
327 }
328 await $.store.set('pet', next)
329}
330
331async function say($: EngineInterface, bubble: string) {
332 await update($, view, v => ({ ...v, bubble }))
333}
334
335async function takeBreak($: EngineInterface) {
336 const now = await $.clock.now()
337 lastBreakAt = now
338 snoozeUntil = 0
339 wake()
340 feel('happy', 2500)
341 const p = await read($, pet)
342 await update($, view, v => ({
343 ...v,
344 mode: 'normal' as const,
345 nextBreakAt: now + p.breakMin * 60_000,
346 bubble: '잘 쉬었어요! 다시 힘내요 💪',
347 }))
348 await changePet($, x => ({
349 ...x,
350 happy: clamp(x.happy + 15),
351 xp: x.xp + 10,
352 breaks: x.breaks + 1,
353 today: { ...x.today, breaks: x.today.breaks + 1 },
354 }))
355}
356
357async function snooze($: EngineInterface) {
358 const now = await $.clock.now()
359 snoozeUntil = now + 10 * 60_000
360 await update($, view, v => ({ ...v, mode: 'normal' as const, nextBreakAt: snoozeUntil, bubble: '알겠어요, 10분 뒤에 다시 알려줄게요 ⏰' }))
361}
362
363async function startBreathing($: EngineInterface) {
364 anim.breatheFrom = anim.tick
365 wake()
366 await update($, view, v => ({
367 ...v,
368 mode: 'breathe' as const,
369 breath: '들이마셔요 · 4 (1/4)',
370 bubble: '유니랑 같이 숨 쉬어요. 원이 커지면 들이마셔요',
371 }))
372}
373
374async function stopBreathing($: EngineInterface) {
375 anim.breatheFrom = -1
376 await update($, view, v => ({ ...v, mode: 'normal' as const, breath: '', bubble: '언제든 다시 불러줘요' }))
377}
378
379async function breathTick($: EngineInterface) {
380 if (anim.breatheFrom < 0) return
381 const sec = Math.floor(((anim.tick - anim.breatheFrom) * TICK_MS) / 1000)
382 if (sec >= 64) {
383 anim.breatheFrom = -1
384 feel('happy', 2500)
385 await update($, view, v => ({ ...v, mode: 'normal' as const, breath: '', bubble: '숨쉬기 끝! 마음이 말랑말랑해졌어요 ☁️' }))
386 await changePet($, p => ({ ...p, happy: clamp(p.happy + 10), xp: p.xp + 8 }))
387 return
388 }
389 const k = sec % 16
390 const line = `${BREATH[Math.floor(k / 4)]} · ${4 - (k % 4)} (${Math.floor(sec / 16) + 1}/4)`
391 await update($, view, v => ({ ...v, breath: line }))
392}
393
394async function petIt($: EngineInterface) {
395 wake()
396 feel('happy', 2000)
397 const top = petTop(ROWS) - 2
398 anim.hearts.push({ x: anim.x + 3, y: top }, { x: anim.x + 9, y: top + 1 })
399 await say($, pick(PETTED))
400 await changePet($, p => ({ ...p, happy: clamp(p.happy + 5), xp: p.xp + 1 }))
401}
402
403async function feed($: EngineInterface) {
404 wake()
405 if ((await read($, pet)).full >= 95) return say($, '이미 배불러요! 나중에 줘요 🙅')
406 feel('eat', 2000)
407 await say($, pick(FED))
408 await changePet($, p => ({ ...p, full: clamp(p.full + 25), xp: p.xp + 1 }))
409}
410
411async function showToday($: EngineInterface) {
412 await update($, view, v => ({ ...v, mode: 'today' as const }))
413}
414
415async function closeToday($: EngineInterface) {
416 await update($, view, v => ({ ...v, mode: 'normal' as const }))
417}
418
419async function refreshUsage($: EngineInterface) {
420 const u = await $.session.usage()
421 const context = u.context.percent ?? null
422 const fiveHour = u.rateLimits.find(r => r.kind === 'five_hour')?.percentUsed ?? null
423 const week = u.rateLimits.find(r => r.kind === 'seven_day')?.percentUsed ?? null
424 const cost = u.cost?.usd ?? null
425 const v = await read($, view)
426 if (v.context === context && v.fiveHour === fiveHour && v.week === week && v.cost === cost) return
427 await update($, view, x => ({ ...x, context, fiveHour, week, cost }))
428 if ((context ?? 0) >= 80 && !warnedContext) {
429 warnedContext = true
430 $.ui.toast(`🧠 컨텍스트 ${context}%: 유니가 배불러해요. /compact 하면 가벼워져요`)
431 }
432 if ((context ?? 0) < 60) warnedContext = false
433}
434
435async function everyHalfMinute($: EngineInterface) {
436 const t = await $.clock.now()
437 anim.hour = new Date(t).getHours()
438 await changePet($, p => ({ ...p, full: clamp(p.full - 0.2), happy: clamp(p.happy - 0.1) }))
439 const v = await read($, view)
440 const p = await read($, pet)
441 if (v.mode === 'normal' && t - lastBreakAt >= p.breakMin * 60_000 && t >= snoozeUntil) {
442 const tip = pick(STRETCH)
443 await update($, view, x => ({ ...x, mode: 'break' as const, bubble: `${p.breakMin}분 일했어요. 잠깐 쉬어요! ${tip}` }))
444 $.ui.toast(`🦄 ${p.name}: ${p.breakMin}분 지났어요. ${tip}`)
445 }
446 if (anim.hour < 5 && t - lastNightNag > 60 * 60_000) {
447 lastNightNag = t
448 await say($, `새벽 ${anim.hour}시예요. ${p.name}는 졸려요… 이제 자는 건 어때요? 🌙`)
449 }
450}
451
452async function paintPane($: EngineInterface) {
453 const v = await read($, view)
454 step(v)
455 if (anim.columns === 0) return
456 const p = await read($, pet)
457 void $.ui.blit({ requestId: PANE, key: 'scene', cells: toCells(frame(p, v)) })
458}
459
460// ── 훅 ─────────────────────────────────────────────
461
462export const register: Register = on => {
463 on('session.start', async ($, e, next) => {
464 const now = await $.clock.now()
465 anim.lastActive = now
466 anim.hour = new Date(now).getHours()
467 lastBreakAt = now
468
469 const saved = { ...NEW_PET, ...((await $.store.get('pet')) as Partial<Pet> | undefined) }
470 const today = dayOf(now)
471 const streak =
472 saved.lastDay === today ? saved.streak : saved.lastDay === dayOf(now - 86_400_000) ? saved.streak + 1 : 1
473 const loaded = await update($, pet, () => withToday({ ...saved, streak, lastDay: today }, now))
474 await $.store.set('pet', loaded)
475
476 if ((await read($, view)).startedAt === 0) {
477 const h = anim.hour
478 const greet =
479 h < 5 ? '새벽이에요… 너무 무리하지 마요 🌙' : h < 12 ? '좋은 아침이에요! ☀️' : h < 18 ? '좋은 오후예요! 🌤️' : '좋은 저녁이에요 🌆'
480 await update($, view, () => ({
481 ...NEW_VIEW,
482 startedAt: now,
483 nextBreakAt: now + loaded.breakMin * 60_000,
484 bubble: loaded.streak > 1 ? `${greet} ${loaded.streak}일 연속 만났어요!` : greet,
485 }))
486 }
487
488 await $.command.register({
489 name: 'yuni',
490 description: '유니 패널 · 숨 / 쉬기 / 오늘 / 이름 <이름> / 알림 <분> / 정보줄 켜기·끄기',
491 argumentHint: '[숨|쉬기|오늘|이름|알림|정보줄]',
492 immediate: true,
493 })
494 void $.ui.open({ id: PANE, title: '유니' })
495
496 timers.push(
497 $.clock.every(TICK_MS, () => void paintPane($)),
498 $.clock.every(1000, () => void breathTick($)),
499 $.clock.every(10_000, () => void refreshUsage($)),
500 $.clock.every(30_000, () => void everyHalfMinute($)),
501 )
502 void refreshUsage($)
503 return next(e)
504 })
505
506 on('tool.call', async ($, e, next) => {
507 wake()
508 anim.inflight += 1
509 const tool = String(e.tool)
510 const ko = TOOL_KO[tool]
511 const args = e as unknown as Record<string, unknown>
512 const kind = /^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool)
513 ? 'edit'
514 : /^(Read|Grep|Glob|WebSearch|WebFetch)$/.test(tool)
515 ? 'search'
516 : /^(Bash|PowerShell)$/.test(tool)
517 ? 'bash'
518 : null
519 const file = kind === 'edit' ? argOf(args, 'file_path') || argOf(args, 'notebook_path') : ''
520 const friendId = tool === 'Agent' ? (e.tool_use_id ?? `agent-${anim.tick}`) : ''
521
522 await update($, view, v => ({
523 ...v,
524 line: ko ?? (tool.startsWith('mcp__') ? '외부 도구를 쓰고 있어요 🔌' : `${tool} 쓰는 중`),
525 friends: friendId
526 ? [...v.friends, { id: friendId, label: short(argOf(args, 'description') || '도우미', 24), since: Date.now() }]
527 : v.friends,
528 }))
529 try {
530 const ran = await next(e)
531 if (ran.isError) {
532 feel('sad', 4000)
533 await say($, pick(OOPS))
534 }
535 return ran
536 } finally {
537 anim.inflight = Math.max(0, anim.inflight - 1)
538 if (friendId) await update($, view, v => ({ ...v, friends: v.friends.filter(f => f.id !== friendId) }))
539 await changePet($, p => ({
540 ...p,
541 xp: p.xp + 1,
542 counts: kind ? { ...p.counts, [kind]: p.counts[kind] + 1 } : p.counts,
543 today: {
544 ...p.today,
545 tools: p.today.tools + 1,
546 files: file && !p.today.files.includes(file) ? [...p.today.files, file].slice(-200) : p.today.files,
547 },
548 }))
549 }
550 })
551
552 // 위험한 명령어: 이미 허락된 상태여도 한 번 더 묻게 만든다 (더 엄격하게만 바꾼다)
553 on('tool.check', async ($, e, next) => {
554 const verdict = await next(e)
555 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return verdict
556 const what = dangerOf(argOf(e.input, 'command'))
557 if (!what) return verdict
558 if (e.tool_use_id) {
559 anim.alertUntil = anim.tick + 50
560 wake()
561 await say($, `🚨 잠깐요! ${what}이에요. 정말 괜찮은지 확인해 주세요`)
562 await changePet($, p => ({ ...p, guards: p.guards + 1, xp: p.xp + 3, today: { ...p.today, guards: p.today.guards + 1 } }))
563 }
564 if (verdict.decision !== 'allow') return verdict
565 return { ...verdict, decision: 'ask' as const, reason: `🦄 유니 경보: ${what}이에요. 되돌리기 어려우니 한 번 더 확인해 주세요.` }
566 })
567
568 on('prompt.submit', async ($, e, next) => {
569 wake()
570 await update($, view, v => ({ ...v, line: '요청을 읽고 생각하고 있어요 💭' }))
571 return next(e)
572 })
573
574 on('turn.complete', async ($, e, next) => {
575 wake()
576 const done = await next(e)
577 if (e.reason === 'answer') {
578 feel('happy', 3000)
579 const v = await update($, view, x => ({
580 ...x,
581 line: '기다리고 있어요',
582 bubble: x.mode === 'normal' ? pick(CHEERS) : x.bubble,
583 }))
584 sparkle(await read($, pet), v)
585 await changePet($, p => ({
586 ...p,
587 xp: p.xp + 5,
588 turns: p.turns + 1,
589 full: clamp(p.full + 2),
590 today: { ...p.today, turns: p.today.turns + 1 },
591 }))
592 } else {
593 await update($, view, v => ({ ...v, line: '멈췄어요' }))
594 }
595 void refreshUsage($)
596 return done
597 })
598
599 on('command.run', { command: 'yuni' }, async ($, e) => {
600 const [sub = '', ...rest] = e.args.trim().split(/\s+/)
601 const arg = rest.join(' ')
602 const open = () => $.ui.open({ id: PANE, title: '유니' })
603 if (sub === '숨') {
604 await open()
605 await startBreathing($)
606 return { text: '숨쉬기 시작' }
607 }
608 if (sub === '쉬기') {
609 await takeBreak($)
610 return { text: '휴식 기록' }
611 }
612 if (sub === '오늘') {
613 await open()
614 await showToday($)
615 return { text: '오늘 기록을 패널에 띄웠어요' }
616 }
617 if (sub === '이름' && arg) {
618 const name = arg.slice(0, 12)
619 await changePet($, p => ({ ...p, name }))
620 await say($, `이제 제 이름은 ${name}예요!`)
621 return { text: `이름: ${name}` }
622 }
623 if (sub === '알림' && Number(arg) > 0) {
624 const breakMin = Math.min(240, Math.round(Number(arg)))
625 await changePet($, p => ({ ...p, breakMin }))
626 return { text: `휴식 알림: ${breakMin}분마다` }
627 }
628 if (sub === '정보줄') {
629 const enabled = arg !== '끄기'
630 await changePet($, p => ({ ...p, band: enabled }))
631 return { text: `입력창 위 정보 줄: ${enabled ? '켜짐' : '꺼짐'}` }
632 }
633 const opened = await $.ui.open({ id: PANE, title: '유니', focus: true })
634 return { text: opened.isPlaced ? '유니 패널' : '터미널을 넓히면 유니가 보여요' }
635 })
636
637 // ── 그리기: 패널 ──
638 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
639 const { Box, Text, Button } = $.ui.resolve(e)
640 const p = await read($, pet)
641 const v = await read($, view)
642 const lv = levelOf(p.xp)
643 const st = stageOf(p.turns)
644 const nextStage = STAGES[st + 1]
645 const columns = Math.max(24, Math.min(72, e.props.bodyColumns))
646
647 let scene = <Text>{`🦄 ${p.name}`}</Text>
648 if (e.surface === 'terminal') {
649 const { Raster } = $.ui.resolve(e)
650 anim.columns = columns
651 scene = <Raster key="scene" columns={columns} rows={ROWS} cells={toCells(frame(p, v))} />
652 } else {
653 const { Svg } = $.ui.resolve(e)
654 scene = (
655 <Svg
656 source={toSvg(paintAt(p, v, columns))}
657 alt={`${p.name}: ${STAGES[st]?.name}`}
658 width={Math.min(520, columns * 9)}
659 />
660 )
661 }
662
663 const growth =
664 st === 0
665 ? p.turns >= CRACK_AT
666 ? `🥚 금이 간 알 · 답변 ${STAGES[1].turns - p.turns}번 더 하면 부화`
667 : `🥚 알 · 답변 ${CRACK_AT - p.turns}번 더 하면 금이 가요`
668 : `🦄 ${p.branch} ${STAGES[st]?.name} · ${COATS[p.coat]?.name} 털${nextStage ? ` · 다음 진화까지 답변 ${nextStage.turns - p.turns}번` : ' · 최종 진화'}`
669
670 if (v.mode === 'today') {
671 const t = p.today
672 const files = t.files.map(tail)
673 return (
674 <Box flexDirection="column">
675 {scene}
676 <Text bold>{`📅 오늘 (${t.day})`}</Text>
677 <Text>{`답변 ${t.turns}번 · 도구 ${t.tools}번 · 휴식 ${t.breaks}번 · 막은 위험 ${t.guards}번`}</Text>
678 <Text wrap="wrap">
679 {files.length === 0
680 ? '✏️ 오늘 고친 파일은 아직 없어요'
681 : `✏️ 고친 파일 ${files.length}개: ${files.slice(-8).join(', ')}${files.length > 8 ? ' …' : ''}`}
682 </Text>
683 <Text dimColor>{`⏰ ${hhmm(t.firstAt)}부터 함께했어요${v.cost !== null ? ` · 이번 세션 비용 $${v.cost.toFixed(2)}` : ''}`}</Text>
684 <Box gap={1}>
685 <Button key="close-today" hotkey="1" variant="primary" label="닫기" onPress={() => closeToday($)} />
686 </Box>
687 </Box>
688 )
689 }
690
691 return (
692 <Box flexDirection="column">
693 {scene}
694 {v.bubble !== '' && <Text color="magenta" wrap="wrap">{`💬 ${v.bubble}`}</Text>}
695 {v.mode === 'breathe' && <Text bold>{`🌬️ ${v.breath}`}</Text>}
696 <Text dimColor wrap="truncate-end">{`지금: ${v.line}`}</Text>
697 <Text wrap="truncate-end">{growth}</Text>
698 <Text wrap="truncate-end">
699 {`⭐ Lv.${lv} (${p.xp}/${xpFor(lv + 1)}) · 💗 ${Math.round(p.happy)} · 🍬 ${Math.round(p.full)} · 🛡️ ${rankOf(p.guards)} (${p.guards})`}
700 </Text>
701 {v.friends.length > 0 && (
702 <Text dimColor wrap="truncate-end">{`🥚 순찰대 ${v.friends.length}: ${v.friends.map(f => f.label).join(', ')}`}</Text>
703 )}
704 {v.mode === 'break' ? (
705 <Box gap={1}>
706 <Button key="rested" hotkey="1" variant="primary" label="쉬었어요" onPress={() => takeBreak($)} />
707 <Button key="snooze" hotkey="2" label="10분 뒤에" onPress={() => snooze($)} />
708 </Box>
709 ) : (
710 <Box gap={1} flexWrap="wrap">
711 <Button key="pet" hotkey="1" label="쓰다듬기" onPress={() => petIt($)} />
712 <Button key="feed" hotkey="2" label="간식" onPress={() => feed($)} />
713 {v.mode === 'breathe' ? (
714 <Button key="breath" hotkey="3" label="숨쉬기 그만" onPress={() => stopBreathing($)} />
715 ) : (
716 <Button key="breath" hotkey="3" label="숨쉬기" onPress={() => startBreathing($)} />
717 )}
718 <Button key="rest" hotkey="4" label="쉬었어요" onPress={() => takeBreak($)} />
719 <Button key="today" hotkey="5" label="오늘" onPress={() => showToday($)} />
720 </Box>
721 )}
722 </Box>
723 )
724 })
725
726 // ── 그리기: 입력창 위 정보 줄 ──
727 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
728 const p = await read($, pet)
729 if (!p.band || e.props.hasSurvey) return next(e)
730 // 다른 mod의 정보 줄을 덮지 않도록 아래 체인이 그린 것을 밑에 붙인다
731 const below = await next(e)
732 const v = await read($, view)
733 const { Box, Text } = $.ui.resolve(e)
734 const st = stageOf(p.turns)
735 const ctx = v.context
736 const ctxColor = ctx === null ? undefined : ctx >= 80 ? 'red' : ctx >= 60 ? 'yellow' : undefined
737 const now = Date.now()
738 const breakIn = Math.max(0, Math.round((Math.max(v.nextBreakAt, lastBreakAt + p.breakMin * 60_000) - now) / 60_000))
739 const usage = [v.fiveHour !== null ? `5시간 ${Math.round(v.fiveHour)}%` : '', v.week !== null ? `주간 ${Math.round(v.week)}%` : '']
740 .filter(Boolean)
741 .join(' · ')
742 return (
743 <Box flexDirection="column">
744 <Box gap={1}>
745 <Text bold>{`🦄 ${p.name} ${st === 0 ? '알' : STAGES[st]?.name} Lv.${levelOf(p.xp)}`}</Text>
746 <Text dimColor>│</Text>
747 <Text wrap="truncate-end">{v.line}</Text>
748 <Text dimColor>│</Text>
749 <Text color={ctxColor} dimColor={ctxColor === undefined}>{`🧠 ${ctx === null ? '-' : ctx}%`}</Text>
750 {usage !== '' && <Text dimColor>{`⏳ ${usage}`}</Text>}
751 <Text dimColor>{v.mode === 'break' ? '☕ 쉬는 시간!' : `☕ ${breakIn}분`}</Text>
752 {v.friends.length > 0 && <Text dimColor>{`🥚 ${v.friends.length}`}</Text>}
753 </Box>
754 {below}
755 </Box>
756 )
757 })
758
759 on('session.end', async ($, e, next) => {
760 // ponytail: /clear도 session.end라서 타이머는 멈추지 않는다
761 await $.store.set('pet', await read($, pet))
762 return next(e)
763 })
764}
765
766// Desktop은 blit이 없어서 상태가 바뀔 때마다 한 장씩 다시 그린다
767function paintAt(p: Pet, v: View, columns: number) {
768 const saved = anim.columns
769 anim.columns = columns
770 const painted = frame(p, v)
771 anim.columns = saved
772 return painted
773}
774hooks/scene.ts 272 lines1// 유니의 무대를 픽셀로 칠한 뒤, 터미널용 반쪽 블록 칸(▀)이나 Desktop용 SVG로 바꾼다.
2import { ALICORN, BABY, BRANCHES, COATS, CRACK, EGG, FRIEND_EGG, FRIEND_FOAL, HIDE, LYING, YOUTH } from './sprites'
3import type { Branch, Sprite } from './sprites'
4
5// 호스트 환경에는 있지만 es2023 lib에는 아직 없는 메서드
6declare global {
7 interface Uint8Array {
8 toBase64(): string
9 }
10}
11
12export const PET_W = 16
13export const PET_H = 16
14
15export type Eyes = 'open' | 'closed' | 'happy' | 'sleepy'
16
17export type SceneInput = {
18 columns: number
19 rows: number
20 tick: number
21 hour: number
22 petX: number
23 bob: number
24 facingLeft: boolean
25 stage: number
26 cracked: boolean
27 hiding: boolean
28 lying: boolean
29 branch: Branch
30 coat: number
31 eyes: Eyes
32 tear: boolean
33 alert: boolean
34 ring: number
35 rainbow: number
36 hearts: { x: number; y: number }[]
37 sparkles: { x: number; y: number; color: number }[]
38 friends: { x: number; y: number; foal: boolean }[]
39 zzz: boolean
40}
41
42type Glyph = { x: number; y: number; ch: string; color: number }
43export type Painted = { px: Uint32Array; W: number; PH: number; glyphs: Glyph[] }
44
45const OUTLINE = 0x2b2238
46const HEART = 0xff6b8a
47const TEAR = 0x7ec8ff
48const DEFAULT = 0x01000000
49export const RAINBOW = [0xff6b6b, 0xffa94d, 0xffe066, 0x8ce99a, 0x74c0fc, 0xb197fc]
50
51const lerp = (a: number, b: number, k: number) => {
52 const ch = (shift: number) => Math.round(((a >> shift) & 255) * (1 - k) + ((b >> shift) & 255) * k)
53 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
54}
55
56export const isNight = (hour: number) => hour >= 19 || hour < 5
57
58function sky(hour: number): [number, number] {
59 if (hour >= 5 && hour < 7) return [0xffb5a7, 0xffe5d9]
60 if (hour >= 7 && hour < 17) return [0x9fd3f0, 0xf3e8ff]
61 if (hour >= 17 && hour < 19) return [0x6d597a, 0xffb4a2]
62 return [0x141b38, 0x2b3a67]
63}
64
65const CLOUD = ['..xxx...', '.xxxxxx.', 'xxxxxxxx']
66const HEART_SHAPE = ['.x.x.', 'xxxxx', '.xxx.', '..x..']
67
68export const groundTop = (rows: number) => rows * 2 - 3
69export const petTop = (rows: number) => groundTop(rows) - PET_H
70
71export function spriteFor(s: Pick<SceneInput, 'stage' | 'hiding' | 'lying'>): Sprite {
72 if (s.hiding) return HIDE
73 if (s.stage === 0) return EGG
74 if (s.lying) return LYING
75 return [EGG, BABY, YOUTH, ALICORN][s.stage] ?? ALICORN
76}
77
78export function paint(s: SceneInput): Painted {
79 const W = s.columns
80 const PH = s.rows * 2
81 const px = new Uint32Array(W * PH)
82 const glyphs: Glyph[] = []
83 const put = (x: number, y: number, c: number) => {
84 x = Math.round(x)
85 y = Math.round(y)
86 if (x >= 0 && x < W && y >= 0 && y < PH) px[y * W + x] = c
87 }
88 const stamp = (rows: readonly string[], x0: number, y0: number, colors: Record<string, number>, flip = false) =>
89 rows.forEach((row, y) =>
90 [...row].forEach((ch, x) => {
91 const c = colors[ch]
92 if (c !== undefined) put(x0 + (flip ? row.length - 1 - x : x), y0 + y, c)
93 }),
94 )
95
96 const night = isNight(s.hour)
97 const [top, bottom] = sky(s.hour)
98 const gTop = groundTop(s.rows)
99 for (let y = 0; y < gTop; y++) {
100 const c = lerp(top, bottom, y / Math.max(1, gTop - 1))
101 for (let x = 0; x < W; x++) px[y * W + x] = c
102 }
103
104 if (night) {
105 for (let i = 0; i < Math.floor(W / 3); i++) {
106 const twinkle = ((s.tick >> 3) + i) % 7 === 0
107 put((i * 37 + 11) % W, (i * 17 + 3) % Math.max(1, gTop - 6), twinkle ? 0x8890b8 : 0xfff8d6)
108 }
109 for (let dy = -3; dy <= 3; dy++)
110 for (let dx = -3; dx <= 3; dx++)
111 if (dx * dx + dy * dy <= 9 && (dx - 1.6) ** 2 + (dy + 1) ** 2 > 6) put(W - 6 + dx, 4 + dy, 0xfdf3c4)
112 } else {
113 const cloud = s.hour >= 17 ? 0xffd6e0 : 0xffffff
114 for (const [offset, y] of [
115 [0, 0],
116 [W * 0.55, 2],
117 ] as const)
118 stamp(CLOUD, ((s.tick * 0.04 + offset) % (W + 10)) - 9, y, { x: cloud })
119 }
120
121 if (s.rainbow > 0) {
122 // 레벨이 오르거나 진화하면 하늘에 무지개가 뜬다
123 const cx = W / 2
124 const cy = gTop + 4
125 for (let y = 0; y < gTop; y++)
126 for (let x = 0; x < W; x++) {
127 const band = Math.floor((W * 0.55 - Math.hypot((x - cx) / 1.4, (y - cy) * 1.1)) / 1.2)
128 const c = RAINBOW[band]
129 if (c !== undefined && band >= 0) put(x, y, s.rainbow >= 1 ? c : lerp(px[y * W + x] ?? 0, c, s.rainbow))
130 }
131 }
132
133 // 멀리 보이는 언덕
134 for (let x = 0; x < W; x++) {
135 const h = Math.round(3 + 2 * Math.sin(x / 7) + Math.sin(x / 3.1))
136 for (let y = gTop - h; y < gTop; y++) put(x, y, night ? 0x2c4a45 : 0xbfe3b4)
137 }
138
139 const grass = night ? [0x3f6b55, 0x2f5444, 0x23423a] : [0x9ad89a, 0x74c27c, 0x5aa66a]
140 for (let y = gTop; y < PH; y++) for (let x = 0; x < W; x++) px[y * W + x] = grass[Math.min(2, y - gTop)] ?? 0
141 for (let x = 3; x < W; x += 7) put(x, gTop - 1, RAINBOW[(x >> 3) % RAINBOW.length] ?? 0xffffff)
142
143 const look = BRANCHES[s.branch]
144 const coat = COATS[s.coat] ?? COATS[0]!
145 const darkCoat = coat.body < 0x808080
146 const pTop = petTop(s.rows) + s.bob
147 const cx = s.petX + PET_W / 2 - 0.5
148 const cy = petTop(s.rows) + PET_H / 2
149
150 if (s.ring > 0) {
151 const color = night ? 0xa5b4fc : 0xffffff
152 for (let y = 0; y < gTop; y++)
153 for (let x = 0; x < W; x++) if (Math.abs(Math.hypot((x - cx) / 1.6, y - cy) - s.ring) < 0.5) put(x, y, color)
154 }
155
156 const colors: Record<string, number> = {
157 o: OUTLINE,
158 f: coat.body,
159 F: coat.shade,
160 m: look.mane,
161 M: look.mane2,
162 h: s.alert && (s.tick >> 1) % 2 ? 0xd7263d : look.horn,
163 k: darkCoat ? 0xfff6cf : 0x2a2238,
164 p: 0xffb3c6,
165 u: 0xc9b9d6,
166 W: 0xe6f6ff,
167 e: 0xfff3dc,
168 s: look.mane,
169 w: 0xfff6cf,
170 }
171
172 for (const f of s.friends) stamp(f.foal ? FRIEND_FOAL : FRIEND_EGG, f.x, f.y, colors)
173
174 const sprite = spriteFor(s)
175 const grid = sprite.rows.map(r => [...r])
176 const set = (x: number, y: number, ch: string) => {
177 const row = grid[y]
178 if (row && x >= 0 && x < row.length) row[x] = ch
179 }
180 const e = sprite.eye
181 if (e && !s.hiding) {
182 const last = e.y + e.h - 1
183 for (let y = e.y; y <= last; y++) set(e.x, y, 'f')
184 if (s.eyes === 'open') for (let y = e.y; y <= last; y++) set(e.x, y, 'k')
185 else if (s.eyes === 'happy') set(e.x, e.y, 'k')
186 else if (s.eyes === 'sleepy') {
187 set(e.x, last, 'k')
188 if (e.h > 1) set(e.x, e.y, 'o')
189 } else set(e.x, last, 'k')
190 }
191 let x0 = s.petX
192 if (s.stage === 0 && !s.hiding && s.eyes === 'happy') x0 += Math.round(Math.sin(s.tick * 0.8))
193 stamp(
194 grid.map(r => r.join('')),
195 x0,
196 pTop,
197 colors,
198 s.facingLeft,
199 )
200 if (s.stage === 0 && s.cracked && !s.hiding) for (const [x, y] of CRACK) put(x0 + x, pTop + y, OUTLINE)
201 if (s.tear && e) {
202 const tx = x0 + (s.facingLeft ? PET_W - 1 - e.x : e.x)
203 put(tx, pTop + e.y + e.h, TEAR)
204 put(tx, pTop + e.y + e.h + 1, TEAR)
205 }
206
207 for (const h of s.hearts) stamp(HEART_SHAPE, h.x, h.y, { x: HEART })
208 for (const p of s.sparkles) put(p.x, p.y, p.color)
209
210 const hornX = x0 + (s.facingLeft ? PET_W - 1 - sprite.horn[0] : sprite.horn[0])
211 const glyphRow = Math.floor((pTop + sprite.horn[1] - 1) / 2)
212 if (s.alert) glyphs.push({ x: hornX, y: Math.max(0, glyphRow - 1), ch: '!', color: 0xd7263d })
213 if (s.zzz) {
214 const phase = (s.tick >> 3) % 3
215 for (let k = 0; k <= phase; k++)
216 glyphs.push({
217 x: Math.round(hornX + (s.facingLeft ? -2 - k : 2 + k)),
218 y: glyphRow - k,
219 ch: k === 2 ? 'Z' : 'z',
220 color: night ? 0xe0e7ff : 0x4a4e69,
221 })
222 }
223 return { px, W, PH, glyphs: glyphs.filter(g => g.x >= 0 && g.x < W && g.y >= 0 && g.y < s.rows) }
224}
225
226/** 뿔 끝의 화면 좌표: 반짝이가 여기서 터진다 */
227export function hornTip(s: Pick<SceneInput, 'stage' | 'hiding' | 'lying' | 'petX' | 'facingLeft' | 'rows'>) {
228 const sp = spriteFor(s)
229 return { x: s.petX + (s.facingLeft ? PET_W - 1 - sp.horn[0] : sp.horn[0]), y: petTop(s.rows) + sp.horn[1] }
230}
231
232export function toCells({ px, W, PH, glyphs }: Painted): string {
233 const rows = PH / 2
234 const words = new Uint32Array(W * rows * 3)
235 for (let cy = 0; cy < rows; cy++)
236 for (let cx = 0; cx < W; cx++) {
237 const i = (cy * W + cx) * 3
238 words[i] = 0x2580
239 words[i + 1] = px[cy * 2 * W + cx] || DEFAULT
240 words[i + 2] = px[(cy * 2 + 1) * W + cx] || DEFAULT
241 }
242 for (const g of glyphs) {
243 const i = (g.y * W + g.x) * 3
244 words[i] = g.ch.charCodeAt(0)
245 words[i + 1] = g.color
246 words[i + 2] = px[g.y * 2 * W + g.x] || DEFAULT
247 }
248 return new Uint8Array(words.buffer).toBase64()
249}
250
251const hex = (c: number) => '#' + c.toString(16).padStart(6, '0')
252
253// Desktop 앱용: 같은 픽셀을 가로로 이어 붙인 사각형으로 그린다
254export function toSvg({ px, W, PH, glyphs }: Painted): string {
255 const parts: string[] = []
256 for (let y = 0; y < PH; y++) {
257 let x = 0
258 while (x < W) {
259 const c = px[y * W + x] ?? 0
260 let run = 1
261 while (x + run < W && px[y * W + x + run] === c) run++
262 parts.push(`<rect x="${x}" y="${y}" width="${run}" height="1" fill="${hex(c)}"/>`)
263 x += run
264 }
265 }
266 for (const g of glyphs)
267 parts.push(
268 `<text x="${g.x + 0.5}" y="${g.y * 2 + 1.7}" font-size="2" font-family="monospace" text-anchor="middle" fill="${hex(g.color)}">${g.ch}</text>`,
269 )
270 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${PH}" shape-rendering="crispEdges">${parts.join('')}</svg>`
271}
272hooks/sprites.ts 176 lines1// 유니의 도트 그림. 한 글자 = 한 픽셀, 오른쪽을 보는 옆모습.
2// o 테두리 · f 몸 · F 몸 그림자 · m/M 갈기 두 색 · h 뿔 · k 눈 · p 볼 · u 발굽
3// W 날개 · e 알껍질 · s 알무늬 · w 빛나는 눈 · . 투명
4
5export type Box = { x: number; y: number; w: number; h: number }
6export type Sprite = { rows: string[]; eye?: Box; horn: [number, number] }
7
8export const EGG: Sprite = {
9 rows: [
10 '................',
11 '................',
12 '......oooo......',
13 '.....oeeeeo.....',
14 '....oeessseo....',
15 '....oeeeeeeo....',
16 '...oesseeeeeo...',
17 '...oeeeeesseo...',
18 '...oeeeeeeeeo...',
19 '...oessseeeeo...',
20 '...oeeeeeesso...',
21 '....oeeesseo....',
22 '....oeeeeeeo....',
23 '.....oeeeeo.....',
24 '......oooo......',
25 '................',
26 ],
27 horn: [8, 2],
28}
29
30// 답변 20번이 넘으면 알에 금이 간다
31export const CRACK: [number, number][] = [
32 [7, 4], [8, 5], [7, 6], [6, 7], [7, 8], [8, 9], [9, 8], [10, 9],
33]
34
35// 사용량이 바닥나면 알 속으로 숨는다: 금 사이로 눈만 빛난다
36export const HIDE: Sprite = {
37 rows: [
38 '................',
39 '................',
40 '......oooo......',
41 '.....oeeeeo.....',
42 '....oessseeo....',
43 '....oeeeeeeo....',
44 '...oeeseeeeeo...',
45 '...oeoeoeoeoeo..',
46 '...okwkkkkkwko..',
47 '...oeoeoeoeoeo..',
48 '...oeseeeeseo...',
49 '...oeeeeeeeeo...',
50 '....oeeseeeo....',
51 '.....oeeeeo.....',
52 '......oooo......',
53 '................',
54 ],
55 horn: [8, 2],
56}
57
58export const BABY: Sprite = {
59 rows: [
60 '................',
61 '................',
62 '................',
63 '................',
64 '.............o..',
65 '............oho.',
66 '........mmooooo.',
67 '.......mMmofffo.',
68 '.......mMofffkfo',
69 '......mMmofffkpo',
70 '......mmooffffo.',
71 '....m.offffffo..',
72 '...mMoffffffo...',
73 '...m.ofoFofo....',
74 '.....ofo.ofo....',
75 '.....ouo.ouo....',
76 ],
77 eye: { x: 13, y: 8, w: 1, h: 2 },
78 horn: [13, 4],
79}
80
81export const YOUTH: Sprite = {
82 rows: [
83 '..............o.',
84 '.............oho',
85 '............oho.',
86 '.......mmo.oooo.',
87 '......mmMoofffo.',
88 '.....mmMmofffkfo',
89 '.....mMmmofffkfo',
90 '....mmMmoffffffo',
91 '...mMmmooffffppo',
92 '..mMmoffffoooo..',
93 '.mMmofffffffo...',
94 '.mMoffffffffo...',
95 'mMmoffffffffo...',
96 'mM.ofoFoofoFo...',
97 'm..ofo.o.ofo....',
98 '...ouo...ouo....',
99 ],
100 eye: { x: 13, y: 5, w: 1, h: 2 },
101 horn: [14, 0],
102}
103
104// 알리콘: 청년 유니콘 등 위에 날개를 얹는다
105const WING = ['..oo..', '.oWWo.', 'oWWWWo', 'oWWWo.', '.oWo..', '..o...']
106export const ALICORN: Sprite = {
107 rows: YOUTH.rows.map((row, y) => {
108 const w = WING[y - 4]
109 if (!w) return row
110 return [...row].map((ch, x) => (w[x - 4] && w[x - 4] !== '.' ? w[x - 4] : ch)).join('')
111 }),
112 eye: YOUTH.eye,
113 horn: YOUTH.horn,
114}
115
116// 컨텍스트가 가득 차면 배불러서 드러눕는다
117export const LYING: Sprite = {
118 rows: [
119 '................',
120 '................',
121 '................',
122 '................',
123 '................',
124 '................',
125 '................',
126 '..............o.',
127 '.............oho',
128 '............oho.',
129 '......mmo..oooo.',
130 '....mmMmoooffkfo',
131 '..mmMmooffffffpo',
132 '.mMmoffffffffoo.',
133 'mMmoffffffffffo.',
134 'oooooooooooooo..',
135 ],
136 eye: { x: 13, y: 11, w: 1, h: 1 },
137 horn: [14, 7],
138}
139
140// 서브에이전트 친구: 굴러오는 알, 끝나면 날개 달린 아기가 되어 날아간다
141export const FRIEND_EGG = ['..oo..', '.oeso.', 'oeeeeo', 'oseeso', 'oeeeeo', '.oeeo.', '..oo..']
142export const FRIEND_FOAL = ['......o.', '.....oho', '.oo.mooo', 'oWWomofk', 'oWWWoffo', '.ooffffo', '..ofoofo', '..o..o..']
143
144export type Branch = '알' | '불꽃' | '바람' | '강철'
145
146export const BRANCHES: Record<Branch, { mane: number; mane2: number; horn: number; about: string }> = {
147 알: { mane: 0xc6b3ff, mane2: 0xffb3d9, horn: 0xffd75e, about: '아직 어떤 유니콘이 될지 몰라요' },
148 불꽃: { mane: 0xff7a59, mane2: 0xffc145, horn: 0xffd75e, about: '코드를 많이 고치는 사람의 유니콘' },
149 바람: { mane: 0x7ad7ff, mane2: 0xb38cff, horn: 0xe0f7ff, about: '많이 찾아보고 읽는 사람의 유니콘' },
150 강철: { mane: 0x8b98a9, mane2: 0xcfd8e3, horn: 0xe8edf2, about: '명령어를 많이 돌리는 사람의 유니콘' },
151}
152
153// 부화할 때 털색이 무작위로 정해져서 매번 다른 유니콘이 나온다
154export const COATS = [
155 { name: '눈송이', body: 0xfffaf3, shade: 0xe9dcf0 },
156 { name: '복숭아', body: 0xffe8dc, shade: 0xf5c6b5 },
157 { name: '라벤더', body: 0xf0e6ff, shade: 0xd3c0f5 },
158 { name: '민트', body: 0xe6fbf2, shade: 0xb5e8d3 },
159 { name: '밤하늘', body: 0x4b4f7a, shade: 0x363a5e },
160]
161
162export const STAGES = [
163 { name: '알', turns: 0 },
164 { name: '아기 망아지', turns: 50 },
165 { name: '청년 유니콘', turns: 200 },
166 { name: '알리콘', turns: 500 },
167] as const
168
169export const CRACK_AT = 20
170
171export function stageOf(turns: number) {
172 let i = 0
173 STAGES.forEach((s, n) => turns >= s.turns && (i = n))
174 return i
175}
176types/index.d.ts 50 lines1export type DayLog = {
2 day: string
3 turns: number
4 tools: number
5 files: string[]
6 breaks: number
7 guards: number
8 firstAt: number
9}
10
11export type Pet = {
12 name: string
13 xp: number
14 happy: number
15 full: number
16 turns: number
17 branch: '알' | '불꽃' | '바람' | '강철'
18 coat: number
19 counts: { edit: number; search: number; bash: number }
20 guards: number
21 breaks: number
22 streak: number
23 lastDay: string
24 breakMin: number
25 band: boolean
26 today: DayLog
27}
28
29export type Friend = { id: string; label: string; since: number }
30
31export type View = {
32 line: string
33 bubble: string
34 mode: 'normal' | 'break' | 'breathe' | 'today'
35 breath: string
36 startedAt: number
37 nextBreakAt: number
38 friends: Friend[]
39 context: number | null
40 fiveHour: number | null
41 week: number | null
42 cost: number | null
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 yuni: { pet: Pet; view: View }
48 }
49}
50