01 · 제품 기획과 시나리오
초등학교 2학년 아이의 작품을 직접 조작하는 감상 경험으로 확장한다. 이름은 ‘준영의 작은 미술관’. 플레이어는 흩어진 밤하늘을 복원하는 작은 큐레이터다. 실패·감점·시간 제한 없이 관찰과 성취감을 제공한다. 게임 방식은 직사각형 이미지 조각의 자리 교환 퍼즐이며, 돌출부가 있는 전통 직소 퍼즐이나 빈칸 슬라이딩 퍼즐이 아니다.
02 · 요구사항 추적
| 요구 | 구현 | 검증 지점 |
|---|---|---|
| 그림 기반 퍼즐 | 원본 전체를 동일한 격자로 분할, CSS 배경 좌표로 조각 표현 | 완성 시 전체 그림과 일치 |
| 난이도 | 6 / 12 / 24조각 | 격자 3×2 / 4×3 / 6×4 |
| 제한 찬스 | 판마다 3회, 상태에 잔여량 저장 | 0회 또는 완성 시 비활성 |
| 완수 애니메이션 | 별 파티클과 메달, 완료 통계 dialog | 모션 감소 설정 대응 |
| 원본 감상 | 원본 JPEG, 포인터·휠·핀치·키보드 | 100~800%, 이동 경계 제한 |
| 문서 | 독립형 HTML 가이드·개발·후속 기획 3종 | download 링크와 인쇄 스타일 |
| 범용 접근 | 반응형 정적 HTML/CSS/JS, 서버 API 없음 | 데스크톱·모바일 뷰포트 |
03 · 기술 스택과 파일 구조
index.html 화면·다이얼로그·문서 다운로드 style.css 테마·반응형·축하 애니메이션 engine.js 순열·교환·힌트·저장 검증 (독립 함수) app.js 상태·렌더링·저장·감상 제스처 assets/ 원본 JPEG + 1600px 게임 이미지 docs/ 자체 스타일을 포함하는 독립 HTML 3종 tests/engine.cjs Node 기본 assert를 사용하는 규칙 테스트
게임용 1,600×1,200 이미지와 4,000×3,000 원본을 구분한다. 원본 파일은 수정 없이 복사한다. 문서에는 외부 의존성이 없고, 게임 전체 폴더를 내려받으면 인터넷 없이도 기본 플레이가 가능하다. 서비스 워커 캐시나 설치형 PWA는 포함하지 않는다.
04 · 아키텍처와 상태
↕ localStorage
감상 입력 → 포인터 맵 → 확대·이동 좌표 → 이미지 transform
{
level: 0, // 난이도 인덱스 0..2
tiles: [2, 5, 1, 0, 3, 4], // 위치 인덱스 → 원래 조각 번호
hints: 3, // 0..3
moves: 0, // 성공한 교환 + 찬스 사용 횟수
elapsed: 0 // 활성 플레이 초
}선택 조각, 감상 여부, 확대 배율은 일시적인 UI 상태다. 영구 키는 junyeong-starry-v1. 복원 전에 난이도, 조각 순열의 크기·중복·범위, 잔여 찬스, 시간과 횟수를 검사한다. 손상되었으면 기본 난이도로 초기화한다. 저장 예외는 게임 진행을 방해하지 않는다. 여러 탭 간 동기화·서버 백업은 지원하지 않으며 마지막 저장이 우선한다.
05 · 핵심 알고리즘
A. 섞기와 항상 풀 수 있는 규칙
Fisher–Yates로 전체 순열을 만든 뒤 제자리에 남은 고정점을 다른 위치와 교환한다. 시작부터 맞은 조각이 없도록 보정한다. 두 임의 조각을 교환할 수 있어 슬라이딩 퍼즐의 홀짝 제약이 없다. 이미 맞은 조각을 잠가도 남은 순열은 교환만으로 풀 수 있다. 시간·공간 복잡도는 O(n)이다. 보정 때문에 모든 순열이 균등 확률로 나오지는 않지만 교육용 게임에서 요구하지 않는다.
B. 조각 렌더링
column = piece % cols
row = floor(piece / cols)
background-size = (cols × 100)% (rows × 100)%
background-position = (column / (cols−1) × 100)%
(row / (rows−1) × 100)%판의 종횡비는 원본과 같은 4:3이다. 각 버튼 배경으로 동일한 이미지의 다른 구간을 표시하여 개별 조각 파일과 캔버스 추출 없이 동작한다.
C. 교환과 찬스
선택 A → 선택 B
if A ≠ B and tiles[A] ≠ A and tiles[B] ≠ B:
swap(tiles[A], tiles[B])
moves += 1
찬스:
target = 첫 번째 틀린 위치
source = tiles.indexOf(target)
swap(tiles[target], tiles[source])
hints -= 1; moves += 1찬스는 최소 한 조각을 맞추며 이미 맞은 조각을 훼손하지 않는다. 매 이동마다 정답 수를 재계산하고 전부 맞으면 한 번의 완료 대화상자를 연다. 완성 판을 다시 로드하면 완성 상태를 보존하며 축하 창을 반복 표시하지 않는다.
D. 확대와 좌표 보존
base = min(viewportWidth / imageWidth, viewportHeight / imageHeight) nextZoom = clamp(zoom × factor, 1, 8) ratio = nextZoom / zoom x = pointerX − (pointerX − x) × ratio y = pointerY − (pointerY − y) × ratio transform = translate(x, y) scale(base × nextZoom)
포인터 아래의 이미지 지점을 유지하면서 확대한다. 두 포인터의 거리 비율을 핀치 배율로, 중점 변화를 이동량으로 쓴다. 포인터 취소·캡처 해제도 정리한다. 이미지가 화면보다 작으면 중앙 정렬하고, 크면 빈 공간이 드러나지 않도록 경계를 제한한다. 회전·창 크기 변경 때 화면 맞춤으로 재설정한다.
06 · UI/UX와 접근성
남색 미술관과 따뜻한 종이색 프레임, 금빛 보상을 사용한다. 650px 이하에서는 보드와 도구를 세로로 배치한다. 드래그 정밀도 대신 두 번의 탭으로 조작한다. 정답은 색뿐 아니라 별과 레이블로 구분한다. 모든 동작 버튼은 키보드 조작 가능하며 동적 상태는 polite live region으로 전달한다. 감상 영역에는 터치 기본 스크롤을 막고 게임 페이지의 스크롤은 유지한다. 팝업은 native dialog로 초점과 Escape 닫기를 제공한다.
07 · 검증과 운영
node tests/engine.cjs로 세 난이도의 섞기 불변식, 찬스의 단조 증가, 교환 잠금, 저장 데이터 검증을 실행한다. node --check app.js로 문법을 확인한다. 브라우저 검증 내용과 한계는 README의 검증 기록을 참조한다.
| 수동 회귀 항목 | 합격 기준 |
|---|---|
| 난이도 변경·취소 | 취소 시 기존 판 유지, 확인 시 모든 통계 초기화 |
| 찬스 4번째 요청 | 3회까지만 사용, 남은 횟수 0 |
| 새로고침 | 동일 순열·찬스·통계 복원 |
| 모바일 | 가로 넘침 없이 탭과 스크롤 가능, 핀치 확대 |
| 완성 | 축하 표시, 감상 복귀, 새 판 가능 |
| 저장 차단·손상 | 예외 없이 새 판 실행 |
로컬: python3 -m http.server 8000 실행 후 localhost:8000에 접속한다. 배포는 현재 폴더 전체를 일반 정적 호스팅에 올린다. 빌드·환경 변수·DB·비밀키·유료 서버가 필요 없다. 원본 사진의 공개 여부는 배포 전 보호자가 결정할 수 있다. 이 구현은 정적 파일로 배포한다. 기존 Cloudflare 계정의 Workers Static Assets를 사용하며 별도 게임 서버나 데이터베이스를 생성하지 않는다.
최신 주요 브라우저를 대상으로 한다. OS별 실제 장치 전수 검증은 별도 필요하다. 타이머는 초 단위 UI용이며 경쟁 기록·정밀 측정 용도가 아니다. 자동 저장은 기기 내에만 남는다.