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

동전 던지기 9종 — 3D 앞뒤 전환부터 5:5 로 모이는 집계까지

동전 던지기는 앞뒤를 어떻게 정할까 — 03번은 체공 시간에 회전 속도를 곱해 반 바퀴 수의 홀짝으로 면을 읽고, 05번은 횟수가 쌓일수록 5:5 로 좁아지는 띠를 그리며, 08번은 쌓인 동전 위를 튕겨 내려간다.

동전 던지기는 앞뒤를 고르는 일이 아니라 각도를 읽는 일이다 — 동전 던지기 시뮬레이션은 회전각 하나로 모양과 면을 함께 정하는 데서 시작한다

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

아홉 장면은 같은 규칙을 따른다. 동전의 납작한 정도는 각 θ 의 cos 값이 정하고, 보이는 면은 그 cos 값의 부호가 정하며, 던지기의 결과는 반 바퀴 수의 홀짝에서만 나온다. 난수는 선형 합동 생성기 seeded() 하나뿐이라 같은 시드는 언제나 같은 결과를 만들고, 소스에는 Math.random·Date·fetch 호출이 0건이다. 그래서 이 편의 동전 던지기 시뮬레이션은 계산이 보이는 데모일 뿐 실제 동전이 공정한지 가리는 도구가 아니다.

순서는 동전 한 개에서 시작해 집계로 넓어진다. 01·02 는 모양과 옆면 빛을, 03·04 는 던지기와 가장자리 착지를, 05·06·07 은 던진 결과가 쌓여 만드는 비율·치우침·연속을, 08·09 는 쌓인 더미 위 튕김과 두 선택지 결정을 다룬다.

동전 던지기 9종 — 납작해졌다 뒤집히는 3D 앞뒤 전환·빛으로 칠하는 금속 가장자리·반 바퀴 수로 면을 읽는 포물선 던지기·좁은 틈에 서는 가장자리 착지·5:5 로 모이는 집계 띠·무게중심이 쏠린 동전·연속 면이 자라는 띠·쌓인 동전 위 튕김·두 선택지 결정 히어로

아홉 장면의 면적과 움직임

장면 누적면적 움직인 프레임 강도 평균
01 가로축 회전 24.5% 23/23 63.7
02 옆면 빛 13.8% 23/23 65.5
03 던지기 17.1% 23/23 48.5
04 가장자리 18.1% 23/23 85.6
05 수렴 띠 19.1% 23/23 78.3
06 쏠린 동전 11.4% 23/23 93.9
07 연속 띠 18.3% 23/23 59.7
08 튕김 15.8% 23/23 69.1
09 결정 히어로 20.3% 23/23 64.0

동전이 커서 화면을 크게 쓰는 01 이 24.5% 로 가장 넓고, 두 동전의 비율 띠만 자라는 06 이 11.4% 로 가장 좁은데 강도 평균은 93.9 로 가장 높다. 아홉 장면 모두 23/23 이라 렌더한 프레임 사이에서 멈춘 구간이 없다.

01가로축을 돌며 납작해졌다 뒤집히는 3D 앞뒤 전환

동전은 지름이 그대로인 원판이라 ANGLE() 이 돌린 각 θ 에서 세로 지름만 COS_SQUASH 배로 줄고, 각이 90도를 지나 cos θ 가 음수가 되면 앞면 무늬와 뒷면 무늬가 서로 바뀐다. 오른쪽의 작은 동전은 같은 납작함에 늘 앞면 무늬만 그려 면이 바뀌는 동전과 대비된다. 한 바퀴는 OMEGA 4.2 라디안/초로 1.50초이고 첫 면 교체는 0.30초쯤에 일어난다.

가로축을 돌며 납작해졌다 뒤집히는 3D 앞뒤 전환
새 창에서 열기

01 · 가로축을 돌며 납작해졌다 뒤집히는 3D 앞뒤 전환 — 측정 누적면적 24.478% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

OMEGAANGLECOS_SQUASH
    function ANGLE() { return still ? 0 : angle0 + omegaNow * sceneT; }
    function faceName(n) { return n === 0 ? '앞면' : '뒷면'; }
    function draw() {
        skyFill(BG_TOP, BG_BOT);
        tableBack(230);
        const th = ANGLE();
        const COS_SQUASH = Math.cos(th);
        const cx = 168;
        const cy = 154;
        // 동전 둘레 후광 — 세로로 얼마나 펴졌는지(|cos|)에 비례해 퍼진다
        disc(cx, cy, COIN_R * (1.15 + 0.35 * Math.abs(COS_SQUASH)), COS_SQUASH >= 0 ? GOLD : SKY, 0.34 * Math.abs(COS_SQUASH));
        oval(cx, 240, COIN_R * (0.8 + 0.3 * Math.abs(COS_SQUASH)), COIN_R * 0.26, INK, 0.2);
        drawCoin(cx, cy, COIN_R, th, THICK_PX, 0.36, -0.46, 0.81, 1, 0);

02옆면 두께와 빛 방향으로 칠하는 금속 가장자리

동전을 비스듬히 세우면 두 원판 사이로 옆면 띠가 보이고, 옆면 둘레를 RIM_N 40 조각으로 나눠 조각마다 법선과 빛 방향의 내적 dotLight 로 밝기를 정한다. 돌수록 밝은 조각이 한쪽으로 쏠려 지나가고 아래 40칸 눈금이 같은 함수의 값을 그대로 칠한다. 오른쪽 동전은 두께가 0 이라 옆면 조각이 없어 종이처럼 얇다.

옆면 두께와 빛 방향으로 칠하는 금속 가장자리
새 창에서 열기

02 · 옆면 두께와 빛 방향으로 칠하는 금속 가장자리 — 측정 누적면적 13.775% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

THICK_RIMRIM_NdotLight
    function lightLen() { return Math.hypot(LIGHT[0], LIGHT[1], LIGHT[2]); }
    // 조각 하나의 밝기 — 조각 법선과 빛 방향의 내적을 0 에서 1 로
    function dotLight(pm, th) {
        const n = lightLen();
        const d = (Math.cos(pm) * LIGHT[0] + Math.sin(pm) * Math.cos(th) * LIGHT[1] + Math.sin(pm) * Math.sin(th) * LIGHT[2]) / n;
        return clamp(0.5 + 0.5 * d, 0, 1);
    }

03체공 시간에 회전 속도를 곱해 반 바퀴 수로 면을 읽는 포물선 던지기

손에서 출발한 동전은 중력만 받는 포물선을 그리고, AIR_T 0.9초에 초당 반 바퀴 수 hr(= 2 * spinOf(k))를 곱한 값이 halfTurns 다. 착지 순간 Math.round 로 반올림해 짝수면 던지기 전 면, 홀수면 반대 면이 위로 온다. 첫 던지기는 반 바퀴 12.6 이 13 홀수라 뒷면, 둘째는 13.68 이 14 짝수라 앞면, 셋째는 14.76 이 15 홀수라 뒷면이다.

체공 시간에 회전 속도를 곱해 반 바퀴 수로 면을 읽는 포물선 던지기
새 창에서 열기

03 · 체공 시간에 회전 속도를 곱해 반 바퀴 수로 면을 읽는 포물선 던지기 — 측정 누적면적 17.137% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

AIR_TSPIN_RATEhalfTurns
        // 반 바퀴 수 판독 — 던지는 동안 숫자가 오르고, 착지하면 반올림해 면을 말한다
        const e = tosses[k];
        const live = tossHalf(e, sceneT);
        const landed = sceneT >= e.t0 + e.T;
        box(286, 66, 184, 74, 12, PAPER, 0.9);
        text('반 바퀴 수', 296, 82, INK, 1, 11, 700, 'left');
        text(live.toFixed(1), 462, 84, INK, 1, 22, 800, 'right');
        if (landed) {
            const r = Math.round(landHalf(e));
            const verdict = '반 바퀴 {r} · {p} → {f}'.replace('{r}', String(r)).replace('{p}', r % 2 === 0 ? '짝수' : '홀수').replace('{f}', faceName(upFace(r)));
            tag(verdict, 462, 124, upFace(r) === 0 ? GOLD : SKY, INK, 12, 'right');
        }
        else {
            text('초당 회전수 {s}'.replace('{s}', spinOf(k).toFixed(1)), 296, 124, INK, 1, 11, 700, 'left');
        }

04두께가 만드는 아주 좁은 틈에서 가장자리로 서는 착지

동전이 서려면 착지 각이 EDGE_WIN(thick) 으로 정해진 좁은 창 안에 들어와야 하고, 이 창은 두께와 지름의 비의 atan 이다. N_THROWS 100번을 같은 시드 수열로 던지면 두께 8 인 동전은 8번, 두께 16 이면 14번 서고 두께 0 은 한 번도 서지 않는다. 두께 8 일 때 창 전체 폭은 반원의 7.9% 라서 서는 일이 드물다.

두께가 만드는 아주 좁은 틈에서 가장자리로 서는 착지
새 창에서 열기

04 · 두께가 만드는 아주 좁은 틈에서 가장자리로 서는 착지 — 측정 누적면적 18.149% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

EDGE_WINtiltAtLandN_THROWS
    function EDGE_WIN(thick) { return Math.atan(thick / DIAM_E); }
    // 던질 때마다의 착지 기울기(라디안, 0 에서 π) — 같은 약속 수열
    function tiltAtLand(seed, n) {
        const rng = seeded(seed);
        const out = [];
        for (let i = 0; i < n; i += 1) {
            out.push(rng() * PI);
        }
        return out;
    }
    // 0 앞면 · 1 뒷면 · 2 가장자리
    function landKind(ang, thick) {
        if (thick > 0 && Math.abs(ang - PI / 2) < EDGE_WIN(thick)) {
            return 2;
        }
        return ang < PI / 2 ? 0 : 1;
    }

05던진 횟수가 쌓일수록 앞면 비율이 5:5 로 모이는 좁아지는 띠

고정 수열로 던진 결과를 한 번에 하나씩 쌓아 ratioLine 으로 앞면 비율을 꺾은선으로 잇고, 그 둘레에 bandHalf(n) = 1/(2√n) 폭의 띠를 겹친다. 비율은 10번째 0.600, 25번째 0.560, 50번째 0.520, 100번째 0.560 이고 띠 밖으로 나간 점은 outsideCount 로 세면 100번까지 7개다. 가로축이 ln n 이라 처음 몇 번이 넓게 펼쳐진다.

던진 횟수가 쌓일수록 앞면 비율이 5:5 로 모이는 좁아지는 띠
새 창에서 열기

05 · 던진 횟수가 쌓일수록 앞면 비율이 5:5 로 모이는 좁아지는 띠 — 측정 누적면적 19.085% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TOSS_NbandHalfratioLine
    function bandHalf(n) { return 1 / (2 * Math.sqrt(n)); }
    // n 번째 던지기까지의 앞면 비율(n = 1 에서 시작)
    function ratioLine(seq) {
        const out = [];
        let heads = 0;
        for (let i = 0; i < seq.length; i += 1) {
            if (seq[i] === 0) {
                heads += 1;
            }
            out.push(heads / (i + 1));
        }
        return out;
    }
    // 띠 밖으로 나간 던지기 번호 수 — n 번째 비율이 0.5 에서 bandHalf(n) 보다 멀면 센다
    function outsideCount(seq) {
        const r = ratioLine(seq);
        let c = 0;
        for (let i = 0; i < r.length; i += 1) {
            if (Math.abs(r[i] - 0.5) > bandHalf(i + 1) + 1e-9) {
                c += 1;
            }
        }
        return c;
    }

06무게중심을 옮긴 동전이 앞면 쪽으로 쏠리는 비율

동전 한쪽에 무게를 몰아 두면 무거운 면이 아래로 내려앉는 각의 몫이 커지고, 소스의 headsP(bias) 는 5:5 에 치우침의 절반을 더한다. 같은 수 u 로 보통 동전(u < 0.5)과 치우친 동전(u < 0.55)을 가르는 pairSeq 라서 두 동전은 같은 던지기에서 갈린다. 100번 던지면 보통 동전은 앞면 51번, BIAS_OFFSET 0.10 인 치우친 동전은 58번이다.

무게중심을 옮긴 동전이 앞면 쪽으로 쏠리는 비율
새 창에서 열기

06 · 무게중심을 옮긴 동전이 앞면 쪽으로 쏠리는 비율 — 측정 누적면적 11.394% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BIAS_OFFSETheadsPpairSeq
    // 치우친 동전의 앞면이 위로 올 확률 — 무거운 뒷면이 아래로 내려앉는 각의 몫이라 5:5 에 쏠림(지름 대비)의 절반을 더한다
    function headsP(bias) { return 0.5 + bias * 0.5; }
    function pairSeq(seed, n, bias) {
        const rng = seeded(seed);
        const fair = [];
        const heavy = [];
        const p = headsP(bias);
        for (let i = 0; i < n; i += 1) {
            const u = rng();
            fair.push(u < 0.5 ? 0 : 1);
            heavy.push(u < p ? 0 : 1);
        }
        return [fair, heavy];
    }

07같은 면이 이어질 때 길이가 자라는 연속 띠

던진 결과를 칸 하나씩 가로 띠에 칠하고 cellRun 이 칸마다 끝나는 연속 길이를 세어 가장 긴 연속에 굵은 테두리를 두른다. 이 200번 수열에서 가장 긴 연속은 8칸이고, 아래 띠의 handMade 수열은 같은 면을 30% 확률로 잇다가 세 칸이 이어지면 면을 바꿔 3칸을 넘지 않는다. 앞면 수는 던진 수열이 95, 지어낸 수열이 92 라 개수만으로는 구별되지 않는다.

같은 면이 이어질 때 길이가 자라는 연속 띠
새 창에서 열기

07 · 같은 면이 이어질 때 길이가 자라는 연속 띠 — 측정 누적면적 18.313% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STRIP_NcellRunhandMade
    // 칸마다 그 칸에서 끝나는 연속 길이
    function cellRun(seq) {
        const out = [];
        for (let i = 0; i < seq.length; i += 1) {
            out.push(i > 0 && seq[i] === seq[i - 1] ? out[i - 1] + 1 : 1);
        }
        return out;
    }
    // 가장 긴 연속의 끝 칸 번호(앞쪽 우선)
    function runEnd(seq, upto) {
        const r = cellRun(seq);
        let best = -1;
        let bl = 0;
        for (let i = 0; i < upto; i += 1) {
            if (r[i] > bl) {
                bl = r[i];
                best = i;
            }
        }
        return best;
    }

08쌓인 동전 위로 떨어져 튕기는 연쇄

동전이 쌓인 더미 위로 떨어지면 hopPeaks 가 떨어진 거리에서 첫 착지 속도를 REST_E 0.45 배로 줄여 튀는 높이를 하나씩 내고, 속도가 HOP_MIN 30 밑이면 눕는다. 더미가 3장, 4장, 5장일 때 떨어진 거리는 127.7, 116.7, 105.7px 이고 첫 튐 높이는 25.8, 23.6, 21.4px 로 낮아진다. 튐은 세 번(25.8, 5.2, 1.1px)이고 3장일 때 튄 시간의 합은 0.614초다.

쌓인 동전 위로 떨어져 튕기는 연쇄
새 창에서 열기

08 · 쌓인 동전 위로 떨어져 튕기는 연쇄 — 측정 누적면적 15.761% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REST_EhopPeaksSTACK0
    // 한 번의 낙하 — 떨어진 거리 d 에서 첫 착지 속도와 튄 높이들
    function hopPeaks(d, e) {
        const out = [];
        let v = Math.sqrt(2 * GRAV * d) * e;
        while (v >= HOP_MIN) {
            out.push(v * v / (2 * GRAV));
            v *= e;
        }
        return out;
    }

09두 선택지를 걸고 던지는 탁자 결정 히어로

탁자 왼쪽에 선택지 둘(책 읽기·산책하기)이 놓이고, 동전이 착지하면 halfFor 가 만든 반 바퀴 수의 홀짝이 면을 정해 그 면의 선택지가 밝아진다. 첫 판의 네 번은 뒷면·뒷면·앞면·뒷면이고 반 바퀴 17.15·13.11·15.98·17.09 가 17·13·16·17 로 반올림돼 책 읽기 1, 산책하기 3 이 쌓인다. tally(face) 는 착지 0.25초 뒤에 횟수를 센다.

두 선택지를 걸고 던지는 탁자 결정 히어로
새 창에서 열기

09 · 두 선택지를 걸고 던지는 탁자 결정 히어로 — 측정 누적면적 20.299% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TOSS_COUNTtallyhalfFor
    function build() {
        const rng = seeded(SEED + lap * 31);
        tosses = [];
        faces = [];
        for (let k = 0; k < TOSS_COUNT; k += 1) {
            const f = rng() < 0.5 ? 0 : 1;
            faces.push(f);
            const h = halfFor(f, rng, 6);
            tosses.push({ t0: 0.2 + TOSS_GAP * k, T: AIR_T, x0: 262, y0: 214, x1: 344 + (k % 2) * 36, y1: GROUND_Y, peak: 120, hr: h / AIR_T, h0: 0 });
        }
    }

동전 던지기 코드를 짜면 동전은 어떤 값 몇 개로 움직일까?

동전의 모양과 면을 정하는 값은 네 가지다

drawCoin 의 인자는 열 개이고, 그중 회전각 th, 두께 thick, 빛 방향 lx·ly·lz, 시작 면 startFace 네 가지가 모양과 면을 정한다. 위쪽 원판은 세로 반지름이 R·|cos th| 인 타원이고, cos th 가 양수면 시작 면을, 음수면 반대 면을 그린다. 옆면은 둘레 40 조각 중 sin(pm) * s 가 양수인 조각만 칠한다.

    function drawCoin(cx, cy, R, th, thick, lx, ly, lz, a, startFace) {
        const c = Math.cos(th);
        const s = Math.sin(th);
        const sg = c >= 0 ? 1 : -1;
        const ac = Math.max(Math.abs(c), 0.012);
        const half = thick / 2;
        const near = -sg * s * half;
        const far = -near;
        const nearFace = sg > 0 ? startFace : 1 - startFace;
        if (thick > 0) {
값 뜻
th 회전각. 모양과 면이 여기서 나온다
thick 옆면 두께. 0 이면 옆면 조각이 없다
lx·ly·lz 빛 방향. 조각 밝기의 내적에 쓴다
startFace 각이 0 일 때 보이는 면. 0 이 앞면이다

첫 화면에 붙일 때

첫 화면에 넣는다면 09 의 두 선택지 카드가 결정 도우미가 된다. 카드의 글자만 바꾸면 점심 메뉴나 발표 순서 같은 두 갈래 고르기에 쓸 수 있고, 면을 정하는 계산은 halfFor 와 upFace 두 함수에 모여 있다. 이 편의 03 은 반 바퀴 수의 홀짝으로 면 둘을 가르고, 주사위 굴리기 편은 회전 행렬로 큐브의 윗면을 읽는다는 점이 다르다.

확률 수업에서 쓸 때

수업이라면 05 의 띠가 먼저 쓰인다. 던진 횟수를 100에서 400 으로 바꾸고 판 시간도 늘리면 띠가 절반 폭으로 좁아지는 모습이 보이고, 06 은 같은 수열로 두 동전을 나란히 놓아 치우침이 비율에 얼마나 드러나는지 비교하게 한다. 07 은 사람이 지어낸 수열과 던진 수열을 나란히 놓고 가장 긴 연속을 찾게 하는 과제로 쓸 수 있다.

던지는 세기를 정하고, 체공 시간을 구하고, 회전수로 착지 각을 읽고, 위로 온 면을 정하기까지 — 동전 던지기 한 번이 계산되는 순서

던지기 한 번의 계산 순서

먼저 Toss 객체에 출발점·도착점·체공 시간 T·꼭대기 높이 peak·초당 반 바퀴 수 hr 를 정한다. 위치는 tossXY 가 포물선으로, 각은 tossHalf 가 시간에 비례해 키우고, 착지 뒤에는 halfAt 이 가장 가까운 정수 반 바퀴로 감쇠 진동하며 눕힌다. 마지막으로 upFace 가 반올림한 반 바퀴 수의 홀짝으로 위로 온 면을 정한다.

    function tossXY(k, t) {
        const u = clamp((t - k.t0) / k.T, 0, 1);
        return [lerp(k.x0, k.x1, u), lerp(k.y0, k.y1, u) - 4 * k.peak * u * (1 - u)];
    }
    // 착지한 뒤 — 가장 가까운 평평한 각(반 바퀴 수 반올림)으로 감쇠 진동하며 눕는다
    function halfAt(k, t) {
        const q = t - (k.t0 + k.T);
        if (q <= 0) {
            return tossHalf(k, t);
        }
        const hl = landHalf(k);
        const hf = Math.round(hl);
        return hf + (hl - hf) * Math.exp(-9 * q) * Math.cos(26 * q);
    }
    function hopLift(k, t) {
        const q = t - (k.t0 + k.T);
        return q <= 0 ? 0 : 12 * Math.exp(-7 * q) * Math.abs(Math.sin(15 * q));
    }
    // 위로 온 면 — 반 바퀴 수를 반올림해 짝수면 던지기 전 면(0), 홀수면 반대 면(1)
    function upFace(h) { return Math.abs(Math.round(h)) % 2; }

판이 되풀이될 때 시드가 바뀐다

한 판은 LAP_S 가 지나면 lap 이 1 오르고 build() 가 새 던지기와 수열을 만드는데, 04·05·06·07 은 시드가 SEED + lap 으로 바뀐다. 시연이 같은 결과만 되풀이하지 않는 것은 이 시드 덕분이고, 사람이 Enter·Space·클릭으로 던지면 restart() 가 lap 을 올려 새 판을 연다. 첫 판은 START_S 만큼 앞서 시작해 04·06·07 이 열자마자 이미 쌓인 모습으로 보인다.

동전 던지기가 깨지는 순간 — 면이 바뀌어도 무늬가 그대로인 동전·착지 각이 반 바퀴 경계에서 흔들리는 판정·두께가 0 이라 가장자리에 서지 못하는 동전

면이 바뀌어도 무늬가 그대로인 동전

01 의 오른쪽 동전은 scale(1, |cos θ|) 만 걸고 faceArt(0, sr, 1) 로 늘 앞면 무늬만 그려, 가장 좁아지는 순간에도 뒷면이 나오지 않아 납작한 스티커처럼 보인다. 면 교체를 cos θ 의 부호에 거는 한 줄이 빠지면 동전이 아니라 접히는 그림이 된다.

        // 오른쪽 대비판 — 면을 바꾸지 않고 늘 앞면 무늬만 그린다. 가장 좁을 때도 무늬가 그대로라 어색하다
        const sx = 372;
        const sy = 160;
        const sr = 42;
        coinShadow(sx, 240, sr, 0, 1);
        ctx.save();
        ctx.translate(sx, sy);
        ctx.scale(1, Math.max(Math.abs(COS_SQUASH), 0.012));
        faceArt(0, sr, 1);
        ctx.restore();

착지 각이 반 바퀴 경계에서 흔들리는 판정

반올림 경계는 반 바퀴 수의 소수 부분이 0.5 인 곳이다. 03 의 첫 던지기 12.6 은 경계 12.5 에서 0.1 떨어져 있어서 SPIN_RATE 가 7 에서 8 로만 올라도 14.4 가 되어 면이 바뀐다. 09 는 halfFor 가 정수 반 바퀴에 ±0.2 만 흔들어 경계에서 0.3 이상 떨어지게 만들어 판정이 흔들리지 않는다.

두께가 0 이라 가장자리에 서지 못하는 동전

04 의 landKind 는 thick > 0 일 때만 가장자리를 판정하고, EDGE_WIN(0) 도 0 이라 창이 없다. 오른쪽 격자의 두께 0 동전이 100번 던져도 서지 못하는 것이 이 때문이고, 두께를 8 로 두면 같은 수열에서 8번 선다. 서는 횟수를 늘리려면 지름을 줄이거나 두께를 키워 창을 넓혀야 한다.

동전 아홉 장면의 완성 소스와 React 판 파일은 아래 zip 에 들어 있고, 압축을 풀 때 쓰는 비밀번호는 jfztqwgh 이다.

던진 횟수와 무게 치우침과 회전 속도를 바꾸면 결과 비율은 얼마나 달라질까 — 횟수·치우침·속도를 바꿔 본 차이

이름 바꾼 값 느낌
던지기를 늘려 05 TOSS_N 100 → 400 띠 반폭 1/(2√n) 이 0.050 에서 0.025 로 절반이 되고, 시드 5055 수열의 앞면 비율은 100번째 0.560(5:5 에서 0.060 벗어나 띠 밖)에서 400번째 0.4925(0.0075 차이로 띠 안)로 가라앉는다. 띠 밖 점의 수는 누적이라 7개에서 25개로 늘어난다. 다만 TOSS_GAP 0.07 이라 400번째는 28초쯤에 그려지므로 화면에서 보려면 LAP_S·RESULT_S·REST_S 도 함께 늘려야 하고, 그대로면 한 판에 136번째까지만 나온다
무게를 더 치우쳐 06 BIAS_OFFSET 0.10 → 0.25 headsP 가 0.55 에서 0.625 로 올라 시드 6056 수열 100번의 앞면이 58번에서 66번이 되고, 보통 동전은 51번 그대로다
더 빠르게 돌려 03 SPIN_RATE 7 → 8 체공 0.9초 동안 첫 던지기의 반 바퀴 수가 12.6 에서 14.4 가 되어 반올림이 13(홀수)에서 14(짝수)로 바뀌고, 위로 오는 면이 뒷면에서 앞면으로 뒤집힌다

값을 바꾸기 전에 볼 것

세 값은 서로 다른 장면의 상수라 한 장면에서 하나씩만 바꾸면 비교가 깨끗하다. 05 와 06 은 시드가 고정이라 같은 값이면 같은 숫자가 나오고, 03 의 SPIN_RATE 는 던질 때마다 SPIN_STEP 0.6 씩 오르므로 첫 던지기만 비교 기준으로 삼는다.

움직임 줄이기에서는 던진 결과 한 장 — 앞뒤 결과를 문장으로 읽어 주고 키보드로 다시 던지는 접근성

Enter·Space 로 던지고 P·N·R 로 다룬다

아홉 장면 모두 무대 상자가 role="application" 이고 aria-label 에 조작이 적혀 있다. Enter·Space·클릭은 새 시드로 다시 던지기, P 는 일시정지, N 은 한 걸음이지만 아홉 항목 모두 시간표 script 에 항목이 없어 stepOnce 가 늘 restart 로 가고, 멈춘 채 다음 던지기의 첫 장면만 연다. R 은 처음부터 다시다. 캔버스는 role="img" 이고 aria-label 이 장면을 문장으로 설명한다.

    pad.addEventListener('keydown', function (e) {
        if (e.altKey || e.ctrlKey || e.metaKey) {
            return;
        }
        const k = e.key;
        if (k === 'Enter' || k === ' ') {
            e.preventDefault();
            wake();
            restart();
        }
        else if (k === 'p' || k === 'P') {
            togglePause();
        }
        else if (k === 'n' || k === 'N') {
            stepOnce();
        }
        else if (k === 'r' || k === 'R') {
            wake();
            restart();
        }
    }, { signal: off.signal });

상태 줄은 시연 중에는 조용하다

상태 줄은 시연 중 aria-live="off" 라 동전이 도는 동안 문장이 쏟아지지 않고, 사람이 처음 손대는 순간 wake() 가 polite 로 바꾼다. 한 판의 결과 문장은 role="status" 의 data-role="done" 문단에 장면마다 정한 RESULT_S 초에 한 번만 쓰고, restart() 가 doneSaid 를 풀어 새 판의 결과를 다시 알린다.

    // 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
    let doneSaid = false;
    function checkDone() {
        if (!doneSaid && sceneT >= RESULT_S) {
            doneSaid = true;
            doneP.textContent = statusText();
        }
    }

움직임 줄이기와 숨은 탭

prefers-reduced-motion 이 켜져 있으면 루프 없이 showRest 가 REST_S 초 지점까지 시간표를 돌려 결과 한 장을 그린다. 01 과 02 는 회전 없이 각도를 고정하고(01 은 앞면 정면, 02 는 θ = 1.15), 03·09 는 첫 던지기가 착지한 장면, 04·05·06·07·08 은 던진 결과가 쌓인 장면에서 멈춘다. REST_S 는 03·09 가 1.9초, 08 이 3.6초, 07 이 5.6초, 04 가 6.0초, 05 가 8.6초, 06 이 11.0초다. 탭이 숨으면 루프를 멈추고 돌아오면 첫 프레임의 시간 차를 버린다.

    function showRest() {
        stopLoop();
        sceneT = 0;
        evAt = 0;
        script = [];
        build();
        runTo(REST_S);
        doneSaid = false;
        showNow();
    }

글자는 진한 잉크 (40, 36, 54) 위에서 알약 바탕과의 대비가 금빛 11.7, 하늘빛 9.6, 민트 9.1, 분홍 6.9, 종이색 14.0 대 1 이다.

동전 던지기 FAQ — 라이브러리 없이 만들기·동전 던지기 확률이 정말 5:5 인지·동전 던지기 사이트 대신 직접 만들 때 필요한 것

동전 던지기 확률이 정말 5:5 인지는 이 코드로 알 수 있나요

알 수 없다. 05 는 시드 5055 로 고정된 수열이 100번째 0.560 에서 400번째 0.4925 로 5:5 에 다가가는 모양을 보여 주는 설명용이고, 실제 동전의 공정함을 판정하는 검정은 하지 않는다.

동전 던지기에 라이브러리가 필요한가요

그렇지 않다. 캔버스 2D 만으로 그려지고 아홉 장면의 소스에 라이브러리·WebGL·CDN 이 0건이며, 난수 호출도 0건이라 같은 시드는 같은 결과를 낸다.

직접 만들어 추첨에 쓰려면 무엇을 더해야 하나요

시드를 매번 새로 받는 부분이다. 이 데모는 시연이 재현되도록 시드를 소스에 박아 두었으므로, 추첨에 쓰려면 시드를 사용자가 고칠 수 없는 값으로 정하는 코드를 더해야 하고 이 편은 그 부분을 만들지 않았다.

참고로 MDN 의 ellipse 문서와 requestAnimationFrame 문서, 기본 애니메이션 튜토리얼, 움직임 줄이기 설정은 prefers-reduced-motion 문서가 기준이다. 회전하는 물체는 팽이 편을, 돌아서 멈추는 판은 룰렛 편을 이어서 보면 된다.

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

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