HTML6 Games설치없는 무료 웹 게임 / devlog

설치 없이, 지금 바로 플레이

브라우저에서 즐기는 무료 미니게임 모음. 로그인하면 기록이 계정에 저장되어 어느 기기에서든 이어서 볼 수 있어요.

게임 목록
게임 목록
모든 개발 이야기

젤리 캐릭터는 뼈대 대신 모양 다섯 개로 움직인다

2026년 11월 1일 · 그림·소리 · English

지금까지 만든 3D 게임 두 개는 그림을 다른 곳에서 가져왔다. 타워크레인은 상자 도형을 코드로 만들었고, 러시 3D 는 Kenney 가 CC0 로 공개한 모델 팩을 썼다(지난 글). 세 번째 게임 젤리 점프는 주인공을 Blender 로 직접 만들었다. 누르고 있으면 젤리가 납작하게 눌리고, 떼면 늘어나며 다음 블록으로 뛰는 원버튼 게임이다.

러시 3D 의 캐릭터는 뼈대(skeleton)로 움직인다. 팔다리에 뼈를 넣고 뼈를 돌리면 붙어 있는 살이 따라 움직이는 방식이다. 젤리에는 팔다리가 없다. 몸 전체가 눌리고 퍼지고 출렁인다. 이런 움직임에는 모양 자체를 여러 벌 저장해 두는 방식이 맞다. Blender 에서는 shape key, glTF 와 three.js 에서는 morph target 이라고 부른다. 기본 모양과 같은 정점 수로 "눌린 모양", "늘어난 모양"을 따로 저장해 두고, 0~1 사이의 가중치로 섞는다. 가중치 0.5 면 기본 모양과 눌린 모양의 중간이다.

정점을 끌지 않고 식으로 만들었다

몸은 물방울 모양이다. 아래 반은 납작한 반구이고, 위로 갈수록 좁아지다가 끝이 살짝 솟는다. 지름 0.56, 키 0.6 단위에 둘레 28칸, 높이 16단이다. 눈 둘, 분홍 볼 둘, 흰 하이라이트 하나를 몸과 합쳐 메시 하나로 만들었다. 따로 두면 몸이 눌릴 때 눈이 제자리에 떠 있게 된다. 색은 텍스처 대신 정점마다 색을 넣는 정점색으로 칠했다. 재질 하나, 텍스처 없음이라 화면에 그릴 때 드로우콜 하나로 끝난다.

shape key 는 보통 정점을 손으로 끌어서 만든다. 이번에는 Blender 의 파이썬 API(bpy)로 원래 정점 위치를 받아 새 위치를 돌려주는 식을 썼다. 눌린 모양은 높이를 0.55배로 줄이고 가운데 높이쯤을 가장 많이 옆으로 퍼뜨린다. 식으로 만들면 숫자 하나만 바꿔 다시 만들 수 있고, 눈과 볼도 같은 식을 따라 몸과 함께 움직인다. 설명을 위해 줄인 예시는 이렇다.

import bpy, math
from mathutils import Vector

ob = bpy.data.objects['jelly']
H = 0.6  # body height
ob.shape_key_add(name='Basis')
rest = [v.co.copy() for v in ob.data.vertices]

def add(name, fn):
    key = ob.shape_key_add(name=name, from_mix=False)
    for i, p in enumerate(rest):
        key.data[i].co = fn(p)

def squash(p):  # flatter, wider around the middle
    t = p.z / H
    s = 1.12 + 0.42 * math.sin(math.pi * min(t * 1.1, 1)) ** 0.8
    return Vector((p.x * s, p.y * s, p.z * 0.55))

def stretch(p):  # taller and thinner
    return Vector((p.x * 0.78, p.y * 0.78, p.z * 1.45))

add('squash', squash)
add('stretch', stretch)

이렇게 다섯 개를 만들었다. 힘을 모을 때 눌리는 squash, 뛸 때 늘어나는 stretch, 좌우로 출렁이는 wobbleL·wobbleR, 바닥에 떨어져 퍼지는 splat 이다.

기본 모양과 shape key 넷을 하나씩 1로 켠 모습. 왼쪽부터 기본, squash, stretch, wobbleL, splat

동작은 이 가중치를 시간에 따라 바꾸는 키프레임이다. 대기할 때 말랑거리는 idle, 착지 출렁임 land, 떨어지는 동안 펄럭이는 fall, 바닥에 철퍼덕 splat 네 개를 30fps 로 만들었다. Blender 의 NLA 편집기에 동작마다 트랙을 하나씩 두면 glTF 로 내보낼 때 트랙 하나가 동작 하나가 되고, 트랙 이름이 동작 이름이 된다.

힘 모으기와 공중에서 늘어나는 모양은 동작으로 만들지 않았다. 얼마나 눌릴지는 플레이어가 누른 시간에 달려 있어서, 미리 만든 동작으로는 맞출 수 없다. 이 둘은 게임 코드가 매 프레임 가중치를 직접 정한다. three.js 에서는 메시의 morphTargetInfluences 배열에 숫자를 넣으면 되고, 어느 칸이 어느 키인지는 morphTargetDictionary 에 이름으로 들어 있다.

let mesh = null;
model.traverse((o) => { if (o.isMesh && o.morphTargetInfluences) mesh = o; });
const index = mesh.morphTargetDictionary; // { squash: 0, stretch: 1, ... }

function charge(seconds) {
  const w = Math.min(seconds / 1.2, 1); // full squash after 1.2 s
  mesh.morphTargetInfluences[index.squash] = w;
}

기본 모양이 다섯 겹으로 들어갔다

처음 내보낸 glb 를 열어 보니 메시의 기본 가중치가 [1, 1, 1, 1, 1] 이었다. glTF 에서 이 값은 동작이 없을 때 쓰는 기본값이다. 이대로면 동작이 멈춘 순간 젤리가 다섯 모양을 모두 더한 모양이 된다.

Blender 의 glTF 내보내기는 내보내는 순간 shape key 에 들어 있는 값을 그대로 기본 가중치로 쓴다. 나는 내보내기 전에 값을 모두 0 으로 돌려 두었다. 그런데 NLA 트랙에 동작이 걸려 있으면 내보내기가 장면을 계산하면서 트랙 쪽 값이 다시 들어왔고, 트랙 둘레 프레임에서 그 값이 1 로 읽혔다.

그래서 가중치가 모두 0 인 1프레임짜리 동작 rest 를 만들어 맨 아래 트랙에 두고, 트랙이 끝난 뒤에도 그 값을 유지(HOLD)하게 했다. 다른 동작이 없는 프레임에서는 이 트랙이 값을 0 으로 붙잡는다. 다시 내보내니 기본 가중치가 모두 0 이 되었다. rest 는 내보내기에만 필요하니, 모델 파일을 묶을 때 동작 목록에서 뺀다.

게임 쪽에도 동작과 직접 정하는 가중치가 부딪히는 자리가 있었다. three.js 의 애니메이션 믹서는 재생 중인 동작이 있으면 매 프레임 그 동작의 값으로 다섯 가중치를 모두 덮어쓴다. 착지 동작이 켜진 채로 힘 모으기 값을 넣으면 그리기 전에 지워진다. 그래서 가중치를 직접 정하기 전에는 동작을 멈춘다. 처음에는 공통 애니메이션 도구에 멈추기가 없어서 믹서를 직접 멈췄고, 나중에 도구에 멈추기 함수를 더했다.

이 모든 과정을 거친 모델은 정점 642개, 삼각형 1260개다. 내보낸 glb 는 115KB 였는데, 묶는 단계에서 정점 위치와 shape key 데이터를 32비트 실수 대신 16비트 정수로 저장하니 77KB 가 되었다.

착지할 때 너무 퍼졌다

게임에 넣고 해 보니 착지할 때 젤리가 순간 크게 퍼지는 느낌이 들었다. 눈으로는 너무 빨라서 의도한 출렁임인지 아닌지 알 수 없었다. 게임 시간을 4배 느리게 돌리고 착지 전후 몇 프레임의 가중치를 숫자로 찍었다.

공중에서 늘어나 있던 젤리가 한 프레임 만에 거의 최대로 눌려 있었다. 0.81 이면 힘을 거의 끝까지 모았을 때와 같은 납작함이다. land 동작의 첫 키를 squash 0.85 로 잡아 둔 것이 원인이었다. 게임도 공중 모양에서 land 로 바꿀 때 섞는 시간 없이 바로 바꾸고 있었다.

land 의 첫 키를 0.12 로 낮추고, 두 프레임 뒤에 0.45 까지 올라가게 바꿨다. 되튀는 stretch 와 두 번째 squash 도 0.2, 0.15 로 줄였다. 게임 코드에서는 land 로 넘어갈 때 0.05초 동안 섞게 했다. 같은 방법으로 다시 재니 가장 납작한 순간이 0.81 에서 0.44 로 내려갔다. 둥근 모양을 유지한 채 살짝 눌렸다가 돌아온다.

소리도 같은 식으로 움직임에 맞췄다. 누르고 있는 동안 0.1초마다 조금씩 높아지는 짧은 음이 나서, 힘이 얼마나 모였는지 귀로도 알 수 있다. 블록 가운데에 연속으로 내리면 한 음씩 올라간다. 떨어져서 끝나는 소리는 처음에 떨어질 자리가 정해지는 순간, 즉 뛰는 순간에 났다. 젤리는 아직 공중에 있는데 끝나는 소리부터 들리니 어색했다. 이 소리와 진동을 바닥에 닿아 철퍼덕 퍼지는 순간으로 옮겼다.

Blender 에서 고칠 때마다 내보내기 설정을 맞추고 묶고 점검하는 일이 번거로워서, 명령 하나로 묶었다. 화면 없는 Blender 를 따로 띄워 저장된 .blend 를 열고(--background), shape key 값을 0 으로 둔 채 정해진 설정으로 glb 를 내보낸 다음, 모델 묶기와 점검까지 이어서 한다. 내보낸 기본 가중치가 0 이 아니면 경고를 띄운다. 젤리로 돌리면 1.8초가 걸리고, 결과 파일은 손으로 내보낸 것과 바이트까지 같았다.

node tools/art/export-blend.mjs jelly

뼈대 없이 모양 다섯 개만으로도 캐릭터가 충분히 살아 있어 보였다. 다만 shape key 는 만들기보다 섞기가 어려웠다. 가중치 하나가 크게 튀면 바로 이상해 보이는데 너무 빨라서 눈으로는 잡기 어렵다. 느리게 돌리고 숫자로 찍어 보는 것이 가장 빨랐다.

참고한 문서

이 글과 관련된 게임

젤리 점프말랑한 젤리가 꾹 눌려 힘을 모았다가 통! 하고 다음 디저트 블록으로 뛰어요. 누른 만큼 멀리 뛰니 거리를 잘 재세요. 해 보기

다른 글

홈 화면에 추가하기

  1. 화면 아래(아이패드는 위)의 공유 버튼을 누르세요. 네모에서 화살표가 위로 나온 모양이에요.
  2. 목록을 내려 "홈 화면에 추가"를 누르세요.
  3. 오른쪽 위 "추가"를 누르면 홈 화면에 HTML6 Games 아이콘이 생겨요.

Safari 에서만 추가할 수 있어요. 홈 화면에서 열면 주소창 없이 앱처럼 보이고, 인터넷이 끊겨도 열립니다.