에코플레이룸 · 2026-09-18

게임을 더 게임답게 만드는 법

기능이 다 돌아가는데도 게임이 허접하게 느껴진다면, 모자란 것은 대개 그래픽이 아니라 「입력에 대한 반응」이다. 그 반응을 부품 단위로 분해해서, 웹게임(JS + canvas / three.js)에 그대로 옮길 수 있는 수치와 코드로 정리했다.

1. 기능이 다 돼도 게임 같지 않은 이유

이 글은 디시인사이드 AI 개발 갤러리의 한 글deepnight 의 Game Feel 데모에서 출발했다. 거기서 던진 문제에, 에코플레이룸에서 웹게임을 만들며 쓰는 기준을 붙여 정리했다.

AI 와 함께 웹게임을 여러 장르로 만들어 보면 늘 같은 자리에서 걸린다. 이동도 되고, 공격도 되고, 적도 죽고, 점수도 올라간다. 요구한 기능은 전부 들어갔고 버그도 딱히 없다. 그런데 직접 만져보면 게임을 한다는 느낌이 아니라 기능 몇 개를 이어붙인 화면을 만지는 느낌이 난다. 이동은 되지만 무게가 없고, 발사는 되지만 손에 아무것도 돌아오지 않고, 적은 죽지만 때렸다는 사실이 전달되지 않는다.

명세에 「느낌」이 적혀 있지 않다

기능 명세는 무엇이 일어나는가만 적는다. 클릭하면 총알이 생기고, 총알이 날아가고, 맞으면 체력이 준다. 그대로 구현하면 요구사항을 정확히 완수한 것이고 틀린 곳이 하나도 없다. 비어 있는 칸은 그 일이 플레이어에게 어떤 감각으로 도착해야 하는가다.

사람이 만들든 AI 가 만들든 이 칸이 비면 결과는 같다. 적지 않은 것은 채워지지 않고, 시키는 쪽이 정하지 않은 값은 만드는 쪽이 임의로 정한다. 그래서 「느낌」을 형용사로 두지 않고 켜고 끌 수 있는 부품과 숫자로 바꿔 적는 것 — 이 글은 그 목록이다.

그레이박싱이 알려 주는 것

그레이박싱 (Grayboxing)

완성된 모델과 텍스처를 깔기 전에, 박스와 캡슐과 단색 바닥만으로 맵 구조·이동·전투·상호작용을 먼저 확인하는 작업 방식이다.

여기서 가져올 것은 절차가 아니라 사실 하나다. 회색 박스 상태에서도 손맛은 이미 확인된다. 캐릭터가 캡슐이어도 이동이 기분 좋고 공격에 반응이 있으면 게임처럼 느껴지고, 반대로 리소스를 아무리 갈아끼워도 밋밋한 반응은 그대로 밋밋하다. 그림을 바꿔도 반응은 바뀌지 않기 때문이다.

그림을 바꿔서 해결되는 문제와, 반응을 바꿔야 해결되는 문제는 따로 있다.

아래 4장과 6장은 그 「손맛」을 구현 가능한 단위로 쪼갠 목록이고, 5장은 그중 무엇을 내 장르에 먼저 넣을지 고르는 표다.

2. Game Feel 과 Juice

Game Feel = 플레이어의 입력과 게임의 반응 사이에서 느껴지는 전체적인 감각.

Juice = 플레이어의 행동에 게임이 풍부하게 반응하도록 만드는 것.

Game Feel

버튼을 눌렀을 때 얼마나 즉각적으로 반응하는지, 캐릭터가 어떻게 가속하고 멈추는지, 점프가 가벼운지 묵직한지 — 전부 여기에 들어간다.

이동 하나만 해도 버튼을 누르자마자 최고 속도로 움직일 수도 있고, 조금씩 가속해서 움직일 수도 있다. 손을 떼면 즉시 멈출 수도 있고 관성이 남을 수도 있다. 숫자로 보면 가속도·감속도 몇 개를 바꾸는 문제인데, 플레이어가 받는 느낌은 꽤 크게 달라진다.

점프도 마찬가지다. 올라갈 때와 내려올 때 중력을 다르게 주거나(→ 레시피 6), 버튼을 짧게 눌렀을 때 낮게 뛰게 하거나, 착지할 때 캐릭터와 카메라가 살짝 반응하게 만드는 것(→ 레시피 7)만으로도 느낌이 완전히 달라진다.

Juice

여기에 사운드, 애니메이션, 파티클, 화면 반응을 더해서 행동에 대한 피드백을 풍부하게 만드는 쪽이 Juice 다.

둘의 관계

겹치는 부분이 많지만 같은 개념은 아니다. Game Feel 은 이동·가속·점프·조향·입력 반응처럼 플레이어가 조작하면서 느끼는 감각 전체에 가깝고, Juice 는 거기에 얹는 피드백의 풍부함에 가깝다.

쉽게 말해 — Juice 는 좋은 Game Feel 을 만들기 위해 쓸 수 있는 도구 중 하나다. 그래서 Juice 만 잔뜩 뿌려도 조작 자체가 굼뜨면 소용이 없고, 반대로 조작이 좋아도 피드백이 없으면 「맞췄다」는 사실이 전달되지 않는다.

작업 방식: 하나씩 얹고 그때마다 직접 만져본다

Juice 를 다루는 고전적인 시연 방식이 하나 있다 — Breakout 류 게임을 하나 만들어놓고 그 위에 효과를 한 개씩 추가하면서 변화를 실시간으로 보여주는 것.

처음에는 공이 움직이고, 블록과 충돌하고, 블록이 없어지고, 점수가 올라가는 게 전부다. 기능적으로는 아무 문제가 없다. 그런데 파티클, 사운드, 화면 반응, 스쿼시 앤 스트레치, 트레일, 애니메이션을 하나씩 붙이면 갑자기 훨씬 게임 같아진다.

여기서 가져와야 할 교훈은 「효과를 넣어라」가 아니라 「효과를 하나씩 넣고 그때마다 직접 만져봐라」는 작업 방식 쪽이다. 열 개를 한꺼번에 넣으면 무엇이 좋아졌고 무엇이 방해가 되는지 구분할 수 없다.

그래서 우리 규칙 — 연출은 개별로 껐다 켤 수 있게 만든다. 디버그 메뉴에 체크박스를 하나씩 다는 비용은 30분이고, 그게 있어야 밸런스를 잡을 수 있다. 4장의 카탈로그가 통째로 33개의 토글로 되어 있는 이유도 이것이다.

3. 총 한 발 비교

말로는 잘 잡히지 않으니 행동 하나를 놓고 비교해 보자. 총 한 발이다. 아래 첫 줄이 명세를 그대로 구현한 상태 — 기능만 놓고 보면 흠잡을 곳이 없다.

기능만 있는 상태
클릭 총알 생성 충돌 HP 감소
플레이어가 받는 정보: 「총알 오브젝트가 하나 생성됐다」
피드백이 추가된 상태
클릭 반동+ 총구 화염+ 탄피 배출+ 총성+ 카메라 범프·셰이크
명중 히트스톱+ 피격 블링크+ 스쿼시+ 넉백+ 피 파티클
플레이어가 받는 정보: 「내가 적을 한 대 제대로 때렸다」

게임 규칙 자체는 똑같다. 결국 총알이 적에게 닿으면 체력이 줄어드는 것뿐이다. 바뀐 것은 그 사실을 플레이어에게 몇 개의 감각 채널로 알려주는가뿐이다.

우리 규칙(경험칙) — 하나의 게임 이벤트에 최소 3개 채널로 응답한다. ① 화면 전체(카메라·플래시) ② 대상 자체(스쿼시·블링크·넉백) ③ 파편(파티클·사운드). 세 채널 중 하나라도 비면 그 이벤트는 「일어났지만 느껴지지 않는」 상태가 되기 쉽다. 숫자로 증명된 법칙이 아니라 우리가 반복해서 확인한 기준선이다. 채널의 종류는 장르마다 바뀐다 — 오디오 게임이면 셋 다 소리(피치·좌우·잔향)이고, 방치형이면 숫자·팝·버튼이다(→ 5장).

4. 피드백 요소 카탈로그 — 공통 25 + 액션 전용 8

게임에 넣을 수 있는 피드백을 독립적으로 켜고 끌 수 있는 단위 33개로 쪼갠 목록이다. 이름은 코드에서 그대로 쓰는 식별자로 잡아뒀다 — AI 에게 지시할 때 이 이름을 그대로 부르면 의도가 정확히 전달된다. 「구현 핵심」의 수치는 실제로 검증된 기준값이고, 난이도는 우리 스택(JS + canvas / three.js) 기준이다.

33개 중 25개는 장르를 가리지 않는 공통이고, 나머지 8개는 액션·플랫포머에서만 의미가 있는 것이라 표 아래 접힌 부록으로 내렸다. 방치형이나 서사 게임을 만들면서 탄피·시체·절벽잡기를 고민할 필요는 없다.

수치 읽는 법 — 먼저 읽고 표를 봐라

이 표의 속도·가속도 수치는 16px 그리드 · 셀/프레임 단위로 적혀 있다(2D 플랫포머의 일반적인 표기). 우리 코드로 옮길 때:

  • ×16 = 픽셀/프레임, 다시 ×60 = 픽셀/초.
    예) 점프 dy = -0.45 → 7.2 px/frame → 약 430 px/s. 중력 0.05 → 0.8 px/frame² → 약 2,880 px/s².
  • 단, 카메라 범프·셰이크 값은 이미 화면 픽셀이다(변환하지 않는다). bump(0, 10) 은 10px.
  • 시간 값은 초 단위다. 그대로 쓰면 된다.
  • 감쇠식의 tmod 는 「목표 FPS 대비 프레임 배율」이다. dt 기반 루프에서는 v *= Math.pow(frict, dt * 60) 으로 옮긴다. 맨 v *= 0.85 는 프레임레이트 의존이 되므로 쓰지 않는다.

요소 구분과 수치는 deepnight/gamefeel (MIT License, © 2019 Sébastien Bénard) 소스에서 읽어 px·초로 환산했다. 동작하는 데모에서 같은 요소를 하나씩 켜고 끌 수 있다.

요소언제무엇을구현 핵심난이도
카메라 / 화면 — 4개
flashbang플레이어가 발사화면 전체에 노란 섬광.전체 화면 크기의 단색 비트맵을 Add 블렌드로 얹고 알파를 트윈으로 0 까지. 색 0xffcc00, 시작 알파 0.04, 소요 0.1s. 알파가 극단적으로 낮다는 게 핵심.
camShakesXY발사(X) / 높은 곳에서 착지(Y)카메라를 가로·세로로 흔든다.결정론적 사인파 + 선형 감쇠. x += cos(f*1.1)*2.5*pow*ratio, y += sin(0.3+f*1.7)*2.5*pow*ratio. X/Y 주파수를 다르게(1.1 vs 1.7) 둬서 대각선 줄이 생기지 않게 한다. 발사 0.1s/pow 0.2, 착지 0.6s*p / pow 0.8*p.
camBumpXY발사 / 높은 곳에서 착지카메라를 짧은 시간 급격히 밀어낸다.셰이크와 다른 메커니즘. 오프셋을 한 번 더하고 지수 감쇠 bumpOff *= 0.85^tmod. 발사 bump(-dir*3, 0), 착지 bump(0, 10*pow), 대시 bump(dir*6, 0).
camBumpZoom착지 / 대시짧은 시간 급격히 줌인.세 번째 메커니즘. zoom = baseZoom + bumpZoomFactor, bumpZoomFactor *= 0.9^tmod. 값은 0.03(착지는 0.03*pow). 3% 줌인이면 충분하다.
스쿼시 & 스트레치 — 2개
mobSquashAndStrech적이 총알에 맞음적을 젤리공처럼 찌그러뜨린다.setSquashX(0.6) → 가로 0.6 / 세로 1.4. 복원 sq += (1-sq)*min(1, 0.2*tmod) — 약 5프레임(0.08s)이면 거의 원상복구.
heroSquashAndStrech점프·착지·대시·발사주인공을 젤리공처럼 찌그러뜨린다.점프 squashX 0.55(세로로 늘림) / 더블점프 0.66 / 대시 1.3 / 발사 1.2 / 착지 squashY = 1-0.5*pow. 축 하나를 정하면 반대축은 2-s.
애니메이션 / 변주 — 2개
randomizeBullets발사 시탄 산포를 살짝 무작위화해 더 자연스럽게.각도 ang += 0.04 - rnd(0, 0.065)(약 ±1.9°), 속도 배율 rnd(0.95, 1.05), 발사 Y 오프셋 rnd(0, 1.5), 연사 간격도 고정 0.06s → rnd(0.04, 0.07) 로 흔든다.
basicAnimations상시플레이어의 기본 애니메이션.상태 기반 등록: 상승(dy<0) / 하강 / 착지 / 달리기(|dx| ≥ 0.08). 달리기 프레임 1에서 카메라 범프 0.5px + 셰이크 0.3s 를 걸어 발걸음을 화면에 전달한다.
파티클 / 이펙트 — 8개
blinkImpact총알이 적에게 명중엔티티에 짧은 흰색 플래시.blink(0xffffff)0.06s 유지 후 RGB 를 각각 다른 비율로 감쇠 r*0.60, g*0.55, b*0.50(프레임당). 색상을 더하기(colorAdd)로 적용.
lighting발사 / 명중노란 헤일로 파티클.halo 텍스처, 스케일 rnd(2,3), Add 블렌드, scaleMul 0.92, 수명 0.1s, 페이드아웃 0.3s. 색은 0xff0000 ↔ 0xffcc00 사이 랜덤 보간.
gunShotFx발사총구에 짧은 폭발 파티클.3층 구조. ① 긴 선 1개(scaleX dir*rnd(1.5,2), 수명 0.12s) ② 코어 6개(수명 0.03~0.06s) ③ 방사형 잔선 9~10개(±0.9rad 부채꼴, 속도 4~8, 마찰 0.83, 수명 0.03~0.06s). 색 #ffb600 → #ff4a00 (0.06s).
bulletTail총알 이동 중총알에 짧은 빛 꼬리.0.03초마다 현재 위치에 선 파티클 하나를 떨군다. 수명 0.1s, 알파 0.3→0.03(0.15s), scaleXMul 0.95, 마찰 0.87. 즉 꼬리는 「길게 늘인 선」이 아니라 짧은 잔상의 연속.
bulletImpactFx총알이 무엇에든 명중충돌 지점에 짧은 임팩트 파티클.총구 화염의 ①번과 동일한 선 파티클을 법선 반대 방향으로. 수명 0.12s, scaleXMul 0.94 / scaleYMul 0.91. 명중 지점은 적 중심 - dir*4 로 살짝 앞당긴다.
bulletImpactDustFx총알이 무엇에든 명중충돌 지점에서 바닥으로 떨어지는 먼지 덩어리.5개. dx = normal*rnd(0.3,2), dy = rnd(-1,0.5), 중력 rnd(0.1,0.2), 마찰 rnd(0.92,0.95), 수명 0.4~2s, alphaFlicker 0.4. 바닥·벽에 바운스.
jumpFx점프 / 더블점프 / 착지작은 연기 뭉치.착지 연기 20개(색 #b78662, 알파 0.1~0.2, 마찰 0.92~0.94, 수명 0.3~0.9s), 더블점프는 선 15개 + 연기 10개(색 #616986). 착지는 낙하 3셀 이상일 때만.
dashFx대시파란 빛 궤적 라인 파티클.30개. 색 #2f3caf, dx = dir*rnd(4,8), 마찰 ~0.8, 수명 약 0.06s. 아주 짧고 아주 많다 — 이게 「속도선」의 정체.
반동 — 2개
gunRecoilVisual발사실제 물리 위치는 그대로 두고 스프라이트만 살짝 밀어낸다.sprOffsetX += -dir*rnd(1,3) px, 감쇠 *= 0.8^tmod. 렌더 좌표에만 더해진다. 판정에 영향 0.
gunRecoilMovement발사플레이어 엔티티를 실제로 살짝 밀어낸다.vBase.dx += -dir*rnd(0, 0.01) (최대 0.16 px/frame ≈ 10 px/s). 위 요소와 별개로 둔 이유가 여기 있다 — 하나는 그림, 하나는 물리다.
물리 / 제어 — 2개
controlLocks높은 곳에서 착지강한 이벤트 뒤 조작을 잠깐 잠가 「스턴」을 흉내낸다.낙하 4셀 초과: lockControls(0.3*p) + walkLock(0.75*p). 그 이하: lockControls(0.06) + vx *= 0.7. 발사에도 0.1s 잠금이 걸린다. 짧게, 그러나 확실하게.
enemyPhysicalReactions적 피격 / 플레이어가 근처에 착지적이 물리적으로 밀린다. 절벽에서 떨어질 수도 있다.피격: 첫 타(0.4s 쿨다운) vBump += (dir*rnd(0.040,0.060), -0.05), 연타는 rnd(0.005,0.010)연타 시 넉백이 1/6로 줄어든다. 착지 충격은 12셀 이내 적 전부에게 거리 비례로 전달.
조작 보조 — 2개
justInTimeJump발이 땅에서 떨어진 직후이미 땅이 아니어도 점프를 허용한다. (코요테 점프)땅에 있는 매 프레임 allowJitJump 타이머를 0.15초로 재설정. 공중에서 그 타이머가 살아 있으면 일반 점프를 허용. 150ms 는 관대한 편(통상 80~120ms).
ctrlQueue조작이 잠겨 있을 때 입력입력을 큐에 담아 잃어버리지 않는다. (입력 버퍼)점프·발사·대시 입력을 타임스탬프와 함께 큐에 넣고, 평소의 isPressed 대신 consumePress소비하며 읽는다. 버퍼 창 길이는 100~150ms 권장.
시간 조작 — 1개
slowMos대시동작을 예측할 시간을 주기 위해 게임을 느리게 한다.대시 중 매 프레임 addSlowMo(S_Dash, 0.4s, 0.8). 여러 슬로모는 배율을 곱해서 누적. 현재 속도는 보간 — 느려질 땐 0.6(빠르게 진입), 돌아올 땐 0.2(천천히 복귀). 최종 timeMul = 0.2 + 0.8*cur바닥을 깔아 완전정지를 막는다.
렌더 — 2개
levelTextures상시레벨 텍스처를 그린다.끄면 회색 박스 = 그레이박싱 상태가 된다. 이 하나만 꺼봐도 「리소스 품질과 손맛은 별개」라는 1장의 주장이 바로 확인된다. 프로토타입 점검용 스위치로 남겨둘 가치가 있다.
heroSprite상시주인공 스프라이트와 그 애니메이션을 그린다.끄면 주인공이 단색 사각형 + 총 모양 그래픽으로 대체된다. 총알도 스프라이트 대신 사각형 + 꼬리 사각형. 연출 코드는 전부 그대로 돌아간다.
액션·플랫포머 전용 8개 — 펼쳐 보기

총기·피·시체·절벽처럼 액션 게임의 문법에 묶인 요소들이다. 값 자체는 그대로 쓸 수 있지만, 다른 장르에서는 대개 대응물이 없거나 톤을 깨뜨린다. 슈팅·액션을 만들 때만 펼쳐 본다.

요소언제무엇을구현 핵심난이도
애니메이션 — 1개
gunAnimations발사조준·발사 관련 무기 애니메이션.발사 전 0.33s 조준(charge) 단계를 거치고 이후 연사는 0.06s 차지. 반동 중 총 스프라이트 회전 -0.15*ratio, 위치 x -= 4*ratio, y -= 2. 비무장 시엔 총을 rotation 0.4 로 내린다.
파티클 / 흔적 — 3개
cartridges발사튀어나와 바닥에 튕기는 탄피. 아주 오래 남는다.1개/발. dx = dir*rnd(0.7,2.8), dy = -rnd(3,4), 중력 gy 0.25, 마찰 0.96, 회전 rnd(0.1,0.2). 수명 12~15초, 마지막 5~7초에 페이드. 바닥 충돌 시 1~2회 바운스(dy *= -0.7).
bulletWallBurnFx총알이 벽에 명중벽에 남아 노랑→빨강으로 천천히 식는 점들.7개, 움직이지 않음. 색 애니메이션 #ffd524~#ff6606 → #990000(0.3~2s), 수명 2~3초, 알파 깜빡임 0.4. 「탄흔이 식는다」를 색으로만 표현.
blood총알이 적에게 명중(아주 많은) 피 파티클. 벽·바닥에 들러붙고 아주 오래 남는다.뒤쪽 분출(qty 2) + 앞쪽 비산(qty 0.6). 점은 ceil(qty*rnd(9,15))개 = 한 방에 약 20~40개. 색 #b70000. 충돌하면 그 자리에 고정되고 벽면을 따라 scale 2~3배로 늘어난다. 수명 12~15s.
물리 / 제어 — 1개
cadavers적 사망시체 엔티티가 생겨 바닥으로 떨어진다.1회 바운스. 공중: scaleX→0.6, scaleY→1.3, 회전 dir*0.9 로 보간(0.6). 착지: scaleX→1.5, scaleY→0.2 로 보간(0.3) — 납작해진다. 벽 충돌 시 dx *= -0.6 + 카메라 범프.
조작 보조 (지형) — 3개
smallStepsHelper낮은 턱을 걸어서 지날 때작은 계단을 자동으로 올라간다.레벨에 M_SmallStep 마크를 미리 구워두고, 진행 방향·셀 내 위치(xr ≥ 0.6)·하강 중 조건이 맞으면 vx += 0.15*dir, vy = -0.35 로 튀어 올린다. 0.1s 재진입 잠금.
cliffGrabHelper절벽 끝을 몇 px 차이로 놓쳤을 때가장자리를 자동으로 잡는다.위와 같은 마크 기반(M_Cliff), 판정만 관대(yr > 0.2). 조작 잠금 0.2s. 「플레이어가 의도한 대로 됐다」고 느끼게 만드는 대표적 거짓말.
climbFx턱·절벽을 오를 때보조가 발동했을 때 작은 먼지.연기 10개 + 흙 픽셀 20개(수명 4~5s, 바운스). 색 #b1664b. 보조 기능을 눈에 보이게 만들어 「시스템이 도와줬다」가 아니라 「내가 기어올랐다」로 읽히게 한다.

카탈로그에 없는 것 하나 — 히트스톱(hit stop). 위 33개는 지속적으로 화면에 보이는 연출이라 토글로 나눠 관리하기 좋지만, 히트스톱은 게임 루프의 시간 배율 자체를 건드리는 것이라 성격이 다르다. 효과는 이 목록 전체에서 가장 크다. 6장 레시피 1번으로 따로 다룬다.

5. 장르별 적용표

4장의 카탈로그는 액션 게임 하나에서 뽑아낸 목록이다. 그런데 웹게임의 폭은 슈팅부터 방치형·오디오·서사·XR 까지 넓고, 같은 연출이 어떤 장르에서는 정답이고 어떤 장르에서는 독이다. 경영 게임에서 카메라가 흔들리면 손맛이 아니라 버그로 읽힌다.

그래서 먼저 이 표에서 내 게임의 줄을 찾고, 「먼저 넣을 것 3개」만 붙인 다음 6장 레시피로 내려간다. 「넣지 말 것」은 지시할 때 명시적으로 빼라고 적는다 — 안 적으면 AI 는 거의 반드시 넣는다.

장르먼저 넣을 것 3개넣지 말 것이 장르만의 손맛
슈팅 · 액션 히트스톱 67ms · 카메라 범프 3px · 피격 블링크 0.06s 10px 넘는 셰이크, 연사에 거는 히트스톱, 조준을 흔드는 줌 범프 「맞췄다」의 확인이 전부다. 명중 순간 화면 · 대상 · 파편 세 채널이 같이 터져야 한다. 저격류는 발사 직전의 호흡과 흔들림이 연출의 절반 — 쏘고 난 뒤가 아니라 쏘기 전에 긴장이 있어야 한다.
물리 · 발사
예: LAUNCH · 에코 브레이크
발사 직전 당김(anticipation) 0.15~0.3s · 궤적 잔상 · 착탄 슬로모 0.3s × 0.4 비행 중 카메라 셰이크(궤적 예측을 방해한다), 조준선을 덮는 파티클 재미가 기다림에 있다. 당기는 동안 힘이 쌓이는 게 스케일·색·진동으로 보이고, 놓는 순간이 정점이고, 착탄에서 한 번 더 온다. 착탄 직후 0.3초의 정적이 결과를 읽을 시간을 준다 — 바로 다음 턴으로 넘기지 않는다.
방치 · 경영 · 건설
예: BEE PLANET · PROMO KING
숫자 트윈 0.3~0.5s · 획득 팝(+N 떠오름) · 버튼 스쿼시 0.92 카메라 셰이크 · 히트스톱 · 화면 플래시 — 거의 전부 금지다. 넣으면 버그로 읽힌다 「늘어나는 것이 보이는가」가 전부다. 자동 수익은 매 틱마다 띄우지 말고 한 덩어리로 합쳐 주기적으로 팝시킨다. 건설 완료는 오버슛 한 번(easeOutBack). 오프라인 보상은 숫자가 끝까지 굴러가는 것을 보여준다 — 그 3초가 이 장르의 타격감이다.
3D 디펜스 · 탐험
예: 시티 워치
부모 Group 셰이크 · emissive 피격 플래시 · FOV 범프 +2~3° camera.position 직접 셰이크(추적 로직과 싸운다), mesh.scale 0.85 미만 2D 값을 그대로 옮기면 전부 과하다. 스케일 폭은 좁게, 대신 빛으로 말한다. 그리고 접지감이 타격감보다 먼저다 — 유닛이 바닥에서 떠 있으면 어떤 연출을 얹어도 가볍게 보인다. 그림자 한 장이 파티클 열 개보다 낫다. (→ 레시피 13)
파티 · 미니게임 · 리듬 입력 즉시 반응(<50ms) · 판정 등급 연출(Perfect/Good/Miss) · 카운트인 조작 잠금, 입력을 늦추는 히트스톱, 스킵 못 하는 긴 연출 판정이 곧 피드백이다. 등급마다 색 · 크기 · 소리를 다르게 하고, 연속 성공은 누적해서 커지게(콤보) 한다. 실패도 즉시 · 명확하게 알린다 — 이 장르에서 가장 나쁜 것은 애매한 실패다.
오디오 · 감각
예: 모기 헌터 · 세컨드 사이트
소리 3채널 — 피치 · 좌우 팬 · 잔향(거리) 정답을 흘리는 시각 연출, 화면 셰이크(소리에 집중이 깨진다) 3장의 3채널 규칙을 통째로 소리로 옮긴다. 화면 전체 = 마스터 필터, 대상 = 피치·음색, 파편 = 반사음·잔향. 시각 피드백은 플레이어가 답을 확정한 뒤에만 준다. (→ 레시피 14)
시뮬 · 카메라 · 서사
예: 시그널 홈
이징(easeOutCubic) · 페이드 0.2~0.3s · 타이포 타이밍 파티클, 셰이크, 시도 때도 없이 튀는 UI 주스가 템포로 나타난다. 대사는 글자 단위로 흐르고 중요한 줄 앞에서 한 박 쉰다. 카메라 시뮬은 셔터의 물리적 순간(블랙아웃 · 셔터음 · 1~2px 미세 흔들림)만 정확하면 나머지는 정적이어도 된다. (→ 레시피 11)
XR
예: 이상현상 처리반
시선·컨트롤러 즉시 반응 · 햅틱 · 공간 음향 카메라 셰이크 · FOV 범프 · 강제 시점 이동 — 전부 멀미의 원인 연출의 자유가 가장 좁은 장르다. 플레이어의 머리는 절대 건드리지 않는다. 흔들 대상은 세계가 아니라 오브젝트 쪽이고, 피드백은 손(햅틱)과 귀(공간 음향)로 보낸다. 화면을 흔들고 싶어질 때마다 물체를 흔든다.

장르표 위에 있는 것 셋 — 어느 장르든 ① 입력한 그 프레임에 무언가 반응하고 ② 값이 즉시 튀지 않고 보간되며(레시피 11) ③ 모든 감쇠가 프레임레이트에 독립이어야 한다(Math.pow(f, dt*60)). 이 셋은 장르를 고르기 전에 이미 깔려 있어야 한다.

6. 구현 레시피 — 14개

각 항목은 원리 / 기준값 / 의사코드 / 과하면 네 칸이다. 기준값은 원 단위와 px·초 환산을 같이 적었다. 위에서부터 넣어라 — 위 3개(히트스톱·셰이크·범프)만 붙여도 체감의 절반이 온다.

1~10 은 액션 계열에서 뽑은 순서이고, 11~14(트위닝 · 모바일 터치 · 3D · 사운드)는 장르를 가리지 않는다. 액션이 아닌 게임을 만들고 있다면 5장에서 줄을 고른 뒤 11번부터 읽어도 된다.

01 히트스톱 (Hit Stop / Frame Freeze)

원리

타격이 발생한 순간 게임 시간만 아주 짧게 멈춘다. 뇌는 이 정지를 「충격 때문에 세상이 멈췄다」로 읽는다. 격투 게임의 타격감은 거의 전부 여기서 나온다. 투자 대비 효과가 이 목록에서 가장 크다.

우리 기본값은 0.1배다. 완전히 멈추면 「멈췄다」가 아니라 「끊겼다」로 읽히는 경우가 많아서다. 다만 격투 게임처럼 완전 정지(0배)를 쓰는 장르도 있다 — 타격이 드물고 한 방이 무거운 게임에서는 정지가 오히려 정답이고, 그쪽 전통은 프레임 단위로 정지 길이를 표에 박아 관리한다. 연사·다타격이면 0.1배를 기본으로 둔다.

기준값
  • 기본값: 4프레임 ≈ 67ms, 그동안 시간 배율 ×0.1
  • 약한 타격 30~50ms / 보통 60~80ms / 처형·보스 킬 120~150ms
  • 연사 무기는 발사당 20ms 이하 또는 아예 빼라 (아래 부작용 참고)
  • 정지 길이는 실시간으로 재야 한다. 게임 시간으로 재면 영원히 안 끝난다.
의사코드
let hitStopUntil = 0;                 // 실시간 기준 종료 시각(ms)

function hitStop(ms = 67) {
  hitStopUntil = Math.max(hitStopUntil, performance.now() + ms);
}

function frame(nowMs) {
  const rawDt = Math.min((nowMs - last) / 1000, 1 / 20);   // 스파이크 컷
  last = nowMs;

  const frozen  = nowMs < hitStopUntil;
  const timeMul = frozen ? 0.1 : 1;     // 0 이 아니라 0.1
  const dt = rawDt * timeMul;

  world.update(dt);      // 물리·엔티티
  fx.update(dt);         // 파티클도 같이 느려져야 한 덩어리로 보인다
  ui.update(rawDt);      // UI·입력 폴링은 실시간
  render();
}

// 호출부
onBulletHitEnemy(e) { hitStop(67); blink(e); setSquashX(e, 0.6); }
과하면

초당 10발짜리 무기에 67ms 히트스톱을 걸면 게임의 40% 가 슬로모가 된다 — 조작이 늪에 빠진 것처럼 느껴진다. 연사 무기는 히트스톱 대신 카메라 범프와 파티클로 처리하고, 히트스톱은 단발·강타·처치에만 쓴다. 겹칠 때 누적되지 않도록 Math.max 로 종료 시각을 갱신하는 것도 필수다.

02 카메라 셰이크 (감쇠)

원리

카메라 위치에 시간 함수로 만든 진동 오프셋을 더한다. 우리는 주파수가 다른 사인파 두 개로 간다(X 1.1, Y 1.7). 결정론적이라 재현과 디버그가 쉽고 코드가 두 줄이다. 두 주파수가 같으면 흔들림이 대각선 한 줄로 뭉개지므로 반드시 다르게 둔다.

펄린 노이즈 셰이크도 널리 쓰이는 정석이고, 사인파보다 더 자연스럽다 — 축마다 1D 노이즈를 하나씩 두고 시간을 흘리면 된다(감쇠를 제곱으로 주는 「트라우마」 방식과 조합하는 구현이 유명하다). 난수 자체가 나쁜 게 아니다. 문제는 매 프레임 독립 난수를 쓰는 경우로, 그때만 프레임레이트에 따라 흔들림의 성격이 달라진다. 노이즈든 사인파든 시간의 함수이기만 하면 된다.

감쇠는 선형(남은 시간 비율)이다. 지수 감쇠를 쓰면 꼬리가 길게 남아 「진동이 안 멈추는」 느낌이 난다.

기준값
  • 진폭 상수 2.5 화면 픽셀 × power × 남은비율 — 변환 불필요(이미 픽셀)
  • 발사: 0.1초 / power 0.2 → 최대 0.5px. 거의 안 보이는 수준이 정답
  • 착지: 0.6*pow 초 / power 0.8*pow, pow = clamp((낙하셀-2)/6, 0, 1)
  • 달리기 발걸음: 0.3초 / power 0.2
  • 주파수는 프레임 카운터 기준이므로 dt 루프에선 f = elapsedSec * 60
의사코드
const cam = { shakeLeft: 0, shakeTotal: 0, shakePow: 1 };

// 새 셰이크가 들어와도 진행 중인 더 긴 셰이크를 잘라먹지 않는다.
// (그냥 덮어쓰면 약한 셰이크가 강한 셰이크를 끊어버린다)
function shake(sec, pow = 1) {
  if (sec <= cam.shakeLeft) { cam.shakePow = Math.max(cam.shakePow, pow); return; }
  cam.shakeLeft = cam.shakeTotal = sec;
  cam.shakePow  = pow;
}

function shakeOffset(elapsedSec, dt) {
  if (cam.shakeLeft <= 0) return [0, 0];
  cam.shakeLeft -= dt;
  const ratio = Math.max(0, cam.shakeLeft / cam.shakeTotal);   // 선형
  const f = elapsedSec * 60;
  return [
    Math.cos(f * 1.1) * 2.5 * cam.shakePow * ratio,
    Math.sin(0.3 + f * 1.7) * 2.5 * cam.shakePow * ratio,
  ];
}

// three.js 라면 카메라 위치가 아니라 부모 Object3D 에 더한다
// (추적 로직과 셰이크를 분리해야 서로 싸우지 않는다)
과하면

가장 흔한 과잉이다. 픽셀 단위로 세어보면 발사 셰이크의 실제 진폭은 0.5px다. 대충 맡기면 보통 10~20px 가 들어가고, 그러면 조준이 불가능해지고 멀미가 난다. 또 셰이크를 동시에 여러 개 켜지 않는다. 그리고 설정에서 끌 수 있게 만들어라(접근성).

03 카메라 범프 / 줌 범프

원리

셰이크와 완전히 다른 메커니즘이다. 셰이크는 진동, 범프는 한 방향으로 확 밀렸다가 지수적으로 돌아오는 것. 방향이 있으므로 「어디서 충격이 왔는지」를 전달한다. 총을 오른쪽으로 쏘면 카메라가 왼쪽으로 밀린다.

줌 범프는 세 번째 메커니즘 — 기준 줌에 얹는 별도 항이라, 카메라 추적이나 줌 연출과 간섭하지 않는다.

기준값
  • 감쇠: 위치 범프 0.85^tmod(약 6프레임 = 0.1s면 40%), 줌 범프 0.9^tmod
  • 발사 3px(발사 반대 방향) / 대시 6px / 착지 10px × pow / 발걸음 0.5px
  • 줌 범프 +0.03 (기준 줌의 3%). 착지는 0.03 × pow
  • 범프는 카메라 목표 위치가 아니라 최종 렌더 오프셋에 더한다
의사코드
let bumpX = 0, bumpY = 0, bumpZoom = 0;

const camBump     = (x, y) => { bumpX += x; bumpY += y; };
const camBumpAng  = (a, d) => { bumpX += Math.cos(a)*d; bumpY += Math.sin(a)*d; };
const camBumpZoom = (z)    => { bumpZoom = z; };          // 더하지 않고 덮어쓴다

function updateCamera(dt, elapsed) {
  const k = dt * 60;                       // tmod
  bumpX *= Math.pow(0.85, k);
  bumpY *= Math.pow(0.85, k);
  bumpZoom *= Math.pow(0.90, k);

  const [sx, sy] = shakeOffset(elapsed, dt);
  const zoom = baseZoom + bumpZoom;

  // canvas 2D
  view.x = -focusX + (canvas.width / zoom) * 0.5 - bumpX + sx;
  view.y = -focusY + (canvas.height / zoom) * 0.5 - bumpY + sy;
  view.x = Math.round(view.x * zoom) / zoom;   // 픽셀아트면 반올림
}

// 발사 시:  camBump(-dir * 3, 0);  shake(0.1, 0.2);
// 대시 시:  camBump(dir * 6, 0);   camBumpZoom(0.03);
과하면

줌 범프를 0.1 이상 주면 화면이 「펌프질」하는 것처럼 보이고, 픽셀아트에서는 매 프레임 스케일이 바뀌면서 텍스처가 지글거린다. 픽셀 게임이라면 줌 범프를 아예 빼거나 정수 스케일에서만 쓰는 것이 낫다. 위치 범프도 발사마다 3px 이상이면 연사 시 화면이 계속 좌우로 끌려다닌다.

04 스쿼시 & 스트레치

원리

애니메이션 12원칙의 첫 번째. 한 축을 줄이면 다른 축을 늘려서 부피가 보존되는 것처럼 보이게 한다. 스프라이트 하나만 있어도 점프·착지·피격·대시가 전부 다르게 보이기 시작한다.

구현은 sqY = 2 - sqX — 진짜 부피 보존(1/s)이 아니라 합을 보존하는 싼 근사다. 0.5~1.5 구간에서는 눈으로 구분되지 않고, 나눗셈이 없어 0 근처에서 폭발하지 않는다.

기준값
  • 복원: sq += (1 - sq) * min(1, 0.2 * tmod) — 프레임당 20%, 약 0.08~0.12초면 원상복구
  • 점프 squashX 0.55 (세로로 쭉) / 더블점프 0.66
  • 대시 squashX 1.3 (가로로 납작) / 발사 squashX 1.2
  • 적 피격 squashX 0.6
  • 착지 squashY = 1 - 0.5*p, p = 0.2 + 0.8*clamp(낙하셀/6,0,1) → 최소 0.9, 최대 0.5
  • 피벗은 발밑(0.5, 1.0). 중심으로 두면 캐릭터가 땅을 뚫거나 뜬다
의사코드
function setSquashX(e, sx) { e.sqX = sx;     e.sqY = 2 - sx; }
function setSquashY(e, sy) { e.sqX = 2 - sy; e.sqY = sy;     }

function updateSquash(e, dt) {
  const k = Math.min(1, 0.2 * dt * 60);
  e.sqX += (1 - e.sqX) * k;
  e.sqY += (1 - e.sqY) * k;
}

// canvas 2D — 피벗을 발밑에 두고 그린다
ctx.save();
ctx.translate(e.x + e.sprOffsetX, e.y);          // e.y = 발바닥
ctx.scale(e.dir * e.scaleX * e.sqX, e.scaleY * e.sqY);
ctx.drawImage(img, -w / 2, -h, w, h);
ctx.restore();

// three.js — mesh.scale 에 곱하고, geometry 를 미리 y=0 바닥 기준으로 옮겨둔다
mesh.scale.set(baseS * e.sqX, baseS * e.sqY, baseS * e.sqX);
과하면

0.4 미만 / 1.6 초과로 가면 캐릭터가 고무처럼 보여 톤이 깨진다(사실적인 게임이라면 특히). 복원 계수를 0.05 같이 낮추면 계속 출렁이는 것처럼 보인다 — 스쿼시는 「튕기고 즉시 원복」이 원칙이고, 눈에 남는 것은 첫 2~3프레임뿐이다. 3D 에서는 스케일이 노멀을 망가뜨려 조명이 튈 수 있으니 값 폭을 더 좁게 0.85~1.15 로 잡는다(→ 레시피 13).

05 피격 블링크

원리

맞은 대상을 한 프레임 동안 흰색으로 태운다. 「맞았다」를 전달하는 가장 값싼 방법이고, 스프라이트가 어떤 색이든 통한다. 중요한 건 색을 바꾸는 게 아니라 더하는 것(additive) — 실루엣과 디테일이 살아 있어야 한다.

감쇠율을 RGB 마다 다르게 준다(0.60 / 0.55 / 0.50). 파랑이 먼저 빠져서 흰색 → 노란색 → 원래색으로 자연스럽게 식는다. 세 값을 같게 두면 그냥 회색으로 흐려진다.

기준값
  • 유지 0.06초(약 4프레임) 동안 최대 밝기 고정
  • 이후 프레임당 r *= 0.60, g *= 0.55, b *= 0.50 → 총 잔상 약 0.15초
  • 플레이어 피격은 더 길게(0.1s 유지) + 무적 시간 동안 알파 깜빡임을 별도로
의사코드
function blink(e, r = 1, g = 1, b = 1) { e.bk = [r, g, b]; e.bkHold = 0.06; }

function updateBlink(e, dt) {
  if (!e.bk) return;
  const k = dt * 60;
  if (e.bkHold > 0) { e.bkHold -= dt; return; }
  e.bk[0] *= Math.pow(0.60, k);
  e.bk[1] *= Math.pow(0.55, k);
  e.bk[2] *= Math.pow(0.50, k);
  if (e.bk[0] < 0.01) e.bk = null;
}

// canvas 2D: 스프라이트를 그린 뒤 같은 실루엣을 additive 로 덧칠
drawSprite(e);
if (e.bk) {
  ctx.save();
  ctx.globalCompositeOperation = 'lighter';
  ctx.globalAlpha = 1;
  ctx.drawImage(tintedCache(e, e.bk), ...);   // 흰색으로 칠한 캐시본
  ctx.restore();
}

// three.js: material.emissive.setRGB(...e.bk)  — 재질 인스턴스를 공유하지 말 것
과하면

유지 시간을 0.2초 이상 주면 연타할 때 적이 계속 흰색 덩어리로 남아 무엇을 때리는지 안 보인다. 또 화면 전체 플래시(flashbang)와 동시에 쓰면 광과민성 위험이 있다 — 둘 중 하나만 쓰고, 화면 플래시의 알파는 0.04 수준으로 유지한다.

06 코요테 점프 + 입력 버퍼

원리

플랫포머가 「반응이 안 좋다」는 인상을 주는 원인의 8할은 이 둘이 없어서다. 둘 다 플레이어를 위한 거짓말이고, 제대로 작동하면 플레이어는 있다는 사실조차 모른다.

  • 코요테 시간 — 발이 땅에서 떨어진 뒤에도 잠깐 점프를 받아준다. 「분명히 눌렀는데 안 뛰었다」를 없앤다.
  • 입력 버퍼 — 착지 직전(또는 조작 잠금 중)에 누른 점프를 버려두지 않고 큐에 넣었다가, 가능해지는 순간 실행한다. 「연속 점프가 씹힌다」를 없앤다.

같이 넣어야 한다. 하나만 넣으면 반대쪽 구멍이 그대로 남는다.

기준값
  • 코요테: 0.15초면 관대한 편. 일반적으로 80~120ms 가 무난, 정밀 플랫포머는 60ms
  • 입력 버퍼: 100~150ms
  • 점프 초속 0.45 셀/프레임 = 7.2 px/frame ≈ 430 px/s / 더블점프 0.52500 px/s
  • 중력 0.05 셀/프레임² ≈ 2,880 px/s²
  • 상승 중 중력 완화: gravityMul = 0.1 + 0.9*(1 - ratio) 를 점프 후 0.1초(더블점프 0.3s, 대시 0.2s) 적용 → 정점이 뜨는 느낌이 난다
  • 공중 가속 0.019 > 지상 가속 0.015 — 공중 제어를 오히려 더 준다. 지상 정지 마찰 0.85^tmod
의사코드
const COYOTE = 0.15, BUFFER = 0.12;
let coyote = 0, jumpBuf = 0;
let reduceGrav = 0, reduceGravMax = 0;     // 남은 시간 / 그때 정한 전체 길이

function setReduceGrav(sec) { reduceGrav = sec; reduceGravMax = sec; }

function updateHero(dt, input) {
  const k = dt * 60;

  coyote  = onGround ? COYOTE : Math.max(0, coyote - dt);
  jumpBuf = input.jumpPressedThisFrame ? BUFFER : Math.max(0, jumpBuf - dt);

  if (jumpBuf > 0 && coyote > 0 && !controlsLocked) {
    vy = -430;              // px/s
    jumpBuf = 0; coyote = 0;   // 소비 즉시 비운다
    setReduceGrav(0.1);        // 상승 구간 중력 완화 (더블점프면 0.3)
    setSquashX(hero, 0.55);
    fx.jumpSmoke(hero.x, hero.y);
  }

  // 가변 점프 높이: 버튼을 떼면 상승을 잘라낸다
  if (!input.jumpDown && vy < 0) vy *= Math.pow(0.5, k);

  reduceGrav = Math.max(0, reduceGrav - dt);
  const ratio = reduceGravMax > 0 ? reduceGrav / reduceGravMax : 0;
  const gMul  = 0.1 + 0.9 * (1 - ratio);   // 완화 직후 0.1배 → 끝나면 1배
  vy += 2880 * gMul * dt;

  // 좌우: 가속 + 지수 마찰 (즉시 최고속도로 만들지 않는다)
  if (input.right) vx += (onGround ? 864 : 1094) * dt;
  else if (input.left) vx -= (onGround ? 864 : 1094) * dt;
  else if (onGround) vx *= Math.pow(0.85, k);
}
과하면

코요테 시간이 200ms 를 넘으면 허공에서 점프하는 게 눈에 보인다 — 특히 카메라가 캐릭터를 크게 잡는 게임에서. 입력 버퍼가 300ms 를 넘으면 플레이어가 취소했다고 생각한 입력이 뒤늦게 튀어나와서 오히려 「내 말을 안 듣는다」가 된다. 그리고 버퍼는 소비 즉시 비워야 한다. 안 비우면 한 번 누른 점프가 두 번 나간다.

07 착지 반응 (스턴 + 먼지 + 범프)

원리

착지는 게임에서 가장 자주 일어나는 「충격」이다. 그런데 대부분의 프로토타입은 vy = 0; onGround = true; 로 끝낸다. 무게가 사라진다.

제대로 된 착지는 한 이벤트가 6개 시스템을 동시에 건드린다 — 카메라 범프 / 셰이크 / 줌 범프 / 스쿼시 / 조작 잠금 / 먼지 / 주변 적 밀어내기. 그리고 전부 낙하 높이에 비례한다. 이 「강도 스케일링」이 핵심이다. 한 칸 내려온 것과 절벽에서 떨어진 것이 같은 연출이면 둘 다 가짜가 된다.

기준값
  • 강도 pow = clamp((낙하셀 - 2) / 6, 0, 1)2셀(32px) 이하는 무반응, 8셀에서 최대
  • 카메라 범프 0, 10*pow px / 셰이크 0.6*pow 초, power 0.8*pow / 줌 범프 0.03*pow
  • 스쿼시 squashY = 1 - 0.5*p2, p2 = 0.2 + 0.8*clamp(낙하셀/6,0,1)
  • 조작 잠금: 4셀 초과 → 0.3*p3초 + 걷기 잠금 0.75*p3초 / 이하 → 0.06초 + vx *= 0.7
  • 먼지: 3셀 이상일 때만, 20개, 수명 0.3~0.9s
  • 주변 적: 4셀 이상 낙하 시 12셀 이내 적에게 거리 비례로 dy = -rnd(0.3,0.4)*pow
의사코드
function onLand(fallCells) {
  const pow = clamp((fallCells - 2) / 6, 0, 1);
  if (pow <= 0 && fallCells < 3) { lockControls(0.06); vx *= 0.7; return; }

  camBump(0, 10 * pow);
  shake(0.6 * pow, 0.8 * pow);
  camBumpZoom(0.03 * pow);

  const p2 = 0.2 + 0.8 * clamp(fallCells / 6, 0, 1);
  setSquashY(hero, 1 - 0.5 * p2);

  if (fallCells > 4) {
    const p3 = clamp((fallCells - 4) / 2, 0, 1);
    lockControls(0.3 * p3);
    walkLock(0.75 * p3);          // 조작은 풀렸지만 걷기는 아직 (비틀거림)
  } else {
    lockControls(0.06);
    vx *= 0.7;
  }

  if (fallCells >= 3) fx.landSmoke(hero.x, hero.footY, 1);

  if (fallCells >= 4) for (const m of mobs) {          // 지면을 타고 전달되는 충격
    const p = 1 - clamp(distCells(hero, m) / 12, 0, 1);
    m.vBump.x += dirTo(hero, m) * rand(0.1, 0.2) * p * 960;
    m.vBump.y  = -rand(0.3, 0.4) * p * 960;
  }
}
과하면

조작 잠금은 가장 위험한 항목이다. 0.3초를 넘으면 즉시 「렉이 걸렸다」로 읽힌다. 반드시 입력 버퍼(레시피 6)와 같이 넣어라 — 잠금 중 누른 점프가 버려지면 스턴은 그냥 버그다. 발사에 0.1초 잠금을 걸 때도 마찬가지다. 그리고 낮은 점프에까지 먼지와 셰이크를 넣으면 평지 이동이 계속 지저분해진다 — 하한선(2~3셀)을 반드시 둬라.

08 반동 — 시각 오프셋과 실제 이동의 분리

원리

이 문서에서 가장 중요한 구조적 원칙. 엔티티의 위치에 영향을 주는 경로를 세 층으로 나눈다.

  1. sprOffsetX순수 시각. 그리기 좌표에만 더해진다. 물리·판정에 영향 0. 감쇠 0.8^tmod.
  2. vBump외부 충격 속도(넉백). 마찰 0.93. 플레이어 입력과 따로 계산되므로 서로 싸우지 않는다.
  3. vBase플레이어(또는 AI)가 만든 속도. 마찰 0.9.

이동 = vBase + vBump 의 합, 렌더 = 이동 결과 + sprOffsetX.

카탈로그에 gunRecoilVisualgunRecoilMovement따로 있는 이유가 정확히 이것이다. 하나는 그림을 밀고, 하나는 몸을 민다. 대부분의 상황에서 시각 오프셋만으로 충분하고, 실제 이동은 조준을 방해한다. 적 넉백을 vBump 로 처리하는 이유도 같다 — vBase 에 넣으면 적 AI 의 이동 의도를 덮어쓴다.

같은 분리는 스쿼시(레시피 4)에도 적용된다. sqX/sqY 는 렌더 스케일일 뿐 히트박스를 건드리지 않는다. 히트박스는 절대 연출을 따라가지 않는다.

기준값
  • 시각 반동 sprOffsetX += -dir * rnd(1, 3) px, 감쇠 0.8^tmod(약 0.1초면 사라짐)
  • 실제 반동 vBase.x += -dir * rnd(0, 0.01) 셀/프레임 ≈ 0~10 px/s — 대단히 작다
  • 적 넉백 첫 타 vBump += (dir*rnd(0.040,0.060), -0.05) ≈ 38~58 px/s, 마찰 0.93 → 총 이동 약 9~14px
  • 연타(0.4초 이내) 넉백은 rnd(0.005, 0.010)약 1/6. 안 그러면 적이 화면 밖으로 날아간다
  • 마찰: 입력 속도 0.9 / 충격 속도 0.93 (충격이 조금 더 오래 남는다)
의사코드
// --- 엔티티 상태 ---
e.vBase = { x: 0, y: 0 };   // 플레이어/AI 가 만든 속도
e.vBump = { x: 0, y: 0 };   // 외부 충격
e.sprOffsetX = 0;           // 그림만

function shoot(e, dir) {
  e.sprOffsetX += -dir * rand(1, 3);            // 레이어 1 (그림)
  e.vBase.x    += -dir * rand(0, 0.01) * 960;   // 레이어 3 (아주 약하게)
  camBump(-dir * 3, 0);
  shake(0.1, 0.2);
  setSquashX(e, 1.2);
  fx.muzzle(e); fx.cartridge(e); flash(0xffcc00, 0.04, 0.1);
}

function hitEnemy(m, fromDir) {
  const first = !m.recentlyHit;                 // 0.4초 쿨다운
  const p = first ? rand(0.040, 0.060) : rand(0.005, 0.010);
  m.vBump.x += fromDir * p * 960;               // 레이어 2 (넉백)
  if (first) m.vBump.y = -0.05 * 960;
  m.recentlyHit = 0.4;
  blink(m); setSquashX(m, 0.6); fx.blood(m, fromDir);
}

function stepEntity(e, dt) {
  const k = dt * 60;
  e.x += (e.vBase.x + e.vBump.x) * dt;          // 합으로 이동
  e.y += (e.vBase.y + e.vBump.y) * dt;
  e.vBase.x *= Math.pow(0.90, k);
  e.vBump.x *= Math.pow(0.93, k);
  e.sprOffsetX *= Math.pow(0.80, k);            // 물리와 무관하게 따로 감쇠
}

const drawX = e.x + e.sprOffsetX;               // 렌더에서만 합류
과하면

실제 이동 반동을 크게 주면 연사할수록 뒤로 밀려나서 조준이 불가능해진다. 기준값이 최대 10 px/s 라는 걸 기억해라 — 「느껴지지만 못 알아챌」 수준으로 의도된 값이다. 넉백 역시 쿨다운 없이 매 타격마다 풀로 주면 적이 화면 밖으로 사라진다. 그리고 시각 오프셋을 물리에 섞는 순간 이 구조 전체가 무너진다 — 벽 끼임, 판정 어긋남, 재현 불가능한 버그가 여기서 나온다.

09 슬로모 (Slow Motion)

원리

히트스톱이 「찰나의 정지」라면 슬로모는 「예고된 느림」이다. 목적이 다르다 — 대표적인 용처는 대시다. 타격감이 아니라 움직임을 예측할 시간을 주기 위해서다.

구현의 세 가지 요령:

  • 여러 슬로모는 곱으로 누적한다. 0.8 × 0.5 = 0.4. 더하기로 하면 겹칠 때 0 이하로 떨어진다.
  • 진입은 빠르게, 복귀는 천천히. 보간 계수를 비대칭으로 둔다 — 느려질 때 0.6, 돌아올 때 0.2. 반대로 하면 「느려지는 게 늦게 오고 갑자기 정상으로 돌아오는」 최악의 조합이 된다.
  • 최종 배율에 바닥을 깐다: 0.2 + 0.8*cur. 슬로모 배율이 0 이 되어도 게임은 20% 속도로 계속 돈다.
기준값
  • 대시: 0.4초 / 배율 0.8 (20% 감속). 대시 지속 중 매 프레임 갱신
  • 보간: 감속 0.6 / 복귀 0.2 (프레임당)
  • 최종 시간 배율 timeMul = 0.2 + 0.8 * cur → cur 0.8 일 때 실제 0.84배
  • 슬로모 잔여 시간은 실시간으로 차감한다(안 그러면 안 끝난다)
  • 연출용 슬로모(보스 처치 등)는 0.3~0.5배 × 0.5~1.0초 정도가 상한
의사코드
const slowMos = new Map();
let cur = 1;

function addSlowMo(id, sec, factor = 0.3) {
  const s = slowMos.get(id);
  if (s) { s.f = factor; s.t = Math.max(s.t, sec); }
  else slowMos.set(id, { t: sec, f: factor });
}

function updateSlowMo(realDt) {
  for (const [id, s] of slowMos) {
    s.t -= realDt;                       // 실시간으로 차감
    if (s.t <= 0) slowMos.delete(id);
  }
  let target = 1;
  for (const s of slowMos.values()) target *= s.f;   // 곱으로 누적

  // 느려질 땐 빠르게(0.6), 정상으로 돌아올 땐 천천히(0.2)
  cur += (target - cur) * (target > cur ? 0.2 : 0.6);
  if (Math.abs(cur - target) <= 0.001) cur = target;

  return 0.2 + 0.8 * cur;                // 바닥 20%
}

// 프레임에서 히트스톱과 곱해서 쓴다
const timeMul = updateSlowMo(rawDt) * (frozen ? 0.1 : 1);
과하면

슬로모가 UI·입력까지 느려지면 게임이 멈춘 것처럼 보인다. 입력 폴링과 HUD 는 실시간 dt 로 돌려라. 또 사운드 피치를 같이 내리지 않으면 어색하고, 내리면 CPU 를 먹는다 — 웹에서는 playbackRate 로 BGM 만 살짝(0.9배) 내리는 선이 적당하다. 그리고 슬로모를 자주 쓰면 효과가 사라진다. 드물게 써야 특별하다.

10 파티클 — 총구 · 탄피 · 먼지 · 피

원리

파티클을 「예쁘게 뿌리는 것」으로 생각하면 실패한다. 파티클은 전부 역할이 정해져 있고 수명이 역할에 맞게 극단적으로 다르다.

  • 순간 이펙트(총구·임팩트·대시) — 수명 0.03~0.12초. 눈에는 「번쩍」으로만 남는다. 개수가 많고 아주 짧다.
  • 중간 이펙트(먼지·연기) — 0.3~2초. 움직임의 흔적을 그린다.
  • 흔적(탄피·피·탄흔) — 2~15초. 「여기서 싸웠다」는 기록을 남긴다. 이게 없으면 전투 후 공간이 처음과 똑같아진다.

그리고 하나의 이펙트가 여러 층으로 이루어진다. 총구 화염은 「긴 선 1개 + 코어 6개 + 방사형 잔선 10개」의 3층이다. 단일 스프라이트 하나로는 절대 같은 밀도가 나오지 않는다.

기준값
이펙트개수수명핵심 수치
총구 화염1 + 6 + 9~100.12s / 0.03~0.06s잔선 부채꼴 ±0.9rad, 속도 4~8, 마찰 0.83. 색 #ffb600→#ff4a00(0.06s), 잔선 #ef5100. Add 블렌드
탄피1 / 발12~15sdx=dir*rnd(0.7,2.8), dy=-rnd(3,4), 중력 0.25, 마찰 0.96, 회전 rnd(0.1,0.2), 바운스 1~2회(dy*=-0.7), 색 #efc04b
탄착 먼지50.4~2sdx=normal*rnd(0.3,2), 중력 rnd(0.1,0.2), 마찰 rnd(0.92,0.95), 알파 깜빡임 0.4
벽 탄흔72~3s정지. 색만 #ffd524 → #990000 으로 0.3~2s 동안 식는다
20~40 / 타격12~15s#b70000, 중력 0.2~0.25, 마찰 0.96~0.97. 충돌 시 그 자리에 고정 + 벽면 방향으로 scale 2~3배
착지 연기200.3~0.9s#b78662, 알파 0.1~0.2, 좌우 교대로 퍼짐, 마찰 0.92~0.94, 천천히 커짐(scaleMul 1.002)
대시 궤적300.06s#2f3caf, dx=dir*rnd(4,8), Add. 아주 많고 아주 짧다
총알 꼬리0.03s마다 10.1s선 파티클을 떨궈 잔상 연속으로 꼬리를 만든다. scaleXMul 0.95, 마찰 0.87
의사코드
// --- 풀 (매 발사마다 객체를 만들면 GC 가 프레임을 먹는다) ---
const POOL = Array.from({ length: 2048 }, () => ({ alive: false }));
let cursor = 0;
function alloc() {
  for (let i = 0; i < POOL.length; i++) {
    const p = POOL[(cursor + i) % POOL.length];
    if (!p.alive) { cursor = (cursor + i + 1) % POOL.length; p.alive = true; return p; }
  }
  return null;                                  // 가득 차면 버린다 (절대 늘리지 않는다)
}

// --- 공통 스텝: 모든 파티클이 같은 식을 쓴다 ---
function stepParticle(p, dt) {
  const k = dt * 60;
  p.dy += p.gy * k;
  p.x += p.dx * k; p.y += p.dy * k;
  p.dx *= Math.pow(p.frict, k); p.dy *= Math.pow(p.frict, k);
  p.scaleX *= Math.pow(p.scaleXMul ?? 1, k);
  p.rotation += p.dr * k;
  p.life -= dt;
  if (p.fadeAfter !== undefined && p.life < p.fadeAfter)
    p.alpha = p.life / p.fadeAfter;
  if (p.life <= 0) p.alive = false;
  if (p.onUpdate) p.onUpdate(p);                // 바운스·벽 부착 등
}

// --- 총구 화염: 3층 ---
function muzzle(x, y, dir) {
  const long = alloc();                                   // ① 긴 선
  Object.assign(long, { x, y, dx: 0, dy: 0, gy: 0, frict: 1,
    scaleX: dir * rand(1.5, 2), scaleY: rand(1.2, 1.4), life: 0.12, add: true });

  for (let i = 0; i < 6; i++) {                           // ② 코어
    const p = alloc(); if (!p) break;
    Object.assign(p, { x: x + rand(-1, 1), y: y + rand(-1, 1),
      dx: 0, dy: 0, gy: 0, frict: 1, scaleX: dir * rand(0.7, 1.5),
      life: rand(0.03, 0.06), add: true });
  }

  const n = 10;                                           // ③ 방사형 잔선
  for (let i = 0; i < n; i++) {
    const a = (dir > 0 ? 0 : Math.PI) - 0.9 + 1.8 * (i + 1) / n + rand(-0.1, 0.1);
    const p = alloc(); if (!p) break;
    Object.assign(p, { x, y, rotation: a,
      dx: Math.cos(a) * rand(4, 8), dy: Math.sin(a) * rand(4, 8),
      gy: 0, frict: rand(0.82, 0.84), life: rand(0.03, 0.06), add: true });
  }
}
과하면

가장 흔한 실패는 긴 수명 파티클의 개수 폭발이다. 피는 타격당 20~40개에 수명이 15초다 — 초당 10발을 쏘면 4,000개가 동시에 살아 있게 된다. 풀 크기를 2048 로 고정하고 넘치면 버려라. 풀은 절대 동적으로 늘리지 않는다.

두 번째 실패는 순간 이펙트의 수명을 「보이게」 늘리는 것이다. 총구 화염이 0.3초 남아 있으면 연사할 때 화면이 노란 죽이 된다. 0.05초는 짧아서 안 보이는 게 아니라, 짧아서 정확히 보이는 것이다.

세 번째는 Add 블렌드 남용. 겹치면 순식간에 흰색으로 타버려서 형태가 사라진다 — 총구·빛만 Add, 연기·피·탄피는 Normal 이다.

여기서부터 — 1~10 의 수치는 위 소스에서 확인한 값이고, 11~14 는 웹·모바일에서 통용되는 일반 기준값이다. 자기 게임에서 재 보고 조정하라.

11 트위닝 · 이징 — 장르를 가리지 않는 기본기

원리

Juice It or Lose It 의 핵심은 사실 파티클이 아니라 이것이다. 값이 즉시 바뀌지 않게 만드는 것 하나로 화면 전체의 인상이 바뀐다. 카메라가 없는 장르(방치 · 경영 · 서사 · 퍼즐)에서는 이게 주스의 거의 전부다.

세 가지 용도로 쓴다.

  • 숫자 보간 — 점수 · HP · 자원이 목표값까지 굴러간다. 실제 값은 즉시 바뀌고 보이는 값만 따라간다.
  • 등장 오버슛 — 스케일 0 → 1 을 easeOutBack 으로 넘겨 살짝 튀게 한다. 팝업 · 카드 · 획득 표시.
  • 순차 지연(stagger) — 목록이 한꺼번에 나타나지 않고 30~50ms 씩 밀려 들어온다. 같은 내용이 훨씬 비싸 보인다.
기준값
  • 숫자 카운트업 0.3~0.5초. 큰 수라고 시간을 늘리지 말고 자릿수를 줄여 처리한다
  • 등장 스케일 0 → 1, easeOutBack 0.25~0.35초, 오버슛 계수 1.70158(CSS 로는 cubic-bezier(.34,1.56,.64,1))
  • 순차 지연 항목당 30~50ms, 전체가 0.4초를 넘지 않게. 10개가 넘으면 지연을 줄인다
  • 버튼 · 카드 눌림 0.92 → 1, 0.1초 (→ 레시피 12)
  • 화면 전환 페이드 0.2~0.3초. 그 이상은 연출이 아니라 기다림이다
  • 보간은 프레임 독립으로 — v += (target - v) * (1 - Math.pow(0.001, dt / T)), T 는 수렴 시간
  • HUD 는 실시간 dt로 돌린다. 히트스톱 · 슬로모에 숫자까지 멈추면 멈춘 것처럼 보인다
의사코드
// --- 이징 5개 (t: 0~1) ---
const easeOutQuad    = t => 1 - (1 - t) * (1 - t);
const easeOutCubic   = t => 1 - Math.pow(1 - t, 3);
const easeInOutCubic = t => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2;
const easeOutBack    = t => { const c = 1.70158;
  return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); };
const easeOutElastic = t => (t === 0 || t === 1) ? t
  : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * (2 * Math.PI / 3)) + 1;

// --- 숫자 카운트업 ---
const hud = { score: 0, shown: 0, pop: 0 };

function addScore(n) { hud.score += n; hud.pop = 0.12; fx.popText('+' + n); }

function updateHud(rawDt) {                     // 실시간 dt (히트스톱 영향 없음)
  const k = 1 - Math.pow(0.001, rawDt / 0.4);   // 0.4초 수렴, 프레임 독립
  hud.shown += (hud.score - hud.shown) * k;
  if (Math.abs(hud.score - hud.shown) < 0.5) hud.shown = hud.score;

  hud.pop = Math.max(0, hud.pop - rawDt);       // 변한 항목만 튄다
  scoreEl.textContent   = Math.round(hud.shown);
  scoreEl.style.transform = 'scale(' + (1 + 0.18 * easeOutQuad(hud.pop / 0.12)) + ')';
}

// --- 등장 오버슛 + 순차 지연 ---
function appear(el, i) {
  el.style.transition = 'none';
  el.style.transform  = 'scale(0)';
  setTimeout(() => {                            // 항목당 40ms
    el.style.transition = 'transform .3s cubic-bezier(.34,1.56,.64,1)';
    el.style.transform  = 'scale(1)';
  }, i * 40);
}
과하면

전부 튀면 아무것도 안 튄 것과 같다. 값이 바뀔 때 튀는 것은 변한 항목 하나뿐이어야 한다. HUD 전체가 매번 출렁이면 어떤 수치가 바뀌었는지 오히려 못 읽는다. easeOutElastic 은 아껴 쓴다 — 두 번 이상 출렁이면 장난감처럼 보이고, 실패 · 경고에 쓰면 톤이 깨진다. 그리고 보간은 연출이지 상태가 아니다: 판정 · 저장 · 승패는 언제나 실제 값으로 하고, 트윈이 끝나기 전에 게임이 끝나도 문제가 없어야 한다.

12 모바일 터치 피드백

원리

우리 게임은 대부분 모바일 터치가 주 조작이다. 그런데 터치에서 「반응이 느리다」의 원인은 연출이 아니라 이벤트 선택인 경우가 많다. click 은 브라우저가 더블탭 확대 여부를 판단할 때까지 기다린 뒤에야 온다.

그래서 순서를 뒤집는다 — pointerdown 에서 그림과 소리를 먼저 내보내고, 게임 로직은 그 뒤에 돌린다. 결과가 늦더라도 손끝의 반응은 즉시여야 한다.

기준값
  • 반응은 pointerdown 에서. click 을 기다리지 않는다
  • 버튼 스쿼시 0.92 → 1, 0.1초. 값을 더 내리면 버튼이 꺼지는 것처럼 보인다
  • 탭 영역 44px 하한. 아이콘이 24px 여도 히트 영역은 44px 로 키운다
  • touch-action: manipulation 으로 약 300ms 지연 제거, -webkit-tap-highlight-color: transparent 로 회색 사각형 제거
  • navigator.vibrate([10])Android Chrome 계열에서만 동작하고 iOS Safari 는 무시한다. 있으면 좋은 보너스로 취급하고, 반드시 설정에서 끌 수 있게 한다
  • 중요한 피드백은 터치 지점 위쪽에 띄운다 — 아래는 손가락이 가린다
  • 연출 값은 PC 기준의 절반부터 시작한다. 화면이 작아 셰이크 · 파티클이 상대적으로 크게 보인다
의사코드
/* CSS — 이 셋이 없으면 무엇을 얹어도 굼뜨다 */
.btn {
  touch-action: manipulation;              /* 더블탭 확대 대기(약 300ms) 제거 */
  -webkit-tap-highlight-color: transparent;
  user-select: none;
  min-width: 44px; min-height: 44px;       /* 시각 크기와 무관하게 히트 영역은 44px */
  transition: transform .1s ease-out;
}
.btn.down { transform: scale(0.92); }
body { overscroll-behavior: none; }        /* 풀투리프레시 차단 */
// pointerdown 에서 끝낸다
btn.addEventListener('pointerdown', (e) => {
  e.preventDefault();                            // 스크롤·확대 차단
  btn.classList.add('down');                     // ① 그림  (0.92 → 1)
  sfx.play('tap');                               // ② 소리  — 로직보다 먼저
  if (navigator.vibrate) navigator.vibrate(10);  // ③ 진동  — Android 만, iOS 는 무시
  fire();                                        // 게임 로직은 마지막
}, { passive: false });

const up = () => btn.classList.remove('down');
btn.addEventListener('pointerup', up);
btn.addEventListener('pointercancel', up);       // 손가락이 버튼 밖으로 나간 경우
과하면

진동을 매 탭마다 길게(50ms 이상) 주면 금방 거슬리고 배터리도 먹는다 — 10ms 짧게, 중요한 순간에만, 그리고 옵션으로 끌 수 있게. preventDefault() 를 화면 전체에 걸면 스크롤이 필요한 UI 까지 죽으니 버튼 · 캔버스에만 건다. 그리고 진동이 안 온다고 iOS 에서 대체 연출을 억지로 넣지 않는다 — 시각 · 청각 피드백이 이미 제대로 있으면 진동은 없어도 된다.

13 three.js — 3D 에서의 같은 원칙

원리

3D 라고 원칙이 바뀌지는 않는다. 바뀌는 것은 어디에 값을 얹느냐값의 폭이다. 2D 수치를 그대로 옮기면 거의 전부 과해 보인다.

가장 중요한 하나 — 셰이크와 범프를 camera.position 에 직접 더하지 않는다. 카메라 위치는 추적 로직의 것이라, 같은 값을 두 주인이 쓰면 매 프레임 서로 덮어쓴다. 카메라를 부모 Group 에 넣고 흔드는 것은 그 Group 이다.

기준값
  • 셰이크 · 범프는 부모 Group 의 position. 카메라는 추적 로직 전용
  • 스쿼시는 mesh.scale, 폭은 0.85~1.15 — 2D 의 0.55~1.4 를 그대로 쓰면 고무가 된다(→ 레시피 4)
  • 줌 범프 대신 FOV 범프 +2~3°, 감쇠 0.9^tmod. 카메라를 앞뒤로 움직이는 것보다 안전하다
  • 피격 플래시는 material.emissive 0.6 → 0. 재질 인스턴스를 공유하지 않는다(공유하면 같은 종류의 적이 전부 같이 번쩍인다). 인스턴싱이면 onBeforeRender 에서 uniform 을 개체별로 넣는다
  • 히트스톱 · 슬로모는 clock.getDelta() 에 배율을 곱하고, 애니메이션 믹서에도 같은 dt 를 넘긴다
  • 파티클은 InstancedMesh 또는 Points미리 최대 개수만큼 만들어 두고 재사용한다. 매번 new Mesh 는 프레임을 먹는다
  • 그림자 · 접지가 타격감보다 먼저다. 유닛이 바닥에서 떠 보이면 어떤 연출도 가볍다
의사코드
// --- 셰이크·범프는 카메라가 아니라 부모 Group 에 ---
const rig = new THREE.Group();
rig.add(camera);              // camera.position 은 추적 로직의 것. 건드리지 않는다.
scene.add(rig);

let bumpX = 0, bumpY = 0, fovBump = 0;
const baseFov = camera.fov;

function updateRig(dt, elapsed) {
  const k = dt * 60;
  bumpX   *= Math.pow(0.85, k);
  bumpY   *= Math.pow(0.85, k);
  fovBump *= Math.pow(0.90, k);

  const [sx, sy] = shakeOffset(elapsed, dt);          // 레시피 2 그대로
  rig.position.set(bumpX + sx * U, bumpY + sy * U, 0); // U = 화면 1px 의 월드 길이
  camera.fov = baseFov + fovBump;                      // 줌 범프 대신 FOV (+2~3도)
  camera.updateProjectionMatrix();
}

// --- 피격 플래시: 재질을 공유하면 맞지 않은 적까지 번쩍인다 ---
mesh.material = sharedMaterial.clone();                // 개체마다 한 번만
function flashHit(m, v) { m.material.emissive.setScalar(v); }   // v: 0.6 → 0

// --- 스쿼시: geometry 를 미리 y=0 바닥 기준으로 옮겨둔다 ---
mesh.scale.set(s * e.sqX, s * e.sqY, s * e.sqX);       // sqX 는 0.85~1.15 안쪽

// --- 시간 배율은 clock delta 에 곱한다 ---
const raw = clock.getDelta();
const dt  = raw * timeMul;      // slowMo × hitStop
mixer.update(dt);               // 믹서도 같이 느려져야 한 덩어리로 보인다
particles.update(dt);
hud.update(raw);                // UI 만 실시간
과하면

FOV 를 5° 넘게 흔들면 원근이 출렁여 멀미가 난다. 카메라를 앞뒤(Z)로 밀어 줌을 흉내내는 것도 같은 이유로 피한다. 스케일 연출은 노멀을 망가뜨려 조명이 튀므로 비균등 스케일(sqX ≠ sqY)을 크게 주지 않는다. 그리고 3D 에서 파티클 수를 2D 감각으로 잡으면 (30개짜리 대시 궤적 같은 것) 모바일 GPU 가 바로 무너진다 — 개수를 절반으로 시작해서 올린다.

14 사운드 — 시각 13개를 합친 것만큼

원리

실전에서 사운드는 위의 시각 연출을 전부 합친 것만큼 효과가 크다. 그런데 웹에서는 규칙 몇 개를 놓치면 바로 「기계음」이나 「딱딱거리는 잡음」이 되어 오히려 품질을 깎는다.

핵심은 셋이다. ① 같은 소리를 그대로 반복하지 않는다(피치 무작위화) ② 같은 순간에 같은 소리를 여러 번 내지 않는다볼륨을 즉시 대입하지 않는다(딱 소리가 난다).

그리고 오디오 게임에서는 이 장이 곧 게임 그 자체다 — 시각의 3채널을 피치 · 좌우 팬 · 잔향으로 옮겨 「거리」와 「방향」을 전달한다.

기준값
  • 반복음 피치 ±8% 무작위(playbackRate). 넘기면 다른 소리로 들린다
  • 같은 id 의 중복 재생은 30~50ms 안에 1개만. 동시 재생 상한은 16개, 넘치면 오래된 것부터 끊는다
  • 발사음은 알림이라 조금 길게, 타격음은 확인이라 짧고 날카롭게(80~150ms)
  • AudioContext첫 터치에서 생성 · resume(). 그 전에 만들면 suspended 로 남는다
  • 슬로모 중에는 BGM 만 playbackRate 0.9. 효과음까지 내리면 둔해진다
  • 볼륨은 gain.setTargetAtTime(v, now, 0.01~0.05) 지수 감쇠로. gain.value = 0 즉시 대입은 클릭 노이즈를 만든다
  • 오디오 게임의 거리 — 팬 -1 ~ +1, 잔향은 드라이/웻 비율로(멀수록 웻을 올린다). 볼륨만으로는 거리가 안 읽힌다
의사코드
let ctx = null, master = null;

// 첫 터치에서만 깨운다 (브라우저 자동재생 정책)
addEventListener('pointerdown', () => {
  if (ctx) { if (ctx.state === 'suspended') ctx.resume(); return; }
  ctx = new (window.AudioContext || window.webkitAudioContext)();
  master = ctx.createGain();
  master.gain.value = 1;
  master.connect(ctx.destination);
}, { capture: true });

const lastAt = new Map();

function play(id, buf, { vol = 1, pan = 0, pitch = 0.08 } = {}) {
  if (!ctx) return;
  const now = ctx.currentTime;
  if (now - (lastAt.get(id) ?? -1) < 0.03) return;   // 같은 순간엔 1개만
  lastAt.set(id, now);

  const src = ctx.createBufferSource();
  src.buffer = buf;
  src.playbackRate.value = 1 + (Math.random() * 2 - 1) * pitch;   // ±8%

  const g = ctx.createGain();
  g.gain.value = 0;
  g.gain.setTargetAtTime(vol, now, 0.01);      // 즉시 대입하면 딱 소리가 난다

  const p = ctx.createStereoPanner();
  p.pan.value = pan;                            // -1 왼쪽 ~ +1 오른쪽

  src.connect(g).connect(p).connect(master);
  src.start();
  return g;
}

function fadeOut(g, sec = 0.2) { g.gain.setTargetAtTime(0, ctx.currentTime, sec / 3); }

// 슬로모 중에는 BGM 만 내린다
bgm.playbackRate.value = slowMoActive ? 0.9 : 1;
과하면

피치 범위를 ±20% 이상 주면 같은 소리로 안 들려서 정보가 깨진다 — 특히 오디오 게임에서는 치명적이다. 모든 소리에 리버브를 걸면 전부 진흙이 되고, 중복 억제를 빼면 다중 타격에서 볼륨이 합쳐져 클리핑된다. 그리고 음량은 항상 설정에서 줄이고 끌 수 있어야 한다 — 웹 게임은 사무실에서 열리는 경우가 많다.

7. AI 에게 시키는 법

「총 쏘는 기능 만들어줘」와 「총 발사의 게임필을 개선해줘」는 전혀 다른 결과를 낳는다. 게임 개발에서 쓰는 이름으로 부르면 AI 는 생각보다 정확히 알아듣는다. 다만 이름만으로는 강도가 전달되지 않는다. 에코플레이룸에서는 세 단계로 나눠 시킨다.

① 먼저 진단시킨다 — 고치라고 하기 전에

무엇이 밋밋한지를 AI 가 먼저 말하게 만든다. 요령은 장르를 못 박는 것이다. 장르를 안 주면 진단이 액션 게임 기준으로 쏠려서, 방치형에 히트스톱을 권하는 답이 돌아온다.

아직 코드는 고치지 마. 이 게임의 장르는 <장르> 다.
<대상 행동> 을 입력 → 반응 → 결과 확인 세 구간으로 나눠서, 구간마다 플레이어에게 도착하는 신호가 무엇인지 적어줘.
비어 있는 구간과, 이 장르에서는 오히려 방해가 될 연출을 각각 골라서 우선순위를 매겨줘. 수정은 그다음에 따로 시킨다.

돌아온 우선순위 중 두세 개만 골라 다음 단계로 넘긴다. 이 한 단계가 과잉의 절반을 막는다.

② 치트시트를 첨부하고 그 장르의 「먼저 3개」만 시킨다

게임필 치트시트는 이 문서의 수치를 표만으로 압축한 파일이다. 산문이 없어 임의 해석의 여지가 적고, 컨텍스트도 2천 토큰 남짓밖에 먹지 않는다. 지시에는 넣을 것뺄 것을 같이 적는다 — 뺄 것을 적지 않으면 거의 반드시 들어간다.

game-feel-cheatsheet.md 첨부. 이 표 기준으로 <대상 행동> 의 게임필을 손봐줘.
장르는 <장르> — ④ 표에서 그 줄의 「먼저 3개」만 넣고, 같은 줄의 「금지」는 명시적으로 빼.
값은 ③ 표에 적힌 숫자 그대로 쓰고, 표에 없는 값을 새로 지어내지 마. 없으면 없다고 말해줘.
감쇠는 전부 Math.pow(f, dt*60), 시간 배율은 ① 층 구조, 연출과 물리의 분리는 ② 원칙.

치트시트를 첨부할 수 없는 환경이라면 4장의 요소 이름과 6장의 기준값을 지시문에 숫자로 옮겨 적는다. 두 곳의 수치는 같아야 하므로 한쪽을 고치면 다른 쪽도 고친다.

③ 하나씩 넣고 토글로 남긴다

세 개를 한 번에 받으면 무엇이 좋아졌고 무엇이 방해가 됐는지 구분할 수 없다. 한 개씩 받고, 받을 때마다 직접 만져본다.

세 개를 한꺼번에 넣지 말고 첫 번째 것만 먼저 넣어줘.
각 연출은 디버그 메뉴에서 개별로 껐다 켤 수 있게 하고, 기본값은 켬으로.
내가 만져보고 괜찮다고 하면 그때 다음 것을 시킬게. 아래 증상이 보이면 바로 되돌린다.

체크박스 하나 다는 비용은 30분이고, 그게 있어야 나중에 밸런스를 잡을 수 있다. 4장 카탈로그가 통째로 33개의 토글로 되어 있는 이유도 같다.

「juicy 하게 해줘」는 만능 주문이 아니다

단어 하나로 강도까지 전달되지는 않는다. 상한선을 비워 두면 화면이 크게 흔들리고, 파티클이 수백 개 깔리고, UI 가 전부 튄다. 시키는 쪽이 정하지 않은 값은 만드는 쪽이 정한다.

그래서 형용사 대신 4장의 요소 이름과 6장의 숫자를 박아 넘긴다. 장르에 맞지 않는 것은 5장 표를 근거로 빼라고 적는다. 위 ②가 그 방식이다.

과잉의 증상 — 이게 보이면 되돌려라

증상원인고치는 법
조준이 안 된다 / 멀미가 난다셰이크 진폭 과다(10px+)발사 셰이크는 0.5px 수준. 강한 이벤트에만 크게
화면이 계속 펌프질한다줌 범프 과다, 픽셀아트에서 비정수 스케일0.03 이하로, 픽셀 게임은 아예 제거
조작이 늪에 빠진 느낌히트스톱을 연사에도 걸었다단발·강타·처치에만. 연사는 범프+파티클로
「렉이 걸렸다」조작 잠금 0.3초 초과, 또는 입력 버퍼 없음잠금은 0.3초 이하 + 반드시 버퍼와 세트
적이 안 보인다블링크 유지 과다 / 파티클이 실루엣을 덮음블링크 0.06초, 파티클은 뒤 레이어로
프레임이 떨어진다긴 수명 파티클 누적, 풀 없이 객체 생성고정 풀(2048) + 넘치면 버림
화면이 흰색으로 탄다Add 블렌드 남용빛·화염만 Add. 연기·피는 Normal
벽에 끼거나 판정이 어긋난다시각 오프셋·스쿼시를 물리에 섞었다레이어 분리 복구(레시피 8)
고무 인형처럼 보인다스쿼시 폭 과다 / 복원 계수 과소0.6~1.4 안쪽, 복원 0.2
허공에서 점프한다코요테 시간 200ms 초과80~150ms

작업 방식 — 한 번에 하나씩 넣고 그때마다 직접 만져본다. 열 개를 한꺼번에 넣으면 무엇이 좋아졌고 무엇이 방해가 되는지 구분할 수 없다. 그리고 넣은 것은 토글로 남겨둬라 — 나중에 밸런스를 잡을 수 있는 유일한 방법이다.

8. 프로토타입 체크리스트

새 게임의 프로토타입이 「왜인지 허접하다」고 느껴질 때, 아래를 순서대로 확인한다. 체크 상태는 이 브라우저에 저장된다.

0 / 52
영역확인 항목
입력 반응
입력 반응버튼을 누른 그 프레임에 무언가 반응하는가? (실제 결과가 늦더라도 예비 동작·소리는 즉시)
입력 반응조작이 잠긴 동안 누른 입력이 버려지지 않고 큐에 쌓이는가? (레시피 6)
입력 반응같은 동작을 연속으로 눌렀을 때 두 번째가 씹히지 않는가?
이동 가속 / 감속
이동속도가 0 → 최고로 즉시 튀지 않는가? (가속 구간이 있는가)
이동손을 뗐을 때 지수 마찰로 감속하는가? (v *= 0.85^(dt*60))
이동방향 전환이 관성 때문에 답답하지 않은가? (반대 입력 시 감속을 더 세게)
이동발걸음에 맞춰 카메라·스프라이트가 미세하게 반응하는가? (0.5px 범프면 충분)
점프
점프코요테 시간이 있는가? (80~150ms)
점프버튼을 짧게 누르면 낮게 뛰는가? (가변 점프 높이)
점프상승과 하강의 중력이 다른가? (정점에서 잠깐 뜨는 느낌)
점프뛸 때 스쿼시(세로로 늘림)와 먼지가 나오는가?
점프낮은 턱을 자동으로 넘어가는가? 절벽 끝을 몇 px 차로 놓쳤을 때 잡아주는가?
공격
공격발사 순간 화면이 반응하는가? (범프 3px + 셰이크 0.1s/0.5px)
공격총구 화염이 단일 스프라이트가 아니라 여러 층인가?
공격반동이 시각 오프셋실제 이동으로 분리돼 있는가? (레시피 8)
공격탄도·속도·연사 간격에 미세한 무작위가 들어 있는가? (±2°, ±5%)
공격탄피·잔해처럼 오래 남는 흔적이 있는가? (10초 이상)
피격
피격히트스톱이 있는가? (60~80ms, 기본 배율 0.1 — 완전정지는 장르에 따른 선택)
피격맞은 대상이 블링크하는가? (0.06s 유지 후 RGB 차등 감쇠)
피격맞은 대상이 스쿼시되는가? (가로 0.6)
피격넉백이 AI 이동과 별도 속도 레이어로 들어가는가?
피격연타 시 넉백이 줄어드는가? (첫 타의 1/6 수준)
피격명중 지점에 파티클이 법선 반대 방향으로 튀는가?
죽음
죽음죽는 순간이 피격보다 명확히 더 큰가? (히트스톱 120ms+, 셰이크 강화)
죽음시체·파편이 남아서 물리적으로 떨어지는가? (그냥 사라지지 않는가)
죽음전투가 끝난 공간이 전투 전과 달라 보이는가? (탄흔·피·탄피)
카메라
카메라추적에 데드존이 있는가? (가로 4% / 세로 10% 정도)
카메라추적이 즉시 붙지 않고 가속 + 마찰로 따라오는가? (마찰 0.89)
카메라셰이크와 범프가 서로 다른 메커니즘으로 구현돼 있는가?
카메라셰이크가 시간의 함수인가? (다른 주파수의 사인파 또는 펄린 노이즈 — 매 프레임 독립 난수만 피한다)
카메라셰이크를 설정에서 끌 수 있는가? (접근성)
사운드
사운드반복 재생되는 소리의 피치가 무작위화돼 있는가? (±8%)
사운드한 프레임에 같은 소리가 겹쳐 터지지 않는가?
사운드발사음과 타격음이 구분되는가? (타격음이 더 짧고 날카롭게)
사운드첫 사용자 입력 전에 오디오 컨텍스트를 깨우지 않는가? (웹 정책)
UI 반응
UIHP·점수가 즉시 바뀌지 않고 보간되는가? (숫자가 올라가는 게 보이는가)
UI값이 변할 때 해당 UI 가 한 번 튀는가? (스쿼시 0.1초)
UIHUD 와 입력이 슬로모·히트스톱의 영향을 받지 않는가? (실시간 dt)
UI모든 UI 가 동시에 튀지 않는가? (변한 것만)
모바일 터치
모바일반응이 pointerdown 에서 일어나는가? (click 을 기다리지 않는가)
모바일버튼이 눌린 순간 스쿼시되는가? (0.92 → 1, 0.1초)
모바일탭 영역이 44px 이상이고 touch-action: manipulation 이 걸려 있는가?
모바일손가락이 가리는 자리에 중요한 피드백을 두지 않았는가? (진동은 Android 전용 + 끌 수 있게)
3D (three.js)
3D셰이크·범프가 카메라가 아니라 부모 Group 에 걸려 있는가?
3D피격 플래시가 재질 인스턴스를 공유하지 않는가? (맞은 개체만 번쩍이는가)
3D스케일 폭이 0.85~1.15 안쪽이고, 시간 배율이 clock.getDelta() 와 믹서에 같이 걸리는가?
3D유닛이 바닥에 붙어 보이는가? (그림자·접지 — 없으면 어떤 연출도 가볍다)
전체
전체텍스처를 전부 회색 박스로 바꿔도 여전히 만질 맛이 나는가? (그레이박싱 테스트)
전체하나의 이벤트에 최소 3개 채널이 응답하는가? (우리 경험칙 — 채널은 장르에 맞게, 5장)
전체모든 감쇠식이 Math.pow(f, dt*60) 형태인가? (프레임레이트 독립)
전체연출 값들이 이벤트 강도에 비례하는가? (한 칸 낙하와 절벽 낙하가 다른가)
전체각 연출을 개별로 껐다 켤 수 있는가? (디버그 토글)

이 문서를 쓰는 순서 — 새 게임을 시작할 때는 이 체크리스트만 열어두고, 프로토타입이 굴러가기 시작하면 5장에서 내 장르의 줄을 찾아 「먼저 3개」를 고른 뒤 6장 레시피를 위에서부터 적용한다. AI 에게 맡길 때는 7장 ②대로 치트시트를 첨부한다.

9. 참고문헌