noon에는 표정이 있다. 나른함, 흡족함, 화남, 호기심 같은 것들. 그런데 눈 모양 변화만으로 표현할 수 없는 순간 반응이 있었다. 화날 때 눈 옆에 빠직 혈관 터지는 것. 방문자가 나타났을 때 "??" 팝업. 드물게 ✦ 반짝이는 별눈.
이것들을 지금까지는 각자 하드코딩해왔다. 기존 코드(spawnNoticeMark)는 방문자 알림용 ! 하나만 처리하던 함수였는데, 마크 종류가 늘어나면서 일반화가 필요해졌다.
워크북이 진실원이다
마크 시스템을 코딩하기 전에 먼저 워크북(eye-workbench.html)을 열었다. noon에는 눈 셰이프와 표정을 빠르게 시험해보는 HTML 파일이 있는데, 여기서 디자인을 확정하면 Swift로 포팅하는 식으로 작업해왔다. 이번 표정 개편도 같은 방식으로 했다.
마크 10종의 모양과 위치를 워크북 탭에서 정렬했다. 화냄 빠직(vein_angry), 놀람 말풍선(surprise_bubble), 땀방울(sweat_drop), 충격 버스트(shock_burst), 호기심 물음표(curious_double) 등. 빠직은 양쪽 눈 바깥 위 (±40, +30) 위치에 ±16° 기울어지게, 호기심 ??는 오른눈 우상단 (+22, +42)에. 각도와 좌표를 HTML 워크북에서 직접 보면서 맞췄다. SpriteKit 시뮬레이터를 켜고 매번 확인하는 것보다 훨씬 빠르니까.
"워크북이 진실원이다"라는 주석을 코드에 달았다(커밋 02c94c2). 코드는 포팅이고, 디자인은 워크북에서 결정한다는 원칙을 못으로 박아둔 것이다.
절차적 노드와 에셋 스프라이트
마크 구현 방식은 두 가지로 갈렸다.
별(✦), 눈송이, ! 같은 단순 기호는 CGPath로 직접 그리는 절차적 방식을 썼다. 에셋 이미지를 관리할 필요 없고, 눈 색(currentEyeColorHex)을 바로 입힐 수 있어서다.
빠직, 충격 버스트, 말풍선처럼 형태가 복잡한 것은 PNG 에셋을 쓰는 스프라이트 방식으로 했다. 선으로 절차적으로 그리기엔 형태 자체가 너무 불규칙하다.
기존 spawnNoticeMark를 뜯어서 일반화했다. 원래 이 함수는 방문자 알림 ! 하나만 처리하던 거였는데, 어떤 SKNode든 받을 수 있게 인터페이스를 바꿨다. 이게 spawnMarkNode. PNG 에셋 쪽은 markSprite로 따로 뺐다.
func spawnMarkNode(_ node: SKNode, hold: TimeInterval = 0.45, ...)
func markSprite(_ imageName: String, box: CGSize, at pos: CGPoint, ...) -> SKSpriteNode
spawnMarkNode는 어떤 SKNode든 받아서 "팟 등장 → hold → 위로 살짝 사라짐" 애니메이션을 붙여준다. markSprite는 이미지를 box 크기에 비율 맞춰 잘라 스프라이트를 만들어 반환한다. 이 둘을 조합하면 절차적이든 에셋이든 같은 등장 방식으로 씬에 올릴 수 있다.
빠직이 눈을 따라간다
화냄 빠직(vein_angry)을 붙이면서 생각보다 신경 쓸 게 있었다.
빠직을 씬에 고정으로 얹으면 안 된다. noon의 눈은 idle gaze라는 시선 이동이 있다. 눈동자가 천천히 여기저기 움직이는데, 빠직이 가만히 있으면 눈이 움직일 때 빠직만 혼자 떠 있는 꼴이 된다.
그래서 syncAngryVeins()를 만들었다. 매 프레임 update() 루프에서 호출해서 빠직 노드 위치를 leftEyeGroup.position + leftEye.position(gaze)로 갱신하는 함수다.
func syncAngryVeins() {
let gaze = leftEye.position // 양쪽 눈 동일 gaze
angryVeinNodes[0].position = CGPoint(
x: leftEyeGroup.position.x + gaze.x - 15,
y: leftEyeGroup.position.y + gaze.y + 30
)
// 오른쪽도 동일
}
눈 기준 상대 좌표로 붙어 있게 한 것이다. gaze가 오른쪽으로 치우쳐도 빠직은 그 눈 옆에서 같이 오른쪽으로 따라간다.
같은 이유로 일반 마크도 follow: Bool = true 옵션을 달았다. 기본값이 true라서 spawnMarkNode로 올린 마크는 syncFollowMarks()가 매 프레임 base + gaze로 위치를 갱신한다. 단, follow=true면 사라질 때 위로 떠오르는 moveBy 효과를 쓸 수 없다. 매 프레임 위치를 덮어쓰니까 이동 애니메이션이 씹힌다. 그래서 follow 마크는 제자리에서 페이드아웃만 한다.
한 가지 더, zPosition을 처음에 60으로 줬다가 나중에 110으로 올렸다. noon에는 리본·모자 같은 악세서리(z=92)와 강수 파티클(z=100)이 위에 얹힌다. 거기에 가려지면 마크가 악세서리 뒤에 숨는다. 그래서 이것들 위로 올렸다.
분노눈알도 바꿨다
화냄 표정에서 눈 모양도 개편했다. 기존 눈 셰이프를 재활용하지 않고, EyeShapePaths.angry()라는 새 셰이프를 만들었다.
워크북 S.angry에서 확정한 형태를 그대로 포팅했다. 통통한 둥근 사각형인데, 위쪽 변이 비스듬하다. 바깥쪽이 높고 안쪽이 낮은 \/ 형태다. 눈이 안쪽으로 내려다보는 것처럼 보인다. 왼눈과 오른눈은 x 미러링으로 처리(isLeftEye ? 1 : -1).
이 셰이프는 화냄(angry)과 자신감(confident) 두 상태가 공용으로 쓴다. 결의의 눈으로 쓸 수 있어서다.
이번 작업에서 결국 핵심은 좌표 하나였다. 눈 위에 뜨는 마크는 씬에 고정하면 눈이 움직일 때 혼자 떠 있고, idle gaze를 매 프레임 따라가야 비로소 눈에 붙어 있는 것처럼 보인다. spawnMarkNode의 follow 옵션도 syncAngryVeins()도 그 한 가지를 위해 만든 배관이다.