Skip to content

Commit 8471da8

Browse files
committed
feat: Desktop 펫 조회기능 추가
1 parent 122d2c3 commit 8471da8

9 files changed

Lines changed: 318 additions & 25 deletions

File tree

skills/add-pet-emotion-animation.md

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,100 @@ emotion이 base 펫과 동일한 위치에 나타나도록 `emotionYOffsets` 값
227227
- 디버깅 시 `minGap``maxGap``1.0`으로 설정하면 1초마다 emotion이 나타나서 위치 확인이 쉬움
228228
- standing 포즈와 sitting 포즈는 보정값이 다를 수 있음 (DESSERT_FOX 기준: standing `5.0`, sitting `2.5`)
229229

230-
### 6. PersonaEmotionType 구현하기
231-
org.gitanimals.render.app.PersonaEmotionAssets에 PersonaEmotionAssets.dessertFox를 참고하여 새롭게 추가한 emotion의 PersonaEmotionAssets을 추가하세요.
230+
### 6. PersonaEmotionAssets 구현하기
231+
232+
`src/main/kotlin/org/gitanimals/render/app/PersonaEmotionAssets.kt` 파일의 `PersonaEmotionAssets` sealed interface에 새로운 펫의 구현체를 추가해야 합니다. 이 인터페이스는 펫의 메타데이터와 애니메이션 다운로드 URL 정보를 포함합니다.
233+
234+
**구현 예시 (DESSERT_FOX 참고):**
235+
236+
```kotlin
237+
data object {PetClassName} : PersonaEmotionAssets {
238+
override val personaType: PersonaType = PersonaType.{PET_ENUM_NAME}
239+
override val name: String = "{Display Name}"
240+
override val author: String = "{Author Name}"
241+
override val description: String = "{Detailed Description}"
242+
243+
// SVG 좌표계 설정 (보통 -15 -25 45 45 사용)
244+
override val viewBox = ViewBox(x = -15, y = -25, width = 45, height = 45)
245+
246+
// 레이아웃 설정
247+
override val layout = Layout(
248+
contentBox = Box(x = -2, y = -2, width = 20, height = 18),
249+
centerX = 7.5,
250+
baselineY = 15.0,
251+
visibleHeightRatio = 0.58,
252+
baselineBottomRatio = 0.05
253+
)
254+
255+
// 눈 추적(Eye Tracking) 설정 (미지원 시 enabled = false)
256+
override val eyeTracking = EyeTracking(
257+
enabled = false,
258+
states = listOf("idle"),
259+
eyeRatioX = 0.52,
260+
eyeRatioY = 0.45,
261+
maxOffset = 1.5,
262+
bodyScale = 0.25,
263+
shadowStretch = 0.15,
264+
shadowShift = 0.3,
265+
ids = EyeTrackingIds(eyes = "eyes-js", body = "body-js", shadow = "shadow-js"),
266+
shadowOrigin = "7.5px 14px"
267+
)
268+
269+
// 애니메이션 타이밍 설정 (ms 단위)
270+
override val timings = Timings(
271+
minDisplay = mapOf(
272+
"attention" to 4000,
273+
"error" to 5000,
274+
"notification" to 2500,
275+
"working" to 1000,
276+
"thinking" to 1000
277+
),
278+
autoReturn = mapOf(
279+
"attention" to 4000,
280+
"error" to 5000,
281+
"notification" to 2500
282+
),
283+
mouseIdleTimeout = 20000,
284+
mouseSleepTimeout = 60000,
285+
wakeDuration = 5000
286+
)
287+
288+
// 히트박스 설정
289+
override val hitBoxes = HitBoxes(
290+
default = Box(x = -3, y = -8, width = 22, height = 20),
291+
sleeping = Box(x = -3, y = -5, width = 22, height = 18)
292+
)
293+
294+
override val sleepingHitboxFiles = listOf("sleeping.svg")
295+
override val miniMode = MiniMode(supported = false)
296+
297+
// 오브젝트 스케일 보정
298+
override val objectScale = ObjectScale(
299+
widthRatio = 1.9,
300+
heightRatio = 1.3,
301+
offsetX = -0.45,
302+
offsetY = -0.25
303+
)
304+
305+
// 실제 SVG 컨텐츠 반환 로직 구현
306+
override fun getAsset(emotion: String): String {
307+
return when (emotion) {
308+
"error" -> {petName}ErrorEmotionSvg
309+
"happy" -> {petName}HappyEmotionSvg
310+
"idleFollow" -> {petName}IdleFollowEmotionSvg
311+
"notification" -> {petName}NotificationEmotionSvg
312+
"thinking" -> {petName}ThinkingEmotionSvg
313+
"typing" -> {petName}TypingEmotionSvg
314+
else -> throw IllegalArgumentException("Invalid emotion: $emotion")
315+
}
316+
}
317+
}
318+
```
319+
320+
**참고 사항:**
321+
- `error`, `happy`, `idleFollow` 등의 프로퍼티는 인터페이스의 기본 구현(`get()` 접근자)을 통해 자동으로 다운로드 URL (`/assets/images?...`)을 반환합니다.
322+
- `getAsset` 메서드는 `AnimationController``/assets/images` API에서 실제 SVG 파일을 응답할 때 사용됩니다.
323+
- 새로운 펫 구현체를 추가한 후 `companion object``from` 메서드에서 해당 펫이 올바르게 조회되는지 확인하세요 (Sealed interface의 `sealedSubclasses`를 사용하므로 자동으로 포함됩니다).
232324

233325
## buildEmotionAnimation 파라미터
234326

src/main/kotlin/org/gitanimals/core/PersonaType.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import kotlin.random.Random
88

99
enum class PersonaType(
1010
val weight: Double,
11+
val haveAnimation: Boolean = false,
1112
val grade: PersonaGrade = PersonaGrade.DEFAULT,
1213
val personaEvolution: PersonaEvolution = PersonaEvolution.nothing,
1314
) {
@@ -1770,7 +1771,7 @@ enum class PersonaType(
17701771
.toString()
17711772
},
17721773

1773-
DESSERT_FOX(0.05) {
1774+
DESSERT_FOX(0.05, haveAnimation = true) {
17741775
override fun loadSvg(name: String, animationId: Long, level: Long, mode: Mode): String {
17751776
val emotion = buildEmotionAnimation(
17761777
idPrefix = "dessert-fox",

src/main/kotlin/org/gitanimals/render/app/AssetsFacade.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,21 @@ class AssetsFacade(
3737
AssetsResponse.createSvg(PersonaEmotionAssets.from(personaType))
3838
}
3939
}
40+
41+
fun findAssetSvg(
42+
token: String,
43+
personaType: PersonaType,
44+
emotion: String,
45+
): String {
46+
if (!ProfileIdentifier.isLocal()) {
47+
val identityUser = identityApi.getUserByToken(token)
48+
val renderUser = userService.getUserByName(name = identityUser.username)
49+
50+
require(renderUser.havePersona(personaType)) {
51+
"personaType에 해당하는 Persona가 존재하지 않습니다."
52+
}
53+
}
54+
55+
return PersonaEmotionAssets.from(personaType).getAsset(emotion)
56+
}
4057
}

src/main/kotlin/org/gitanimals/render/app/PersonaEmotionAssets.kt

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,107 @@ import org.gitanimals.core.*
44

55
sealed interface PersonaEmotionAssets {
66
val personaType: PersonaType
7+
val schemaVersion: Int get() = 1
8+
val name: String
9+
val author: String
10+
val version: String get() = "1.0.0"
11+
val description: String
12+
val license: String get() = "MIT"
13+
val viewBox: ViewBox
14+
val layout: Layout
15+
val eyeTracking: EyeTracking
16+
val timings: Timings
17+
val hitBoxes: HitBoxes
18+
val sleepingHitboxFiles: List<String>
19+
val miniMode: MiniMode
20+
val objectScale: ObjectScale
21+
722
val error: String
23+
get() = "/assets/images?personaType=${personaType}&emotion=error"
824
val happy: String
25+
get() = "/assets/images?personaType=${personaType}&emotion=happy"
926
val idleFollow: String
27+
get() = "/assets/images?personaType=${personaType}&emotion=idleFollow"
1028
val notification: String
29+
get() = "/assets/images?personaType=${personaType}&emotion=notification"
1130
val thinking: String
31+
get() = "/assets/images?personaType=${personaType}&emotion=thinking"
1232
val typing: String
33+
get() = "/assets/images?personaType=${personaType}&emotion=typing"
34+
val sleeping: String?
35+
get() = null
36+
val waking: String?
37+
get() = null
38+
39+
fun getAsset(emotion: String): String
1340

1441
data object DessertFox : PersonaEmotionAssets {
1542
override val personaType: PersonaType = PersonaType.DESSERT_FOX
16-
override val error: String = dessertFoxErrorEmotionSvg
17-
override val happy: String = dessertFoxHappyEmotionSvg
18-
override val idleFollow: String = dessertFoxIdleFollowEmotionSvg
19-
override val notification: String = dessertFoxNotificationEmotionSvg
20-
override val typing: String = dessertFoxTypingEmotionSvg
21-
override val thinking: String = dessertFoxThinkingEmotionSvg
43+
override val name: String = "Tanning Fox"
44+
override val author: String = "sumi"
45+
override val description: String =
46+
"A coding fox with a pocket notebook — pixel-perfect fennec inspired by GitAnimals"
47+
override val viewBox = ViewBox(x = -15, y = -25, width = 45, height = 45)
48+
override val layout = Layout(
49+
contentBox = Box(x = -2, y = -2, width = 20, height = 18),
50+
centerX = 7.5,
51+
baselineY = 15.0,
52+
visibleHeightRatio = 0.58,
53+
baselineBottomRatio = 0.05
54+
)
55+
override val eyeTracking = EyeTracking(
56+
enabled = false,
57+
states = listOf("idle"),
58+
eyeRatioX = 0.52,
59+
eyeRatioY = 0.45,
60+
maxOffset = 1.5,
61+
bodyScale = 0.25,
62+
shadowStretch = 0.15,
63+
shadowShift = 0.3,
64+
ids = EyeTrackingIds(eyes = "eyes-js", body = "body-js", shadow = "shadow-js"),
65+
shadowOrigin = "7.5px 14px"
66+
)
67+
override val timings = Timings(
68+
minDisplay = mapOf(
69+
"attention" to 4000,
70+
"error" to 5000,
71+
"notification" to 2500,
72+
"working" to 1000,
73+
"thinking" to 1000
74+
),
75+
autoReturn = mapOf(
76+
"attention" to 4000,
77+
"error" to 5000,
78+
"notification" to 2500
79+
),
80+
mouseIdleTimeout = 20000,
81+
mouseSleepTimeout = 60000,
82+
wakeDuration = 5000
83+
)
84+
override val hitBoxes = HitBoxes(
85+
default = Box(x = -3, y = -8, width = 22, height = 20),
86+
sleeping = Box(x = -3, y = -5, width = 22, height = 18)
87+
)
88+
override val sleepingHitboxFiles = listOf("sleeping.svg")
89+
override val miniMode = MiniMode(supported = false)
90+
override val objectScale = ObjectScale(
91+
widthRatio = 1.9,
92+
heightRatio = 1.3,
93+
offsetX = -0.45,
94+
offsetY = -0.25
95+
)
96+
97+
override fun getAsset(emotion: String): String {
98+
return when (emotion) {
99+
"error" -> dessertFoxErrorEmotionSvg
100+
"happy" -> dessertFoxHappyEmotionSvg
101+
"idleFollow" -> dessertFoxIdleFollowEmotionSvg
102+
"notification" -> dessertFoxNotificationEmotionSvg
103+
"thinking" -> dessertFoxThinkingEmotionSvg
104+
"typing" -> dessertFoxTypingEmotionSvg
105+
else -> throw IllegalArgumentException("Invalid emotion: $emotion")
106+
}
107+
}
22108
}
23109

24110
companion object {
@@ -30,3 +116,44 @@ sealed interface PersonaEmotionAssets {
30116
}
31117
}
32118
}
119+
120+
data class ViewBox(val x: Int, val y: Int, val width: Int, val height: Int)
121+
data class Layout(
122+
val contentBox: Box,
123+
val centerX: Double,
124+
val baselineY: Double,
125+
val visibleHeightRatio: Double,
126+
val baselineBottomRatio: Double
127+
)
128+
129+
data class Box(val x: Int, val y: Int, val width: Int, val height: Int)
130+
data class EyeTracking(
131+
val enabled: Boolean,
132+
val states: List<String>,
133+
val eyeRatioX: Double,
134+
val eyeRatioY: Double,
135+
val maxOffset: Double,
136+
val bodyScale: Double,
137+
val shadowStretch: Double,
138+
val shadowShift: Double,
139+
val ids: EyeTrackingIds,
140+
val shadowOrigin: String
141+
)
142+
143+
data class EyeTrackingIds(val eyes: String, val body: String, val shadow: String)
144+
data class Timings(
145+
val minDisplay: Map<String, Int>,
146+
val autoReturn: Map<String, Int>,
147+
val mouseIdleTimeout: Int,
148+
val mouseSleepTimeout: Int,
149+
val wakeDuration: Int
150+
)
151+
152+
data class HitBoxes(val default: Box, val sleeping: Box)
153+
data class MiniMode(val supported: Boolean)
154+
data class ObjectScale(
155+
val widthRatio: Double,
156+
val heightRatio: Double,
157+
val offsetX: Double,
158+
val offsetY: Double
159+
)

src/main/kotlin/org/gitanimals/render/app/response/AssetsResponse.kt

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
package org.gitanimals.render.app.response
22

3-
import org.gitanimals.render.app.PersonaEmotionAssets
3+
import com.fasterxml.jackson.annotation.JsonProperty
4+
import org.gitanimals.render.app.*
45

56
data class AssetsResponse(
6-
val animationAssets: PersonaEmotionAssets,
7+
val schemaVersion: Int,
8+
val name: String,
9+
val author: String,
10+
val version: String,
11+
val description: String,
12+
val license: String,
13+
val viewBox: ViewBox,
14+
val layout: Layout,
15+
val eyeTracking: EyeTracking,
16+
val states: Map<String, List<String?>>,
17+
val timings: Timings,
18+
val hitBoxes: HitBoxes,
19+
val sleepingHitboxFiles: List<String>,
20+
val miniMode: MiniMode,
21+
val objectScale: ObjectScale,
722
val mimeType: String,
823
) {
924

@@ -15,9 +30,33 @@ data class AssetsResponse(
1530
animationAssets: PersonaEmotionAssets,
1631
): AssetsResponse {
1732
return AssetsResponse(
18-
animationAssets = animationAssets,
33+
schemaVersion = animationAssets.schemaVersion,
34+
name = animationAssets.name,
35+
author = animationAssets.author,
36+
version = animationAssets.version,
37+
description = animationAssets.description,
38+
license = animationAssets.license,
39+
viewBox = animationAssets.viewBox,
40+
layout = animationAssets.layout,
41+
eyeTracking = animationAssets.eyeTracking,
42+
states = mapOf(
43+
"idle" to listOf(animationAssets.idleFollow),
44+
"thinking" to listOf(animationAssets.thinking),
45+
"working" to listOf(animationAssets.typing),
46+
"error" to listOf(animationAssets.error),
47+
"attention" to listOf(animationAssets.happy),
48+
"notification" to listOf(animationAssets.notification),
49+
"sleeping" to listOf(animationAssets.sleeping),
50+
"waking" to listOf(animationAssets.waking),
51+
),
52+
timings = animationAssets.timings,
53+
hitBoxes = animationAssets.hitBoxes,
54+
sleepingHitboxFiles = animationAssets.sleepingHitboxFiles,
55+
miniMode = animationAssets.miniMode,
56+
objectScale = animationAssets.objectScale,
1957
mimeType = MIME_TYPE_SVG_IMAGE,
2058
)
2159
}
2260
}
2361
}
62+

src/main/kotlin/org/gitanimals/render/controller/AnimationController.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,13 @@ class AnimationController(
5555
): AssetsResponse {
5656
return assetsFacade.findAllAssets(token, personaType)
5757
}
58+
59+
@GetMapping(value = ["/assets/images"], produces = ["image/svg+xml"])
60+
fun getAssetImage(
61+
@RequestHeader(HttpHeaders.AUTHORIZATION) token: String,
62+
@RequestParam("personaType") personaType: PersonaType,
63+
@RequestParam("emotion") emotion: String,
64+
): String {
65+
return assetsFacade.findAssetSvg(token, personaType, emotion)
66+
}
5867
}

src/main/kotlin/org/gitanimals/render/controller/PersonaController.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ class PersonaController(
2424
) {
2525

2626
@GetMapping("/users/{username}")
27-
fun getUserByName(@PathVariable("username") username: String): UserResponse {
28-
return UserResponse.from(userService.getUserByNameWithAllContributions(username))
27+
fun getUserByName(
28+
@PathVariable("username") username: String,
29+
@RequestParam("filter-animation", defaultValue = "false") filterAnimation: Boolean,
30+
): UserResponse {
31+
return UserResponse.of(
32+
user = userService.getUserByNameWithAllContributions(username),
33+
filterAnimation = filterAnimation,
34+
)
2935
}
3036

3137
@GetMapping("/personas/{persona-id}")

0 commit comments

Comments
 (0)