Files
tree-of-life/index.html

369 lines
12 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Tree of Life</title>
<style>
:root{
--sky-top: #bfe3f0;
--sky-bottom: #eaf6ee;
--text: #1c2b22;
--muted: #4c6155;
}
@media (prefers-color-scheme: dark){
:root{
--sky-top: #16222a;
--sky-bottom: #0c1712;
--text: #eaf3ec;
--muted: #9db3a6;
}
}
:root[data-theme="dark"]{
--sky-top: #16222a; --sky-bottom: #0c1712;
--text: #eaf3ec; --muted: #9db3a6;
}
:root[data-theme="light"]{
--sky-top: #bfe3f0; --sky-bottom: #eaf6ee;
--text: #1c2b22; --muted: #4c6155;
}
*{ box-sizing:border-box; }
html,body{
height:100%; margin:0; overscroll-behavior:none;
}
body{
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:0.9rem;
padding: 1.4rem 1rem;
background: linear-gradient(180deg, var(--sky-top), var(--sky-bottom));
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Pretendard, Roboto, sans-serif;
text-align:center;
overflow:hidden;
}
h1{
margin:0;
font-size: clamp(1.5rem, 5vw, 2.2rem);
letter-spacing: 0.01em;
font-weight: 700;
}
.stage{
position:relative;
width:min(94vw, 520px);
aspect-ratio: 3/4;
max-height: 62vh;
border-radius: 20px;
overflow:hidden;
touch-action: none;
cursor: grab;
}
.stage.grabbing{ cursor: grabbing; }
.stage canvas{ display:block; width:100%; height:100%; }
p.hint{
margin:0;
color: var(--muted);
font-size: 0.92rem;
}
noscript{ color: var(--muted); }
</style>
</head>
<body>
<h1>Tree of Life</h1>
<div class="stage" id="stage"></div>
<p class="hint">나무를 드래그해서 흔들어 보세요 🍎</p>
<script src="./vendor/three.min.js"></script>
<script>
const stage = document.getElementById('stage');
// ---------- renderer / scene / camera ----------
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'low-power' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const skyColor = new THREE.Color(0xbfe3f0);
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);
function syncSky(){
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
const root = document.documentElement.getAttribute('data-theme');
const isDark = root ? root === 'dark' : dark;
const hex = isDark ? 0x16222a : 0xbfe3f0;
skyColor.setHex(hex);
scene.background = skyColor;
scene.fog.color.setHex(hex);
}
syncSky();
matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change', syncSky);
new MutationObserver(syncSky).observe(document.documentElement, { attributes:true, attributeFilter:['data-theme'] });
// ---------- lights ----------
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
const sun = new THREE.DirectionalLight(0xfff2d8, 0.9);
sun.position.set(4, 6, 3);
scene.add(sun);
// ---------- ground ----------
const groundMat = new THREE.MeshStandardMaterial({ color: 0x5f9e63, roughness: 1 });
const ground = new THREE.Mesh(new THREE.CircleGeometry(6, 32), groundMat);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
// soft blob-shadow texture (radial gradient on a canvas)
function makeShadowTexture(){
const c = document.createElement('canvas');
c.width = c.height = 128;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(64,64,4,64,64,64);
g.addColorStop(0, 'rgba(0,0,0,0.35)');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0,0,128,128);
return new THREE.CanvasTexture(c);
}
const shadowTex = makeShadowTexture();
function makeShadowBlob(size){
const m = new THREE.Mesh(
new THREE.PlaneGeometry(size, size),
new THREE.MeshBasicMaterial({ map: shadowTex, transparent:true, depthWrite:false })
);
m.rotation.x = -Math.PI/2;
m.position.y = 0.01;
return m;
}
const treeShadow = makeShadowBlob(3.6);
scene.add(treeShadow);
// ---------- tree ----------
const treeGroup = new THREE.Group();
scene.add(treeGroup);
const trunkGeo = new THREE.CylinderGeometry(0.26, 0.4, 2.2, 8);
trunkGeo.translate(0, 1.1, 0);
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x6b4a35, roughness: 1, flatShading:true });
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
treeGroup.add(trunk);
const foliageSpecs = [
{ pos:[0, 3.3, 0], r:1.15, c:0x4f9d6e },
{ pos:[0.95, 2.7, 0.5], r:0.95, c:0x3f8a5d },
{ pos:[-0.95, 2.7, -0.4], r:0.95, c:0x5aab77 },
{ pos:[0.55, 2.25,-0.95], r:0.85, c:0x3f8a5d },
{ pos:[-0.6, 2.15, 0.85], r:0.85, c:0x4f9d6e },
{ pos:[0, 2.6, 0], r:1.0, c:0x5aab77 },
];
const treeHitMeshes = [trunk];
for (const spec of foliageSpecs){
const geo = new THREE.IcosahedronGeometry(spec.r, 0);
const mat = new THREE.MeshStandardMaterial({ color: spec.c, roughness: 0.9, flatShading:true });
const blob = new THREE.Mesh(geo, mat);
blob.position.set(...spec.pos);
treeGroup.add(blob);
treeHitMeshes.push(blob);
}
// ---------- apples ----------
const appleHomes = [
new THREE.Vector3(1.55, 2.75, 0.65),
new THREE.Vector3(-1.5, 2.65, -0.55),
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),
];
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) => {
const mesh = new THREE.Mesh(appleGeo, appleMat);
const stem = new THREE.Mesh(stemGeo, stemMat);
stem.position.y = 0.16;
mesh.add(stem);
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,
};
});
// ---------- drag-to-shake interaction ----------
const raycaster = new THREE.Raycaster();
const pointerNdc = new THREE.Vector2();
let dragging = false;
let lastX = 0, lastY = 0;
const angle = { x: 0, z: 0 };
const angVel = { x: 0, z: 0 };
const SPRING_K = 42, SPRING_C = 7;
let shakeEnergy = 0;
let lastDetachAt = -10;
function toNdc(clientX, clientY){
const rect = renderer.domElement.getBoundingClientRect();
pointerNdc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointerNdc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
}
function hitsTree(clientX, clientY){
toNdc(clientX, clientY);
raycaster.setFromCamera(pointerNdc, camera);
return raycaster.intersectObjects(treeHitMeshes, false).length > 0;
}
renderer.domElement.addEventListener('pointerdown', (e) => {
if (!hitsTree(e.clientX, e.clientY)) return;
dragging = true;
lastX = e.clientX; lastY = e.clientY;
stage.classList.add('grabbing');
renderer.domElement.setPointerCapture(e.pointerId);
});
renderer.domElement.addEventListener('pointermove', (e) => {
if (!dragging) return;
const dx = (e.clientX - lastX) / renderer.domElement.clientWidth;
const dy = (e.clientY - lastY) / renderer.domElement.clientHeight;
lastX = e.clientX; lastY = e.clientY;
const sensitivity = 26;
angVel.z += dx * sensitivity;
angVel.x += -dy * sensitivity * 0.6;
});
function releaseDrag(e){
if (!dragging) return;
dragging = false;
stage.classList.remove('grabbing');
try { renderer.domElement.releasePointerCapture(e.pointerId); } catch {}
}
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);
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;
}
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;
}
// ---------- animation loop ----------
const clock = new THREE.Clock();
function resize(){
const w = stage.clientWidth, h = stage.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(stage);
resize();
function tick(){
const dt = Math.min(clock.getDelta(), 1/30);
// spring-damped shake
const accelZ = -SPRING_K * angle.z - SPRING_C * angVel.z;
const accelX = -SPRING_K * angle.x - SPRING_C * angVel.x;
angVel.z += accelZ * dt;
angVel.x += accelX * dt;
angle.z = THREE.MathUtils.clamp(angle.z + angVel.z * dt, -0.55, 0.55);
angle.x = THREE.MathUtils.clamp(angle.x + angVel.x * dt, -0.4, 0.4);
treeGroup.rotation.z = angle.z;
treeGroup.rotation.x = angle.x;
treeGroup.updateMatrixWorld();
const rawEnergy = Math.abs(angVel.z) + Math.abs(angVel.x);
shakeEnergy += (rawEnergy - shakeEnergy) * 0.15;
const now = clock.elapsedTime;
if (shakeEnergy > 1.1 && now - lastDetachAt > 0.18){
const attached = apples.filter(a => a.state === 'attached');
if (attached.length){
const chance = 1 - Math.exp(-shakeEnergy * dt * 0.9);
if (Math.random() < chance){
detachApple(attached[Math.floor(Math.random() * attached.length)]);
lastDetachAt = now;
}
}
}
for (const apple of apples){
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;
if (apple.mesh.position.y <= GROUND_Y){
apple.mesh.position.y = GROUND_Y;
if (Math.abs(apple.velocity.y) > 0.6){
apple.velocity.y *= -0.35;
apple.velocity.x *= 0.55;
apple.velocity.z *= 0.55;
} else {
apple.velocity.set(0,0,0);
apple.state = 'resting';
apple.timer = 0;
}
}
apple.shadow.position.set(apple.mesh.position.x, 0.01, apple.mesh.position.z);
const h = Math.max(apple.mesh.position.y - GROUND_Y, 0);
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'){
apple.timer += dt;
const t = Math.min(apple.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';
}
}
renderer.render(scene, camera);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
</script>
</body>
</html>