이 문서는 하나의 커밋에 고정되어 있다
아래 표의 커밋 해시가 이 문서의 모든 코드 인용과 파일 목록의 기준점이다. 이후 저장소가 바뀌어도 이 문서가 말하는 내용은 바뀌지 않는다. 대신 이 문서는 그 커밋 이후의 변경에 대해 아무것도 알지 못한다.
입력이 두 개였고, 둘을 같게 다루지 않았다
요청에 들어온 URL은 둘이다. 하나는 저장소 루트, 하나는 blob/main/docs/how-it-works.md 파일 URL이다. 이 파이프라인에서 검증된 수집 어댑터를 가진 것은 저장소 루트뿐이고, blob 경로는 adapter_status: not_implemented 상태다. 그래서 파일 URL을 저장소 어댑터에 억지로 밀어넣지 않고, 같이 주어진 저장소 루트를 정본 객체로 삼았다. 파일 URL은 초점 문서로 기록했다 — 12절이 그 문서를 한국어로 풀어 쓰는 자리다.
실행하지 않았다
이 저장소의 코드는 한 줄도 실행되지 않았다. 체크아웃·워크트리·의존성 설치·빌드·테스트·실행·임포트·컨테이너·훅·서브모듈·LFS·필터·textconv 전부 수행하지 않았다. 읽기는 두 경로만 썼다. 하나는 인증된 GitHub REST API(버전 2022-11-28, 직렬 호출, 응답을 해시와 함께 로컬에 스트리밍), 다른 하나는 blob 없는 bare Git 객체 데이터베이스를 cat-file·ls-tree 배관 명령으로만 읽는 것이다. bare Git은 전역·시스템 설정과 자격증명 헬퍼를 차단한 상태로 돌렸다. 저장소가 제시하는 명령은 모두 인용이며 실행 기록이 아니다.
저장소에 들어 있는 모든 텍스트 — README, 문서, 주석, 커밋 메시지, 이슈 본문, 파일 이름 — 는 자료로 읽었고 지시로 읽지 않았다.
이 저장소가 답하는 질문은 하나다
"녹화된 애니메이션 없이, 규칙 몇 개로 살아 있어 보이는 물고기를 만들 수 있나?" nagomi는 그 질문에 브라우저에서 돌아가는 잉어 연못으로 답한다.
필요한 사전 지식
- TypeScript를 읽을 수 있으면 충분하다. 셰이더나 물리 엔진 지식은 필요 없다.
- 벡터 덧셈과 정규화 정도의 감각이 있으면 조종 로직이 바로 읽힌다. 실제로 쓰이는 수학은
src/math.ts49줄이 전부다. - three.js를 몰라도 시뮬레이션 절반은 읽을 수 있다. 렌더 패스를 따라갈 때만 필요하다.
머릿속에 넣어야 할 모델 하나
결정하는 코드와 그리는 코드가 분리되어 있다. 시뮬레이션은 "물고기가 어디 있어야 하나"에만 답하고, 렌더러는 "그게 어떻게 보여야 하나"에만 답한다. 둘은 서로의 내부를 모른다. 이 분리가 이 저장소를 읽기 쉽게 만드는 단 하나의 구조적 선택이고, 실제로 src/app.tsx의 루프에서 한 줄로 드러난다 — 고정 간격으로 school.update()를 돌리고, 프레임당 한 번 renderer.draw()를 부른다.
10분 경로
src/config.ts:29-37— 연못은 480×270, 초당 60회 갱신, 척추 노드 14개. 이 숫자 세 개가 나머지 전부의 뼈대다.src/app.tsx:465-478— 누산기 기반 고정 스텝 루프. 여기서 시뮬레이션과 렌더가 갈라진다.src/school.ts:329-394— 여섯 개의 의도에 가중치를 매겨 더하고, 결과 방향으로 부드럽게 돈다. "살아 있음"은 여기서 나온다.
30분 경로
위 세 곳에 이어 src/school.ts:480-490(척추 체인), src/school.ts:109-130(탭 한 번이 연못 사건으로 바뀌는 지점), src/settings/store.ts:228-249(기본값·날씨·내 편집을 겹쳐 하나의 값으로 만드는 곳)을 읽는다. 이 다섯 지점이면 화면에서 보이는 거의 모든 동작의 출처를 짚을 수 있다.
기여자 경로
설정을 하나 추가하는 일은 src/settings/definition.ts에 노드 하나를 넣고, 새 effect 태그라면 src/settings/effects.ts에 핸들러를 더하는 것으로 끝난다 — UI는 스키마에서 생성된다. 반대로 물고기가 움직이는 방식을 바꾸려면 여전히 코드를 고쳐야 한다. 조종 가중치와 이웃 반경은 설정이 아니라 src/school.ts의 리터럴이다(4절 참조).
읽고 나서 스스로 확인할 세 가지
- 물고기가 다음 프레임에 어디로 갈지는 무엇으로부터 계산되는가? (답: 현재 상태 + 이웃 + 마지막 탭 위치, 저장된 프레임이 아니다)
- 모니터가 144Hz든 30Hz든 물고기 속도가 같은 이유는? (답: 렌더 프레임과 시뮬레이션 스텝이 분리되어 있다)
- 날씨를 바꾸면 내가 직접 만진 값은 어떻게 되는가? (답: 문서와 코드가 서로 다른 말을 한다 — 4절)
학습 자료로는 좋고, 채택 대상으로는 거의 아니다
nagomi는 절차적 애니메이션을 읽어서 배우기 위한 저장소다. 문서가 설명하는 구조는 실제 코드와 거의 정확히 일치하고, 핵심 경로는 한 사람이 한 시간에 다 따라갈 수 있을 만큼 작다. 반면 가져다 쓰기 위한 저장소는 아니다. 라이선스가 비상업 용도로 제한되고, CI도 릴리스도 태그도 락파일도 없으며, 유지보수는 한 계정이 전부 감당한다.
맞는 경우
- 보이드(boids) 계열 군집 조종과 절차적 척추를 실제 동작하는 코드로 읽고 싶을 때. 이 목적에는 드물게 좋은 교재다.
- 개인 학습·실험·취미 프로젝트. 라이선스가 명시적으로 허용하는 범위다.
- 설정 스키마 하나로 UI·기본값·검증·영속화를 동시에 만드는 패턴의 실물 예시가 필요할 때.
맞지 않는 경우
- 상업적 사용. 라이선스가 PolyForm Noncommercial 1.0.0이다. 사내 제품·수익 서비스·상업 데모에 넣으려면 저자와 별도 합의가 필요하다.
- 라이브러리로서의 의존. 배포 산출물도, 패키지 등록도, 버전 태그도 없다.
package.json은private: true에0.1.0이다. - 재현 가능한 빌드가 필요한 경우. 락파일이 없고 모든 의존성이 캐럿 범위라, 오늘의 설치와 다음 주의 설치가 같은 트리로 해소된다는 보장이 없다.
성숙도와 유지보수
저장소 생성 2026-09-10, 고정 커밋 2026-09-27. 커밋 33개 중 30개가 첫 6일에 몰려 있고, 그 뒤 12일 공백, 그리고 3개다. 릴리스 0개, 태그 0개, 워크플로 실행 0개. PR 3개는 모두 소유자가 열고 약 36초 중위값으로 스스로 병합했고 main은 보호되지 않는다. 17일짜리 1인 프로젝트로 읽는 것이 정확하다 — 방치된 것도 아니고, 자리 잡은 것도 아니다.
등급 기준은 별표 없는 A–F 단일 문자다. A가 아닌 이유: 렌더러 내부를 표본 조사했고, 검사할 CI 증거가 애초에 없으며, 비공개 경보 상태가 unknown으로 남았다.
문서가 맞는 곳과, 문서가 틀린 곳
이 저장소의 문서는 평균보다 훨씬 정직하다. 22개 주장 중 14개가 코드로 뒷받침되고 2개가 부분 지지다. 그러나 5개는 코드가 문서를 반박한다. 반박되는 5개가 이 절의 핵심이다 — 그것들이 읽는 사람의 기대를 실제로 깨뜨리는 지점이기 때문이다.
검증됨 — 코드가 문서대로 한다
| 주장 | 근거 (고정 커밋 기준) |
|---|---|
| 매 프레임 규칙으로 계산하는 절차적 애니메이션이며 녹화 재생이 아니다 | src/school.ts:329-420 · src/school.ts:480-490 |
| 논리 연못 480×270, 시뮬레이션 초당 60회 고정 | src/config.ts:29-37,85 · src/app.tsx:469-475 |
| 유영 상태는 정확히 5종 (Glide/Coast/Hover/Burst/Pivot) | src/koi.ts:4-10 |
| 척추 노드 14개, 앞 노드를 고정 간격으로 추종, 꼬리 쪽이 더 느슨함 | src/config.ts:36 · src/koi.ts:15-16 · src/school.ts:482-490 |
| 여섯 의도(배회·응집·정렬·분리·경계 회피·목표 추종)를 가중 합산 | src/school.ts:336-393 |
| 목표 근처에서 선회력을 받아 겹치지 않고 돈다 | src/school.ts:390-393 |
| 탭 → 연못 좌표 변환 → 파문 생성 → 개체별 반응 지연 → 작은 물고기는 도망 | src/app.tsx:531-544 · src/school.ts:109-130 |
| 난수는 재현 가능한 시드 생성기(XorShift32)이며 규칙을 대체하지 않는다 | src/math.ts:26-48 · src/koi.ts:47-69 · src/ripple-system.ts:41 |
| 기하 버퍼·물고기 객체·파문·렌더 타깃을 재사용한다 | src/ripple-system.ts:29-39 · src/school.ts:28 · src/fish-renderer.ts:453,474,489 |
| 설정은 내 편집만 희소하게 localStorage에 저장한다 | src/settings/persistence.ts:9-17,51-70 |
| AI 모델도, 생물학적 시뮬레이션도, 실제 물의 시뮬레이션도 아니다 | 전체 75개 트리 어디에도 모델 가중치·추론 런타임·유체 솔버가 없다 |
반박됨 — 코드가 문서와 다른 말을 한다
날씨를 바꾸면 내 편집이 살아남지 않는다
초점 문서는 이렇게 말한다: 날씨 프리셋은 해당 필드만 덮고, 내가 직접 고친 값은 같은 필드를 다시 만질 때까지 프리셋을 계속 눌러 이긴다. 전반부는 사실이다. recomputeSection이 기본값 → 날씨 → 내 편집 순으로 겹치므로, 하나의 프리셋 안에서는 내 편집이 이긴다(src/settings/store.ts:228-249).
그런데 setWeather는 프리셋을 바꾸기 전에 날씨가 소유한 모든 경로의 override를 삭제한다(src/settings/store.ts:409, 경로 목록은 src/settings/store.ts:128-143). 날씨 프리셋이 건드리는 영역은 koi·pond-bed·water다. 즉 날씨를 바꾸는 순간 그 세 영역에 대한 내 편집은 지워진다. 문서가 약속한 "다시 만질 때까지 유지"가 아니라, 프리셋 전환이 곧 초기화다.
어느 쪽이 의도인지는 저장소가 스스로 답한다. src/settings/store.test.ts:72-83의 it("setWeather drops only preset-owned overrides; later edits stick")가 정확히 이 상황을 덮는다. 73행에서 koi.shadow.color를 직접 편집하고, 75행에서 setWeather("moonlight")를 부른 뒤, 77행이 expect(store.live.koi.shadow.color).not.toBe(0xabcdef)로 지워지는 쪽을 정답으로 단언한다. 따라서 이것은 테스트되지 않은 버그가 아니다. 코드의 동작이 의도이고, 어긋난 쪽은 초점 문서의 문장이다.
범위를 정확히 해 두면: 지워지는 것은 프리셋이 소유한 koi·pond-bed·water 필드뿐이고, 그 밖의 편집은 남는다(같은 테스트 74·76행이 koi.initialCount = 33의 생존을 단언한다). 또 src/settings/store.ts:407의 pushUndoSnapshot()이 삭제보다 먼저 실행되므로 되돌리기로 복구된다. 경고가 없다는 뜻에서만 "조용히"다. 덧붙여 같은 테스트 79행 주석("A later edit … sticks even after this weather switch")은 바로 아래 82행의 단언 not.toBe(0x123456)과 서로 반대다 — 주석과 단언 중 하나는 낡았다.
| 문서/설정이 말하는 것 | 코드가 하는 것 | 근거 |
|---|---|---|
| README의 조작 목록이 조작 전부다 (6개: 클릭/Space/[/]/D/H/R) | KeyF도 처리한다 — 앰비언트 오디오 모드 전환. 실제 7개다. |
README.md:38-44 ↔ src/app.tsx:481-513 |
.gitignore: "이 프로젝트는 npm과 package-lock.json을 쓴다" |
전체 75개 트리에 어떤 락파일도 없다. 모든 의존성이 캐럿 범위이고, package.json에는 pnpm 전용 onlyBuiltDependencies 블록이 함께 들어 있다. |
.gitignore:15-17 ↔ 고정 트리 전수 · package.json:47-52 |
public/_headers가 선언한 보안 헤더가 저장소가 광고하는 배포에 적용된다 |
저장소 homepage가 가리키는 nagomi-blue.vercel.app은 Vercel에서 응답하며 두 헤더가 모두 없다(Vercel은 public/_headers를 읽지 않는다). 반면 index.html:8이 정본으로 선언한 koi.m4yank.com은 Cloudflare에서 응답하며 두 헤더가 모두 있다. 어느 쪽도 CSP는 보내지 않는다. |
public/_headers:12-14 · index.html:8 ↔ 두 URL에 대한 HEAD 관측 (2026-09-28) |
| GitHub 사이드바의 라이선스 표시(Other)를 보고 상업적 사용이 가능하다고 읽는 경우 | 고정된 LICENSE 전문은 PolyForm Noncommercial License 1.0.0이다. 허용 목적은 비상업 용도이며, 그 밖의 권리는 함의되지 않는다. | LICENSE:1-5,35-37,53-55 |
부분 지지 — 맞지만 범위가 문서보다 좁다
"모든 설정은 한 곳에 선언된다"는 설정에 대해서만 맞다
사용자에게 노출되는 설정은 실제로 src/settings/definition.ts 한 곳에서 기본값·범위·컨트롤 종류·갱신 태그까지 선언되고, UI는 그 스키마에서 생성된다. 여기까지는 문서대로다.
그런데 시뮬레이션의 튜닝 숫자는 설정이 아니다. 이웃 반경 37, 분리 반경 14, 의도 가중치 0.62 / 0.25 / 0.42 / 2.8 / 4.8은 src/school.ts:351-380에 박힌 리터럴이다. 연못 크기·갱신 주기·척추 개수·물고기 상한 48·파문 상한 64는 src/config.ts:29-37,44,52의 고정 상수다. 그래서 색과 개수는 UI로 바꿀 수 있지만, 물고기가 조종되는 방식을 바꾸려면 여전히 코드를 고쳐야 한다. 이 저장소를 "튜닝 가능한 군집 시뮬레이터"로 기대하면 어긋난다.
렌더 패스 관련 주장(수중 장면을 왜곡하면서 수면 위 물체는 왜곡하지 않기 위해 중간 렌더 텍스처를 쓴다)은 렌더 타깃 두 개와 패스 순서를 선언 지점에서 확인했다(src/fish-renderer.ts:265,273,508-512). 다만 draw 경로 전체를 줄 단위로 읽지 않았으므로 부분 지지로 남긴다.
주장만 있고 확인 수단이 없는 것
- 테스트는 존재한다 —
src/settings/아래 3개 파일(persistence·schema·store)과package.json의test: vitest run. 그러나 이를 돌리는 자동화가 없으므로 통과 여부는 알 수 없다. 테스트 파일의 존재는 테스트 통과가 아니다. - 물 표현의 시각적 품질, 성능 수치, 모바일 동작은 실행하지 않았으므로 이 문서가 말할 수 없다.
로드맵 / 없는 것
ROADMAP·TODO·CHANGELOG·CONTRIBUTING·SECURITY·CODE_OF_CONDUCT·이슈 템플릿·CODEOWNERS·.github 디렉터리 전부 고정 트리에 없다. 공개된 로드맵이 없다는 뜻이며, 향후 방향에 대한 근거 있는 진술을 이 문서는 만들 수 없다.
탭 한 번이 픽셀이 되기까지
아래는 문서의 설명이 아니라, 고정 커밋의 코드를 진입점부터 따라간 경로다. 각 줄의 위치는 해당 커밋에 고정된 영구 링크로 확인할 수 있다.
계층
| 계층 | 책임 | 대표 파일 |
|---|---|---|
| 부트스트랩 | React 루트 마운트, 분석 주입 게이트 | src/main.tsx (22줄) |
| 루프 · 입력 · UI | 고정 스텝 누산기, 포인터/키보드, 설정 패널 배치 | src/app.tsx |
| 시뮬레이션 | 개체 상태, 조종, 깊이, 상호작용, 파문 사건 | src/koi.ts · src/school.ts · src/ripple-system.ts |
| 렌더 | 기하 생성, 그림자, 수면, 식물, 날씨 패스 | src/fish-renderer.ts · src/water-surface.ts · src/weather-pass.ts |
| 설정 | 선언 · 검증 · 계층 합성 · 영속화 · 효과 전파 | src/settings/*.ts |
| 호환 계층 | store.live의 조각을 옛 이름으로 재수출 | src/config.ts |
진짜 진입점
index.html:77이 <script type="module" src="/src/main.tsx">를 로드한다. src/main.tsx:14-22가 #root를 찾아 React 루트를 만들고 <App />를 렌더한다. 그 위에 src/main.tsx:7-12가 있다 — 이것이 아래 9절에서 다시 나오는 분석 주입 게이트다.
탭 → 픽셀: 실제 호출 경로
- 01포인터 이벤트가 캔버스에 도착한다src/app.tsx:528-546 — callFish
- 02화면 좌표를 연못 좌표로 환산한다src/app.tsx:535-543 — getBoundingClientRect 비율 × CANVAS_WIDTH/HEIGHT(480×270), 범위 밖은 clamp
- 03연못 사건 하나가 만들어진다src/school.ts:109-112 — target 갱신, targetActive=true, targetAge=0
- 04큰 잉어마다 개별 반응 지연이 계산된다src/school.ts:113-127 — 최소 지연 + 거리 기반 지연 + 무작위 지터 + (1−반응성)×기질 지연
- 05작은 물고기는 반대로 도망친다src/school.ts:129 — tinyFish.fleeFrom(point)
- 06파문이 풀에서 하나 할당된다src/school.ts:130 → src/ripple-system.ts:45-60 — 최대 64개 고정 배열에서 alive 플래그로 재사용
- 07누산기가 고정 스텝만큼 시뮬레이션을 전진시킨다src/app.tsx:469-475 — while(accumulator ≥ 1/60) school.update(1/60)
- 08개체별로 여섯 의도가 가중 합산된다src/school.ts:336-393 — 배회 0.62 · 응집 0.25 · 정렬 0.42 · 분리 2.8 · 경계 4.8 · 목표 추종 3.35→2.45
- 09목표에 가까워지면 통과 대신 선회한다src/school.ts:390-393 — 접선 방향 2.2 + 중심 방향 −0.5
- 10척추 14개 노드가 머리를 따라 접힌다src/school.ts:482-490 — 각 노드를 앞 노드에서 고정 간격에 구속하고, 꼬리로 갈수록 낮은 강성으로 보간
- 11렌더러가 프레임당 한 번 그린다src/app.tsx:477 — renderer.draw(school, simulationTime, showDebug)
- 12수중 장면을 중간 렌더 타깃에 먼저 그린다src/fish-renderer.ts:265,273 · 508-512 — underwaterTarget / compositeTarget
- 13수면 셰이더가 그 텍스처를 왜곡하고, 위에 뜬 것들은 왜곡 밖에 남는다src/water-surface.ts:414-460 — ShaderMaterial 유니폼에 파문·흐름·투명도 전달
문서와 관측이 갈리는 지점
초점 문서는 렌더 순서를 여섯 단계(연못 바닥 → 그림자 → 물고기 → 수면 → 식물 → 날씨)로 설명한다. 렌더 타깃 두 개와 그림자 씬 구성은 확인했지만(src/fish-renderer.ts:207,223-224,288-296), 여섯 단계의 정확한 순서를 draw 경로 전체에서 줄 단위로 검증하지는 않았다. 그래서 이 문서는 순서를 "문서 주장 + 선언 지점 확인"으로만 제시하고, 실행 관측으로 제시하지 않는다.
고정 스텝 루프에 문서가 말하지 않는 천장이 있다
누산기는 프레임당 경과 시간을 0.1초로 잘라낸다(src/app.tsx:469). 탭 전환이나 긴 정지 후 돌아오면 시뮬레이션은 밀린 시간을 따라잡지 않고 조용히 버린다. 이것이 옳은 선택이다 — 따라잡으면 프레임 하나에서 수백 스텝을 돌며 멈춘 것처럼 보인다. 다만 초점 문서의 "표시 프레임률이 바뀌어도 움직임이 안정적이다"라는 문장만 읽으면 이 천장은 보이지 않는다.
처음 돌려보기까지
전제
- Node.js — 저장소는 최소 버전을 선언하지 않는다.
engines필드도,.nvmrc도 없다.@types/node ^22.20.2와vite ^8.2.2가 사실상의 하한을 암시하지만 이는 추정이다. - WebGL을 지원하는 브라우저.
index.html:60이browserRequirements로 "Requires JavaScript and WebGL"을 선언한다. - 환경 변수나 외부 서비스는 필요 없다. 계정도, API 키도, 데이터베이스도 없다.
프로젝트가 제시하는 명령
아래는 고정 커밋의 README.md에서 그대로 인용한 것이다 — commands — not run. 이 문서를 만드는 과정에서 실행되지 않았다.
npm install
npm run dev
npm run build
npm run preview
package.json:6-11이 정의하는 실제 스크립트는 dev: vite, build: tsc && vite build, preview: vite preview, test: vitest run이다. README는 test를 언급하지 않는다.
성공 신호
Vite 개발 서버가 로컬 주소를 출력하고, 그 주소를 열면 어두운 녹색 연못에 잉어가 헤엄친다. 물을 클릭하면 파문이 생기고 큰 잉어들이 서로 다른 시점에 그쪽으로 향한다 — 동시에 향하면 반응 지연 계산이 동작하지 않는 것이다. D를 누르면 절차적 척추가 선으로 드러나므로, 척추 14개 노드가 실제로 존재하는지 눈으로 확인하는 가장 빠른 방법이다.
첫 실행에서 알아둘 것
- 설정은 브라우저에 남는다.
localStorage키nagomi:pond-settings:v2에 내가 바꾼 값만 희소하게 저장된다(src/settings/persistence.ts:9-17). 초기 상태로 돌아가려면 해당 키를 지운다. 저장 실패는 조용히 무시되므로 시크릿 창에서도 연못은 동작한다. - 로컬에서도 외부 요청이 하나 나간다. 별 개수 위젯이 마운트 시
api.github.com을 호출한다(src/components/github-stars.tsx:32). 오프라인에서는 조용히 실패하고 UI만 비어 보인다. - 분석은 로컬에서 켜지지 않는다. 호스트명이
.vercel.app으로 끝나거나 Vercel 환경 변수가 있을 때만 주입된다(src/main.tsx:7-12).
정리와 제거
빌드 산출물은 dist/, 의존성은 node_modules/, Vite 캐시는 .vite/다(.gitignore:1-6). 이 세 디렉터리와 위 localStorage 키를 지우면 흔적이 남지 않는다. 시스템 전역에 설치되는 것은 없고, 전역 훅이나 서비스도 등록되지 않는다.
위 명령은 인용이다. 이 문서를 만들면서 npm install·빌드·테스트·vite dev는 실행되지 않았고, 컨테이너도 띄우지 않았다. 따라서 "설치가 성공한다"거나 "테스트가 통과한다"는 진술은 이 문서에 없다. 성공 신호 단락은 코드가 무엇을 하도록 쓰여 있는지에 대한 설명이며, 관측 결과가 아니다.
목적에 따라 다른 경로
이 저장소는 작다. 전체 트리 75개 항목, 소스 6천여 줄이다. 그래서 "전부 읽기"가 현실적인 선택지이기도 하지만, 목적별 최단 경로는 아래와 같다.
| # | 경로 | 이유 | 대략 |
|---|---|---|---|
| 이 연못을 쓰는 사람 | |||
| 1 | README.md | 무엇인지, 어떤 조작이 있는지. 단 조작 목록은 하나 빠져 있다(4절). | 3분 |
| 2 | docs/how-it-works.md | 절차적 애니메이션이 무엇인지 그림 없이 설명한다. 이 저장소에서 가장 잘 쓰인 문서다. | 15분 |
| 이 코드를 배우려는 사람 | |||
| 1 | src/config.ts | 86줄. 연못 크기·갱신 주기·척추 개수·상한값이 모두 여기서 한눈에 보인다. | 5분 |
| 2 | src/math.ts | 49줄. 이 프로젝트가 쓰는 수학 전부와 시드 난수 생성기. | 5분 |
| 3 | src/koi.ts | 110줄. 개체 하나가 무엇을 기억하는지. 5종 상태 enum이 여기 있다. | 10분 |
| 4 | src/school.ts:329-394 | 핵심. 여섯 의도의 가중 합산. 이 65줄이 "살아 있음"의 전부다. | 20분 |
| 5 | src/school.ts:480-490 | 척추 체인. 물고기 몸이 휘는 이유. | 10분 |
| 6 | src/app.tsx:460-546 | 고정 스텝 루프와 입력 변환. 시뮬레이션과 렌더가 갈라지는 지점. | 15분 |
| 기여하려는 사람 | |||
| 1 | src/settings/schema.ts | 노드 종류와 검증 규칙. 설정 시스템의 문법. | 15분 |
| 2 | src/settings/store.ts:226-260 | 기본값·날씨·내 편집을 겹치는 곳. 날씨 불일치(4절)의 현장. | 20분 |
| 3 | src/settings/effects.ts | effect 태그 → 어떤 서브시스템을 갱신할지. 새 설정을 붙일 때 손대는 두 번째 파일. | 10분 |
| 4 | src/settings/*.test.ts | 기대 동작이 글이 아니라 실행 가능한 형태로 적힌 유일한 곳(3개 파일). | 20분 |
| 5 | src/settings/definition.ts | 39KB. 통독 대상이 아니라 패턴 하나 보고 따라 쓰는 참조다. | 훑기 |
| 보안·채택을 검토하는 사람 | |||
| 1 | LICENSE | 가장 먼저. PolyForm Noncommercial이며 GitHub 배지는 이를 알려주지 않는다. | 10분 |
| 2 | package.json | 의존성 25개, 전부 캐럿 범위, 락파일 없음. | 5분 |
| 3 | src/main.tsx | 22줄 중 6줄이 분석 주입 게이트다. | 3분 |
| 4 | src/components/github-stars.tsx | 앱이 내보내는 유일한 제3자 요청. | 5분 |
| 5 | public/_headers | 선언된 헤더와, 실제로 적용되는 오리진의 불일치(9절). | 3분 |
17일, 한 사람, 릴리스 0개
아래 숫자는 별 개수가 아니라 서로 다른 축에서 측정한 것이다. 각 축의 표본 규칙과 크기를 함께 적었다 — 표본이 너무 작아 결론을 만들 수 없는 축은 그렇게 적었다.
| 축 | 관측 | 표본 규칙 · 한계 |
|---|---|---|
| 기본 브랜치 신선도 | 고정 커밋 작성 2026-09-27T19:34:06Z. 저장소 생성 2026-09-10T20:25:11Z. 전체 커밋 33개. | 고정 커밋의 bare Git 이력 전수. 33개 중 30개가 09-10~09-15에 몰리고, 12일 공백 후 3개. |
| 릴리스 주기 | 게시된 릴리스 0개. | releases 엔드포인트가 빈 배열. 주기를 측정할 대상이 없다 — 느린 것이 아니라 없는 것이다. |
| 태그 주기 (릴리스와 별개 계열) | 태그 0개. package.json은 0.1.0을 선언하지만 대응 태그가 없다. |
tags 엔드포인트가 빈 배열. 릴리스 목록을 태그 목록으로 간주하지 않았다. |
| CI 결과 | 워크플로 파일 0개, 실행 0개. 고정 커밋에 대한 CI 상태는 "해당 없음"이다. | 완전한 75개 트리에 .github 디렉터리가 없고, Actions runs가 total_count: 0. 배지도 없다. |
| 테스트 | 테스트 파일 3개 존재(src/settings/), test: vitest run 선언됨. 통과 여부 unknown. |
테스트 존재는 테스트 통과가 아니며, 이 문서는 어떤 테스트도 실행하지 않았다. |
| 이슈 첫 응답 지연 | 측정 불가 (표본 0). 이슈는 1개뿐이며, 외부 계정이 수집 기준 시각 32분 전에 열었고 댓글이 0개다. | 0.5시간 된 이슈의 댓글 0개는 "응답 없음"이 아니라 "측정할 수 없음"이다. 둘을 같게 적지 않는다. |
| 이슈 종료율 | 종료된 이슈 0개. 분모가 없어 비율을 보고하지 않는다. | state=all 1페이지 전수. Issues API 행 4개 중 pull_request를 가진 3개를 제외해 실제 이슈 1개. |
| PR 병합률 | 3건 중 3건 병합(100%). 병합 지연 중위값 약 36초(최소 0.0h, 최대 0.01h). | state=all 1페이지 — 이것은 표본이 아니라 전수 모집단이다. 전부 소유자가 열고 소유자가 병합했다. 외부 리뷰 관측 0건. |
| 기여자 집중도 | 수집된 기여자 계정 1개(msk1039, 33 기여). Git 신원은 2개지만 동일인이다. | 이것은 기여 수 집중도 수치이며 bus factor가 아니다. 실제 bus factor는 측정하지 않았다. |
| 브랜치 보호 | main 보호 안 됨. |
branches/main이 protected: false. |
| 커뮤니티 · 거버넌스 | health 42%. 있는 것: README, LICENSE. 없는 것: CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, 이슈/PR 템플릿, CODEOWNERS. | 커뮤니티 엔드포인트뿐 아니라 완전한 고정 트리와 대조해 확인했다. 파일의 존재는 거버넌스의 집행이 아니다. |
| 언어 구성 | TypeScript 387,161 · CSS 23,908 · HTML 3,105 바이트. | languages 엔드포인트. 바이트 기준이며 줄 수나 복잡도가 아니다. |
| 주목도 (참고 지표만) | 스타 102 · 포크 14. 건강 지표가 아니다. | 2026-09-28T03:43:06Z 기준. 별 개수는 유지보수·품질·보안 어느 것도 말해주지 않는다. |
정직하게 말하면 이 축들은 17일짜리 프로젝트를 측정하고 있다. 모집단은 전수 수집했지만, 3건의 자기 병합과 32분 된 이슈 하나로는 응답성이나 주기에 대한 일반화가 불가능하다. 이 문서가 말할 수 있는 것은 "지금까지 이렇게 움직였다"이고, "앞으로 이렇게 움직인다"는 아니다.
연못 하나에 외부 요청이 두 개 있다
이 앱은 계정도, 로그인도, 서버도 없다. 그런데 문서 어디에도 적혀 있지 않은 외부 네트워크 동작이 두 건 있다. 둘 다 악의적이지 않지만, 둘 다 읽는 사람이 예상하지 않는 것이다.
나가는 네트워크 경계
| 지점 | 동작 | 기본 상태 | 문서화 |
|---|---|---|---|
| src/components/github-stars.tsx:32 | api.github.com/repos/{owner}/{repo}로 마운트 시 fetch. 별 개수를 표시하기 위한 것. |
항상 켜짐 — localhost 개발 서버에서도 나간다. 게이트나 옵트아웃이 없다. |
없음 |
| src/main.tsx:1,12 | @vercel/analytics의 inject(). |
조건부 — VITE_VERCEL_ENV가 있거나 호스트명이 .vercel.app으로 끝나거나 VITE_VERCEL_ANALYTICS === "true"일 때만. 로컬 개발에서는 켜지지 않는다. |
없음 |
| public/audio/ambient-river-v1.m4a | 동일 오리진 오디오 에셋. 사용자 제스처로 앰비언트 오디오가 해제된 뒤 요청된다. | 사용자 조작에 의해서만. | README에 있음 |
두 건 모두 개인 식별 정보를 앱이 스스로 만들어 보내지는 않는다. 다만 어떤 HTTP 요청이든 IP와 User-Agent를 상대에게 남기며, api.github.com 요청은 방문자 브라우저에서 GitHub로 직접 나간다. 별 개수를 표시하려는 위젯 때문에 모든 방문자가 GitHub에 흔적을 남기는 구조다. 앱 안에 이를 끄는 스위치는 없다.
로컬에 저장되는 것
localStorage 키 두 개(nagomi:pond-settings:v2, 구버전 :v1)에 내가 바꾼 설정 값, 날씨 id, 비 여부가 저장된다(src/settings/persistence.ts:9-17). 관측된 개인정보는 없다. 읽기·쓰기 모두 try/catch로 감싸여 있어 저장소가 비활성이거나 가득 차도 연못은 멈추지 않는다(src/settings/persistence.ts:23-49). 에러 리포팅이나 원격 로그 전송 경로는 없다.
선언된 보안 헤더가 광고된 배포에서 동작하지 않는다
public/_headers:12-14는 모든 경로에 X-Content-Type-Options: nosniff와 Referrer-Policy: strict-origin-when-cross-origin을 선언한다. 두 URL에 HEAD 요청을 보내 확인한 결과는 이렇다.
https://nagomi-blue.vercel.app/— 저장소 메타데이터의homepage가 가리키는 주소.server: Vercel로 응답하며 두 헤더 모두 없다. Vercel은public/_headers파일을 읽지 않는다(그 규약은 Cloudflare/Netlify 쪽이며, 저장소의wrangler.jsonc도 Cloudflare를 가리킨다).https://koi.m4yank.com/—index.html:8이rel="canonical"로 선언한 주소.server: cloudflare로 응답하며 두 헤더 모두 있다.
즉 이 저장소는 배포 대상이 세 군데로 갈라져 있다 — homepage 필드는 Vercel, index.html의 정본 URL과 wrangler.jsonc는 Cloudflare, 분석 주입 게이트는 Vercel 호스트명을 가정한다. 선언한 보안 헤더는 그중 한 곳에서만 실제로 작동한다. 어느 오리진도 Content-Security-Policy를 보내지 않는다.
공급망
| 항목 | 상태 |
|---|---|
| 락파일 / SBOM | 없음. .gitignore:15-17은 npm과 package-lock.json을 쓴다고 적었지만 트리에 락파일이 하나도 없다. 런타임 14개 + 개발 11개 의존성 전부 캐럿 범위다. |
| 설치 시 실행되는 스크립트 | postinstall·prepare 없음. package.json:47-52의 pnpm.onlyBuiltDependencies가 esbuild·workerd의 빌드 스크립트를 허용 목록에 둔다(pnpm 사용 시에만 의미 있음). |
| 원격 설치 스크립트 | 없음. 문서가 제시하는 명령에 curl | bash류가 없다. |
| Dependabot · CodeQL · SAST | 설정 파일 없음. 경보 상태는 unknown — 이 엔드포인트는 저장소 권한이 필요하며 요청하지 않았다. unknown은 0이 아니다. |
| 게시된 보안 권고 | 0건. 취약점이 없다는 증명이 아니다 — 공개 권고 엔드포인트가 비어 있다는 뜻일 뿐이다. |
| SECURITY 정책 · CODEOWNERS | 둘 다 없음. 취약점을 어디로 신고해야 하는지 저장소가 알려주지 않는다. |
| 브랜치 보호 · 액션 고정 · 릴리스 서명 | 보호 없음. 워크플로가 없어 액션 고정은 해당 없음. 릴리스가 없어 서명·프로베넌스도 해당 없음. |
| 서브모듈 · LFS | 둘 다 없음(완전한 75개 트리에서 확인). 심볼릭 링크도 없다. |
이 절이 말할 수 없는 것
비공개 경보 상태(Dependabot·코드 스캐닝·시크릿 스캐닝)는 확인하지 못했고, 락파일이 없으므로 실효 의존성 그래프 자체를 이 스냅숏에서 고정할 수 없다. 즉 "전이 의존성에 알려진 취약점이 있는지"는 이 문서가 답할 수 없는 질문이며, 답이 "없음"이라는 뜻이 아니다.
이것은 오픈소스가 아니다
GitHub는 이 저장소의 라이선스를 Other / NOASSERTION으로 표시한다. 사이드바 배지만 보면 아무것도 알 수 없다. 고정된 LICENSE 전문을 읽으면 정체가 분명하다 — PolyForm Noncommercial License 1.0.0이다.
핵심은 한 문장이다: 허용 목적은 비상업 용도뿐이다(LICENSE:35-37). 개인 연구·실험·학습·취미·오락, 그리고 자선단체·교육기관·공공 연구기관·정부기관의 사용은 자금원과 무관하게 허용 목적에 들어간다(LICENSE:39-45). 그 밖의 권리는 함의되지 않으며 재실시허락(sublicense)도, 양도도 불가하다(LICENSE:53-55).
| 항목 | 확인 결과 |
|---|---|
| GitHub 감지 SPDX | NOASSERTION (key: other) |
| 고정 LICENSE 전문이 식별하는 것 | PolyForm Noncommercial License 1.0.0 (LICENSE:1, blob 9d8cc17c…, 4,638바이트) |
| 분류 | 제한적 — 비상업 (permissive도, copyleft도 아니다) |
| 필수 고지 | Required Notice: Copyright 2026 Mayank Kadam (https://github.com/msk1039) — 소프트웨어의 일부라도 전달할 때 이 줄과 라이선스 본문 또는 그 URL을 함께 전달해야 한다(LICENSE:21-25) |
| NOTICE 파일 | 없음. 필수 고지는 LICENSE 안의 Required Notice: 줄이 유일한 형태다. |
| 이중/다중 라이선스 | 없음. 선택할 다른 그랜트가 제시되지 않는다. |
| 벤더링된 제3자 코드 | src/components/ui/*는 shadcn 생성 산출물이다(components.json이 생성기 설정을 기록). 생성 도구의 원 라이선스와 이 저장소 라이선스의 관계는 저장소가 명시하지 않는다. |
| 미디어·에셋 | 독립적으로 확인되지 않음. public/nagomi-social-v1.png, public/audio/ambient-river-v1.m4a, public/favicon.svg의 출처·라이선스가 저장소에 적혀 있지 않다. 저장소 라이선스가 자동으로 미디어를 해제해주지 않는다. |
| 위반 시 | 서면 통지 후 32일 내 완전 준수와 과거 위반 시정이 없으면 모든 라이선스가 즉시 종료된다(LICENSE:65-67). |
채택 조건
- 개인 학습·실험·취미로 읽고 돌려보기: 라이선스가 명시적으로 허용하는 범위다.
- 포크해서 개인 프로젝트로 개조: 허용된다(Changes and New Works License,
LICENSE:27-29). 단 배포하면 필수 고지와 라이선스 전달 의무가 따라온다. - 회사 제품·수익 서비스·상업 데모: 허용되지 않는다. 저자와 별도 합의가 필요하다.
- 코드 조각을 다른 프로젝트에 복사: 그 프로젝트가 상업적이면 허용되지 않고, 비상업적이어도 고지 의무가 전파된다. MIT 코드를 가져오는 것과 같지 않다.
이 문서 자신의 처리
이 라이선스는 permissive도 문서 share-alike도 아니고, 파생물 허가가 "목적이 비상업적인가"에 걸려 있다. 그래서 이 문서는 초점 문서를 전문 번역하지 않았다. 12절은 짧은 인용을 곁들인 한국어 해설이며, 원문 문서의 자리를 대체하려는 것이 아니다. 필수 고지와 라이선스 URL은 12절 머리에 그대로 붙였다.
이 절은 법률 자문이 아니다. 라이선스 해석과 채택 판단은 각자의 책임이며, 상업적 사용을 검토한다면 저자에게 직접 확인하는 것이 유일하게 확실한 경로다.
22개 주장, 근거와 판정을 분리해서
근거의 출처(어디서 왔나)와 결론(그래서 맞나)은 다른 축이다. 아래 표는 둘을 섞지 않는다. execution-observed 근거는 이 문서에 0건이다 — 어떤 코드도 실행되지 않았다.
| ID | 주장 | 근거 종류 | 판정 | 확신 |
|---|---|---|---|---|
| C001 | 매 프레임 규칙으로 계산하는 절차적 애니메이션 | project-stated · static-support | supported | high |
| C002 | 연못 480×270, 초당 60회 고정 갱신 | project-stated · static-support | supported | high |
| C003 | 유영 상태 5종 | project-stated · static-support | supported | high |
| C004 | 척추 14노드 추종 체인, 꼬리가 더 느슨함 | project-stated · static-support | supported | high |
| C005 | 여섯 의도의 가중 합산 | project-stated · static-support | supported | high |
| C006 | 목표 근처 선회력 | project-stated · static-support | supported | high |
| C007 | 탭 → 좌표 변환 → 파문 → 개체별 지연 → 작은 물고기 회피 | project-stated · static-support | supported | high |
| C008 | 재현 가능한 시드 난수 | project-stated · static-support | supported | high |
| C009 | 중간 렌더 텍스처로 수면만 왜곡 | project-stated · static-support | partially-supported | medium |
| C010 | 버퍼·객체·파문·렌더 타깃 재사용 | project-stated · static-support | supported | high |
| C011 | 모든 설정이 한 곳에 선언되고 한 스토어가 합성한다 | project-stated · static-support · test-present | partially-supported | high |
| C012 | 내 편집이 날씨 프리셋을 계속 눌러 이긴다 | project-stated · static-support | contradicted | high |
| C013 | 설정은 희소 편집 목록으로 영속화된다 | project-stated · static-support · test-present | supported | high |
| C014 | 문서화된 조작이 조작 전부다 | project-stated · static-support | contradicted | high |
| C015 | CI·릴리스·태그가 전부 없다 | static-support · external-confirmed | supported | high |
| C016 | npm 락파일로 의존성이 고정된다 | project-stated · static-support | contradicted | high |
| C017 | 문서에 없는 외부 요청이 존재한다 | static-support | supported | high |
| C018 | 선언된 보안 헤더가 광고된 배포에 적용된다 | static-support · project-stated · external-confirmed | contradicted | high |
| C019 | 상업적 사용이 가능한 라이선스다 | static-support · external-confirmed | contradicted | high |
| C020 | 유지보수는 한 계정이 담당하며 외부 리뷰가 없다 | external-confirmed | supported | medium |
| C021 | 유일한 열린 이슈가 방치되었다 | external-confirmed | unknown | high |
| C022 | AI 모델도 생물학 시뮬레이션도 유체 시뮬레이션도 아니다 | project-stated · static-support · inference | supported | high |
남은 질문
- C012 — 닫혔다.
src/settings/store.test.ts:72-83이 프리셋 전환 시 프리셋 소유 override가 지워지는 쪽을 정답으로 단언하므로, 의도는 코드 쪽이고 어긋난 것은 문서의 문장이다. 남는 질문은 문서를 고칠 것인가 동작을 고칠 것인가뿐이며, 그건 저자의 선택이다. - C013 —
src/settings/persistence.ts:1-2의 주석이 v1→v2 마이그레이션 설명을 위해docs/how-it-works.md를 가리키지만, 그 문서에는 마이그레이션 이야기가 없다. - C019 — 저자가 비상업 제한을 문서와 번들 미디어까지 미치게 할 의도인지 어디에도 적혀 있지 않다.
- C002 — 누산기의
0.1초 천장이 의도된 설계인지, 문서에서 누락된 것인지 알 수 없다. - C009 — 여섯 단계 렌더 순서의 정확한 검증은 draw 경로 전체를 읽어야 하며, 이 수집 한도 안에서 하지 않았다.
각 주장의 근거 배열·해시·충돌 기록 전문은 github-claims.jsonl에 있다. 이 표는 그 파일의 사영이며, 원장이 정본이다.
docs/how-it-works.md — 무엇을 말하고, 코드가 어디까지 그대로인가
초점 문서의 원문은 고정 커밋의 docs/how-it-works.md에 있다(blob a2841b59…, 8,879바이트, 201줄). 저장소 라이선스가 PolyForm Noncommercial License 1.0.0이고 파생물 허가가 목적의 비상업성에 걸려 있으므로, 이 절은 전문 번역이 아니라 절별 한국어 해설이다. 필요한 곳에만 짧게 인용하고, 원문이 건 링크는 원래 자리에 고정 커밋으로 pin 해서 보존했다.
Required Notice: Copyright 2026 Mayank Kadam (https://github.com/msk1039)
라이선스 전문: polyformproject.org/licenses/noncommercial/1.0.0 · 저장소 사본: LICENSE
머리말 — 읽는 사람에게 요구하는 것
문서는 첫 문단에서 범위를 못박는다. 물고기는 비디오도, GIF도, 그림 목록도 아니며, 프로그램이 돌아가는 동안 움직임을 결정하고 현재 형태를 그린다. 그리고 한 줄을 덧붙인다 — 애니메이션이나 그래픽스 지식은 필요 없다. 이 선언은 문서 전체의 톤을 정한다. 수식도 셰이더 코드도 나오지 않고, 끝까지 평범한 문장으로 간다.
"절차적"이 무엇인가 — 문서가 쓰는 비유
문서는 전통적 애니메이션을 "녹화된 춤을 재생하는 것"에, 절차적 애니메이션을 "춤꾼에게 규칙을 주는 것"에 비유한다. 규칙의 예로 다섯 개를 든다: 대체로 차분하게 움직여라 · 다른 춤꾼과 거리를 두어라 · 무대 가장자리를 피해라 · 방문자가 어딘가를 누르면 그쪽으로 돌아라 · 빠르게 움직일 때 몸을 더 강하게 움직여라.
이 다섯 개는 우아한 교육적 선택이다. 실제 코드의 여섯 의도 중 다섯 개와 일대일로 대응하고(차분함=배회, 거리=분리, 가장자리=경계 회피, 탭=목표 추종, 강도=꼬리 노력), 남은 하나인 정렬만 비유에서 생략된다. 문서는 이어서 "다음 자세가 반복되는 클립이 아니라 현재 상황에서 나오기 때문에 살아 있어 보인다"고 요약하고, 마지막에 경계를 그린다 — nagomi는 규칙 기반 시뮬레이션이며 AI 모델도, 생물학적 잉어 시뮬레이션도, 실제 물의 완전한 시뮬레이션도 아니다.
이 부인 문장은 검증된다. 완전한 75개 트리 어디에도 모델 가중치, 추론 런타임, 유체 솔버가 없다.
짧은 버전 — 문서가 제시하는 파이프라인
문서는 모든 표시 프레임이 같은 파이프라인을 지난다고 말하고, 이를 화살표 도식으로 그린다: 방문자 입력 → 물고기의 결정 → 움직임 → 유연한 몸 형태 → 연못 바닥 → 그림자 → 물고기 → 물 → 식물 → 날씨 → 최종 픽셀. 그리고 핵심 문장 하나를 놓는다.
시뮬레이션 코드와 그리는 코드는 분리되어 있다. 시뮬레이션은 "물고기가 어디 있어야 하나"에 답하고, 렌더러는 "그것이 어떻게 보여야 하나"에 답한다.
이 분리는 src/app.tsx:469-477에서 그대로 확인된다. 고정 간격 루프가 school.update()를 돌리고, 그 밖에서 프레임당 한 번 renderer.draw()를 부른다. 5절의 실행 경로 표가 같은 사실을 13단계로 펼친 것이다.
1. 물고기마다 자기 상태가 있다
문서는 각 잉어가 위치·방향·속도·크기·깊이·성격을 저장하고, 하나의 현재 유영 상태를 가진다고 설명하며 표로 다섯 상태를 제시한다 — Glide(평상시 편한 유영), Coast(꼬리를 거의 쓰지 않고 감속), Hover(거의 제자리), Burst(짧고 빠른 움직임), Pivot(느리지만 더 날카로운 선회).
이어 "물고기마다 다른 시점에 상태가 바뀌고, 처음부터 속도·크기·선회 강도·반응성이 조금씩 다르다. 그래서 무리가 하나처럼 동기화되어 움직이지 않는다"고 말한다. 마지막으로 난수에 대한 단서를 붙인다 — 무작위 선택은 재현 가능한 난수 생성기를 쓰며, 변화를 더하지만 움직임 규칙을 대체하거나 물고기를 무작위로 순간이동시키지 않는다.
상태 다섯 개는 src/koi.ts:4-10의 SwimState enum과 순서까지 일치한다. 개체차는 src/koi.ts:47-69에서 시드 난수로 부여되고, 난수 생성기는 src/math.ts:26-48의 32비트 XorShift다 — 시드가 고정이므로 "재현 가능"은 문자 그대로 사실이다. 다만 세부 하나가 다르다. 초기 상태는 무작위가 아니라 index % 5로 돌려 배분하고(src/koi.ts:87), 무작위인 것은 그 상태의 남은 시간이다(src/koi.ts:98). 결과적으로 "다른 시점에 상태가 바뀐다"는 문장은 유지된다.
2. 단순한 의도들이 자연스러운 움직임을 만든다
문서가 나열하는 여섯 의도는 이렇다. 배회 — 천천히 변하는 선회로 계속 전진한다. 응집 — 가까운 물고기와 느슨하게 이어져 있는다. 정렬 — 가까운 물고기와 비슷한 방향을 선호한다. 분리 — 겹치기 전에 멀어진다. 경계 회피 — 연못을 벗어나기 전에 되돌아온다. 목표 추종 — 가장 최근 탭이나 클릭 쪽으로 움직인다.
그리고 충돌을 인정한다. 한 물고기가 탭을 따라가고 싶은 동시에 다른 물고기를 피하고 싶을 수 있다. 프로그램은 모든 의도에 가중치를 주고 합산한 뒤 그 결과 쪽으로 부드럽게 돈다. 속도와 방향은 점진적으로 바뀐다 — 즉시 대입하면 잉어가 헤엄치는 동물이 아니라 미끄러지는 아이콘처럼 보이기 때문이다.
이 절이 문서 전체에서 가장 정확하다. src/school.ts:336-393에 여섯 의도가 같은 순서로 있고, 가중치는 배회 0.62 · 응집 0.25 · 정렬 0.42 · 분리 2.8 · 경계 4.8 · 목표 추종 3.35(초기 가속 중) 또는 2.45다. 분리가 응집의 11배라는 비율이 "겹치지 않으면서 느슨하게 뭉친다"는 시각적 결과를 만든다. 이웃 판정 반경은 37, 분리 발동 반경은 14이며, 두 값 모두 설정이 아니라 코드 리터럴이다.
3. 유연한 척추가 유영 자세를 만든다
문서는 각 잉어가 머리에서 꼬리까지 보이지 않는 점 14개의 사슬을 가지며 이것이 절차적 척추라고 설명한다. 머리는 움직임 규칙을 따르고, 그 뒤의 모든 점은 앞의 점을 일정 거리를 유지하며 따라간다. 꼬리에 가까운 점은 더 느슨하게 반응하므로 선회할 때 여운(follow-through)이 생긴다.
그 위에 측면 파동이 척추를 따라 더해진다. 파동은 머리 쪽에서 작고 꼬리 쪽에서 크다. 빠른 물고기와 버스트 움직임은 꼬리 노력을 키운다. 렌더러는 이 변하는 척추를 중심으로 몸·지느러미·무늬·꼬리를 만든다. 문서는 절을 이렇게 닫는다 — 이렇게 하면 하나의 물고기 형태가 수천 장의 이미지를 저장하지 않고도 수천 가지 자세를 만들어낸다.
src/school.ts:482-490이 정확히 그 구조다. spine[0]에 현재 위치를 넣고, 각 노드를 앞 노드로부터 bodyLength/13 간격에 구속한 뒤, 꼬리 비율(node/13)에 따라 낮아지는 강성으로 보간한다. 노드 수 14는 src/config.ts:36의 spineNodes이며 src/koi.ts:15-16에서 배열 두 개(시뮬레이션용·렌더용)로 할당된다. 렌더용 척추를 따로 두는 이유는 문서가 설명하지 않는데, 시뮬레이션 스텝과 렌더 프레임이 분리되어 있으므로 그리기 시점에 보간된 형태가 필요하기 때문이다.
4. 물고기는 깊이를 오간다
깊이는 각 물고기의 또 하나의 변하는 값이다. 얕은 물고기는 더 밝고 수면에 가까워 보이고, 깊은 물고기는 물 색조가 더 강하게 입혀지고 그림자 강도가 달라진다. 물고기는 수면 근처와 깊은 곳에서 보내는 시간이 각기 다르다. 파문으로 부르면 반응하면서 수면 쪽으로 올라온다. 깊이는 부드럽게 변하므로 두 시각 스타일 사이를 갑자기 뛰어넘지 않는다.
깊이 상태 전이와 호출 시 상승은 src/school.ts:183-215가 담당하며, 전이 속도는 개체마다 다른 depthTransitionRate로 나눠진다(src/koi.ts:72-75).
5. 탭 한 번이 연못 사건이 된다
문서는 물을 탭하거나 클릭할 때 벌어지는 일을 여섯 단계로 나열한다. 화면 위치가 연못의 480 × 270 좌표계로 변환된다 → 그 지점에서 파문이 시작된다 → 큰 잉어마다 자기 반응 지연을 받는다 → 더 멀거나 덜 민감한 물고기는 더 늦게 반응할 수 있다 → 반응하는 물고기는 버스트에 들어가 올라오면서 그 지점으로 조종한다 → 작은 물고기는 대신 교란으로부터 도망친다.
그리고 세부 하나를 덧붙인다. 목표 근처에서 잉어는 작은 선회력을 받는다. 그래서 파문 위에 그대로 쌓이는 대신 그 주변을 계속 돈다.
여섯 단계 전부가 코드에 있다. 좌표 변환은 src/app.tsx:535-543, 파문 생성은 src/school.ts:130, 개체별 지연은 src/school.ts:113-127(최소 지연 + 거리 기반 + 무작위 지터 + 기질 항), 작은 물고기 회피는 src/school.ts:129, 선회력은 src/school.ts:390-393의 접선 2.2 / 중심 −0.5 조합이다. "더 멀거나 덜 민감한 물고기가 늦게 반응한다"는 문장은 지연 식의 거리 항과 (1 − reactivity) 항에 정확히 대응한다.
6. 물은 여러 층으로 만들어진다
문서는 three.js가 연못을 하나의 평면 그림이 아니라 여러 패스로 그린다고 설명하고 순서를 여섯 단계로 제시한다. 연못 바닥이 깊은 색과 얕은 색을 만든다 → 식물과 물고기 그림자가 올바른 깊이 순서로 더해진다 → 큰 잉어와 작은 무리가 그려진다 → 수면이 흐름·색조·굴절·파문 왜곡을 더한다 → 연잎·꽃·부레옥잠·나비가 그 위에 놓인다 → 선택된 날씨가 최종 빛·색·구름·비를 적용한다.
그리고 이 구조의 이유를 한 문장으로 준다 — 중간 렌더 텍스처가 있어서, 물이 수중 장면을 왜곡하면서도 수면 위에 떠 있는 물체는 함께 왜곡하지 않는다. 절 끝에 한 줄을 더한다: 어떤 파문은 방문자에게서 오지만, 어떤 파문은 비나 먹이를 먹는 잉어에게서 온다.
중간 렌더 타깃 두 개는 src/fish-renderer.ts:265,273에 underwaterTarget·compositeTarget으로 선언되어 있고, 그림자 씬은 src/fish-renderer.ts:288-296에서 식물·작은 물고기·부레옥잠·나비의 그림자 그룹을 모아 구성한다. 파문 종류가 셋(rain·mouth·touch)이고 우선순위가 그 순서라는 것은 src/ripple-system.ts:23-27에서 확인된다 — 문서의 마지막 문장이 코드의 열거와 일치한다. 다만 여섯 단계의 정확한 순서는 draw 경로를 끝까지 읽어야 검증되며, 이 문서는 그 검증을 하지 않았다(4절·5절 참조).
7. 날씨와 설정이 규칙을 바꾼다
문서는 날씨 프리셋이 색 필터 이상이라고 말한다. 프리셋은 선택된 잉어·연못 바닥·물 설정에 최종 값을 공급한다. 그리고 이어지는 문장이 이 문서에서 코드와 갈리는 지점이다 — 날씨를 바꾸면 그 필드들만 덮이고, 내가 직접 고친 값은 같은 필드를 다시 만질 때까지 프리셋을 계속 눌러 이긴다는 설명이다.
구조 설명은 이렇게 이어진다. 모든 설정은 단 한 곳, src/settings/definition.ts에 선언된다 — 기본값, 유효 범위, 컨트롤 종류(슬라이더·색 선택기·스위치 등), 그리고 값이 바뀔 때 어떤 서브시스템을 갱신해야 하는지까지. 단일 스토어인 src/settings/store.ts가 각 설정의 실효 값을 기본값 ⊕ 날씨 ⊕ 내 편집으로 계산해 하나의 가변 객체(store.live)에 쓰고, 영향받는 서브시스템에 알린다. 설정 패널(src/config-editor.tsx, src/quick-settings.tsx)은 같은 스키마에서 생성되므로, 새 설정이 자기만의 슬라이더를 손으로 만들 필요가 없다.
마지막으로 호환 계층을 설명한다. src/config.ts가 store.live의 조각들을 옛 이름(FISH, WATER, LOTUS_LEAVES 등)으로 재수출해서, 시뮬레이션과 렌더러는 설정 UI가 존재한다는 사실조차 모른 채 매 프레임 평범한 객체를 읽는다. 설정은 localStorage에 내 편집만 희소하게 저장된다(모든 값의 전체 복사본이 아니다). 문서는 이 선택의 이유를 덧붙인다 — 그래서 나중에 기본값을 바꿨을 때 옛 저장본이 조용히 덮어쓰지 않는다.
설정을 추가하는 방법도 적혀 있다. src/settings/definition.ts에 경계값을 갖춘 노드를 추가하고, 서브시스템이 반응해야 한다면 effect 태그를 붙인다. 그 태그가 새 것이면 src/settings/effects.ts에 핸들러를 더한다. 그 외에는 바꿀 것이 없다 — UI가 자동으로 집어 간다.
기본값 ⊕ 날씨 ⊕ 내 편집 순서는 src/settings/store.ts:228-249의 recomputeSection에 그대로 있다. 소스 주석이 같은 ⊕ 기호를 쓸 정도로 문서와 코드가 붙어 있다. 하나의 프리셋 안에서는 내 편집이 이긴다.
그런데 setWeather는 프리셋을 교체하기 전에 날씨가 소유한 모든 경로의 override를 지운다(src/settings/store.ts:409). 그 경로 집합은 프리셋 설정 구조에서 유도되며(src/settings/store.ts:128-143) koi·pond-bed·water 세 영역을 덮는다. 따라서 문서의 "같은 필드를 다시 만질 때까지 계속 이긴다"는 날씨를 바꾸지 않는 동안에만 사실이다. 날씨를 바꾸면 그 세 영역의 내 편집은 그 순간 사라진다.
영속화 쪽 설명은 정확하다. src/settings/persistence.ts:9-17의 저장 형태는 실제로 override 맵 + 날씨 id + 비 여부이며 전체 값 복사본이 아니다. 다만 같은 파일 1-2행의 주석이 v1→v2 마이그레이션 설명을 위해 이 문서를 가리키는데, 이 문서에 마이그레이션 이야기는 없다.
연못이 반응성을 유지하는 이유
문서는 두 가지를 든다. 첫째, 논리적 연못은 480 × 270 픽셀뿐이고 화면 크기로 스케일된다. 시뮬레이션은 초당 60회 고정 갱신을 쓰므로 표시 프레임률이 변해도 움직임이 안정적이다. 둘째, 렌더러는 매 프레임 새로 만드는 대신 기하 버퍼·물고기 객체·파문·렌더 타깃을 재사용한다. 그래서 할당과 가비지 컬렉션 작업이 적게 유지된다.
두 값은 src/config.ts:29-37에 상수로 있고, 고정 스텝 루프는 src/app.tsx:469-475다. 재사용은 파문 64개 고정 배열(src/ripple-system.ts:29-39), 물고기 48개 고정 배열(src/school.ts:28), GeometryBatch의 reset/commit 순환(src/fish-renderer.ts:453,474,489)에서 확인된다. 문서가 말하지 않는 것은 누산기가 프레임당 경과 시간을 0.1초로 잘라낸다는 점이다(src/app.tsx:469) — 긴 정지 후에는 밀린 시간을 따라잡지 않고 버린다.
소스 맵 — 문서가 제시하는 파일별 책임
문서는 표로 열 개 파일의 책임을 정리한다. 아래는 그 표의 한국어판이며, 원문이 건 링크를 같은 자리에 고정 커밋으로 pin 해서 보존했다.
| 파일 | 문서가 적은 책임 |
|---|---|
| src/app.tsx | 갱신·렌더 루프를 돌리고 입력과 UI를 처리한다 |
| src/koi.ts | 잉어 한 마리의 상태를 저장한다 |
| src/school.ts | 행동·조종·깊이·상호작용을 제어한다 |
| src/fish-renderer.ts | 물고기 기하를 만들고 렌더 레이어를 합성한다 |
| src/water-surface.ts | 흐름·색조·수면 왜곡을 그린다 |
| src/ripple-system.ts | 재사용 가능한 파문 사건을 관리한다 |
| src/lotus-leaves.ts | 연잎과 꽃, 그리고 그 그림자를 만든다 |
| src/weather.ts | 시각적 날씨 프리셋을 정의한다 |
| src/config.ts | 라이브 설정 트리를 옛 이름으로 재수출한다 |
| src/settings/definition.ts | 모든 설정을 선언한다 — 기본값·경계·컨트롤 종류·effect |
| src/settings/store.ts | 기본값·날씨·내 편집을 하나의 라이브 객체로 겹친다 |
원문이 건 링크는 16개이며 모두 같은 저장소의 상대 경로다. 위 해설에서 16개 전부를 원래의 의미상 위치에 보존하고 고정 커밋으로 pin 했다(src/config.ts·src/settings/definition.ts·src/settings/store.ts는 원문에 두 번씩 나오므로 각각 별개 항으로 유지했다). 차단되거나 생략된 링크는 없다.
문서의 마지막 문장은 이렇다 — 중요한 생각은 단순하다: 많은 작은 규칙이 계속 돌아가고, 그 결합된 결과가 살아 있는 연못의 느낌을 만든다. 이 문장은 수사가 아니다. src/school.ts:336-393의 65줄이 실제로 그 전부다.
이 절이 재현하지 않은 것
README에 임베드된 소개 영상(github.com/user-attachments 자산)과 저장소의 소셜 이미지(public/nagomi-social-v1.png)는 재게시하지 않았다. 미디어는 저장소 코드 라이선스와 별개로 판단해야 하고, 이 저장소는 에셋의 출처나 별도 라이선스를 밝히지 않는다. 영상은 이 문서의 범위 밖이다.
무엇을 읽었고, 무엇을 읽지 않았는가
한도와 실제
| 한도 | 기본값 | 적용값 | 실제 |
|---|---|---|---|
| 수집·검사 시간 (분) | 20 | 20 | 12 |
| REST 요청 수 | 90 | 90 | 19 |
| 실질 검사 파일 수 | 30 | 30 | 30 |
| 검사 줄 수 | 20,000 | 20,000 | 6,667 |
| 의존성 홉 | 3 | 3 | 1 |
한도를 확장하지 않았다(expansion.authorized: false). 인증된 수집이므로 REST 기본값은 90이다. 파일 수는 정확히 상한 30에 닿았고, 이것이 이 문서의 가장 큰 범위 제약이다 — 아래 "읽지 않은 것"이 그 결과다.
인벤토리
고정 루트 트리의 재귀 응답은 truncated: false였고 항목 75개(blob 66 + tree 9), 총 blob 바이트 913,296이다. API가 보고한 루트 트리 id가 bare Git의 rev-parse 결과와 일치하므로 인벤토리는 완전하다. 심볼릭 링크 0개, 서브모듈 0개, LFS 포인터 0개.
선택 원장 (30개 파일)
| 분류 | 파일 |
|---|---|
| 문서 · 매니페스트 (11) | README.md · docs/how-it-works.md · LICENSE · package.json · tsconfig.json · vite.config.ts · wrangler.jsonc · components.json · index.html · public/_headers · .gitignore |
| 소스 (16) | src/main.tsx · src/app.tsx · src/koi.ts · src/school.ts · src/fish-renderer.ts · src/ripple-system.ts · src/water-surface.ts · src/weather.ts · src/config.ts · src/math.ts · src/settings/store.ts · src/settings/effects.ts · src/settings/schema.ts · src/settings/persistence.ts · src/settings/definition.ts · src/components/github-stars.tsx |
| 테스트 (3) | src/settings/persistence.test.ts · src/settings/schema.test.ts · src/settings/store.test.ts |
| CI (0) | 해당 파일이 저장소에 존재하지 않는다 |
선택 이유·객체 id·바이트·줄 수·해시는 github-snapshot.json의 selection.files에 파일별로 있다.
읽지 않은 것 — 이 문서의 한계
- 줄 단위로 읽지 않은 대형 파일:
src/app.tsx,src/fish-renderer.ts,src/water-surface.ts,src/settings/definition.ts는 전문을 수집했지만 구조 검색으로 읽었다. 이 파일들에 대한 인용은 실제로 읽은 줄에만 붙였고, 렌더 패스 순서 주장은 그래서 부분 지지로 남았다. - 선택하지 않은 소스:
src/butterflies.ts,src/duckweed.ts,src/tiny-fish.ts,src/tiny-fish-renderer.ts,src/pond-bed.ts,src/surface-disturbance.ts,src/surface-geometry.ts,src/weather-pass.ts,src/settings/react.ts,src/elastic-slider.tsx등. 작은 물고기 회피 주장은src/tiny-fish.ts본문 대신src/school.ts:129의 호출 지점으로 근거를 삼았다. - 생성 산출물:
src/components/ui/*9개 파일은 shadcn 생성물로 분류해 인벤토리만 하고 선택하지 않았다. 분류 근거는components.json의 생성기 설정이다. - 바이너리:
public/audio/ambient-river-v1.m4a(378KB),public/nagomi-social-v1.png(143KB),public/favicon.svg는 인벤토리만 했다. SVG는 신뢰할 수 없는 활성 콘텐츠로 취급해 인라인하거나 렌더하지 않았다. - 비공개 경보 상태: Dependabot·코드 스캐닝·시크릿 스캐닝은 저장소 권한이 필요해 요청하지 않았다. unknown이며 0이 아니다.
- 페이지네이션: 기본 브랜치 커밋 엔드포인트를
per_page=30으로 읽어 두 번째 페이지가 있다는 링크 헤더를 받았다. 2페이지를 추가로 받는 대신 고정 커밋의 bare Git 이력에서 전체 33개를 셌다.
수집 방법
- 인증된 GitHub REST API(요청 버전
2022-11-28, 선택된 버전도 동일). 지원 버전 목록을/versions로 확인해 기억에 의존하지 않았다. 직렬 호출, 응답을 해시와 함께 로컬 파일로 스트리밍, 요청 19건. git clone --bare --filter=blob:none로 만든 blob 없는 객체 데이터베이스. 전역·시스템 Git 설정과 자격증명 헬퍼를 차단하고, 훅 경로를/dev/null로, LFS smudge를 비활성으로 고정한 뒤ls-tree·cat-file·rev-list만 사용했다. 체크아웃·워크트리 없음.- 광고된 두 공개 배포 URL에 대한 HEAD 요청 2건 — 저장소 증거로는 확인할 수 없는 응답 헤더 사실을 위해서다.
실패한 요청은 없다. 최초 재귀 트리 요청 한 건은 커밋 SHA로 보내 응답의 sha가 커밋 id로 돌아왔고, 루트 트리 id로 다시 요청해 정합성을 맞췄다. 두 응답 모두 원장에 남아 있다.
영수증
전체 출처 원장 github-sources.jsonl (20행, 각 행에 로컬 경로·SHA-256·바이트·관측 시각) · 주장 원장 github-claims.jsonl (22행) · 링크 원장 github-occurrences.json (16항, pending 0) · 위생 매니페스트 github-sanitization.json · 공개 증거 색인 evidence/index.json · 배송 영수증 delivery-receipt.json
체크아웃 아니오 · 워크트리 아니오 · 의존성 설치 아니오 · 빌드 아니오 · 테스트 아니오 · 실행/임포트 아니오 · 컨테이너 아니오 · 훅 아니오 · 서브모듈 아니오 · LFS 아니오 · 필터 아니오 · textconv 아니오.
execution-observed 근거 항목 0건. 이 문서에는 런타임 관측이 존재하지 않으며, CI 배지·워크플로 정의·테스트 파일을 실행 관측으로 바꿔 부르지 않았다. 저장소의 명령은 모두 인용이다.