zhuzhiliao를 고른 이유는 그 구현 밀도 때문이다. 이 저장소는 Web Audio(실제 녹음 샘플 + 무이음 루프 + 실시간 피치 제어), 고정 스텝 물리 적분, Canvas 2D 레이어 최적화, 모바일 센서 입력, 그리고 Cloudflare Durable Object 기반 실시간 카운터까지를 "빌드 도구 0개·외부 의존성 0개"로 한 파일에 눌러담은 참조 구현이다. 프론트엔드에서 배울 만한 어려운 주제 대부분이 하나의 재미있는 예제 안에 모여 있다.
imsai-sh/zhuzhiliao · 언어 순수 바닐라 JS(GitHub 표기는 HTML) · 외부 의존성 0개 · 본체 index.html 1,521줄(93KB, HTML+CSS+JS+오디오 전부 내장) · 선택적 3D는 Three.js 벤더링 · 백엔드 Cloudflare Worker + SQLite Durable Object · 라이선스 파일 없음(코드는 공개) · 최초 공개 2026-08-01 · ★ 약 978(공개 3일차) · 제작 imsai-sh(sai) · 데모 zhuzhiliao.imsai.cc · TrendShift Daily #2)
zhuzhiliao는 "돌리면 우는 전통 대나무 완구(竹知了)를, 실제 발음 원리(회전 → 마찰 → 진동 → 공명)를 물리로 흉내 내어 브라우저에서 재현한, 외부 의존성 0개의 단일 파일 웹앱"이다. 사용자는 화면을 손가락으로 누르고 원을 그리며(혹은 스페이스바로 자동 회전, 모바일에서는 폰을 흔들어) 완구를 돌린다. 회전이 빠르고 실이 팽팽할수록 소리가 커지고 음이 높아진다. 손을 놓으면 관성으로 여운을 남기며 잦아든다.
가장 쉬운 구현은 뮤직박스다 — 화면을 누르면 미리 녹음된 "와~와~" 파일을 재생하고 끝. 하지만 그러면 빠르게 돌리든 천천히 돌리든 소리가 똑같아, 손맛이 죽는다.
zhuzhiliao는 대신 디지털 트윈(digital twin)처럼 움직인다. 화면 안에 "끈에 매달린 대나무통"이라는 가상의 물리 세계를 두고, 사용자의 손짓을 그 세계의 힘으로 변환한다. 통이 얼마나 빠르게 도는지(각속도)를 매 순간 계산하고, 그 물리량 하나로 소리의 크기·높이·파티클·3D 발광을 전부 구동한다. 그래서 빠르게 돌리면 급하게, 천천히 돌리면 나른하게 운다 — 실물 장난감과 똑같은 반응성이 나온다.
기술적으로 정확히 말하면, zhuzhiliao의 본체는 index.html 파일 하나다. 그 안에 HTML 구조, CSS 스타일, 자바스크립트 로직, 심지어 실제 완구를 녹음한 오디오까지 base64로 인코딩되어 전부 들어 있다. package.json도 없고, 빌드·번들·컴파일 단계도 없다. 저장소에 함께 든 CLAUDE.md는 개발 방식을 이렇게 못 박는다 — "빌드도, lint도, 테스트도 없다. 개발이란 index.html을 고치고 브라우저를 새로고침하는 것이다." 20년 뒤에도 파일을 더블클릭하면 그대로 동작하는 자기완결성을 노린 설계다.
첫째, 향수와 신기함의 결합이 강력하다. 많은 사람에게 竹知了는 어린 시절의 소리다. 그 아날로그 장난감을, 설치 없이 링크만 열면 폰에서 돌릴 수 있다는 점이 즉각적인 공유를 부른다. 게다가 소리가 "그럴듯한 녹음"이 아니라 손짓에 실시간으로 반응하니, 만져 본 사람이 "어, 이거 진짜네?" 하고 놀란다.
둘째, 겉은 장난감인데 속은 진짜 엔지니어링이다. 대충 만든 밈 사이트가 아니다. 실제 완구 녹음에서 정확히 4주기를 잘라 무이음 루프를 굽고, 회전 속도를 물리로 적분하고, iOS·안드로이드의 오디오 함정을 하나하나 방어하고, 서버리스 실시간 카운터와 PWA·SEO까지 붙였다. "이 정도 완성도를 외부 의존성 0개, 빌드 도구 0개로 단일 파일에 담았다"는 점이 개발자 커뮤니티의 감탄을 산다.
셋째, "제로 의존성 단일 파일" 철학이 시의적절하다. 요즘 웹 개발은 수백 MB의 node_modules와 복잡한 빌드 체인 없이는 시작조차 어렵다는 피로감이 크다. zhuzhiliao는 그 반대 극단을 우아하게 증명한다 — 순수 플랫폼 기능(Canvas·Web Audio·Pointer Events)만으로도 이렇게 풍부한 경험을 만들 수 있다는 살아 있는 반례다.
비슷한 "웹 장난감"은 많지만, 대부분 소리는 녹음 재생, 그림은 GIF라 뜯어볼 게 없다. zhuzhiliao는 다르다. 발음을 물리 시뮬레이션으로 합성하기 때문에, 코드를 열면 게임 물리·오디오 DSP·Canvas 성능 최적화·모바일 센서·서버리스 실시간 통신이라는 "각각 책 한 권짜리 주제"들이 실제로 돌아가는 형태로 들어 있다. 재미로 열었다가 공부하고 나가게 되는 저장소다. 학습 자료로서 가치가 트렌딩 순위를 뒷받침한다.
넷째, 깨지지 않도록 설계됐다. WebGL을 못 쓰는 기기에서는 3D 레이어가 조용히 꺼지고 2D 손그림으로 폴백하고, 오디오 디코딩이 실패하면 순수 합성음으로 폴백하며, iOS가 오디오를 멈춰 버리면 컨텍스트를 감지해 되살린다. "어떤 환경에서도 최소한 동작은 한다"는 우아한 저하(graceful degradation)가 곳곳에 박혀 있어, 트렌딩으로 몰려온 온갖 기기에서 무너지지 않았다.
zhuzhiliao의 스택을 보는 올바른 방법은 "라이브러리 목록"이 아니라 "쓰이는 브라우저 표준 API 목록"이다. npm 패키지가 없으니 package.json의 dependencies를 볼 필요가 없다 — 대신 이 앱은 플랫폼이 기본 제공하는 기능들을 폭넓게 동원한다. 크게 프런트(본체) · 선택적 3D · 서버리스 백엔드 세 덩어리로 나뉜다.
| 영역 | 기술 | 역할 |
|---|---|---|
| 렌더링 | Canvas 2D (getContext('2d')) | 밤 배경·대나무·완구·파티클을 매 프레임 그리기 |
| 소리 | Web Audio API | 녹음 샘플 재생 + 실시간 피치 제어, 실패 시 합성음 |
| 애니메이션 | requestAnimationFrame 루프 | 물리·오디오·렌더를 매 프레임 갱신(고정 스텝 적분) |
| 입력 | Pointer Events + DeviceMotion | 마우스/터치/펜 통합 + 폰 흔들기 센서 |
| 선택적 3D | Three.js (벤더링, importmap) | 2D 위에 투명 WebGL 오버레이 — 없어도 동작 |
| 백엔드 | Cloudflare Worker + Durable Object | 전 세계 접속·방문·"와" 횟수 실시간 집계 |
| 저장 | Durable Object 내장 SQLite | 카운트·순 방문자 영속화(외부 DB 없음) |
| 설치/오프라인 | Service Worker + Web Manifest | PWA 설치, 오프라인 플레이 |
| 배포 | Cloudflare Pages (+ Worker route) | 정적 파일은 Pages, /api/*는 Worker |
| 빌드 | 없음 | 번들러·트랜스파일러·테스트 러너 전부 없음 |
본체는 프레임워크가 전혀 없는 순수 자바스크립트다. index.html의 <script> 하나 안에 모든 로직이 절(節)별 주석으로 구분되어 들어 있다: 物理(물리) → 声音(소리) → 视觉特效粒子(파티클) → 绘制(그리기) → 主循环&交互(메인루프·입력) → 计数(카운트). 상태 관리 라이브러리도, DOM 프레임워크도 없다. 화면은 Canvas 한 장에 직접 그리고, 소리는 Web Audio 노드 그래프로 만든다.
3d/ 폴더의 Three.js 레이어는 동적 import()로 나중에 불러온다. 즉 페이지의 핵심 동작(물리·소리·카운트)은 3D 없이도 완결되고, 3D는 성공하면 화면을 더 예쁘게 만드는 "장식"일 뿐이다. Three.js는 npm이 아니라 파일을 저장소에 직접 넣는(벤더링) 방식으로 포함하고, importmap으로 "three"라는 이름을 그 파일에 매핑한다. 모델(model.js)은 외부 3D 파일을 전혀 쓰지 않고 코드로 대나무통·날개를 절차적으로 생성한다.
페이지 하단에는 "지금 접속 · 순 방문자 · 총 방문 · 전 세계 와 횟수"가 실시간으로 흐른다. 이걸 위해 전통적인 서버(Node·DB)를 세우는 대신, Cloudflare Worker + 단일 Durable Object를 쓴다. Durable Object 하나(Counter)가 전역 상태와 실시간 푸시를 모두 맡고, 그 안의 내장 SQLite에 값을 영속화한다. 서버 관리·DB 운영이 사실상 0인 서버리스 실시간 아키텍처의 교과서적 축소판이다.
이 저장소를 이해하는 열쇠는 CLAUDE.md에 적힌 설계 원칙이다 — "물리가 유일한 진실원(唯一事实源)". 매 프레임 물리 엔진이 "지금 통이 얼마나 빨리 돌고, 실이 얼마나 팽팽한가"를 계산해 active(0~1)라는 단 하나의 신호를 만들고, 소리·2D 파티클·3D 발광이 모두 이 active를 읽어서 반응한다. 서로가 서로를 직접 건드리지 않으니 코드가 엉키지 않는다.
발음의 근원은 회전이다. 그래서 물리 엔진은 딱 하나를 정확히 계산하려 한다: 끈 방향의 각속도. 통은 막대(stick)에 끈으로만 연결되는데, 이 끈은 "늘어나면 당기지만 줄어들 땐 밀지 않는(只拉不推)" 현실적 제약을 갖는다.
// 한 스텝의 물리 — 당기기만 하는 탄성끈 + 중력 + 공기저항
function physStep(h){
const dx = tube.x - stick.x, dy = tube.y - stick.y;
const d = Math.hypot(dx, dy) || 1e-6;
const ux = dx/d, uy = dy/d;
let ax = 0, ay = GRAV;
if(d > ROPE_LEN){ // 끈이 늘어난 경우에만 복원력
const vrad = tube.vx*ux + tube.vy*uy;
const f = -ROPE_K*(d - ROPE_LEN) - ROPE_D*vrad; // 스프링 + 감쇠
ax += f*ux; ay += f*uy;
}
ax -= AIR_DRAG*tube.vx; ay -= AIR_DRAG*tube.vy;
tube.vx += ax*h; tube.vy += ay*h; // 반정형 오일러
tube.x += tube.vx*h; tube.y += tube.vy*h;
}
여기서 가장 중요한 기법은 고정 타임스텝(fixed timestep) 적분이다. 화면 주사율은 기기마다 다르다(60Hz·120Hz·가변). 물리를 프레임 시간에 그대로 맡기면 폰마다 장난감이 다르게 돈다. 그래서 실제 흐른 시간을 잘게 쪼개 항상 1/240초짜리 스텝으로만 적분한다:
// 실제 dt를 1/240s 조각으로 쪼개 적분 → 주사율에 무관한 물리
let acc = dt, h = 1/240;
while(acc > 1e-6){ const s = Math.min(h, acc); physStep(s); acc -= s; }
적분이 끝나면 끈 방향 각도의 변화로 각속도 omega를 구하고(각도가 -π~π를 넘나드는 wrap 처리 포함), 저역통과 필터로 매끄럽게 다듬은 뒤, "빠르고 팽팽할 때만 운다"는 규칙으로 active 신호를 만든다:
omega += (dth/dt - omega) * Math.min(1, dt*9); // 각속도 지수 평활
rps = Math.abs(omega) / TAU; // 초당 회전수
taut = clamp((ropeDist/ROPE_LEN - 0.88)/0.12, 0,1);// 끈 팽팽함 0~1
drive = clamp((rps - 1.1)/2.6, 0, 1); // 1.1rps 미만은 무음
const tgt = Math.pow(drive, 1.25) * taut; // 빠름 & 팽팽 둘 다 필요
active += (tgt - active) * Math.min(1, dt*(tgt>active ? 10 : 3.2)); // 상승 빠르게/하강 느리게(여운)
마지막 줄의 비대칭 스무딩이 손맛의 비밀이다 — 소리는 빠르게 커지고 느리게 잦아든다. 손을 놓아도 관성으로 한동안 우는 실물의 여운이 이 한 줄에서 나온다.
소리의 주 전략은 놀랍게도 진짜 완구 녹음이다. 실물을 녹음해 정확히 "와" 4주기(약 1.72초)를 잘라 base64 AAC로 index.html에 통째로 박아 넣었다. 문제는 이걸 무한 반복하면 이음매에서 "뚝뚝" 소리가 난다는 것. 해결책은 등파워 크로스페이드(equal-power crossfade) — 꼬리 50ms를 머리에 사인/코사인 곡선으로 겹쳐 이어 붙여, 티 안 나는 무한 루프를 만든다:
// 꼬리 50ms를 머리에 등파워로 겹쳐 무이음 루프 제작
const F = Math.floor(sr*0.05); // 50ms 프레임 수
const loopLen = buf.length - F;
for(let i = 0; i < F; i++){
const p = Math.PI/2 * i/F;
d[i] = s[i]*Math.sin(p) + s[loopLen + i]*Math.cos(p); // sin²+cos²=1 → 음량 일정
}
그리고 이 루프를 물리에 연결한다. 녹음 원본의 회전 속도(2.33rps)를 기준으로, 현재 rps에 맞춰 재생 속도와 피치를 실시간으로 조절한다. 빠르게 돌리면 급하고 높게, 회전 위상에 따라 미세하게 흔들려 도플러 같은 생동감이 난다:
au.master.gain.setTargetAtTime(0.85*Math.pow(active,1.3), t, 0.07); // 음량
const rate = clamp(Math.pow(rps/RECORDED_RPS, 0.7), 0.6, 1.5); // 재생속도
au.sample.playbackRate.setTargetAtTime(rate, t, 0.09);
au.sample.detune.setTargetAtTime(50*Math.sin(theta+0.9)*..., t, 0.04);// 위상별 피치 흔들림
setTargetAtTime은 파라미터를 지수적으로 부드럽게 바꿔 "지직" 소리를 막는 Web Audio의 정석이다. 만약 오디오 디코딩이 실패하면? 그때는 합성 폴백이 켜진다 — 톱니파 오실레이터(마찰의 거친 배음) + WaveShaper 소프트 클리핑 + 저주파 AM(매미의 알갱이감) + 3개의 병렬 대역통과 공진(대나무통의 공명 포먼트) + 회전 위상에 따라 스윕하는 "wah" 필터. 실물의 발음 원리를 신디사이저로 재현한 것이라, 오디오 DSP 학습용으로도 훌륭하다.
샘플 재생 = "진짜 가수의 목소리를 속도만 바꿔 트는 것", 합성 폴백 = "신디사이저로 그 목소리를 흉내 내는 것". zhuzhiliao는 기본적으로 진짜 녹음을 쓰되, 그게 안 되는 환경에선 원리를 이해한 신디사이저로 대체한다. 두 경로 모두 물리의 active·rps 신호를 입력으로 받으므로, 어느 쪽이 켜지든 사용자는 같은 반응성을 느낀다.
매 프레임 밤하늘·달·대나무 그림자·바위·질감을 전부 다시 그리면 느리다. zhuzhiliao는 변하지 않는 정적 배경을 오프스크린 캔버스에 한 번만 그려 두고(프리컴포지트), 매 프레임엔 그것을 통째로 복사한 뒤 그 위에 움직이는 것(완구·파티클)만 새로 그린다. 별·반딧불의 위치는 Math.random() 대신 씨앗 기반 결정론적 난수 mulberry32(seed)로 배치해, 새로고침해도 같은 밤하늘이 재현되고 리사이즈 때만 다시 계산한다. DPR(고해상도 디스플레이 배율)은 최대 2배로 제한해 과도한 픽셀 연산을 막는다.
// 메인 루프 — 리사이즈 자가 감지 + dt 상한으로 폭주 방지
function frame(now){
if(innerWidth!==W || innerHeight!==H) resize(); // resize 이벤트 안 오는 WebView 대비
const dt = Math.min(0.05, (now-last)/1000) || 0.016; // 탭 복귀 시 dt 폭주 차단
last = now;
update(dt); // 물리 + 오디오 + 파티클
draw(now/1000, dt); // 렌더
requestAnimationFrame(frame);
}
3D 레이어의 진입점 init(canvas)는 성공 시 {resize, render, clear, dispose} 네 메서드를 반환하고, WebGL을 못 쓰거나 file://로 직접 열면 null을 반환한다. 메인은 이 null을 보고 조용히 2D 손그림으로 되돌아간다. CLAUDE.md는 이 규칙을 강조한다 — "이 폴백 사슬은 절대 끊어지면 안 된다(这个回落链不能破)."
export function init(canvas){
let renderer;
try { renderer = new THREE.WebGLRenderer({ canvas, antialias:true, alpha:true }); }
catch(_) { return null; } // WebGL 불가 → null → 메인이 2D로 폴백
...
return { resize(){...}, render(st){...}, clear(){...}, dispose(){...} };
}
오디오에도 같은 정신이 있다. iOS는 앱이 백그라운드에 갔다 오면 AudioContext가 "state는 running인데 실제로는 멈춘" 좀비 상태가 되곤 한다. zhuzhiliao는 currentTime이 실제로 흐르는지를 벽시계와 비교해 감지하고(performance.now() - acAdvanceWall > 1500), 좀비면 컨텍스트를 닫고 다시 만든다 — 단, 무한 재건 루프를 막으려 60초에 3회로 제한한다. 또 브라우저 정책상 첫 사용자 제스처가 있어야 소리가 나므로, touchend·click·keydown을 캡처 리스너로 잡아 그 순간 오디오를 초기화한다(안드로이드 Chrome은 pointerup이 아니라 touchend가 활성화 신호를 준다는 디테일까지 반영).
카운트 계통은 본체와 완전히 분리된 별도 세계다. worker/의 Cloudflare Worker가 단 하나의 Durable Object Counter로 전 세계 카운트를 모은다. 핵심은 두 가지 비용 절감 기법이다. 첫째, WebSocket Hibernation — 유휴 연결은 잠재우고, 심박용 ping/pong은 런타임이 자동 응답해 객체를 깨우지 않는다. 둘째, 쓰기·브로드캐스트 배치 병합 — 2초 내 다중 증가는 디스크에 1번만 쓰고, 350ms 내 변경은 한 프레임으로 합쳐 전원에게 푸시한다:
// Durable Object — ping/pong 자동 응답으로 휴면 유지
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping','pong'));
persist(key){ // 2초 내 다중 증가를 1회 기록으로 병합
this.dirty.add(key);
if(this.persistTimer) return;
this.persistTimer = setTimeout(()=>{ this.persistTimer=null; this.persistNow(); }, 2000);
}
scheduleBroadcast(){ // 350ms 내 변경을 1프레임으로 합쳐 전원 푸시
if(this.flushTimer) return;
this.flushTimer = setTimeout(()=>{
const frame = JSON.stringify(this.stats());
for(const ws of this.ctx.getWebSockets()){ try{ ws.send(frame); }catch(_){} }
}, 350);
}
클라이언트도 영리하다. "와" 횟수는 로컬에서 먼저 세고 1.2초마다 배치로 서버와 상보하며, 페이지를 닫을 때 미전송분은 navigator.sendBeacon으로 보충하고, 연결이 끊기면 지수 백오프로 재연결한다. 남용 방지로 메시지당 상한(30)·연결당 속도 제한(10초에 80)·동시 연결 상한(500)·IP별 분당 제한까지 서버에 박아 두었다. "작은 장난감"이라기엔 백엔드 설계가 상당히 진지하다.
이 구조가 던지는 신호는 명확하다. 첫째, 복잡도가 index.html 하나에 집중되어 있다 — 열면 앱 전체가 한 화면에서 읽힌다. 둘째, 부가 기능은 전부 "떼어낼 수 있게" 격리돼 있다. 3d/를 지워도, worker/가 죽어도 핵심 놀이는 계속된다. 셋째, 배포에 필요한 모든 것이 저장소 안에 있다 — 아이콘·매니페스트·SW·SEO 파일까지. "클론 → Cloudflare Pages 연결 → 끝"이 되도록 자기완결적으로 짜였다.
<script type="importmap">으로 "three" 같은 이름을 실제 파일 경로에 연결해 주는 브라우저 표준. Service Worker는 페이지와 네트워크 사이에 앉아 캐시·오프라인을 담당하는 백그라운드 스크립트. soft-404는 없는 페이지인데도 200 OK를 주는 잘못된 상태 — 진짜 404.html로 이를 피한다.대부분의 웹 개발자는 <audio> 태그 재생에서 멈춘다. zhuzhiliao는 그 너머를 보여 준다. 배울 점: (1) decodeAudioData로 버퍼를 다루고 AudioBufferSourceNode를 loop시키는 법, (2) 등파워 크로스페이드로 무이음 루프를 만드는 신호처리, (3) playbackRate·detune으로 실시간 피치를 제어하고 setTargetAtTime으로 파라미터를 매끄럽게 바꾸는 법, (4) Oscillator→WaveShaper→Gain(AM)→BiquadFilter로 이어지는 신디사이저 노드 그래프. "소리를 코드로 조각한다"는 감각을 실전 예제로 익힐 수 있다.
physStep과 고정 스텝 루프는 게임 물리 입문의 완벽한 축소판이다. 배울 점: 반정형 오일러 적분, "당기기만 하는 끈" 같은 비대칭 제약을 조건문 하나로 표현하는 법, 고정 타임스텝으로 주사율 독립성을 얻는 이유, 각속도 계산에서 각도 wrap(-π↔π)을 다루는 법, 그리고 dt 상한으로 탭 복귀 시 물리 폭주를 막는 실무 감각. 이 패턴들은 팽이·진자·공 튀기기 등 어떤 물리 놀이에도 그대로 옮겨진다.
배울 점: 정적 배경을 오프스크린 캔버스에 프리컴포지트해 매 프레임 재그리기를 피하는 법, DPR 클램프로 레티나 화면에서 픽셀 폭주를 막는 법, mulberry32 같은 결정론적 시드 난수로 "새로고침해도 같은 장면"을 재현하는 법, 그리고 파티클 시스템(파문·날아가는 글자·잔상)을 dt 기반으로 움직여 고주사율에서 가속되지 않게 하는 법. Canvas로 뭔가 그려 본 사람이 "부드럽게, 빠르게"로 넘어가는 다리다.
사실 이 코드에서 가장 값진 부분은 화려한 기능이 아니라 모바일 실전에서만 만나는 함정들을 하나하나 방어한 흔적이다. 배울 점: 브라우저 user activation 정책(첫 제스처 전엔 소리 불가, touchend vs pointerup 차이), iOS의 좀비 AudioContext 감지·재건, suspend/resume 절전, DeviceMotionEvent.requestPermission() 권한 흐름과 저역통과로 중력 성분을 빼는 센서 처리, resize 이벤트가 안 오는 WebView를 위한 자가 감지. 이건 튜토리얼엔 잘 안 나오지만 실서비스에선 반드시 부딪히는 지식이다.
worker/src/index.js는 "서버·DB 없이 전 세계 실시간 상태"를 만드는 법의 압축 강의다. 배울 점: Durable Object(전역 단일 상태 인스턴스)와 내장 SQLite, WebSocket Hibernation API(유휴 연결 무비용), setWebSocketAutoResponse로 심박을 자동 처리하는 법, 쓰기·브로드캐스트 배치 병합으로 무료 한도를 지키는 법, 그리고 다층 rate limiting으로 남용을 막는 법. 클라이언트 쪽 로컬 우선 누적 + sendBeacon + 지수 백오프 재연결까지 짝으로 배울 수 있다.
마지막은 코드가 아니라 태도다. 배울 점: 새 리소스는 반드시 (1)base64 내장, (2)벤더링, (3)무음으로 실패하는 동적 import 중 하나여야 한다는 제약이 어떻게 빌드리스·자기완결을 지켜 내는지. "물리가 유일한 진실원"이라는 한 줄이 어떻게 소리·그림·3D의 결합도를 낮춰 코드를 읽기 쉽게 만드는지. 거대한 프레임워크 없이도 완성도 높은 앱이 가능하다는 증명이다.
zhuzhiliao는 "한 채의 잘 지은 협소주택" 같다. 좁은 단일 파일이라는 대지 위에, 물리(구조)·소리(설비)·렌더(인테리어)·백엔드(수도전기)를 군더더기 없이 배치했다. 각 방(모듈)은 복도(물리 상태) 하나로만 연결되어 서로 간섭하지 않는다. 큰 저택(대형 프레임워크)만 집이 아니라는 걸, 이 작은 집이 설계로 증명한다.
zhuzhiliao는 요구사항이 거의 없는 게 특징이다. GPU도, 고사양 CPU도 필요 없다 — 핵심 놀이는 Canvas 2D와 Web Audio만으로 돌아가기 때문이다. 3D는 있으면 켜지고 없으면 꺼진다. 대신 특정 기능은 실행 환경(보안 컨텍스트)에 조건이 걸린다는 점을 알아 두면 좋다.
| 항목 | 요구/기본값 | 근거·비고 |
|---|---|---|
| 실행 환경 | 모던 브라우저 하나 | Canvas 2D + Web Audio 지원이면 충분 |
| GPU | 불필요 | 2D가 기본, WebGL(3D)은 선택·폴백 있음 |
| 소리 재생 | 첫 사용자 제스처 필요 | 브라우저 정책 — 터치/클릭 전엔 무음 |
| 폰 흔들기 | HTTPS(보안 컨텍스트) 필요 | DeviceMotion은 안전 컨텍스트에서만 이벤트 발생 |
| iOS 센서 | 명시적 권한 승인 | DeviceMotionEvent.requestPermission() |
| 로컬 시연 | python3 -m http.server 8123 | 같은 Wi-Fi로 폰 접속(README 안내) |
| 실시간 카운트 | Cloudflare 계정(배포 시) | Worker + Durable Object · 놀이 자체엔 불필요 |
| 오프라인 플레이 | 1회 방문(SW 캐시) | Service Worker가 정적 자원 선캐시 |
index.html을 file://로 직접 열면 핵심 놀이(누르고 돌리기·소리)는 되지만, 3D는 보안 제약으로 꺼질 수 있고 폰 흔들기(DeviceMotion)는 HTTPS가 아니면 이벤트가 오지 않아 버튼 자체가 숨는다. 그래서 모바일 센서까지 체험하려면 로컬 HTTP 서버로 띄우거나 데모 사이트(zhuzhiliao.imsai.cc)를 쓰는 게 맞다. 또 라이선스 파일이 없으므로(코드는 공개지만 정식 OSS 라이선스 미부여), 코드를 그대로 재사용·재배포할 계획이면 제작자에게 라이선스를 확인하는 게 안전하다.
코드를 실행하지 않고도 할 수 있는 첫 과제. index.html의 <script>를 열어 物理 → 声音 → 粒子 → 绘制 → 主循环 → 计数 주석 경계를 찾고, 각 절이 어떤 변수를 읽고 쓰는지 표로 정리하라. 특히 active·rps가 어디서 만들어져 어디서 소비되는지 화살표로 이으면, "물리가 유일한 진실원"이라는 설계가 눈에 들어온다.
python3 -m http.server 8123으로 띄운 뒤 폰으로 접속해 흔들어 본다. 그다음 코드에서 GRAV·ROPE_K·ROPE_LEN·RECORDED_RPS 같은 상수를 바꿔 가며 손맛이 어떻게 달라지는지 관찰하라. 발음 문턱값(1.1rps)을 낮추면 살살 돌려도 울고, active의 하강 스무딩 계수를 키우면 여운이 짧아진다. "상수 하나가 감각을 어떻게 바꾸는가"를 체감하는 과제다.
아무 짧은 소리(악기 한 음, 엔진음)를 녹음해 decodeAudioData로 불러온 뒤, 등파워 크로스페이드(sin/cos)로 꼬리를 머리에 이어 붙여 이음매 없는 루프를 직접 만들어 보라. 그냥 loop=true만 켰을 때의 "뚝" 소리와 비교하면 크로스페이드의 위력이 확 느껴진다. 나아가 playbackRate를 슬라이더에 연결해 실시간 피치 제어까지 붙여 본다.
physStep + 고정 타임스텝 패턴을 떼어 내 새로운 물리 놀이를 만들어 보라 — 예: 요요(위아래 탄성끈), 팽이(각운동량 감쇠), 그네(진자). 핵심은 "실제 dt를 1/240s 조각으로 쪼개 적분한다"는 골격을 그대로 재사용하되, 힘 계산만 바꾸는 것이다. 60Hz와 120Hz 기기에서 동작이 동일한지 확인하면 프레임 독립성을 체득하게 된다.
worker/를 참고해 Cloudflare Worker + Durable Object로 "지금 몇 명이 보고 있는지"를 실시간 표시하는 최소 위젯을 직접 배포해 보라. WebSocket Hibernation으로 유휴 연결을 재우고, setWebSocketAutoResponse로 심박을 처리하고, 브로드캐스트를 350ms 배치로 묶는 것까지 구현하면 서버리스 실시간의 핵심을 손에 넣는다. 남용 방지 rate limit을 붙이는 것으로 마무리한다.
getContext('2d')의 그리기 기본기, 좌표계·변환·DPR 처리, requestAnimationFrame으로 게임 루프 짜기, dt 기반 이동. 목표: 별이 흐르는 밤하늘 캔버스를 직접 그려 60fps로 돌리기. 심화로 오프스크린 캔버스 프리컴포지트와 mulberry32 결정론적 난수를 붙여 본다.
AudioContext 노드 그래프, decodeAudioData·AudioBufferSourceNode·GainNode, playbackRate/detune로 피치 제어, setTargetAtTime 스무딩, 그리고 Oscillator·BiquadFilter·WaveShaper로 소리를 합성하기. 목표: zhuzhiliao의 합성 폴백 체인을 최소 형태로 재현. 실습: 등파워 크로스페이드로 무이음 루프 만들기.
수치 적분(오일러/반정형), 고정 타임스텝과 프레임 독립성, 각속도·각도 wrap, 스프링-감쇠 동역학. 그리고 Pointer Events 통합(마우스/터치/펜), DeviceMotion 센서와 권한. 목표: physStep을 읽고 "왜 1/240s로 쪼개나"를 설명할 수 있게 되기. 실습: 진자/요요 물리 하나를 직접 구현.
Workers 기초, Durable Objects와 내장 SQLite, WebSocket + Hibernation API, 그리고 Service Worker 캐시 전략(navigation=network-first, static=stale-while-revalidate)과 Web Manifest. 목표: worker/를 읽고 실시간 카운터의 비용 절감 기법(배치 병합·자동 ping/pong)을 이해. 실습: 방문자 카운터 위젯 배포 + 앱 설치까지.
| 키워드 | 뜻 |
|---|---|
| 竹知了(죽지료) | 돌리면 매미 울음 같은 소리가 나는 중국 전통 대나무 완구 |
| stick-slip | 송진 묻은 실이 붙었다 미끄러지길 반복하는 마찰 — 발음의 근원(바이올린 원리) |
| 물리가 유일한 진실원 | 물리 상태만 계산하고, 소리·그림·3D는 그것을 소비만 하는 설계 원칙 |
| 고정 타임스텝 | 실제 dt를 1/240s 조각으로 쪼개 적분 → 주사율에 무관한 물리 |
| 반정형 오일러 | 속도를 먼저 갱신하고 그 속도로 위치를 갱신하는 안정적 적분법 |
| active 신호 | "빠르고 팽팽할 때만" 켜지는 0~1 값 — 소리·파티클·3D를 동시 구동 |
| 등파워 크로스페이드 | sin/cos로 겹쳐 음량을 일정하게 유지하며 이음매를 지우는 기법 |
| playbackRate / detune | 재생 속도·피치를 실시간으로 바꾸는 Web Audio 파라미터 |
| setTargetAtTime | 오디오 파라미터를 지수적으로 부드럽게 바꿔 "지직"을 막는 함수 |
| 오프스크린 프리컴포지트 | 안 변하는 배경을 미리 그려 두고 매 프레임 복사해 재그리기 회피 |
| mulberry32 | 씨앗 기반 결정론적 난수 — 새로고침해도 같은 장면 재현 |
| user activation | 첫 사용자 제스처가 있어야 소리를 낼 수 있는 브라우저 정책 |
| 좀비 AudioContext | state는 running인데 실제론 멈춘 iOS 오디오 상태 → 감지·재건 필요 |
| 우아한 저하 | WebGL/오디오가 안 되면 조용히 대체 경로로 폴백해 앱이 안 죽게 하기 |
| Durable Object | 전 세계에 하나만 존재하며 상태를 기억하는 Cloudflare의 작은 서버 객체 |
| WebSocket Hibernation | 유휴 연결을 재워 비용 0으로 만드는 Cloudflare 기능(ping/pong 자동) |
| sendBeacon | 페이지 종료 순간에도 마지막 데이터를 확실히 보내는 API |
| 벤더링 / importmap | 라이브러리를 저장소에 직접 넣고 이름을 파일에 매핑(빌드 없이 사용) |
| PWA / Service Worker | 웹을 앱처럼 설치·오프라인 실행하게 해 주는 기술과 그 백그라운드 스크립트 |
LatheGeometry/ExtrudeGeometry) · PWA·Service Worker 캐시 전략