diff --git a/README.md b/README.md index 1fa193f..a292954 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Tree of Life - HTML5 page for the podman pages service - 저사양 모바일 GPU에서도 부드럽게 동작하도록 로우폴리 지오메트리, 실시간 그림자 대신 블롭 섀도우, 픽셀비 상한(2x) 적용 - 나무를 마우스/터치로 드래그하면 스프링-댐퍼 물리로 자연스럽게 흔들리고, 일정 세기 이상 흔들면 사과(8개)가 하나씩 떨어짐 - 가지는 사과가 떨어지고 약 2.6초 후 원래 자리에서 새 사과가 다시 자라남(pop-in 애니메이션) — 떨어진 사과가 바구니에 들어갔는지 여부와 무관하게 독립적으로 재생됨 -- 나무 아래 바구니에 사과가 떨어지면 자동으로 담기고(빗나가면 잔디에 튕겼다가 사라짐), 5개가 모이면 축하 메시지 + 컨페티가 뜨고 약 2.8초 후 바구니가 비워지며 반복 가능 +- 나무 아래 바구니에 사과가 떨어지면 자동으로 담기고(빗나가면 잔디에 튕겼다가 사라짐), 5개가 모이면 축하 메시지 + 컨페티와 함께 `quotes.txt`에서 뽑은 랜덤 명언이 뜨고 약 2.8초 후 바구니가 비워지며 반복 가능 ## pages 서비스 연동 diff --git a/index.html b/index.html index 1de4f62..01d74e5 100644 --- a/index.html +++ b/index.html @@ -88,7 +88,20 @@ padding:0.85rem 1.3rem; border-radius:14px; font-weight:700; font-size:1.05rem; box-shadow:0 10px 28px rgba(0,0,0,0.22); + max-width: 80vw; } + .celebrate .quote{ + display:none; + margin-top:0.5rem; + font-weight:500; font-size:0.85rem; + color:#4c6155; + } + .celebrate .quote.show{ display:block; } + @media (prefers-color-scheme: dark){ + .celebrate .quote{ color:#9db3a6; } + } + :root[data-theme="dark"] .celebrate .quote{ color:#9db3a6; } + :root[data-theme="light"] .celebrate .quote{ color:#4c6155; } @media (prefers-color-scheme: dark){ .celebrate .msg{ background: rgba(20,28,22,0.92); color:#eaf3ec; } } @@ -107,7 +120,12 @@
나무를 드래그해서 흔들면 사과가 떨어져요. 바구니에 5개를 모아보세요 🧺
@@ -117,6 +135,13 @@ const stage = document.getElementById('stage'); const celebrateEl = document.getElementById('celebrate'); const confettiLayer = document.getElementById('confettiLayer'); const basketCountEl = document.getElementById('basketCount'); +const quoteEl = document.getElementById('quoteText'); + +let quotes = []; +fetch('./quotes.txt') + .then(r => (r.ok ? r.text() : '')) + .then(text => { quotes = text.split('\n').map(s => s.trim()).filter(Boolean); }) + .catch(() => {}); // ---------- renderer / scene / camera ---------- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'low-power' }); @@ -340,6 +365,8 @@ function knockOffLeaf(){ function createSquirrelMesh(){ const bodyMat = new THREE.MeshStandardMaterial({ color: 0x8a6642, roughness: 0.9, flatShading: true }); const bellyMat = new THREE.MeshStandardMaterial({ color: 0xe4c9a0, roughness: 0.9, flatShading: true }); + const darkMat = new THREE.MeshStandardMaterial({ color: 0x231a14, roughness: 0.5, flatShading: true }); + const noseMat = new THREE.MeshStandardMaterial({ color: 0x3a2a1e, roughness: 0.5, flatShading: true }); const g = new THREE.Group(); @@ -356,6 +383,23 @@ function createSquirrelMesh(){ head.position.set(0, 0.06, 0.22); g.add(head); + const snout = new THREE.Mesh(new THREE.SphereGeometry(0.055, 7, 6), bellyMat); + snout.scale.set(0.9, 0.75, 1); + snout.position.set(0, 0.02, 0.31); + g.add(snout); + + const nose = new THREE.Mesh(new THREE.SphereGeometry(0.017, 5, 4), noseMat); + nose.position.set(0, 0.035, 0.36); + g.add(nose); + + const eyeGeo = new THREE.SphereGeometry(0.02, 5, 4); + const eyeL = new THREE.Mesh(eyeGeo, darkMat); + eyeL.position.set(-0.045, 0.11, 0.28); + g.add(eyeL); + const eyeR = eyeL.clone(); + eyeR.position.x = 0.045; + g.add(eyeR); + const earGeo = new THREE.ConeGeometry(0.035, 0.07, 6); const earL = new THREE.Mesh(earGeo, bodyMat); earL.position.set(-0.06, 0.15, 0.26); @@ -365,11 +409,35 @@ function createSquirrelMesh(){ earR.position.x = 0.06; g.add(earR); - const tail = new THREE.Mesh(new THREE.SphereGeometry(0.14, 8, 6), bodyMat); - tail.scale.set(0.7, 1.4, 0.7); - tail.position.set(0, 0.2, -0.24); - tail.rotation.x = 0.7; - g.add(tail); + const legGeo = new THREE.SphereGeometry(0.042, 6, 5); + const legSpots = [ + [-0.08, -0.1, 0.16], [0.08, -0.1, 0.16], + [-0.09, -0.1, -0.12], [0.09, -0.1, -0.12], + ]; + for (const [x, y, z] of legSpots){ + const leg = new THREE.Mesh(legGeo, bodyMat); + leg.scale.set(0.75, 0.65, 0.75); + leg.position.set(x, y, z); + g.add(leg); + } + + // bushy curved tail made of tapering, gradually offset segments + const tailGroup = new THREE.Group(); + tailGroup.position.set(0, 0.1, -0.22); + tailGroup.rotation.x = 0.45; + g.add(tailGroup); + const tailSegs = [ + { r: 0.1, y: 0, z: 0 }, + { r: 0.095, y: 0.13, z: -0.02 }, + { r: 0.085, y: 0.25, z: -0.02 }, + { r: 0.07, y: 0.35, z: 0.02 }, + { r: 0.05, y: 0.43, z: 0.08 }, + ]; + for (const seg of tailSegs){ + const s = new THREE.Mesh(new THREE.SphereGeometry(seg.r, 7, 6), bodyMat); + s.position.set(0, seg.y, seg.z); + tailGroup.add(s); + } return g; } @@ -525,6 +593,12 @@ function catchInBasket(apple){ function celebrate(){ celebrateEl.classList.add('show'); + if (quotes.length){ + quoteEl.textContent = quotes[Math.floor(Math.random() * quotes.length)]; + quoteEl.classList.add('show'); + } else { + quoteEl.classList.remove('show'); + } spawnConfetti(); setTimeout(clearBasket, 2800); } diff --git a/quotes.txt b/quotes.txt new file mode 100644 index 0000000..4d599d2 --- /dev/null +++ b/quotes.txt @@ -0,0 +1,5 @@ +삶이 있는 한 희망은 있다. - 키케로 +성공은 최종적인 것이 아니고 실패도 치명적인 것이 아니다. 중요한 것은 계속할 용기다. - 윈스턴 처칠 +천 리 길도 한 걸음부터. - 노자 +오늘 할 수 있는 일에 최선을 다하라. - 랄프 왈도 에머슨 +네가 상상할 수 있다면, 이룰 수도 있다. - 월트 디즈니