SLOPSHOPPER

yuni

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

newpanebandguardcommandtoast
v0.3.0NOASSERTIONupdated 2026-10-07RJ-Stony/claude-hangul-mods/yuni
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · yuni
│ ┃ 유니 ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 💬 수고했어요! 오늘도 반짝반짝 ✨ │ ┃ 지금: 기다리고 있어요 › /yuni │ ┃ 🥚 알 · 답변 19번 더 하면 금이 가요 ⎿ yuni: 유니 패널 │ ┃ ⭐ Lv.1 (14/20) · 💗 70 · 🍬 72 · 🛡️ 견습 지킴… │ ┃ [ 쓰다듬기 ] [ 간식 ] [ 숨쉬기 ] [ 쉬었어요 │ 🦄 유니 알 Lv.1 │ 기다리고 있어요 │ 🧠 49% ⏳ 5시간 31% ☕ 0분 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🦄 유니 알 Lv.1 │ 기다리고 있어요 │ 🧠 49% ⏳ 5시간 31% ☕ 0분 ⟨Claude Code's own drawing⟩
Pane · 유니
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 💬 수고했어요! 오늘도 반짝반짝 ✨ 지금: 기다리고 있어요 🥚 알 · 답변 19번 더 하면 금이 가요 ⭐ Lv.1 (14/20) · 💗 70 · 🍬 72 · 🛡️ 견습 지킴이 (0) [ 쓰다듬기 ] [ 간식 ] [ 숨쉬기 ] [ 쉬었어요 ] [ 오늘 ]
README

한글 Claude Code mods

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번. 날개가 생겨요
  • 갈래: 그때까지 제일 많이 한 일로 정해져요. 코드 수정이 많으면 🔥 불꽃, 검색·읽기는 🌬️ 바람, 명령어는 ⚙️ 강철. 털색 5가지는 무작위예요.
  • 반응: 답변이 끝나면 뿔에서 무지개 반짝이가 터지고, 에러가 나면 울어요. 5분 쉬면 잠들어요.
  • 서브에이전트 친구: 도우미를 부르면 작은 알이 굴러오고, 일이 끝나면 아기 유니콘이 되어 날아가요.
  • 컨텍스트·사용량: 컨텍스트가 85%를 넘으면 배불러서 드러눕고, 사용량이 바닥나면 알 속에 숨어요.
  • 뿔 경보: rm -rf, 강제 push, reset --hard, DROP TABLE 같은 명령어가 나오면 뿔이 빨갛게 깜빡이고, 이미 허락된 상태여도 한 번 더 확인을 받아요.
  • 쉼: 50분마다 스트레칭 알림, 박스 호흡(4-4-4-4), 새벽에는 그만 자라고 말해요.

입력창 위 정보 줄

🦄 유니 청년 유니콘 Lv.6 │ 코드를 고치고 있어요 ✏️ │ 🧠 42% ⏳ 5시간 31% · 주간 12% ☕ 18분 🥚 2

명령어: /yuni 뒤에 숨 · 쉬기 · 오늘 · 이름 <이름> · 알림 <분> · 정보줄 켜기/끄기

패널이 저절로 열리려면 터미널이 144칸 이상이어야 해요. 좁으면 /yuni로 직접 여세요.

한글 화면

  • 표: 한글을 2칸으로 계산해서 칸이 어긋나지 않아요.
  • 코드: 24개 언어 문법 색, 언어 이름 머리줄, 4줄 이상이면 줄 번호, ⧉ 복사 버튼.
  • mermaid: 순서도·시퀀스·상태·ER·차트를 상자 그림으로 그려요. 한글 라벨에도 상자가 밀리지 않아요.
  • 도구 줄: ⎿ 명령어 2번 실행 · 파일 3개 읽음처럼 문장 아래에 흐리게 붙여요. 긴 명령어는 짧게 잘라요.
  • 한글 문구: 스피너("생각하는 중"), 걸린 시간("6분 20초 걸렸어요"), 힌트 줄("? 단축키", "esc 중단"), 모드 표시("자동 모드").
  • 테마 15가지: /hangul theme nord. 세부 옵션은 /config에서 한글 설명과 함께 바꿀 수 있어요.

안전

mod는 내 권한으로 실행돼요. 그래서 이 저장소의 mod는 필요한 것만 써요.

mod쓰는 것안 쓰는 것
유니화면, 타이머, 펫 기록 저장, 컨텍스트·사용량 숫자 읽기파일, 네트워크, 프로그램 실행
한글 화면화면, 클립보드(복사 버튼), /hangul copy할 때 마지막 답변 읽기, 테마 설정 저장, 터미널 종류를 알려주는 환경 변수 읽기파일, 네트워크, 프로그램 실행
입력창 꾸미기입력창 색칠, 켜기/끄기 설정 저장파일, 네트워크, 프로그램 실행

설치 전에 claude plugin validate <폴더>로 직접 확인할 수 있어요.

다른 mod와 같이 쓸 때

  • 패널은 탭으로 나란히 생기고, 정보 줄은 다른 mod의 줄 위에 쌓여요. 덮어쓰지 않아요.
  • 한글 화면은 Claude 답변 전체를 다시 그려요. 원본 prismantis처럼 답변을 꾸미는 다른 mod와 같이 켜면 하나만 보여요.

만든 사람과 라이선스

  • 한글 화면은 prismantis(MIT, Nahum Litvin)를 한글로 옮기고 고친 판이에요.
  • 입력창 꾸미기는 nogu66/md-prompt(MIT)에 한글 문구를 입힌 판이에요.
  • 두 원작의 라이선스는 각 폴더의 LICENSE에 그대로 있어요. 이 저장소의 나머지(유니 등)는 MIT예요.
Source 4 files
hooks/register.tsx 774 lines
1import { 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}
774
hooks/scene.ts 272 lines
1// 유니의 무대를 픽셀로 칠한 뒤, 터미널용 반쪽 블록 칸(▀)이나 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}
272
hooks/sprites.ts 176 lines
1// 유니의 도트 그림. 한 글자 = 한 픽셀, 오른쪽을 보는 옆모습.
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}
176
types/index.d.ts 50 lines
1export 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