|
| 1 | +# 펫에 Emotion 애니메이션 추가하기 |
| 2 | + |
| 3 | +## 개요 |
| 4 | + |
| 5 | +펫에 emotion 애니메이션을 추가하면, `loadSvg` 호출 시 랜덤한 타이밍에 emotion이 재생됩니다. |
| 6 | +emotion이 재생되는 동안 base 펫은 숨겨지고, emotion SVG가 대신 표시됩니다. |
| 7 | +이동(act)과 방향전환(flip)은 emotion 중에도 유지됩니다. |
| 8 | + |
| 9 | +## 파일 구조 |
| 10 | + |
| 11 | +``` |
| 12 | +src/main/resources/persona/animal/ |
| 13 | +├── {pet-name}.svg # base 펫 SVG 템플릿 |
| 14 | +└── emotion/{pet-short-name}/ # emotion SVG 디렉토리 |
| 15 | + ├── error.svg |
| 16 | + ├── happy.svg |
| 17 | + ├── idle-follow.svg |
| 18 | + ├── notification.svg |
| 19 | + ├── thinking.svg |
| 20 | + └── typing.svg |
| 21 | +
|
| 22 | +src/main/kotlin/org/gitanimals/core/ |
| 23 | +├── PersonaType.kt # 펫 enum (loadSvg, act, addEmotions) |
| 24 | +├── PersonaEmotionType.kt # emotion 타입 enum (ERROR, HAPPY, IDLE_FOLLOW, NOTIFICATION, THINKING, TYPING) |
| 25 | +└── Svgs.kt # SVG 파일 로딩 |
| 26 | +``` |
| 27 | + |
| 28 | +## 단계별 구현 |
| 29 | + |
| 30 | +### 1. Emotion SVG 파일 작성 |
| 31 | + |
| 32 | +`src/main/resources/persona/animal/emotion/{pet-short-name}/` 디렉토리에 6개의 emotion SVG를 작성합니다. |
| 33 | + |
| 34 | +**필수 규칙:** |
| 35 | + |
| 36 | +- viewBox: 펫 본체 + props(느낌표, 스파클, 말풍선 등)가 모두 포함되도록 설정 |
| 37 | +- ID 패턴: `{pet-name}-*{id}-{emotion}-{part}` (예: `dessert-fox-*{id}-error-shadow`) |
| 38 | +- `*{id}`는 런타임에 `animationId`로 치환됩니다 |
| 39 | +- 각 SVG는 독립적으로 동작하는 완전한 애니메이션이어야 합니다 (style + 구조 포함) |
| 40 | + |
| 41 | +**SVG 내부 구조:** |
| 42 | + |
| 43 | +```xml |
| 44 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-15 -25 45 45"> |
| 45 | + <style> |
| 46 | + @keyframes {pet-name}-*{id}-{emotion}-shadow { ... } |
| 47 | + @keyframes {pet-name}-*{id}-{emotion}-obj { ... } |
| 48 | + /* 기타 keyframes */ |
| 49 | + |
| 50 | + #{pet-name}-*{id}-{emotion}-shadow { animation: ... } |
| 51 | + #{pet-name}-*{id}-{emotion}-obj { animation: ... } |
| 52 | + </style> |
| 53 | + |
| 54 | + <!-- Shadow --> |
| 55 | + <g id="{pet-name}-*{id}-{emotion}-shadow" transform="translate(3, 7)">...</g> |
| 56 | + |
| 57 | + <!-- obj: legs + body + head --> |
| 58 | + <g id="{pet-name}-*{id}-{emotion}-obj"> |
| 59 | + <!-- Legs --> |
| 60 | + <g id="{pet-name}-*{id}-{emotion}-leg-left" transform="translate(5, 5)">...</g> |
| 61 | + <g id="{pet-name}-*{id}-{emotion}-leg-right" transform="translate(8, 5)">...</g> |
| 62 | + <!-- Body --> |
| 63 | + <g transform="translate(-2, 2)">...</g> |
| 64 | + <!-- Head --> |
| 65 | + <g transform="translate(0, -5)"> |
| 66 | + <g id="{pet-name}-*{id}-{emotion}-head-nod">...</g> |
| 67 | + </g> |
| 68 | + </g> |
| 69 | + |
| 70 | + <!-- Props (!, sparkles 등 — obj 바깥에 배치) --> |
| 71 | + <g id="{pet-name}-*{id}-{emotion}-exclam" transform="translate(15, -13)">...</g> |
| 72 | +</svg> |
| 73 | +``` |
| 74 | + |
| 75 | +**emotion별 특징:** |
| 76 | + |
| 77 | +| emotion | 포즈 | 특수 효과 | |
| 78 | +|---------|------|-----------| |
| 79 | +| error | standing | X X 눈, 좌우 흔들림, 빨간 ! 깜빡임 | |
| 80 | +| happy | standing | ^^ 눈, 점프 바운스, 스파클 | |
| 81 | +| idle-follow | standing | 일반 눈, 부드러운 바운스 | |
| 82 | +| notification | standing | 일반 눈, 놀람 점프, 노란 ! 팝인 | |
| 83 | +| thinking | standing | 일반 눈, 말풍선 + 로딩 dots | |
| 84 | +| typing | **sitting** | 일반 눈, 노트북 prop, 고개 끄덕임 | |
| 85 | + |
| 86 | +### 2. Svgs.kt에 SVG 로딩 추가 |
| 87 | + |
| 88 | +```kotlin |
| 89 | +// src/main/kotlin/org/gitanimals/core/Svgs.kt |
| 90 | + |
| 91 | +val {petName}ErrorEmotionSvg: String = ClassPathResource("persona/animal/emotion/{pet-short-name}/error.svg") |
| 92 | + .getContentAsString(Charset.defaultCharset()) |
| 93 | + |
| 94 | +val {petName}HappyEmotionSvg: String = ClassPathResource("persona/animal/emotion/{pet-short-name}/happy.svg") |
| 95 | + .getContentAsString(Charset.defaultCharset()) |
| 96 | + |
| 97 | +val {petName}IdleFollowEmotionSvg: String = ClassPathResource("persona/animal/emotion/{pet-short-name}/idle-follow.svg") |
| 98 | + .getContentAsString(Charset.defaultCharset()) |
| 99 | + |
| 100 | +val {petName}NotificationEmotionSvg: String = ClassPathResource("persona/animal/emotion/{pet-short-name}/notification.svg") |
| 101 | + .getContentAsString(Charset.defaultCharset()) |
| 102 | + |
| 103 | +val {petName}ThinkingEmotionSvg: String = ClassPathResource("persona/animal/emotion/{pet-short-name}/thinking.svg") |
| 104 | + .getContentAsString(Charset.defaultCharset()) |
| 105 | + |
| 106 | +val {petName}TypingEmotionSvg: String = ClassPathResource("persona/animal/emotion/{pet-short-name}/typing.svg") |
| 107 | + .getContentAsString(Charset.defaultCharset()) |
| 108 | +``` |
| 109 | + |
| 110 | +### 3. Base 펫 SVG 템플릿 수정 |
| 111 | + |
| 112 | +base SVG에 3가지 플레이스홀더를 추가합니다. |
| 113 | + |
| 114 | +**3-1. `*{emotion-style}` 플레이스홀더 추가** |
| 115 | + |
| 116 | +`<style>` 블록 안에 `*{emotion-style}`을 추가합니다: |
| 117 | + |
| 118 | +```xml |
| 119 | +<style> |
| 120 | + *{act} |
| 121 | + *{emotion-style} |
| 122 | + |
| 123 | + @keyframes {pet-name}-*{id}-leg-left-move { ... } |
| 124 | + ... |
| 125 | +</style> |
| 126 | +``` |
| 127 | + |
| 128 | +**3-2. base 펫 콘텐츠를 `<g id="{pet-name}-*{id}-base">`로 감싸기** |
| 129 | + |
| 130 | +emotion 재생 시 base를 숨기기 위해 wrapper 그룹을 추가합니다: |
| 131 | + |
| 132 | +```xml |
| 133 | +<svg width="600" height="300" viewBox="0 0 200 100" fill="none" overflow="visible"> |
| 134 | + <g id="{pet-name}-*{id}-base"> |
| 135 | + <!-- 기존 shadow, obj, body, head 등 모든 base 콘텐츠 --> |
| 136 | + </g> |
| 137 | + *{emotions} |
| 138 | +</svg> |
| 139 | +``` |
| 140 | + |
| 141 | +**3-3. `*{emotions}` 플레이스홀더 추가** |
| 142 | + |
| 143 | +base 콘텐츠 `</g>` 닫힘 태그 바로 뒤, `</svg>` 앞에 추가합니다. |
| 144 | + |
| 145 | +### 4. PersonaType.kt에서 loadSvg 구현 |
| 146 | + |
| 147 | +`buildEmotionAnimation()` 공통 메서드를 사용합니다. |
| 148 | + |
| 149 | +```kotlin |
| 150 | +{PET_NAME}(weight) { |
| 151 | + override fun loadSvg(name: String, animationId: Long, level: Long, mode: Mode): String { |
| 152 | + val emotion = buildEmotionAnimation( |
| 153 | + idPrefix = "{pet-name}", // base SVG의 ID prefix와 동일해야 함 |
| 154 | + animationId = animationId, |
| 155 | + totalDuration = 180.0, // 전체 애니메이션 사이클 (초) |
| 156 | + emotionDuration = 3.0, // 각 emotion 재생 시간 (초) |
| 157 | + emotionSvgs = listOf( |
| 158 | + {petName}ErrorEmotionSvg, // index 0 |
| 159 | + {petName}HappyEmotionSvg, // index 1 |
| 160 | + {petName}IdleFollowEmotionSvg, // index 2 |
| 161 | + {petName}NotificationEmotionSvg, // index 3 |
| 162 | + {petName}ThinkingEmotionSvg, // index 4 |
| 163 | + {petName}TypingEmotionSvg, // index 5 |
| 164 | + ), |
| 165 | + emotionYOffsets = listOf( |
| 166 | + 5.0, // error (standing) |
| 167 | + 5.0, // happy (standing) |
| 168 | + 5.0, // idle (standing) |
| 169 | + 5.0, // notif (standing) |
| 170 | + 5.0, // thinking (standing) |
| 171 | + 2.5, // typing (sitting — 앉아있으므로 Y 보정이 다름) |
| 172 | + ), |
| 173 | + minGap = 5.0, // emotion 사이 최소 간격 (초) |
| 174 | + maxGap = 30.0, // emotion 사이 최대 간격 (초) |
| 175 | + ) |
| 176 | + |
| 177 | + return {petName}Svg |
| 178 | + .replace("*{act}", act(animationId)) |
| 179 | + .replace("*{emotion-style}", emotion.css) |
| 180 | + .replace("*{emotions}", emotion.content) |
| 181 | + .replace("*{id}", animationId.toString()) |
| 182 | + .replace("*{level}", level.toSvg(14.0, 2.0)) |
| 183 | + .replace("*{levelx}", (-3 + (-1 * (level.toString().length))).toString()) |
| 184 | + .replace("*{username}", name.toSvg(14.0, 25.0)) |
| 185 | + .replace("*{usernamex}", (23 + (-3 * name.length)).toString()) |
| 186 | + } |
| 187 | + |
| 188 | + override fun addEmotions(emotionType: PersonaEmotionType): String { |
| 189 | + return when (emotionType) { |
| 190 | + ERROR -> {petName}ErrorEmotionSvg |
| 191 | + HAPPY -> {petName}HappyEmotionSvg |
| 192 | + IDLE_FOLLOW -> {petName}IdleFollowEmotionSvg |
| 193 | + NOTIFICATION -> {petName}NotificationEmotionSvg |
| 194 | + THINKING -> {petName}ThinkingEmotionSvg |
| 195 | + TYPING -> {petName}TypingEmotionSvg |
| 196 | + } |
| 197 | + } |
| 198 | +} |
| 199 | +``` |
| 200 | + |
| 201 | +### 5. emotionYOffsets 결정하기 |
| 202 | + |
| 203 | +`emotionYOffsets`는 emotion SVG를 base 펫 위치에 정렬하기 위한 Y축 보정값입니다. |
| 204 | +base SVG와 emotion SVG의 좌표계가 다르기 때문에 필요하며, 정확한 값은 서버를 직접 띄워서 확인하며 조정해야 합니다. |
| 205 | +Agent가 아래 요건에 따라서 스스로 서버를 띄우고 테스트하여 정확한 값을 조정하세요. |
| 206 | + |
| 207 | +**서버 띄우기:** |
| 208 | + |
| 209 | +```bash |
| 210 | +./gradlew bootRun --args="--spring.profiles.active=local" |
| 211 | +``` |
| 212 | + |
| 213 | +**테스트 데이터 설정:** |
| 214 | + |
| 215 | +local MySQL에 접속하여 테스트할 펫 타입으로 변경합니다: |
| 216 | + |
| 217 | +```sql |
| 218 | +USE gitanimalsrender; |
| 219 | +UPDATE persona SET type = '{PET_NAME}'; |
| 220 | +``` |
| 221 | + |
| 222 | +**확인:** |
| 223 | + |
| 224 | +브라우저에서 `localhost:8080/lines/{username}` 으로 조회하면 SVG가 응답됩니다. |
| 225 | +emotion이 base 펫과 동일한 위치에 나타나도록 `emotionYOffsets` 값을 조정하세요. |
| 226 | + |
| 227 | +- 디버깅 시 `minGap`과 `maxGap`을 `1.0`으로 설정하면 1초마다 emotion이 나타나서 위치 확인이 쉬움 |
| 228 | +- standing 포즈와 sitting 포즈는 보정값이 다를 수 있음 (DESSERT_FOX 기준: standing `5.0`, sitting `2.5`) |
| 229 | + |
| 230 | +## buildEmotionAnimation 파라미터 |
| 231 | + |
| 232 | +| 파라미터 | 타입 | 설명 | |
| 233 | +|----------|------|------| |
| 234 | +| `idPrefix` | String | CSS ID prefix. base SVG의 `*{id}` 앞 부분과 동일해야 함 | |
| 235 | +| `animationId` | Long | 펫 인스턴스 고유 ID. ID 충돌 방지용 | |
| 236 | +| `totalDuration` | Double | 전체 애니메이션 사이클 길이 (초) | |
| 237 | +| `emotionDuration` | Double | 각 emotion이 표시되는 시간 (초) | |
| 238 | +| `emotionSvgs` | List\<String\> | emotion SVG 문자열 리스트 | |
| 239 | +| `emotionYOffsets` | List\<Double\> | 각 emotion의 Y축 보정값. emotionSvgs와 같은 순서 | |
| 240 | +| `minGap` | Double | emotion 사이 최소 간격 (초). 기본값 5.0 | |
| 241 | +| `maxGap` | Double | emotion 사이 최대 간격 (초). 기본값 30.0 | |
| 242 | + |
| 243 | +## 동작 원리 |
| 244 | + |
| 245 | +1. `Random(animationId)` 시드로 결정론적 스케줄 생성 |
| 246 | +2. base 펫에 `opacity` 토글 CSS 적용 → emotion 구간에서 base 숨김 |
| 247 | +3. 각 emotion에도 `opacity` 토글 CSS 적용 → 해당 구간에서만 표시 |
| 248 | +4. `steps(1, end)` timing function으로 즉시 전환 (페이드 없음) |
| 249 | +5. emotion SVG 내부의 자체 애니메이션 (바운스, 흔들림 등)은 독립적으로 동작 |
| 250 | +6. `*{id}`는 최종 `.replace("*{id}", animationId.toString())`에서 일괄 치환 |
| 251 | + |
| 252 | +## 디버깅 팁 |
| 253 | + |
| 254 | +- `minGap`과 `maxGap`을 `1.0`으로 설정하면 1초마다 emotion이 나타남 |
| 255 | +- `localhost:8080/lines/{username}`으로 결과 확인 |
| 256 | +- emotion이 안 보이면: CSS `animation` shorthand 대신 longhand 사용 확인 |
| 257 | +- 위치가 맞지 않으면: `emotionYOffsets` 값 조정 |
| 258 | + |
| 259 | +## 참고 구현 |
| 260 | + |
| 261 | +DESSERT_FOX 구현을 참고하세요: |
| 262 | +- base SVG: `src/main/resources/persona/animal/dessert-fox.svg` |
| 263 | +- emotion SVGs: `src/main/resources/persona/animal/emotion/fox/` |
| 264 | +- PersonaType: `PersonaType.kt` DESSERT_FOX 항목 |
| 265 | +- SVG 로딩: `Svgs.kt` dessertFox*EmotionSvg 변수들 |
0 commit comments