Skip to content

Commit 5d064e6

Browse files
committed
feat: gitanimals render에 애니메이션이 포함되도록 추가한다
1 parent cb4f86c commit 5d064e6

11 files changed

Lines changed: 1477 additions & 5 deletions

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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 변수들
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.gitanimals.core
2+
3+
enum class PersonaEmotionType {
4+
5+
ERROR,
6+
HAPPY,
7+
IDLE_FOLLOW,
8+
NOTIFICATION,
9+
THINKING,
10+
TYPING,
11+
;
12+
}

0 commit comments

Comments
 (0)