GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

마우스 따라다니는 캐릭터 9종 — 뱀·잉어·도마뱀·문어를 계산으로 움직이기

마우스 따라다니는 캐릭터는 포인터나 정해 둔 목표점 하나를 쫓아 몸통·다리·촉수가 알아서 끌려오는 작은 생물을 캔버스에 계산으로 그린 화면 장식이다. 관절 하나도 키프레임으로 그리지 않는데 생물은 어떻게 목표점을 따라 헤엄치고 걸을까?

목표점 하나만 주면 몸 전체가 알아서 따라오는 생물 — 마디·각도·다리·촉수를 따로 계산한 장면

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아무도 손대지 않으면 목표점은 판 가운데를 지나는 8자 길을 돈다. 가로로 한 번 흔들리는 동안 세로로 두 번 흔들리는 길이라, 생물은 고리 두 개를 번갈아 돌며 좌우로 크게 휘었다가 판 한복판을 가로지른다. 포인터가 판 위에 올라오면 목표가 포인터 쪽으로 서서히 섞여 들어가고, 포인터가 떠나면 다시 8자 길로 돌아간다.

점이나 글자가 포인터 자리로 미끄러져 오는 마우스 따라다니는 효과 와는 움직이는 단위가 다르다. 거기서는 따라오는 물건 하나하나가 포인터와의 거리만 줄이지만, 여기서는 목표점이 머리 하나만 끌고 나머지 몸은 간격·각도·뼈 길이 같은 조건을 지키면서 끌려온다. 새 떼가 서로를 보며 방향을 맞추는 무리 시뮬레이션 이 여럿의 규칙이라면, 08 은 한 마리가 상태를 바꾸는 규칙이다.

포트폴리오 첫 화면과 404 페이지

포트폴리오 첫 화면에 반려 생물 하나를 두려면 01 의 사슬 뱀이 계산이 가장 단순하다. 한 프레임에 마디 26개의 자리만 다시 놓으면 되고, 방문자가 포인터를 움직이는 순간 뱀이 그쪽으로 고개를 돌려 「이 화면이 나를 본다」 는 느낌을 준다. 길을 잃은 사람이 도착하는 404 페이지라면 03 의 도마뱀이 어울린다. 발을 땅에 박아 두고 한 걸음씩 옮기는 모습이 「여기서 다시 출발하자」 는 문구 옆에서 길 안내 역할을 한다.

쇼핑몰 화면 구석의 도우미로는 08 이 맞는다. 손님이 포인터를 멈추고 상품을 읽는 동안은 혼자 배회하고, 다시 움직이면 다가오며, 포인터를 휙 휘두르면 놀라 달아났다가 1.1초 뒤 진정한다.

이벤트 랜딩과 정원 소개 화면

수족관이나 바다 체험 이벤트 페이지는 04 의 문어 촉수나 06 의 해파리를, 가을 행사 배너는 07 의 거미를 첫 화면에 올리면 주제가 그림 한 장으로 전달된다. 숙소나 정원을 소개하는 랜딩의 첫 화면에는 09 의 연못이 판 넓이의 절반 넘게 움직여 배경 영상 자리를 대신한다. 05 의 애벌레는 진행률 표시줄 위를 기어가는 로딩 캐릭터로, 02 의 잉어는 카페 랜딩의 잔잔한 배경으로 쓰기 좋다.

마우스 따라다니는 캐릭터 9종 — 마디 사슬 뱀·각도 묶은 잉어·두 마디 다리 도마뱀·뻗는 촉수·꿈틀 애벌레·수축 해파리·엇갈려 걷는 거미·성격 있는 반려 생물·잉어 연못

장면마다 480×300 판을 2초 동안 24장 찍은 뒤, 이웃한 두 장 사이의 차이 23개를 모아 아래 표를 만들었다. 연못 전체가 출렁이는 09 가 51.4% 로 가장 넓고, 몸 한 줄만 바닥을 기는 05 가 9.6% 로 가장 좁다. 아홉 장면 모두 23번의 비교에서 23번 화면이 바뀌었다.

장면마다 화면이 바뀐 넓이와 세기

장면 누적면적 움직인 프레임 강도 평균
01 사슬 뱀 14.5% 23/23 82.8
02 각 한계 잉어 17.8% 23/23 84.1
03 도마뱀 12.7% 23/23 79.1
04 문어 촉수 24.9% 23/23 89.7
05 애벌레 9.6% 23/23 95.0
06 해파리 둘 18.0% 23/23 74.0
07 거미 19.9% 23/23 101.5
08 반려 생물 14.5% 23/23 102.7
09 잉어 연못 51.4% 23/23 51.0

01~08 은 판 위에 생물 한두 마리만 있으니 넓이가 작은 것이 정상으로 보인다. 이 여덟 장면은 넓이보다 간격·각 한계·발걸음 규칙이 얼마나 정확히 지켜지는지로 봐야 하고, 그 숫자는 항목마다 적었다.

01마디 사이 거리만 지키며 따라오는 사슬 뱀

머리는 목표까지 남은 거리의 5배 속도(최고 240px/초)로 다가가고, 뒤의 25마디는 아래 followChain 이 「앞 마디에서 지금 내 자리 쪽으로 12px」 인 곳에 하나씩 다시 놓아 몸 길이 300px 를 지킨다. 01 은 maxBend 에 Math.PI 를 넘겨 꺾임 한계를 꺼 두고, 마디마다 다른 반지름(머리 12.94·가장 굵은 곳 13.11·꼬리 2.5px)을 좌우 법선 쪽으로 밀어 이은 닫힌 곡선을 몸 윤곽으로 칠한다. 한 바퀴 1,205px 인 8자 길을 도는 동안 머리와 꼬리는 평균 205.23px 떨어져 있고 이웃 마디는 많아야 0.29 라디안 꺾인다.

마디 사이 거리만 지키며 따라오는 사슬 뱀
새 창에서 열기

01 · 마디 사이 거리만 지키며 따라오는 사슬 뱀 — 측정 누적면적 14.523% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SEGMENT_GAPBODY_WIDTHHEAD_SPEED
    function followChain(xs, ys, gap, maxBend, backAngle) {
        const n = xs.length;
        let prevAng = backAngle;
        for (let i = 1; i < n; i += 1) {
            let ang = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
            if (maxBend < Math.PI && !isNaN(prevAng)) {
                const d = wrapAngle(ang - prevAng);
                if (d > maxBend) {
                    ang = prevAng + maxBend;
                }
                else if (d < -maxBend) {
                    ang = prevAng - maxBend;
                }
            }
            xs[i] = xs[i - 1] + Math.cos(ang) * gap;
            ys[i] = ys[i - 1] + Math.sin(ang) * gap;
            prevAng = ang;
        }
    }

02꺾이는 각을 묶어 접히지 않고 헤엄치는 잉어

잉어 14마디는 같은 followChain 에 꺾임 한계 0.35 라디안을 넘기므로, 모든 마디가 한계까지 꺾여도 도는 반지름이 37.33px 아래로 줄지 않는다. 아래 steerKoi 는 머리를 1초에 3.2 라디안까지만 돌리고 목표 90px 안에서 속도를 170px/초의 30% 까지 낮추며, 앞몸 세 마디의 꺾임 합으로 가슴지느러미를 0.15~1.45 사이에서 벌리고 꼬리지느러미는 강성 60·감쇠 9 의 스프링으로 꼬리 토막을 133.33ms 늦게 따라간다. 6.5초 한 바퀴 동안 40% 의 프레임에서 어느 마디든 한계에 닿아 꺾임이 그 각에서 잘린다.

꺾이는 각을 묶어 접히지 않고 헤엄치는 잉어
새 창에서 열기

02 · 꺾이는 각을 묶어 접히지 않고 헤엄치는 잉어 — 측정 누적면적 17.762% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAX_BENDFIN_GAINTAIL_STIFF
    function steerKoi(k, dt, tx, ty, maxBend) {
        const dx = tx - k.xs[0];
        const dy = ty - k.ys[0];
        const dist = Math.hypot(dx, dy);
        const turn = wrapAngle(Math.atan2(dy, dx) - k.heading);
        k.heading = wrapAngle(k.heading + clamp(turn, -TURN_RATE * dt, TURN_RATE * dt));
        const want = SWIM_SPEED * clamp(dist / SLOW_RADIUS, 0.3, 1);
        k.speed = lerp(k.speed, want, follow(3, dt));
        k.phase += k.speed * dt * WIGGLE_FREQ;
        const move = k.heading + Math.sin(k.phase) * WIGGLE_AMP;
        k.xs[0] += Math.cos(move) * k.speed * dt;
        k.ys[0] += Math.sin(move) * k.speed * dt;
        followChain(k.xs, k.ys, k.gap, maxBend, k.heading + Math.PI);
        // 앞몸 곡률 — 2·3·4번 마디의 꺾인 각 합. 부호가 휜 방향
        const curve = bendAt(k.xs, k.ys, 2) + bendAt(k.xs, k.ys, 3) + bendAt(k.xs, k.ys, 4);
        k.curve = lerp(k.curve, curve, follow(10, dt));
        k.finL = lerp(k.finL, clamp(FIN_BASE + k.curve * FIN_GAIN, 0.15, 1.45), follow(8, dt));
        k.finR = lerp(k.finR, clamp(FIN_BASE - k.curve * FIN_GAIN, 0.15, 1.45), follow(8, dt));
        const n = KOI_JOINTS;
        const tailTarget = Math.atan2(k.ys[n - 1] - k.ys[n - 2], k.xs[n - 1] - k.xs[n - 2]);
        const acc = TAIL_STIFF * wrapAngle(tailTarget - k.tailAng) - TAIL_DAMP * k.tailVel;
        k.tailVel += acc * dt;
        k.tailAng = wrapAngle(k.tailAng + k.tailVel * dt);
    }

03발이 멀어질 때만 한 걸음 내딛는 도마뱀

도마뱀은 척추 18마디(간격 11px·꺾임 한계 0.32) 에 다리 넷을 달고, 다리마다 허벅지 22px·정강이 20px 를 아래 solveKnee 에 넘겨 코사인 관계로 무릎 자리를 푼다. 땅에 붙인 발이 몸 앞쪽 예상 자리에서 38px 넘게 밀리고 반대 대각선 짝이 발을 들고 있지 않을 때만, 그 발이 0.16초 동안 높이 7px 의 호를 그리며 예상 자리보다 19px 더 앞에 내려앉는다. 18초 동안 1초에 5.67걸음을 걸었고 두 짝이 함께 발을 든 프레임은 0 이었다.

발이 멀어질 때만 한 걸음 내딛는 도마뱀
새 창에서 열기

03 · 발이 멀어질 때만 한 걸음 내딛는 도마뱀 — 측정 누적면적 12.678% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_TRIGGERTHIGH_LENSTEP_LIFT
    function solveKnee(hx, hy, fx, fy, a, b, bend, guard, out) {
        const raw = Math.hypot(fx - hx, fy - hy);
        const d = guard ? clamp(raw, Math.abs(a - b) + 0.001, a + b - 0.001) : raw;
        const cosA = (a * a + d * d - b * b) / (2 * a * d);
        const ang = Math.atan2(fy - hy, fx - hx) + bend * Math.acos(cosA);
        const kx = hx + Math.cos(ang) * a;
        const ky = hy + Math.sin(ang) * a;
        const shin = Math.atan2(fy - ky, fx - kx);
        out[0] = kx;
        out[1] = ky;
        out[2] = kx + Math.cos(shin) * b;
        out[3] = ky + Math.sin(shin) * b;
        return cosA;
    }

04끝을 목표에 닿게 뻗는 문어 촉수

문어 촉수 7개는 마디 12개·간격 10px(길이 110px)이고, 아래 반복은 끝을 목표에 놓고 뿌리 쪽으로 간격을 맞춘 뒤 뿌리를 몸통에 되돌려 놓고 끝 쪽으로 다시 맞추는 일을 REACH_ITER 4번 되풀이한다. 촉수마다 목표가 부채꼴로 24px 씩 벌어지고 노이즈로 30px 안에서 흔들리며, 몸통은 목표에서 85px 떨어진 위쪽 자리를 쫓는다. 8초 한 바퀴 동안 끝이 1px 안에 닿은 촉수는 평균 4.69개, 일곱 개가 다 닿은 프레임은 11% 였다.

끝을 목표에 닿게 뻗는 문어 촉수
새 창에서 열기

04 · 끝을 목표에 닿게 뻗는 문어 촉수 — 측정 누적면적 24.913% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REACH_ITERTENTACLE_GAPSWAY_AMP
        for (let it = 0; it < iterations; it += 1) {
            xs[n - 1] = tx;
            ys[n - 1] = ty;
            for (let i = n - 2; i >= 0; i -= 1) {
                const d = Math.hypot(xs[i] - xs[i + 1], ys[i] - ys[i + 1]) || 1;
                xs[i] = xs[i + 1] + (xs[i] - xs[i + 1]) / d * gap;
                ys[i] = ys[i + 1] + (ys[i] - ys[i + 1]) / d * gap;
            }
            xs[0] = rx;
            ys[0] = ry;
            for (let i = 1; i < n; i += 1) {
                const d = Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]) || 1;
                xs[i] = xs[i - 1] + (xs[i] - xs[i - 1]) / d * gap;
                ys[i] = ys[i - 1] + (ys[i] - ys[i - 1]) / d * gap;
            }
        }

05몸을 오므렸다 펴며 기어가는 애벌레

애벌레 14마디는 머리가 지나간 자취 위에만 놓이고, 토막마다 쉬는 간격 13px 를 꼬리에서 머리 쪽으로 흐르는 사인 물결(1초에 11 라디안, 한 주기 0.57초)이 최대 절반까지 줄인다. 아래 crawl 은 간격을 강성 600 의 스프링으로 당긴 뒤 앞으로 가는 마디에는 e^(−1.2·dt), 뒤로 밀리는 마디에는 e^(−28·dt) 를 곱해, 오므렸다 펴는 꿈틀거림을 앞으로 가는 걸음으로 바꾼다. 시연 4초 동안 실제 토막 간격은 1.28~19.6px 사이를 오갔고 몸 굵기는 그 간격에 맞춰 13.6~22.4px 로 부풀었다 줄었다.

몸을 오므렸다 펴며 기어가는 애벌레
새 창에서 열기

05 · 몸을 오므렸다 펴며 기어가는 애벌레 — 측정 누적면적 9.641% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_DEPTHFRICTION_FWDFRICTION_BACK
    function targetGap(i) {
        const fromTail = CAT_JOINTS - 2 - i;
        return REST_GAP * (1 - WAVE_DEPTH * crawlDrive * Math.max(0, Math.sin(wavePhase - fromTail * WAVE_K)));
    }
    function crawl(dt) {
        wavePhase += WAVE_SPEED * dt;
        for (let i = 0; i < CAT_JOINTS; i += 1) {
            catF[i] = 0;
        }
        for (let i = 0; i < CAT_JOINTS - 1; i += 1) {
            const pull = SPRING_K * ((catS[i] - catS[i + 1]) - targetGap(i)) + SEG_DAMP * (catU[i] - catU[i + 1]);
            catF[i] -= pull;
            catF[i + 1] += pull;
        }
        for (let i = 0; i < CAT_JOINTS; i += 1) {
            catU[i] += catF[i] * dt;
            catU[i] *= Math.exp(-(catU[i] >= 0 ? FRICTION_FWD : frictionBack) * dt);
            catS[i] += catU[i] * dt;
        }
    }

06갓을 오므릴 때마다 밀려 나가는 해파리

갓은 1.2초 주기의 앞 360ms 동안 빠르게 오므리고 나머지 840ms 동안 천천히 펴지며, 아래 stepJelly 는 오므린 양이 늘어난 만큼만 갓이 향한 쪽으로 속도를 얹어 한 주기에 합쳐 160px/초를 밀어 준다. 물의 저항 e^(−1.6·dt) 가 그 속도를 깎아 큰 해파리는 2주기 동안 28.19~123.99px/초 사이를 오가고, 작은 해파리는 같은 8자 길을 3.4초 늦게 따라간다. 촉수 다섯 가닥(마디 10·간격 11px)은 직전 움직임의 92% 를 이어받고 약하게 가라앉으며 한 프레임에 간격 맞추기를 3번 한다.

갓을 오므릴 때마다 밀려 나가는 해파리
새 창에서 열기

06 · 갓을 오므릴 때마다 밀려 나가는 해파리 — 측정 누적면적 17.967% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PULSE_IMPULSEWATER_DRAGLINK_PASSES
    function stepJelly(j, dt, gx, gy) {
        const want = Math.atan2(gy - j.y, gx - j.x);
        j.heading = wrapAngle(j.heading + clamp(wrapAngle(want - j.heading), -JELLY_TURN * dt, JELLY_TURN * dt));
        j.phase += dt / PULSE_PERIOD;
        const before = j.squeeze;
        j.squeeze = squeezeAt(j.phase);
        const push = Math.max(0, j.squeeze - before) * PULSE_IMPULSE;
        j.vx += Math.cos(j.heading) * push;
        j.vy += Math.sin(j.heading) * push;
        const drag = Math.exp(-WATER_DRAG * dt);
        j.vx *= drag;
        j.vy *= drag;
        j.x += j.vx * dt;
        j.y += j.vy * dt;
        // 판 가장자리 — JELLY_MARGIN(px) 안으로 들어오면 넘친 거리 × 4 만큼 안쪽으로 속도를 돌려 판 밖으로 나가지 않게
        const ox = j.x < JELLY_MARGIN ? JELLY_MARGIN - j.x : (j.x > W - JELLY_MARGIN ? W - JELLY_MARGIN - j.x : 0);
        const oy = j.y < JELLY_MARGIN ? JELLY_MARGIN - j.y : (j.y > H - JELLY_MARGIN ? H - JELLY_MARGIN - j.y : 0);
        j.vx += ox * 4 * dt;
        j.vy += oy * 4 * dt;
        for (let k = 0; k < TENTACLE_COUNT; k += 1) {
            const r = rimOf(j, k);
            stepTentacle(j.xs[k], j.ys[k], j.pxs[k], j.pys[k], r[0], r[1], dt, LINK_PASSES);
        }
    }

07네 다리씩 두 묶음으로 엇갈려 걷는 거미

거미 다리 여덟은 이웃한 다리끼리 다른 묶음이 되도록 네 개씩 둘로 나뉘고, 아래 조각은 다음 차례 묶음에서 가장 많이 밀린 발이 30px 를 넘을 때 그 묶음 네 다리를 한꺼번에 들어 0.14초 동안 높이 14px 의 호로 옮긴 뒤 차례를 넘긴다. 몸 높이는 땅을 딛은 발들이 선 언덕 높이의 평균에 22px 를 더한 쪽으로 따라가고, 다리마다 solveKnee 를 굽힘 1 로 먼저 풀고, 무릎 y 가 엉덩이와 발 y 의 가운데보다 크면(화면 아래쪽이면) 굽힘 −1 로 다시 풀어 무릎을 위로 올린다. 9초 동안 묶음이 49번 바뀌는 사이 같은 묶음이 두 번 잇따른 적이 없었고, 땅에 붙은 발은 4개 아래로 줄지 않았으며 몸 높이는 18.56~25.39px 사이에서 오르내렸다.

네 다리씩 두 묶음으로 엇갈려 걷는 거미
새 창에서 열기

07 · 네 다리씩 두 묶음으로 엇갈려 걷는 거미 — 측정 누적면적 19.912% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GAIT_SETSSTEP_DISTBODY_CLEAR
            if (worst > STEP_DIST) {
                for (let k = 0; k < spiderLegs.length; k += 1) {
                    const g = spiderLegs[k];
                    if (g.set !== nextSet) {
                        continue;
                    }
                    const h = legHome(g);
                    g.sx0 = g.fx;
                    g.sy0 = g.fy;
                    // 몸이 걸음 동안 갈 거리만큼 앞에 놓는다
                    g.ex = h[0] + bvx * STEP_TIME * 1.6;
                    g.ey = h[1] + bvy * STEP_TIME * 1.6;
                    g.t = 0;
                }
                movingSet = nextSet;
                nextSet = (nextSet + 1) % GAIT_SETS;
                setSwaps += 1;
            }

08쉴 땐 배회하고 따라오다 놀라면 달아나는 반려 생물

반려 생물은 배회·따라가기·놀람 세 상태를 오가며, 목표가 초당 420px 보다 빠르게 160px 안을 지나면 1.1초 동안 반대쪽으로 260px/초로 달아난다. 아래 조각처럼 놀람이 아닐 때는 목표가 초당 25px 이하로 0.6초 머물면 노이즈로 방향을 틀며 45px/초로 배회하고, 목표가 움직이면 170px/초로 다가가되 156px(46 + 110) 안쪽부터 원하는 속도를 줄여 46px 에서 0 으로 둔다. 시연 시계로 4.55초에 놀람(목표 속도 443.66·거리 93.71), 5.63초에 따라가기, 8.17초에 배회로 넘어갔고 달아날 때 최고 속도는 253.99px/초였다.

쉴 땐 배회하고 따라오다 놀라면 달아나는 반려 생물
새 창에서 열기

08 · 쉴 땐 배회하고 따라오다 놀라면 달아나는 반려 생물 — 측정 누적면적 14.476% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STARTLE_SPEEDARRIVE_RADIUSWANDER_TURN
        else {
            pet.idle = targetSpeed > MOVE_SPEED ? 0 : pet.idle + dt;
            pet.state = pet.idle > IDLE_DELAY ? WANDER : FOLLOW;
            if (pet.state === FOLLOW) {
                const sp = MAX_SPEED * clamp((dist - STOP_DIST) / ARRIVE_RADIUS, 0, 1);
                wantX = dx / dist * sp;
                wantY = dy / dist * sp;
            }
            else {
                pet.wanderAng += smoothNoise(pet.clock * 0.7) * WANDER_TURN * dt;
                // 판 가장자리에 가까우면 가운데 쪽으로 방향을 끈다
                const edge = Math.min(pet.x, W - pet.x, pet.y, H - pet.y);
                if (edge < 70) {
                    const home = Math.atan2(H / 2 - pet.y, W / 2 - pet.x);
                    pet.wanderAng += wrapAngle(home - pet.wanderAng) * follow(3, dt);
                }
                wantX = Math.cos(pet.wanderAng) * WANDER_SPEED;
                wantY = Math.sin(pet.wanderAng) * WANDER_SPEED;
            }
        }

09잉어가 지나간 자리에 물결이 번지는 연못 장면

연못 수면은 판 2px 마다 한 칸인 240×150 높이 격자이고, 아래 waveStep 이 1/60초마다 칸마다 「네 이웃 합의 절반 − 한 걸음 전 높이」 에 0.985 를 곱해 물결을 퍼뜨린다(1초면 0.4배로 잦아든다). 잉어 셋은 02 와 같은 steerKoi 로 헤엄치며 꼬리 끝 3×3 칸에 꼬리 각속도와 헤엄 속도만큼 높이를 더하고, 그림을 그릴 때는 칸마다 기울기의 1.6배만큼 바닥을 읽는 자리를 밀어 모래와 조약돌이 일렁이게 한다. 가운데 한 칸에 높이 100 을 준 물결은 15걸음 뒤 반지름 28.64px, 30걸음 50.99px, 60걸음 94.53px 로 번졌다.

잉어가 지나간 자리에 물결이 번지는 연못 장면
새 창에서 열기

09 · 잉어가 지나간 자리에 물결이 번지는 연못 장면 — 측정 누적면적 51.417% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_DAMPWAKE_GAINREFRACT
    function waveStep() {
        const now = waveNow;
        const prev = wavePrev;
        for (let y = 1; y < GRID_H - 1; y += 1) {
            const row = y * GRID_W;
            for (let x = 1; x < GRID_W - 1; x += 1) {
                const i = row + x;
                prev[i] = ((now[i - 1] + now[i + 1] + now[i - GRID_W] + now[i + GRID_W]) * 0.5 - prev[i]) * WAVE_DAMP;
            }
        }
        waveNow = prev;
        wavePrev = now;
    }

절차적 애니메이션 기법은 왜 키프레임 하나 없이도 살아 있는 것처럼 보이나?

키프레임 애니메이션은 미리 그려 둔 자세 몇 장을 시간에 맞춰 바꿔 끼운다. 이 아홉 장면은 자세를 하나도 저장하지 않고, 매 프레임 「마디 간격은 12px」 「이웃 마디는 0.35 라디안까지만 꺾인다」 「발은 38px 밀리기 전까지 땅에 붙어 있다」 같은 조건에서 몸을 다시 만든다. 목표가 어느 쪽으로 가든 몸이 그 길에 맞춰 새로 굽으니, 같은 동작이 되풀이되는 느낌이 생기지 않는 것으로 보인다.

몸이 머리가 지나간 길을 그린다

01 의 뒤 마디는 앞 마디 쪽으로만 끌려가므로 머리가 지나간 자리 근처를 조금씩 안쪽으로 질러 지나간다. 그래서 뱀이 8자 길을 돌면 몸 전체가 방금 지나온 고리 모양을 짧게 남기는데, 이 「지나온 길의 잔상」 이 생물을 길 위에 묶어 두는 장치로 보인다.

한 박자 늦은 지느러미와 촉수

물고기 애니메이션 캐릭터를 키프레임으로 만들면 꼬리를 흔드는 몇 장을 고리로 돌리게 된다. 02 는 꼬리지느러미를 꼬리 토막 방향을 쫓는 스프링으로 두었고, 오차가 가장 작아지는 밀림을 재면 133.33ms 늦게 따라간다. 06 의 촉수도 직전 움직임의 92% 를 이어받아 갓보다 늦게 끌려온다. 직전 자리와 지금 자리의 차로 속도를 대신하는 이 방식은 자바스크립트 물리엔진의 로프 사슬과 같은 뿌리이고, 04 의 촉수는 반대로 끝을 목표에 맞추는 풀이라 관성이 없다.

거리 제약으로 몸을, 코사인 관계로 무릎을, 문턱 거리로 발걸음을 — 생물 한 마리를 세우는 계산

거리만 맞추는 사슬

followChain 은 마디 i 를 「앞 마디 → 지금 내 자리」 방향의 단위 벡터에 간격을 곱한 자리로 옮기는 단순한 규칙이다. 01 한 바퀴 동안 간격 오차는 많아야 3.2e-14px 로, 계산할 때마다 간격이 정확히 다시 맞춰진다. 이 함수는 01·02·03·08·09 에서 불리고, 08 은 몸이 아니라 꼬리를 간격 8·꺾임 한계 0.5 로 끌 때 쓴다.

코사인 관계로 푸는 무릎

엉덩이에서 발까지 거리가 d, 허벅지가 a, 정강이가 b 이면 엉덩이 쪽 각의 코사인은 (a² + d² − b²) ÷ (2·a·d) 다. 엉덩이를 (0, 0), 발을 (30, 0) 에 두고 solveKnee 를 부르면 cos A 가 0.75 이고 무릎은 (16.4, 14.66) 에 놓인다. 거리가 뼈 합 42px 와 같으면 cos A 가 1 이 되어 다리가 곧게 펴지고 무릎은 (22, 0) 이다. 도마뱀 애니메이션에서 발이 바닥을 미끄러지듯 보이는 것은 발이 몸과 같이 움직이기 때문으로 보이는데, 03 은 발을 땅에 고정해 두고 엉덩이만 움직이므로 무릎 각이 걸음마다 달라진다. 앞다리는 팔꿈치가 뒤로, 뒷다리는 무릎이 앞으로 꺾이도록 굽힘 부호를 반대로 넘긴다.

끝에서 당기고 뿌리에서 되당기는 반복

04 의 반복 한 번은 끝→뿌리 맞추기와 뿌리→끝 맞추기 한 쌍이다. 뿌리를 (0, 0) 에 두고 아래로 늘어진 촉수 끝을 (70, 40) 에 닿게 하면 끝 오차가 1번 6.043px, 2번 2.698px, 4번 0.769px, 8번 0.086px, 16번 0.001px 로 줄고, 마디 간격 오차는 매번 0 이다. 데모는 매 프레임 앞 프레임 모양에서 이어 풀기 때문에 4번만 돌린다. 목표가 (150, 0) 처럼 사슬 길이 110px 보다 멀면 반복에 들어가지 않고 그 방향으로 곧게 펴서 끝이 (110, 0) 에 놓이고 40px 가 남는다.

앞뒤가 다른 마찰이 꿈틀거림을 걸음으로 바꾼다

05 의 스프링 힘은 이웃한 두 마디가 같은 크기로 주고받으므로, 그 힘만으로는 몸 전체의 무게중심이 움직이지 않는다. 목표를 멀리 고정하고 10초를 재면 앞뒤 마찰이 1.2 와 28 로 다를 때 81.26px/초로 나아가고, 둘 다 1.2 로 같게 하면 −2.84px/초로 사실상 제자리다. 뒤로 미끄러지는 쪽을 붙잡아 두는 마찰 한 줄이 같은 물결을 전진으로 바꾸는 셈이다.

생물이 깨지는 순간 — 목표가 몸 안으로 파고들 때 접히는 몸통·다리가 닿지 않는 거리에서 튀는 무릎·탭 복귀 뒤 늘어나는 마디

목표가 몸 안쪽으로 들어올 때

02 잉어 사슬(14마디·간격 13px)의 머리를 제 몸 6번 마디 자리로 1초 동안 200px/초로 곧장 몰아넣어 봤다. 꺾임 한계를 끈 사슬은 이웃 마디가 3.12 라디안, 거의 179° 까지 되접혀 세 칸 이상 떨어진 마디 8쌍이 서로 겹치고 가장 가까운 두 마디가 3.97px 까지 붙는다. 같은 시험에 MAX_BEND 0.35 를 두면 겹친 쌍이 0, 가장 가까운 마디가 37.42px, 꺾임 최대가 0.35 로 몸은 머리–꼬리 102.32px 의 호를 그리며 돈다. 01 은 8자 길에서는 겹침이 0 이지만 머리를 방향 제한 없이 목표로 곧장 옮기므로, 방문자가 포인터를 뱀 몸 뒤쪽에 두면 이 시험과 같은 상황이 된다.

다리가 닿지 않는 거리

Math.acos 는 −1 과 1 사이 바깥의 값을 받으면 NaN 을 돌려준다. 허벅지 22·정강이 20 인 다리에 d = 45 를 넣으면 cos A 가 1.07 이 되어 무릎이 (NaN, NaN) 이 되고, 그 값에서 나오는 정강이 방향과 그려질 발 자리까지 모두 NaN 이 되어 다리가 화면에서 사라진다. solveKnee 셋째 줄의 guard 는 d 를 41.999 로 잘라 cos A 를 1 로 묶고, 무릎 (22, 0.14)·발 (42, 0.02) 의 곧게 뻗은 다리를 그린다. 03 은 시연 중에도 엉덩이–발 거리가 최대 77.24px 까지 벌어지고 7% 의 순간이 42px 를 넘으니, 이 한 줄은 실제로 일하는 줄이다.

숨은 탭에서 돌아온 첫 프레임

06 큰 해파리를 같은 상태에 두고 한 걸음에 넘기는 시간만 바꿔 봤다. 다른 탭에 2.4초 있다 돌아온 첫 프레임을 그대로 넘기면 촉수 마디 간격이 최대 6.3배·평균 2.44배로 늘고, 쉴 때 99px 인 가운데 촉수가 241.12px 가 된다. dt 를 0.05초로 자르면 최대 1.06배·길이 100.77px 에 그친다. stepTentacle 에서 dt 가 들어가는 항은 가라앉기 SINK * dt * dt 하나라 2.4초면 172.8px, 0.05초면 0.075px 를 한 번에 끌어내리고, 간격 맞추기는 dt 와 상관없이 3번뿐이라 그 차이를 다 되돌리지 못한다.

    function frame(ts) {
        if (still || paused) {
            rafId = 0;
            return;
        }
        if (lastTs < 0) {
            lastTs = ts;
        }
        const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
        lastTs = ts;
        if (dt > 0) {
            advance(dt);
        }
        draw();
        rafId = window.requestAnimationFrame(frame);
    }

아홉 장면은 모두 두 겹으로 막는다. 위 frame 이 dt 를 DT_MAX 0.05초에서 자르고, visibilitychange 로 탭이 숨으면 루프를 멈췄다가 돌아오면 lastTs 를 −1 로 되돌려 시계를 다시 맞춘다. 세 장면을 직접 깨뜨려 보고 싶다면 받은 파일에서 MAX_BEND, guard 자리의 true, DT_MAX 값을 바꿔 보면 되고, 압축을 여는 비밀번호는 hdkq4gtb 이다.

마디 간격을 넓게, 꺾임 한계를 크게, 발걸음 문턱을 멀게 — 숫자 하나로 바뀌는 생물의 성격

세 가지 모두 상수 한 줄만 바꾸고 같은 함수를 다시 불러 잰 값이다. 셋 다 「크게 바꾸면 더 과감해진다」 는 예상과 다르게 움직였다.

이름 바꾼 값 느낌
마디 간격을 넓게 01 SEGMENT_GAP 12 → 20 몸이 300px 에서 500px 로 길어져 8자 길 한 바퀴의 25% 에서 42% 를 차지하고, 머리–꼬리 최소 거리가 126.77px 에서 32.12px 로 좁아진다
꺾임 한계를 크게 02 MAX_BEND 0.35 → 0.7 최소 회전 반지름이 37.33px 에서 18.96px 로 줄고, 8자 길에서 한계에 닿는 프레임이 40% 에서 0% 가 된다
발걸음 문턱을 멀게 03 STEP_TRIGGER 38 → 60 1초 걸음이 5.67 에서 3.72 로 줄고 보폭이 54.28px 에서 79.12px 로 커진다

굽이가 아니라 몸이 겹친다

간격 20 의 뱀은 굽이가 커지는 대신, 고리를 돌 때 꼬리가 머리 쪽으로 감겨 들어와 제 몸 위를 지나간다. 이웃 꺾임 최대가 0.29 에서 1.18 라디안으로 커지고 겹치는 마디 쌍이 0 에서 3 이 된다. 몸을 길게 만들고 싶다면 간격보다 마디 수를 늘리고 8자 길의 고리를 키우는 쪽이 안전해 보인다.

접히지는 않지만 공처럼 말린다

한계 0.7 의 잉어는 이 길에서 꺾임이 최대 0.4 라디안이라 한계에 한 번도 닿지 않는다. 머리를 제 몸 6번 마디 쪽으로 몰아넣는 시험에서는 머리–꼬리 거리가 102.32px 에서 18.03px 로 줄어, 겹치지는 않아도 몸이 공처럼 말린다.

느긋해 보이지만 다리가 끌린다

문턱 60 의 도마뱀은 걸음이 뜸해져 느긋해 보이지만, 발을 오래 버티는 동안 엉덩이–발 거리가 뼈 합 42px 를 넘는 순간이 7% 에서 31% 로 늘고 최대 거리도 77.24px 에서 80.58px 가 된다. 그 시간 동안 다리는 곧게 펴진 채 발 쪽으로 끌려간다.

움직임 줄이기에서는 쉬는 자세로 멈춘 생물 — 일시정지 버튼과 장면 설명을 갖춘 접근성

장면 설명과 일시정지 버튼

아홉 장면 모두 캔버스에 role="img" 와 지금 장면을 말로 적은 aria-label 이 붙어 있어, 화면 낭독기는 그림 대신 「잉어 세 마리가 지나간 자리에 물결이 번지고 바닥 무늬가 일렁이며 연잎이 흔들리는 연못」 같은 설명을 읽는다. 오른쪽 아래 알약 모양 일시정지 버튼은 Tab 을 누르면 첫 칸으로 잡히고, Enter 를 누르면 aria-pressed 가 true 로 바뀌며 화면이 멈추고, 다시 누르면 이어진다. 포커스가 오면 3px 짙은 외곽선이 생긴다.

움직임 줄이기 설정에서 남는 한 장

운영체제에서 움직임 줄이기를 켜면 루프를 돌리지 않고 생물을 완만한 S 자 쉬는 자세로 놓은 한 장만 그린다. 설정을 도중에 바꿔도 change 이벤트로 바로 그 자세로 넘어간다. 이 상태에서 포인터를 올리면 목표와 눈동자만 바뀐 한 장을 다시 그려, 생물이 움직이지 않으면서도 방문자를 쳐다보게 했다.

08 의 상태 줄

08 만 왼쪽 위에 「배회 중」 같은 상태 줄이 있다. 시연 동안은 aria-live 가 off 라 저절로 바뀌는 상태를 낭독기가 읽지 않고, 방문자가 처음 포인터나 버튼을 쓴 뒤에야 polite 로 바뀐다. 아무도 보지 않는 시연이 낭독기를 계속 떠들게 하지 않으려는 설계로 보인다.

웹사이트에 마우스 따라다니는 생물을 넣기 전에 자주 묻는 질문

마우스 커서 따라다니는 효과처럼 휴대폰에서 손가락도 따라오나요?

따라온다. 판은 포인터 이벤트의 pointermove·pointerdown 으로 목표를 옮기므로 마우스·손가락·펜을 같은 코드로 받는다. 손가락이 판을 떠나 pointerleave·pointercancel 이 오면 목표는 8자 길로 돌아간다.

첫 화면 배경으로 크게 깔면 무거워지지 않나요?

캔버스 안 좌표는 480×300 으로 고정이고 화면 배율은 2 에서 멈추므로, 판을 넓게 늘려도 움직임 계산량은 같다. 가장 무거운 09 도 240×150 칸 물결을 한 프레임에 많아야 4걸음만 계산하고, 바닥 모래와 조약돌 48개는 처음에 한 번만 그려 둔다.

생물 색이나 굵기를 바꾸려면 어디를 고치나요?

색은 스크립트 앞쪽의 CORAL·LAVENDER·BUTTER·SAGE 같은 RGB 배열을, 굵기는 01 의 BODY_WIDTH 15 나 02 의 KOI_SIZE 17 을 바꾸면 된다. 윤곽선은 INK 한 색이라 바탕을 어둡게 바꿀 때는 이 값도 같이 밝혀야 생물이 바탕에 묻히지 않는다. 3D 장면에서 포인터를 쫓는 쪽이 궁금하다면 마우스를 따라오는 CSS 3D 회전 장면 을, 캔버스 픽셀을 직접 계산하는 효과는 html5 canvas 애니메이션 를 함께 보면 된다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.