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

핀볼 뽑기 9종 — 이름 공 추첨판·플리퍼·범퍼·구슬 레이스

핀볼 뽑기는 이름을 단 공을 핀·막대·범퍼 사이로 굴려 당첨을 정하는 캔버스 추첨판이다. 공을 같은 자리에서 떨어뜨리고 칸마다 이름을 붙이면 왜 가운데 이름만 자꾸 당첨될까?

추첨판부터 미니 테이블까지 — 공이 굴러가는 아홉 무대

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

손대지 않으면 칸마다 같은 시드 판이 혼자 돈다. 끝이 있는 01·07·08 은 결과를 1,600ms 보여 준 뒤 같은 시드로 처음부터 다시 굴리고, 02·09 의 플리퍼는 공이 막대 윗면 구역에 들어오면 누르고 240ms(09 는 220ms) 뒤 떼는 가상 입력이 대신 친다. 05·09 의 발사 막대도 공이 올라오면 저절로 당겼다 놓고, 09 는 시연 1,300ms 에 한 번 판을 흔든 뒤 3,600ms 마다 다시 흔든다. 버튼을 처음 누르는 순간 시연이 멎고 그때부터는 누른 대로 움직이며, 01·07·08 의 「다시 추첨」 과 03·04·06 의 「다시 굴리기」 는 시드를 하나 올려 새 판을 연다.

순서는 공이 받는 힘이 한 가지씩 늘어나게 짰다. 01 은 멈춰 있는 핀에 부딪히기만 하고, 02 는 움직이는 막대가 자기 속도를 공에 얹으며, 03 은 닿는 순간 정해진 충격을, 04 는 빗변에서 덧힘을, 05 는 당긴 길이에 비례한 힘을 준다. 06 은 쉬지 않고 도는 막대, 07 은 비탈과 구름 마찰로 순위를 가르고, 08 은 공 하나 대신 60개가 쌓이는 모양으로 확률을 보여 주며, 09 는 02·03·05 를 한 판에 묶는다.

핀볼 뽑기 9종 — 이름 공 추첨판·플리퍼·범퍼·삼각 키커·발사 막대·풍차 막대·구슬 레이스·분포판·미니 테이블

아홉 무대는 캔버스 2D 와 자바스크립트만으로 굴러간다. 물리 엔진·라이브러리·WebGL·CDN·이미지 파일은 하나도 없고, 난수도 Math.random 이 아니라 32비트 정수를 섞는 makeRng 한 곳에서만 나온다. 화면 갱신 간격(requestAnimationFrame 설명)은 16ms 짜리 계산 틱으로 바꿔 쌓고, 항목마다 그 한 틱을 다시 4~6 걸음(SUBSTEPS)으로 쪼개 충돌을 잰다.

2초 동안 찍은 24장을 이웃끼리 견준 23번의 변화

480×300 창에서 렌더한 장면을 바로 앞 장과 비교해, 한 번이라도 색이 바뀐 화소가 무대에서 차지한 넓이를 누적면적으로, 23번 중 화면이 달라진 횟수를 움직인 프레임으로 적었다.

무대 누적면적 움직인 프레임 강도 평균
01 이름 공 추첨판 19.1% 23/23 99.9
02 플리퍼 12.5% 23/23 98.5
03 범퍼 21.5% 23/23 83.1
04 삼각 키커 16.8% 23/23 97.3
05 발사 막대 8.5% 23/23 98.7
06 풍차 막대 28.8% 23/23 103.9
07 구슬 레이스 14.2% 23/23 72.4
08 분포판 8.8% 23/23 82.7
09 미니 테이블 14.3% 23/23 85.5

아홉 무대 가운데 비교할 때마다 화면이 달라지지 않은 곳은 없었다. 06 이 28.8% 로 가장 넓은 것은 날개 셋짜리 막대 두 개가 쉬지 않고 돌면서 구슬 일곱 개를 무대 곳곳으로 쳐 내기 때문으로 보이고, 05 가 8.5% 로 가장 좁은 것은 막대를 당기고 놓는 사이 무대 대부분이 가만히 있기 때문일 것이다.

방송 시청자 추첨부터 팀 발표 순서까지, 붙일 자리

방송 화면에 핀볼 랜덤 추첨기를 띄운다면 01 이 가장 가깝다. 결승선을 먼저 지난 공을 당첨으로 읽으니 화면을 보는 사람 누구나 결과를 눈으로 따라갈 수 있고, 지금 A~H 글자인 공 여덟 개의 이름표 자리에 참가자 이름을 넣어 쓰면 된다. 한 사람만 빠르게 정하는 자리라면 룰렛 돌리기 의 돌림판이 더 단순할 수 있다.

모임에서 발표 순서를 정할 때는 07 이 낫다. 한 명만 남는 01 과 달리 순위표가 여섯 줄 전부를 들어온 차례와 시각으로 남기기 때문이다. 08 은 결과보다 과정을 보여 줄 때 쓴다. 가운데로 몰리는 쏠림을 먼저 그려 보이고 이름을 섞어 배정하니, 경품 추첨 안내 페이지에서 칸 위치가 유리하지 않다는 설명을 그림이 대신한다.

실제 추첨에 옮길 때 바꿔야 할 것이 하나 있다. 데모는 시드 고정이라 결과가 재현된다 — 실제 추첨에 쓸 땐 시드를 외부 입력에서 받아야 한다. 시드 4761 판은 몇 번을 다시 열어도 A 가 1,040ms 에 당첨되고, 「다시 추첨」 도 seed += 1 뒤 restart() 를 부르는 것이라 4761 다음 판은 4762 로 정해져 있다.

01이름 공이 핀 사이로 떨어지는 추첨판

A~H 이름표를 단 공 여덟 개가 핀 66개 사이로 떨어지며, 핀과 겹친 깊이만큼 밀려나고 다가오던 법선 방향 속도만 0.5배로 뒤집혀 튄다. 결승선(y 238)을 공 전체가 먼저 지난 공이 당첨이라 방송 시청자 추첨 화면에서 누가 먼저 들어왔는지를 그대로 결과로 쓸 수 있다. 시드 4761 판은 A 가 1,040ms 에 당첨되고, 핀에 127번 닿은 끝에 5,616ms 에 여덟 개가 모두 칸에서 멎는다.

이름 공이 핀 사이로 떨어지는 추첨판
새 창에서 열기

01 · 이름 공이 핀 사이로 떨어지는 추첨판 — 측정 누적면적 19.085% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PEG_ROWSRESTITUTIONSUBSTEPS
    function hitCircle(b, cx, cy, cr, e) {
        const dx = b.x - cx;
        const dy = b.y - cy;
        const d = Math.hypot(dx, dy);
        const overlap = b.r + cr - d;
        if (overlap <= 0) {
            return -1;
        }
        // 중심이 정확히 겹치면 방향을 정할 수 없다 — 위로 민다
        const nx = d > 1e-9 ? dx / d : 0;
        const ny = d > 1e-9 ? dy / d : -1;
        b.x += nx * overlap;
        b.y += ny * overlap;
        contact.nx = nx;
        contact.ny = ny;
        contact.px = cx + nx * cr;
        contact.py = cy + ny * cr;
        const vn = b.vx * nx + b.vy * ny;
        if (vn >= 0) {
            return 0;
        }
        b.vx -= (1 + e) * vn * nx;
        b.vy -= (1 + e) * vn * ny;
        return -vn;
    }

02도는 빠르기를 공에 실어 쏘는 플리퍼

막대 위에서 공과 가장 가까운 점을 찾고, 축에서 그 점까지의 벡터를 90° 돌려 각속도를 곱한 값을 접점 속도로 넘겨 되튐을 잰다. 끝 접점은 12 라디안/초 × 86px 로 1,032px/s 를 내고 한 번 휘두르는 데 77ms 가 걸려, 막대 윗면에 얹은 공은 축에서 길이의 0.3 자리면 815px/s, 끝이면 1,496px/s 로 날아간다. 브라우저 미니게임 이벤트 페이지에 두면 공이 윗면 구역에 들 때 누르던 시연 규칙을 사람 손이 대신하게 되는데, 이 규칙은 시간표가 아니라 구역으로 정해져 있어 시드가 같으면 같은 순간에 친다.

도는 빠르기를 공에 실어 쏘는 플리퍼
새 창에서 열기

02 · 도는 빠르기를 공에 실어 쏘는 플리퍼 — 측정 누적면적 12.542% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLIPPER_SPEEDFLIPPER_LENGTHFLIPPER_REST_ANGLE
    // 한 걸음(h 초) — 목표 각까지 남은 차를 speed × h 안으로 잘라 돈다. 끝에 닿으면 w 는 0
    function moveFlipper(f, h) {
        const target = f.pressed ? f.up : f.rest;
        const d = clamp(target - f.angle, -f.speed * h, f.speed * h);
        f.angle += d;
        f.w = d / h;
    }
    // 플리퍼와 공 — 막대 위 가장 가까운 점을 찾고, 그 점이 도는 빠르기(각속도 × 축까지 거리)를 상대 속도에 얹는다.
    // 축에서 먼 끝으로 칠수록 접점이 빨라 공이 더 세게 날아간다
    function hitFlipper(b, f, e) {
        const tip = flipperTip(f);
        const c = closestOnSegment(b.x, b.y, f.px, f.py, tip[0], tip[1]);
        const rx = c[0] - f.px;
        const ry = c[1] - f.py;
        // 회전으로 생긴 접점 속도 — 축→접점 벡터를 90° 돌려 각속도를 곱한다
        const svx = -f.w * ry;
        const svy = f.w * rx;
        const vn = hitSegment(b, f.px, f.py, tip[0], tip[1], f.half, e, svx, svy, 0);
        if (vn > 0 && Math.abs(f.w) > 0.5) {
            f.hitR = Math.hypot(rx, ry);
            f.hitSurf = Math.abs(f.w) * f.hitR;
        }
        return vn;
    }

03닿는 순간 정해진 세기로 되미는 범퍼

공을 원끼리의 충돌로 0.5배 되튀긴 다음 닿은 법선 방향으로 BUMPER_KICK 320px/s 를 더하므로, 위에서 100·300·600px/s 로 곧장 들어온 공이 370·470·620px/s 로 나간다. 범퍼 반지름은 그림에서만 스프링으로 움직여 맞은 뒤 11.8% 부풀었다가 −6.8% 까지 넘쳐 줄고 480ms 에 ±1% 안으로 가라앉으며, 충돌 판정은 반지름 24 그대로다. 앱 출시 기념 이벤트 배너처럼 공 몇 개가 쉬지 않고 튀어야 하는 자리에 맞고, 시연 10초 동안 범퍼 넷이 17번 맞았다.

닿는 순간 정해진 세기로 되미는 범퍼
새 창에서 열기

03 · 닿는 순간 정해진 세기로 되미는 범퍼 — 측정 누적면적 21.528% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BUMPER_KICKBUMPER_RADIUSBUMPER_STIFFNESS
    // 공과 범퍼 — 원-원 충돌로 겹친 만큼 밀어내고 되튀긴 다음, 들어온 빠르기와 상관없이 같은 세기 kick 을 법선 방향으로 더한다.
    // 맞는 자리는 그림의 부푼 반지름이 아니라 r 그대로다(부풀기는 그림만)
    function hitBumper(b, m, e) {
        const vn = hitCircle(b, m.x, m.y, m.r, e);
        if (vn <= 0) {
            return vn;
        }
        b.vx += contact.nx * m.kick;
        b.vy += contact.ny * m.kick;
        m.dv += m.pop;
        m.flash = 1;
        m.hits += 1;
        return vn;
    }
    // 반지름 스프링 — 부푼 만큼 되당기는 힘(stiff × d)과 빠르기에 비례하는 저항(damp × dv). 저항이 작아 한 번 넘쳤다가 돌아온다
    function springBumper(m, h) {
        m.dv += (-m.stiff * m.d - m.damp * m.dv) * h;
        m.d += m.dv * h;
        m.flash *= Math.exp(-h * 7);
    }

04고무줄처럼 휘었다 튕겨 내는 삼각 키커

삼각형 빗변을 두께 반 2 의 선분으로 충돌시킨 뒤, 다가오던 빠르기가 40px/s 를 넘고 닿은 법선이 빗변 바깥쪽과 같은 방향일 때만 법선 × 360px/s 를 덧붙인다. 빗변 가운데는 그림에서만 휘는데, 정면으로 150px/s 에 맞으면 −2.24px 휘었다 +1.27px 되튀고 292ms 에 ±0.3px 안으로 펴지며 공은 435px/s 로 나간다. 게임 사이트 첫 화면 장식으로 걸어 두면 가운데 틈으로 빠진 공이 위에서 다시 떨어져, 시연 10초에 24번 튕긴다.

고무줄처럼 휘었다 튕겨 내는 삼각 키커
새 창에서 열기

04 · 고무줄처럼 휘었다 튕겨 내는 삼각 키커 — 측정 누적면적 16.78% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SLING_BOOSTSLING_BEND_MAXSLING_DAMPING
    // 빗변 — 선분 충돌로 되튀긴 뒤, 법선이 빗변 바깥을 향하고 충분히 빨리 닿았으면 법선 방향 덧힘을 얹고 빗변을 안쪽으로 민다
    function hitSling(b, sl) {
        const vn = hitSegment(b, sl.a[0], sl.a[1], sl.c[0], sl.c[1], 2, RESTITUTION, 0, 0, 0);
        if (vn <= SLING_MIN) {
            return;
        }
        if (contact.nx * sl.nx + contact.ny * sl.ny < 0.5) {
            return;
        }
        b.vx += sl.nx * SLING_BOOST;
        b.vy += sl.ny * SLING_BOOST;
        sl.bendV -= vn * SLING_PRESS;
        sl.flash = 1;
        sl.hits += 1;
    }
    // 휨 — 펴는 힘(stiff × bend)과 저항(damp × bendV)으로 감쇠 진동, 깊이는 SLING_BEND_MAX 안으로 자른다
    function bendSling(sl, h) {
        sl.bendV += (-SLING_STIFFNESS * sl.bend - SLING_DAMPING * sl.bendV) * h;
        sl.bend = clamp(sl.bend + sl.bendV * h, -SLING_BEND_MAX, SLING_BEND_MAX);
        sl.flash *= Math.exp(-h * 6);
    }

05당긴 만큼 힘이 쌓이는 발사 막대

쥐고 있는 동안 막대가 60px/s 로 최대 40px 까지 당겨지고, 놓으면 당긴 길이 × 900 의 가속도로 되돌아가다 0 을 처음 지나는 순간 막대 빠르기의 0.85배를 공의 위쪽 속도로 넘긴다. 당김 10·20·30·40px 이 255·510·765·1,020px/s 발사가 되어 predictLaunch 의 0.85 × √900 × 당김과 같은 값이 나오고, 공은 놓고 64ms 뒤에 떠난다. 게임형 회원 가입 축하 화면이라면 버튼을 누르고 있는 시간이 곧 세기라 설명 없이도 손에 익고, 공을 넘긴 막대는 쉬는 자리 위 24px 까지 넘쳤다가 352ms 에 ±1px 안으로 멎는다.

당긴 만큼 힘이 쌓이는 발사 막대
새 창에서 열기

05 · 당긴 만큼 힘이 쌓이는 발사 막대 — 측정 누적면적 8.451% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PLUNGER_STIFFNESSPLUNGER_MAX_PULLLAUNCH_SCALE
            const before = p.pull;
            const a = -p.stiff * p.pull - (p.free ? 0 : p.damp * p.v);
            // 속도를 먼저 갱신하는 적분
            p.v += a * h;
            p.pull += p.v * h;
            if (p.free && before > 0 && p.pull <= 0) {
                p.free = false;
                if (p.ball) {
                    const speed = Math.abs(p.v) * p.scale;
                    p.ball.vx = 0;
                    p.ball.vy = -speed;
                    p.lastPull = p.releasedPull;
                    p.lastLaunch = speed;
                    p.ball = null;
                }
            }
            // 공 없이 놓았거나 아주 조금 당겼다 놓았으면 그대로 출렁이다 멈춘다
            if (p.free && p.releasedPull <= 0) {
                p.free = false;
            }

06돌아가는 풍차 막대에 휘말려 튕기는 구슬

날개 세 개짜리 막대 둘이 6 라디안/초로 서로 반대 방향으로 돌고, 구슬이 맞은 날개 위 점의 회전 속도가 그대로 접점 속도로 들어간다. 멈춘 구슬을 축에서 15·30·45·56px 자리에 두고 날개로 치면 170·295·423·514px/s 로 튀어, 끝에 맞을수록 멀리 간다. 단체 게임 순서 정하기 화면에 두면 구슬 일곱 개가 맞은 자리마다 다르게 튕겨 나가는데, 시연 30초 동안 축 25px 안쪽에 맞은 15번은 평균 260px/s, 45px 바깥에 맞은 79번은 평균 496px/s 였다.

돌아가는 풍차 막대에 휘말려 튕기는 구슬
새 창에서 열기

06 · 돌아가는 풍차 막대에 휘말려 튕기는 구슬 — 측정 누적면적 28.824% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ROTOR_SPEEDROTOR_ARMSROTOR_LENGTH
    // 날개와 공 — 날개 위 가장 가까운 점의 회전 속도를 접점 속도로
    function hitArm(b, r, k) {
        const w = r.dir * ROTOR_SPEED;
        const tip = armTip(r, k);
        const c = closestOnSegment(b.x, b.y, r.x, r.y, tip[0], tip[1]);
        const rx = c[0] - r.x;
        const ry = c[1] - r.y;
        const vn = hitSegment(b, r.x, r.y, tip[0], tip[1], ROTOR_HALF, RESTITUTION, -w * ry, w * rx, 0);
        if (vn > 0) {
            lastR = Math.hypot(rx, ry);
            lastV = Math.hypot(b.vx, b.vy);
            r.glow[k] = 1;
        }
    }

07경사로를 굴러 들어온 순서로 순위가 매겨지는 구슬 레이스

구슬 여섯 개가 지그재그 비탈 네 개를 굴러 내려오고, 비탈에 닿은 걸음마다 접선 빠르기에서 1 − e^(−0.4 × 배율 × h) 몫을 덜어 내는데 그 배율이 구슬마다 시드로 0.7~1.3 이라 빠르기가 갈린다. 시드 4767 판은 E·B·A·C·D·F 순으로 4.51~5.18초에 들어오고, 마찰을 0 으로 두면 같은 시드에서 순위가 B·E·C·A·F·D 로 바뀐다. 팀 발표 순서를 정하는 구슬 레이스 뽑기로 쓰면 순위표가 달리는 동안 코스 진행도로 줄을 바꾸다가, 결승선을 지난 차례와 시각으로 굳는다.

경사로를 굴러 들어온 순서로 순위가 매겨지는 구슬 레이스
새 창에서 열기

07 · 경사로를 굴러 들어온 순서로 순위가 매겨지는 구슬 레이스 — 측정 누적면적 14.235% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RAMP_FRICTIONRAMP_SLOPEMARBLE_COUNT
    // 코스 진행도 — 위에 지나온 비탈 수 × 1000 + 지금 비탈에서 굴러 온 거리. 들어온 구슬은 들어온 차례가 앞선다
    function progressOf(b) {
        if (b.finishMs >= 0) {
            return 1e7 - b.finishMs;
        }
        let k = 0;
        for (let i = 0; i < ramps.length; i += 1) {
            const r = ramps[i];
            const x = clamp(b.x, Math.min(r[0], r[2]), Math.max(r[0], r[2]));
            if (rampY(r, x) < b.y) {
                k = i + 1;
            }
        }
        const on = ramps[Math.min(k, ramps.length - 1)];
        const along = Math.abs(clamp(b.x, Math.min(on[0], on[2]), Math.max(on[0], on[2])) - on[0]);
        return k * 1000 + along;
    }
    function standings() {
        const idx = [];
        for (let i = 0; i < balls.length; i += 1) {
            idx.push(i);
        }
        return idx.sort(function (a, b) { return progressOf(balls[b]) - progressOf(balls[a]); });
    }

08떨어진 칸을 쌓아 종 모양이 드러나는 분포판

공 60개를 70ms 간격으로 떨어뜨려 칸 아홉 개에 든 수를 세고, 막대 높이는 틱마다 남은 차이의 0.14 만큼 따라 올라가며 그 위에 binomShare 로 낸 기대 곡선이 겹친다. 시드 4768 시연에서는 60개 중 16개(26.7%)가 가운데 칸에 들어 기대 몫 27.3% 에 가까웠다. 다 떨어지면 시드로 섞인 이름표가 700ms 동안 제자리를 찾아가고 레몬 공이 든 7번 칸의 H 가 당첨이라, 공정한 경품 추첨 안내 페이지에서 쏠림과 섞기를 한 화면에 보여 줄 수 있다.

떨어진 칸을 쌓아 종 모양이 드러나는 분포판
새 창에서 열기

08 · 떨어진 칸을 쌓아 종 모양이 드러나는 분포판 — 측정 누적면적 8.804% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DROP_INTERVAL_MSSLOT_COUNTSHUFFLE_SLOTS
    // 이항 기대 몫 — 줄마다 반반 갈리면 k 칸에 들 몫 = (ROWS 개 중 k 개를 고르는 수) / 2^ROWS
    function binomShare(k) {
        let c = 1;
        for (let i = 0; i < k; i += 1) {
            c = c * (ROWS - i) / (i + 1);
        }
        return c / Math.pow(2, ROWS);
    }

09플리퍼·범퍼·점수판을 묶은 미니 핀볼 테이블

02 와 같은 함수로 도는 플리퍼 둘(길이 70), 03 의 범퍼 셋(반지름 19, 300px/s), 05 의 발사 막대를 한 판에 얹고, 범퍼 100점·플리퍼 타격 10점을 600ms 동안 처음과 끝이 느린 곡선으로 굴려 올린다. 흔들기 버튼은 판 위 공에 옆으로 160px/s, 위로 56px/s 를 더하고 화면에는 스프링 빠르기만 줘서, 화면이 6.39px 밀렸다 −3.79px 되튀고 592ms 에 ±0.5px 안으로 멎는 동안 물리 좌표는 그대로다. 게임 출시 랜딩 첫 화면에 걸면 시연 20초 동안 점수 1,580점과 흔들기 6번이 혼자 쌓인다.

플리퍼·범퍼·점수판을 묶은 미니 핀볼 테이블
새 창에서 열기

09 · 플리퍼·범퍼·점수판을 묶은 미니 핀볼 테이블 — 측정 누적면적 14.274% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TABLE_GRAVITYNUDGE_IMPULSESCORE_ROLL_MS
    // 흔들기 — 판 위 공에 같은 충격을 더하고, 화면에는 스프링 빠르기를 준다(화면 흔들림은 그림만, 공은 충격만 받는다)
    function nudge() {
        if (!plunger.ball && !ball.out) {
            ball.vx += nudgeDir * NUDGE_IMPULSE;
            ball.vy -= NUDGE_IMPULSE * 0.35;
        }
        shakeV += nudgeDir * SHAKE_KICK;
        nudgeDir = -nudgeDir;
        nudges += 1;
    }

겹친 만큼 밀어내고 닿은 방향으로 되튀기기 — 원·선분 충돌과 도는 플리퍼의 접점 속도 계산

겹친 깊이와 법선 속도만 보는 원 충돌

hitCircle 은 공 중심과 핀 중심 사이 거리 d 를 재고, 반지름 합보다 짧으면 그 차인 겹친 깊이만큼 공을 두 중심을 잇는 방향으로 밀어낸다. 그다음 속도를 그 방향 성분 vn 과 나머지로 나눠, vn 이 다가오는 쪽일 때만 (1 + e) × vn 을 빼서 법선 성분을 −e 배로 뒤집는다. 옆으로 미끄러지는 성분은 손대지 않으니 공은 핀 옆면을 스치며 비스듬히 빠져나가고, 01 의 e 0.5 에서는 다가오던 빠르기의 절반이 되튐으로 남는다. 글자를 상자 하나로 보고 부딪히게 하는 계산은 글자 떨어지는 효과 에서 다뤘는데, 여기서는 공과 핀이 모두 원이라 모서리 없이 중심선 하나로 끝난다.

선분은 가장 가까운 점 하나에서 원이 된다

02·09 의 플리퍼, 04 의 빗변, 06 의 날개, 07 의 비탈은 모두 선분이다. closestOnSegment 가 공 중심에서 선분에 내린 수선의 발을 0~1 구간으로 잘라 구하면, 그 점을 반지름이 선 두께의 반인 원으로 보고 같은 밀어내기와 되튐을 건다. 원과 다른 점은 접점이 움직일 수 있다는 것뿐이라, hitSegment 는 공 속도에서 접점 속도 (svx, svy) 를 뺀 상대 속도로 되튐을 잰다. 07 에서 비탈 방향 힘을 따로 계산하지 않아도 구슬이 굴러 내려가는 것도 이 덕분이다. 충돌이 비탈에 수직인 성분만 지우니 중력 가운데 비탈을 따라가는 몫만 남는다.

도는 막대 위 한 점의 빠르기는 각속도 × 축까지 거리

축에서 접점까지 벡터 (rx, ry) 를 90° 돌리면 그 점이 도는 방향이 되고, 각속도 w 를 곱한 (−w·ry, w·rx) 가 접점 속도다. 크기는 w × r 이라 02 의 끝(86px)은 12 × 86 = 1,032px/s, 06 의 끝(58px)은 6 × 58 = 348px/s 가 된다. 벽돌깨기 게임 만들기 의 패들은 맞은 위치로 반사각을 골랐지만, 플리퍼는 맞은 위치가 곧 그 점의 빠르기라 각도와 세기가 함께 바뀐다. 02 에서 축 0.6~0.7 자리의 결과가 1,269·1,057px/s 로 들쭉날쭉했던 것은 휘두르는 도중 접점이 막대를 따라 미끄러지기 때문으로 보인다.

당긴 길이 40px 이 초속 1,020px 가 되는 길

05 의 막대는 당긴 길이 x 에 900 × x 의 되미는 가속도를 받는다. 이런 힘에서 쌓인 에너지를 다 돌려받으면 0 을 지나는 순간 빠르기가 √900 × x = 30x 이고, 여기에 LAUNCH_SCALE 0.85 를 곱한 25.5x 가 공에 넘어간다. 그래서 40px 은 1,020px/s, 10px 은 255px/s 인데, 10px 로 쏜 공은 레인을 다 오르지 못하고 가장 높이 y 204 까지 갔다가 막대 위로 되떨어진다. 놓은 뒤 처음 0 을 지날 때까지 저항을 0 으로 두는 것은 당긴 만큼을 하나도 깎지 않고 돌려주기 위해서이고, 저항 24 는 공을 넘긴 다음에야 켜진다.

핀볼 뽑기 프로그램은 왜 가운데 칸이 더 많이 당첨되는가?

여덟 줄에서 반반 갈린 공이 모이는 자리

08 의 공은 핀 줄 8개를 지나며 줄마다 왼쪽이나 오른쪽으로 갈린다. 오른쪽으로 간 횟수가 그 공이 들어갈 칸 번호가 되는데, 여덟 번 중 네 번 오른쪽으로 가는 길은 70가지이고 여덟 번 모두 오른쪽인 길은 1가지뿐이다. 길 256가지 가운데 가운데 칸이 70/256 = 27.3%, 양 끝 칸이 1/256 = 0.4% 를 받는 이유가 이것이고, 공 3,000개를 실제로 떨어뜨리자 가운데 칸 28.7%, 양 끝 0.3%·0.2% 가 나왔다. 이 모양은 핀에 닿을 때 옆 빠르기를 0.45배로 줄이고 시드 난수로 ±55px/s 를 섞어야 나온다. 흔들림을 0 으로, 옆 빠르기를 그대로 두자 칸 몫이 7.9~13.0% 로 거의 고르게 퍼져 종 모양이 사라졌다.

이름을 칸에 섞어 붙이면 고르게 돌아오는 당첨

칸이 가운데로 기운다는 것은 칸에 이름을 고정해 두면 그 기울기가 그대로 사람에게 옮는다는 뜻이다. 시드 1000~1299 의 300판을 섞기 없이 돌리자 가운데 D·E·F 가 각 51~52번, 맨 끝 A 는 3번, I 는 4번 당첨됐다. 당첨을 정하는 레몬 공은 반지름 5 로 보통 공(3.5)보다 커서 가운데 칸 몫이 17% 안팎으로 3,000개 분포보다 낮게 나오는데, 반지름을 3.5 로 맞추면 같은 300판에서 가운데 칸이 91번(30.3%)으로 오른다. SHUFFLE_SLOTS 를 켜면 판마다 이름표를 시드 난수로 뒤에서부터 한 자리씩 맞바꿔 칸과 이름의 관계를 끊고, 같은 300판에서 이름마다 27~40번이 나왔다. 고르게 나누면 한 이름에 약 33번이니 그 둘레로 모인 셈이다.

        // 이름 배정 — 섞기면 시드 난수로 뒤에서부터 한 자리씩 맞바꾼다
        names = LABELS.slice(0, SLOT_COUNT);
        if (SHUFFLE_SLOTS) {
            for (let i = names.length - 1; i > 0; i -= 1) {
                const j = Math.floor(rng() * (i + 1));
                const t = names[i];
                names[i] = names[j];
                names[j] = t;
            }
        }

칸은 여전히 가운데로 몰리지만, 어느 이름이 가운데 칸에 앉을지가 판마다 바뀐다. 쏠림을 없애는 것이 아니라 쏠림이 누구에게 갈지를 섞는 방식이다.

공이 더 높이 튀고, 플리퍼가 더 빨리 치고, 범퍼가 더 세게 밀 때 — 값 하나씩 바꾼 변형

이름 바꾼 값 느낌
공이 더 높이 튀게 01 RESTITUTION 0.5 → 0.8 핀에 닿는 횟수가 127번에서 151번으로 늘며 통통 튀고, 당첨은 A 1,040ms 에서 E 1,456ms 로, 마지막 공은 3,360ms 에서 5,744ms 로, 모두 멎기까지는 5,616ms 에서 9,712ms 로 길어진다
플리퍼를 더 빨리 02 FLIPPER_SPEED 12 → 20 끝 접점이 1,032px/s 에서 1,720px/s 로, 한 번 휘두르기가 77ms 에서 46ms 로 짧아지고, 시연 20초 동안 친 직후 공 빠르기가 683~1,815px/s 에서 1,041~2,623px/s 로 오른다(친 횟수 17번 → 20번)
범퍼를 더 세게 03 BUMPER_KICK 320 → 640 위에서 300px/s 로 들어온 공이 470px/s 대신 790px/s 로 튕겨 나가고, 10초 동안 범퍼에 맞은 횟수가 17번에서 53번으로 는다

세 변형 모두 화면이 더 바빠지는 쪽이지만 치르는 값은 다르다. 01 은 결과가 나오기까지 기다림이 4초 넘게 늘고 당첨 공도 A 에서 E 로 바뀌니, 되튐 하나가 같은 시드의 당첨자를 바꾼 셈이다. 02 를 20 으로 올린 채 한 틱을 한 걸음으로 계산하면 막대를 뚫는 자리가 1곳에서 5곳으로 늘어나므로 아래 절과 함께 봐야 하고, 03 은 맞은 횟수가 세 배를 넘어 공이 범퍼 사이를 더 오래 오가는 것으로 보인다.

빠른 공이 얇은 플리퍼를 뚫고 지나갈 때·구슬이 핀 사이에 끼어 멈출 때·무대 폭이 바뀔 때 — 핀볼 물리가 깨지는 곳

한 걸음에 16px 을 넘게 가면 막대를 건너뛴다

02 의 막대 끝은 한 틱(16ms) 동안 1,032 × 0.016 = 16.51px 를 간다. 공이 막대에 닿았다고 판정되는 거리는 반지름 10 과 두께 반 6 을 더한 16px 이라, 한 틱을 한 걸음(SUBSTEPS 1)으로 계산하면 막대 끝이 공을 한 번에 넘어설 수 있다. 공을 막대 윗면 여덟 자리(축에서 길이의 0.3~1.0)에 얹고 눌러 보니 SUBSTEPS 1 에서는 끝 한 자리에서 공이 막대를 뚫고 아래로 빠졌고, 2·3·6 에서는 한 곳도 없었다. FLIPPER_SPEED 를 20 으로 올리면 한 걸음이 27.52px 가 되어 SUBSTEPS 1 에서 0.6~1.0 다섯 자리가 뚫렸고, 2 걸음(13.76px)부터 다시 0곳이었다. 데모가 쓰는 6 걸음에서는 한 걸음 이동이 2.75px 라 닿는 거리 16px 에 한참 못 미친다. 01 핀판도 같은 꼴인데, 핀에 파고든 최대 깊이가 SUBSTEPS 1·4·8 에서 3.38·0.87·0.58px 로 줄었고 핀과 공 반지름 합이 12px 이라 뚫리지는 않았다.

충돌 함수와 아홉 무대의 index.html, 같은 소스에서 만든 리액트 판은 압축 파일 하나로 받을 수 있고, 여는 비밀번호는 z3hydw63 이다.

핀 꼭대기 정중앙에 얹힌 공

좌우가 똑같은 자리에 속도 0 으로 얹힌 공은 밀어내기가 위로만 작용해 옆으로 굴러떨어질 까닭이 없다. 공 하나를 가운데 핀(240, 74) 꼭대기에 올려 두고 STUCK_KICK 을 0 으로 돌리자 300틱(4.8초)이 지나도 x 240.00 에 그대로 있었다. 90 이면 결승선 위에서 12px/s 미만으로 500ms 머문 공을 끼인 것으로 보고 512ms 에 한 번 쳐 내, 768ms 에 핀 반 칸(22px) 밖으로 나가 바닥 칸까지 내려갔다. 사람이 많은 방송 추첨에서 공 하나가 결승선 위에서 멈춰 버리면 판이 끝나지 않으니, 이 한 번의 툭 치기가 판을 끝내 주는 장치다.

화면 px 로 물리를 돌리면 같은 시드에서 당첨이 바뀐다

물리는 표시 크기와 상관없이 480×300 논리 좌표에서 돌고, fitCanvas 가 그릴 때만 표시 폭 / 480 배율을 곱한다. 480 창의 표시 폭 418.6px, 320 창의 294px, 기기 배율 3 에서 같은 시드를 끝까지 돌리면 당첨 A·1,040ms 와 공 여덟 개의 마지막 x 좌표가 소수 셋째 자리까지 같았고, 캔버스 화소만 419·294·837 로 달랐다. 반대로 물리를 화면 px 로 돌렸다면 같은 760px/s² 가 논리 좌표로는 480/294 배 세게 작용하는 셈이라, 320 창에서 같은 시드의 당첨이 C·1,376ms 로 바뀌었다.

    function fitCanvas() {
        // 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        const cssW = cv.getBoundingClientRect().width || W;
        viewScale = cssW / W;
        const k = viewScale * dpr;
        cv.width = Math.max(1, Math.round(W * k));
        cv.height = Math.max(1, Math.round(H * k));
        // 그릴 때만 논리 좌표 → 화소 배율을 곱한다
        ctx.setTransform(k, 0, 0, k, 0, 0);
    }

당첨 결과는 글로도 알린다 — 결과 낭독 영역과 움직임 줄이기 접근성

상태 줄 한 줄이 결과를 읽어 준다

아홉 무대 모두 캔버스 아래 <p data-role="status"> 상태 줄에 결과를 글로 적는다. 시연 판이 끝나면 01 은 「당첨: A」, 07 은 「순위: E, B, A, C, D, F」, 08 은 「당첨 칸 7 → H」 가 남고, 캔버스는 aria-hidden="true" 라 낭독은 이 줄이 맡는다. 시연이 도는 동안 aria-live 는 off 라 되풀이되는 시연 결과를 읽지 않고, 버튼을 처음 누르면 polite 로 바뀐다(aria-live 설명). 글자는 바뀔 때만 고치고, 05 처럼 당기는 동안 값이 계속 변하는 상태는 5px·10px/s 단위로 끊어 적는다.

움직임 줄이기에서는 끝난 판 한 장

prefers-reduced-motion: reduce 면 아홉 무대 모두 애니메이션 루프를 시작하지 않고, 같은 시드 판을 settle() 로 끝까지 계산한 마지막 장면 한 장과 결과 글자만 그린다(prefers-reduced-motion 설명). 끝이 있는 01·07·08 은 각각 1,200·1,300·900틱 안에서 결과가 나온 순간에 멈추고, 끝이 없는 02~06·09 는 80~240틱까지 계산한 장면을 보여 준다. 이 상태에서 버튼을 누르면 그 입력의 결과까지 바로 계산해 한 번 그리니, 05 는 누르면 당긴 화면, 떼면 발사 뒤 장면이 된다. 설정을 도중에 바꿔도 change 이벤트로 곧바로 따라간다.

누르고 있는 버튼도 키보드로

버튼은 아홉 무대 모두 진짜 <button type="button"> 이다. 02·05 의 버튼과 09 의 플리퍼 두 개는 누르고 있는 동안만 동작해서, 포인터를 누르고 떼는 이벤트와 함께 Space·Enter 의 keydown·keyup 을 받아 키를 누르고 있는 동안 플리퍼가 올라가 있고 막대가 당겨진다. 초점이 빠지면(blur) 뗀 것으로 보고, 초점 외곽선은 :focus-visible 2px 실선이며, 09 의 「◀ 플리퍼」·「플리퍼 ▶」 는 aria-label 로 「왼쪽 플리퍼」·「오른쪽 플리퍼」 라고 읽힌다.

추첨판과 핀볼 테이블을 사이트에 올리기 전 자주 묻는 질문

이 추첨판을 실제 이벤트 추첨에 그대로 써도 되나?

물리 계산은 같은 입력에 같은 결과를 내므로, 공정함은 시드를 어디서 받느냐로 정해진다. 데모처럼 4761 을 박아 두면 누구나 결과를 미리 돌려 볼 수 있으니, 추첨 직전에 참가자 앞에서 정한 수나 서버가 내준 값을 시드로 넘기는 편이 낫다. 칸으로 뽑는 08 이라면 이름 섞기를 켜 두어야 가운데 칸 쏠림이 특정 이름에 몰리지 않는다.

핀볼 게임 만들기를 처음 해 본다면 어느 무대부터 보면 되나?

01 처럼 멈춘 원에 부딪히는 것부터가 쉽다. hitCircle 하나와 중력, 고정 틱만 있으면 공이 핀 사이를 지나가고, 선분 충돌을 더하면 04 빗변과 07 비탈이, 접점 속도를 더하면 02 플리퍼와 06 풍차가 된다. 09 는 그 셋을 합친 판이라 마지막에 여는 것이 순서에 맞고, 같은 게임 갈래에서 격자 위 길 찾기가 궁금하다면 미로 게임 만들기 가 이어 읽기 좋다.

공이 벽을 뚫거나 화면 밖으로 사라지는 일은 어떻게 막나?

먼저 한 걸음 이동 거리를 본다. 가장 빠른 물체가 한 걸음에 가는 거리가 공 반지름과 벽 두께 반의 합보다 짧아야 하고, 02 는 한 틱을 6 걸음으로 나눠 플리퍼 끝 이동을 2.75px 로 묶어 둔다. 탭을 오래 비웠다 돌아오면 화면 갱신 간격이 크게 벌어지는데, 아홉 무대 모두 한 프레임에 따라잡는 시간을 100ms, 계산 틱을 6번까지로 잘라 공이 한꺼번에 멀리 튀지 않게 한다.

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

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