Add basket-catch mini goal and increase apples to 8

This commit is contained in:
2026-07-20 01:53:52 +09:00
parent 6b683f2511
commit 558e48db08
2 changed files with 233 additions and 56 deletions

View File

@@ -2,10 +2,11 @@
Tree of Life - HTML5 page for the podman pages service
- 정적 HTML5 + Three.js 기반 3D 사과나무 (`index.html` + `vendor/three.module.min.js`, 외부 CDN 의존성 없이 자체 호스팅)
- 정적 HTML5 + Three.js 기반 3D 사과나무 (`index.html` + `vendor/three.min.js`, 외부 CDN 의존성 없이 자체 호스팅)
- 저사양 모바일 GPU에서도 부드럽게 동작하도록 로우폴리 지오메트리, 실시간 그림자 대신 블롭 섀도우, 픽셀비 상한(2x) 적용
- 나무를 마우스/터치로 드래그하면 스프링-댐퍼 물리로 자연스럽게 흔들리고, 일정 세기 이상 흔들면 사과(5개)가 하나씩 떨어짐
- 떨어진 사과는 바닥에 닿아 통통 튄 뒤 멈추고, 약 2.6초 후 원래 매달려 있던 자리에서 다시 자라남(pop-in 애니메이션)
- 나무를 마우스/터치로 드래그하면 스프링-댐퍼 물리로 자연스럽게 흔들리고, 일정 세기 이상 흔들면 사과(8개)가 하나씩 떨어짐
- 가지는 사과가 떨어지고 약 2.6초 후 원래 자리에서 새 사과가 다시 자라남(pop-in 애니메이션) — 떨어진 사과가 바구니에 들어갔는지 여부와 무관하게 독립적으로 재생됨
- 나무 아래 바구니에 사과가 떨어지면 자동으로 담기고(빗나가면 잔디에 튕겼다가 사라짐), 5개가 모이면 축하 메시지 + 컨페티가 뜨고 약 2.8초 후 바구니가 비워지며 반복 가능
## pages 서비스 연동

View File

@@ -59,6 +59,41 @@
}
.stage.grabbing{ cursor: grabbing; }
.stage canvas{ display:block; width:100%; height:100%; }
.hud{
position:absolute; top:0.6rem; right:0.7rem; z-index:2;
background: rgba(0,0,0,0.35); color:#fff; font-size:0.8rem; font-weight:600;
padding:0.3rem 0.65rem; border-radius:999px;
display:flex; align-items:center; gap:0.3rem;
pointer-events:none;
font-variant-numeric: tabular-nums;
}
.confetti-layer{ position:absolute; inset:0; z-index:3; overflow:hidden; pointer-events:none; }
.confetti-piece{
position:absolute; top:-14px; width:8px; height:14px; border-radius:2px;
animation: confetti-fall linear forwards;
}
@keyframes confetti-fall{
0%{ transform: translateY(0) rotate(var(--rot, 0deg)); opacity:1; }
100%{ transform: translateY(70vh) rotate(calc(var(--rot, 0deg) + 220deg)); opacity:0; }
}
.celebrate{
position:absolute; inset:0; z-index:4;
display:flex; align-items:center; justify-content:center;
opacity:0; pointer-events:none;
transition: opacity 0.3s ease;
}
.celebrate.show{ opacity:1; }
.celebrate .msg{
background: rgba(255,255,255,0.92); color:#1c2b22;
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);
}
@media (prefers-color-scheme: dark){
.celebrate .msg{ background: rgba(20,28,22,0.92); color:#eaf3ec; }
}
:root[data-theme="dark"] .celebrate .msg{ background: rgba(20,28,22,0.92); color:#eaf3ec; }
:root[data-theme="light"] .celebrate .msg{ background: rgba(255,255,255,0.92); color:#1c2b22; }
p.hint{
margin:0;
color: var(--muted);
@@ -69,12 +104,19 @@
</head>
<body>
<h1>Tree of Life</h1>
<div class="stage" id="stage"></div>
<p class="hint">나무를 드래그해서 흔들어 보세요 🍎</p>
<div class="stage" id="stage">
<div class="hud">🧺 <span id="basketCount">0 / 5</span></div>
<div class="confetti-layer" id="confettiLayer"></div>
<div class="celebrate" id="celebrate"><div class="msg">🎉 사과 5개를 모았어요!</div></div>
</div>
<p class="hint">나무를 드래그해서 흔들면 사과가 떨어져요. 바구니에 5개를 모아보세요 🧺</p>
<script src="./vendor/three.min.js"></script>
<script>
const stage = document.getElementById('stage');
const celebrateEl = document.getElementById('celebrate');
const confettiLayer = document.getElementById('confettiLayer');
const basketCountEl = document.getElementById('basketCount');
// ---------- renderer / scene / camera ----------
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'low-power' });
@@ -87,8 +129,8 @@ scene.background = skyColor;
scene.fog = new THREE.Fog(skyColor.getHex(), 9, 16);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 50);
camera.position.set(0, 3.1, 7.2);
camera.lookAt(0, 2.0, 0);
camera.position.set(0, 3.4, 7.7);
camera.lookAt(0, 1.75, 0.4);
function syncSky(){
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
@@ -168,6 +210,38 @@ for (const spec of foliageSpecs){
treeHitMeshes.push(blob);
}
// ---------- basket ----------
const BASKET_POS = new THREE.Vector3(0, 0, 1.15);
const BASKET_RADIUS = 0.8;
const BASKET_RIM_Y = 0.5;
const BASKET_FULL = 5;
const basketGroup = new THREE.Group();
basketGroup.position.copy(BASKET_POS);
scene.add(basketGroup);
const basketMat = new THREE.MeshStandardMaterial({ color: 0xc9974f, roughness: 1, flatShading:true, side: THREE.DoubleSide });
const basketWallGeo = new THREE.CylinderGeometry(BASKET_RADIUS, BASKET_RADIUS * 0.72, 0.5, 12, 1, true);
basketWallGeo.translate(0, 0.25, 0);
basketGroup.add(new THREE.Mesh(basketWallGeo, basketMat));
const basketFloor = new THREE.Mesh(new THREE.CircleGeometry(BASKET_RADIUS * 0.72, 12), basketMat);
basketFloor.rotation.x = -Math.PI / 2;
basketFloor.position.y = 0.02;
basketGroup.add(basketFloor);
const basketRim = new THREE.Mesh(
new THREE.TorusGeometry(BASKET_RADIUS, 0.05, 6, 16),
new THREE.MeshStandardMaterial({ color: 0xa97b3a, roughness: 1, flatShading:true })
);
basketRim.rotation.x = Math.PI / 2;
basketRim.position.y = 0.5;
basketGroup.add(basketRim);
const basketShadow = makeShadowBlob(BASKET_RADIUS * 2.6);
basketShadow.position.set(BASKET_POS.x, 0.005, BASKET_POS.z);
scene.add(basketShadow);
// ---------- apples ----------
const appleHomes = [
new THREE.Vector3(1.55, 2.75, 0.65),
@@ -175,36 +249,46 @@ const appleHomes = [
new THREE.Vector3(0.25, 3.75, 0.85),
new THREE.Vector3(0.95, 1.95, -1.25),
new THREE.Vector3(-0.85, 1.9, 1.15),
new THREE.Vector3(1.1, 3.15, -0.55),
new THREE.Vector3(-1.05, 3.05, 0.3),
new THREE.Vector3(0.15, 2.4, 1.35),
];
const appleGeo = new THREE.SphereGeometry(0.16, 10, 8);
const appleMat = new THREE.MeshStandardMaterial({ color: 0xd1453b, roughness: 0.5, flatShading:true });
const stemGeo = new THREE.CylinderGeometry(0.015, 0.02, 0.14, 5);
const stemMat = new THREE.MeshStandardMaterial({ color: 0x5a3d24, roughness:1 });
const GROUND_Y = 0.16;
const GRAVITY = -9.2;
const RESPAWN_AFTER = 2.6; // seconds resting on the ground before it grows back
const apples = appleHomes.map((home) => {
function createAppleMesh(){
const mesh = new THREE.Mesh(appleGeo, appleMat);
const stem = new THREE.Mesh(stemGeo, stemMat);
stem.position.y = 0.16;
mesh.add(stem);
return mesh;
}
const GROUND_Y = 0.16;
const GRAVITY = -9.2;
const RESPAWN_AFTER = 2.6; // how long a branch stays empty before a new apple grows back
const MISS_LINGER = 1.4; // how long a missed apple stays on the grass before fading away
// a tree "slot" holds at most one apple at a time and regrows independently of
// whatever happens to the apple once it falls (caught in the basket or not)
const treeSlots = appleHomes.map((home) => {
const mesh = createAppleMesh();
mesh.position.copy(home);
treeGroup.add(mesh);
const shadow = makeShadowBlob(0.5);
shadow.visible = false;
scene.add(shadow);
return {
mesh, home, shadow,
state: 'attached', // attached | falling | resting | popping
velocity: new THREE.Vector3(),
timer: 0,
};
return { home, mesh, state: 'attached', timer: 0 }; // attached | empty | popping
});
const fallingApples = []; // independent apples in flight: falling | resting | vanishing
const basketApples = []; // apples caught and resting in the basket
let basketCount = 0;
function updateBasketCounter(){
basketCountEl.textContent = `${basketCount} / ${BASKET_FULL}`;
}
updateBasketCounter();
// ---------- drag-to-shake interaction ----------
const raycaster = new THREE.Raycaster();
const pointerNdc = new THREE.Vector2();
@@ -255,33 +339,95 @@ renderer.domElement.addEventListener('pointerup', releaseDrag);
renderer.domElement.addEventListener('pointercancel', releaseDrag);
renderer.domElement.addEventListener('pointerleave', releaseDrag);
function detachApple(apple){
const world = new THREE.Vector3();
apple.mesh.getWorldPosition(world);
const q = new THREE.Quaternion();
apple.mesh.getWorldQuaternion(q);
scene.add(apple.mesh);
apple.mesh.position.copy(world);
apple.mesh.quaternion.copy(q);
apple.mesh.scale.set(1,1,1);
function knockOffRandomApple(){
const candidates = treeSlots.filter(s => s.state === 'attached');
if (!candidates.length) return;
const slot = candidates[Math.floor(Math.random() * candidates.length)];
apple.velocity.set(
(Math.random() - 0.5) * 1.4 + angVel.z * 0.05,
1.0 + Math.random() * 0.6,
(Math.random() - 0.5) * 1.4
);
apple.state = 'falling';
apple.shadow.visible = true;
const world = new THREE.Vector3();
slot.mesh.getWorldPosition(world);
const q = new THREE.Quaternion();
slot.mesh.getWorldQuaternion(q);
scene.add(slot.mesh);
slot.mesh.position.copy(world);
slot.mesh.quaternion.copy(q);
const shadow = makeShadowBlob(0.5);
scene.add(shadow);
fallingApples.push({
mesh: slot.mesh,
shadow,
velocity: new THREE.Vector3(
(Math.random() - 0.5) * 1.4 + angVel.z * 0.05,
1.0 + Math.random() * 0.6,
(Math.random() - 0.5) * 1.4
),
state: 'falling',
timer: 0,
});
slot.mesh = null;
slot.state = 'empty';
slot.timer = 0;
}
function respawnApple(apple){
treeGroup.add(apple.mesh);
apple.mesh.position.copy(apple.home);
apple.mesh.quaternion.identity();
apple.mesh.scale.set(0.001,0.001,0.001);
apple.state = 'popping';
apple.timer = 0;
apple.shadow.visible = false;
function growSlotApple(slot){
const mesh = createAppleMesh();
mesh.position.copy(slot.home);
mesh.scale.setScalar(0.001);
treeGroup.add(mesh);
slot.mesh = mesh;
slot.state = 'popping';
slot.timer = 0;
}
function catchInBasket(apple){
scene.remove(apple.shadow);
const angle = (basketApples.length / BASKET_FULL) * Math.PI * 2;
const r = 0.32;
apple.mesh.position.set(
BASKET_POS.x + Math.cos(angle) * r,
GROUND_Y + 0.05,
BASKET_POS.z + Math.sin(angle) * r
);
apple.mesh.rotation.set(0, 0, 0);
basketApples.push({ mesh: apple.mesh });
basketCount++;
updateBasketCounter();
if (basketCount >= BASKET_FULL) celebrate();
}
function celebrate(){
celebrateEl.classList.add('show');
spawnConfetti();
setTimeout(clearBasket, 2800);
}
function spawnConfetti(){
const colors = ['#e8574a', '#f2a541', '#4f9d6e', '#4a90c4', '#c46ad9'];
for (let i = 0; i < 28; i++){
const piece = document.createElement('span');
piece.className = 'confetti-piece';
piece.style.left = (Math.random() * 100) + '%';
piece.style.background = colors[i % colors.length];
piece.style.animationDuration = (0.9 + Math.random() * 0.9) + 's';
piece.style.animationDelay = (Math.random() * 0.25) + 's';
piece.style.setProperty('--rot', (Math.random() * 360) + 'deg');
confettiLayer.appendChild(piece);
piece.addEventListener('animationend', () => piece.remove());
}
}
function clearBasket(){
celebrateEl.classList.remove('show');
for (const b of basketApples) scene.remove(b.mesh);
basketApples.length = 0;
basketCount = 0;
updateBasketCounter();
}
// ---------- animation loop ----------
@@ -315,23 +461,33 @@ function tick(){
const now = clock.elapsedTime;
if (shakeEnergy > 1.1 && now - lastDetachAt > 0.18){
const attached = apples.filter(a => a.state === 'attached');
if (attached.length){
if (treeSlots.some(s => s.state === 'attached')){
const chance = 1 - Math.exp(-shakeEnergy * dt * 0.9);
if (Math.random() < chance){
detachApple(attached[Math.floor(Math.random() * attached.length)]);
knockOffRandomApple();
lastDetachAt = now;
}
}
}
for (const apple of apples){
for (let i = fallingApples.length - 1; i >= 0; i--){
const apple = fallingApples[i];
if (apple.state === 'falling'){
apple.velocity.y += GRAVITY * dt;
apple.mesh.position.addScaledVector(apple.velocity, dt);
apple.mesh.rotation.x += apple.velocity.z * dt;
apple.mesh.rotation.z -= apple.velocity.x * dt;
const dx = apple.mesh.position.x - BASKET_POS.x;
const dz = apple.mesh.position.z - BASKET_POS.z;
const inBasket = (dx * dx + dz * dz) <= BASKET_RADIUS * BASKET_RADIUS;
if (inBasket && apple.velocity.y < 0 && apple.mesh.position.y <= BASKET_RIM_Y && basketCount < BASKET_FULL){
catchInBasket(apple);
fallingApples.splice(i, 1);
continue;
}
if (apple.mesh.position.y <= GROUND_Y){
apple.mesh.position.y = GROUND_Y;
if (Math.abs(apple.velocity.y) > 0.6){
@@ -349,13 +505,33 @@ function tick(){
apple.shadow.material.opacity = THREE.MathUtils.clamp(1 - h * 0.7, 0.15, 1);
} else if (apple.state === 'resting'){
apple.timer += dt;
if (apple.timer > RESPAWN_AFTER) respawnApple(apple);
} else if (apple.state === 'popping'){
if (apple.timer > MISS_LINGER){
apple.state = 'vanishing';
apple.timer = 0;
}
} else if (apple.state === 'vanishing'){
apple.timer += dt;
const t = Math.min(apple.timer / 0.45, 1);
const t = Math.min(apple.timer / 0.35, 1);
apple.mesh.scale.setScalar(1 - t);
apple.shadow.material.opacity *= (1 - t * 0.2);
if (t >= 1){
scene.remove(apple.mesh);
scene.remove(apple.shadow);
fallingApples.splice(i, 1);
}
}
}
for (const slot of treeSlots){
if (slot.state === 'empty'){
slot.timer += dt;
if (slot.timer > RESPAWN_AFTER) growSlotApple(slot);
} else if (slot.state === 'popping'){
slot.timer += dt;
const t = Math.min(slot.timer / 0.45, 1);
const s = t < 1 ? THREE.MathUtils.lerp(0.001, 1, 1 - Math.pow(1 - t, 3)) : 1;
apple.mesh.scale.setScalar(s);
if (t >= 1) apple.state = 'attached';
slot.mesh.scale.setScalar(s);
if (t >= 1) slot.state = 'attached';
}
}