838 lines
28 KiB
HTML
838 lines
28 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%; }
|
|
.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; pointer-events:auto; }
|
|
.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);
|
|
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; }
|
|
.celebrate .confirm-btn{
|
|
display:block;
|
|
margin:0.75rem auto 0;
|
|
padding:0.4rem 1.1rem;
|
|
border:none; border-radius:999px;
|
|
background:#4f9d6e; color:#fff;
|
|
font-weight:700; font-size:0.85rem;
|
|
font-family:inherit;
|
|
cursor:pointer;
|
|
}
|
|
.celebrate .confirm-btn:active{ background:#3f8a5d; }
|
|
@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; }
|
|
}
|
|
: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);
|
|
font-size: 0.92rem;
|
|
}
|
|
noscript{ color: var(--muted); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Tree of Life</h1>
|
|
<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 class="quote" id="quoteText"></div>
|
|
<button type="button" class="confirm-btn" id="celebrateConfirm">확인</button>
|
|
</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');
|
|
const quoteEl = document.getElementById('quoteText');
|
|
|
|
// ---------- 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.4, 7.7);
|
|
camera.lookAt(0, 1.75, 0.4);
|
|
|
|
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);
|
|
}
|
|
|
|
// ---------- 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 APPLE_COUNT = 8;
|
|
function randomAppleHome(){
|
|
const spec = foliageSpecs[Math.floor(Math.random() * foliageSpecs.length)];
|
|
const dir = new THREE.Vector3(Math.random() - 0.5, Math.random() - 0.2, Math.random() - 0.5).normalize();
|
|
return new THREE.Vector3(spec.pos[0], spec.pos[1], spec.pos[2]).addScaledVector(dir, spec.r * 0.92);
|
|
}
|
|
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 });
|
|
|
|
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
|
|
const LEAF_LINGER = 1.8; // how long a fallen leaf rests before fading away
|
|
const SQUIRREL_COOLDOWN = 6; // min seconds between squirrel appearances
|
|
|
|
// 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);
|
|
// each grow picks a fresh random spot on the foliage rather than a fixed home
|
|
const treeSlots = Array.from({ length: APPLE_COUNT }, () => {
|
|
const home = randomAppleHome();
|
|
const mesh = createAppleMesh();
|
|
mesh.position.copy(home);
|
|
treeGroup.add(mesh);
|
|
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;
|
|
|
|
// ---------- leaves ----------
|
|
const leafShape = new THREE.Shape();
|
|
leafShape.moveTo(0, -0.09);
|
|
leafShape.quadraticCurveTo(0.07, -0.04, 0.065, 0.03);
|
|
leafShape.quadraticCurveTo(0.05, 0.08, 0, 0.11);
|
|
leafShape.quadraticCurveTo(-0.05, 0.08, -0.065, 0.03);
|
|
leafShape.quadraticCurveTo(-0.07, -0.04, 0, -0.09);
|
|
const leafGeo = new THREE.ShapeGeometry(leafShape);
|
|
const leafMats = [0x4f9d6e, 0x3f8a5d, 0x5aab77].map(
|
|
(c) => new THREE.MeshStandardMaterial({ color: c, roughness: 0.85, side: THREE.DoubleSide })
|
|
);
|
|
function createLeafMesh(){
|
|
const mesh = new THREE.Mesh(leafGeo, leafMats[Math.floor(Math.random() * leafMats.length)]);
|
|
const s = 0.8 + Math.random() * 0.35;
|
|
mesh.scale.set(s, s, s);
|
|
return mesh;
|
|
}
|
|
const fallingLeaves = []; // falling | resting | vanishing
|
|
|
|
function randomFoliageWorldPoint(){
|
|
const spec = foliageSpecs[Math.floor(Math.random() * foliageSpecs.length)];
|
|
const dir = new THREE.Vector3(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize();
|
|
const local = new THREE.Vector3(spec.pos[0], spec.pos[1], spec.pos[2]).addScaledVector(dir, spec.r * 0.9);
|
|
return treeGroup.localToWorld(local);
|
|
}
|
|
|
|
function knockOffLeaf(){
|
|
if (fallingLeaves.length > 30) return;
|
|
const world = randomFoliageWorldPoint();
|
|
const mesh = createLeafMesh();
|
|
mesh.position.copy(world);
|
|
mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
|
|
scene.add(mesh);
|
|
|
|
fallingLeaves.push({
|
|
mesh,
|
|
velocity: new THREE.Vector3(
|
|
(Math.random() - 0.5) * 0.8 + angVel.z * 0.04,
|
|
0.3 + Math.random() * 0.3,
|
|
(Math.random() - 0.5) * 0.8
|
|
),
|
|
spin: new THREE.Vector3(
|
|
(Math.random() - 0.5) * 4,
|
|
(Math.random() - 0.5) * 4,
|
|
(Math.random() - 0.5) * 4
|
|
),
|
|
driftPhase: Math.random() * Math.PI * 2,
|
|
state: 'falling',
|
|
timer: 0,
|
|
});
|
|
}
|
|
|
|
// ---------- squirrel ----------
|
|
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();
|
|
|
|
const body = new THREE.Mesh(new THREE.SphereGeometry(0.16, 8, 6), bodyMat);
|
|
body.scale.set(1, 0.85, 1.3);
|
|
g.add(body);
|
|
|
|
const belly = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), bellyMat);
|
|
belly.scale.set(0.8, 0.7, 1);
|
|
belly.position.set(0, -0.04, 0.05);
|
|
g.add(belly);
|
|
|
|
const head = new THREE.Mesh(new THREE.SphereGeometry(0.11, 8, 6), bodyMat);
|
|
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);
|
|
earL.rotation.x = -0.3;
|
|
g.add(earL);
|
|
const earR = earL.clone();
|
|
earR.position.x = 0.06;
|
|
g.add(earR);
|
|
|
|
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;
|
|
}
|
|
|
|
const fallingSquirrels = []; // falling | fleeing
|
|
|
|
function knockOffSquirrel(){
|
|
if (fallingSquirrels.length) return;
|
|
const world = randomFoliageWorldPoint();
|
|
const mesh = createSquirrelMesh();
|
|
mesh.position.copy(world);
|
|
scene.add(mesh);
|
|
|
|
const shadow = makeShadowBlob(0.55);
|
|
scene.add(shadow);
|
|
|
|
// camera looks down roughly -Z, so world X is screen left/right — flee sideways
|
|
const fleeSign = Math.random() < 0.5 ? -1 : 1;
|
|
const fleeDir = new THREE.Vector3(fleeSign, 0, (Math.random() - 0.5) * 0.35).normalize();
|
|
fallingSquirrels.push({
|
|
mesh,
|
|
shadow,
|
|
velocity: new THREE.Vector3(
|
|
(Math.random() - 0.5) * 1.2 + angVel.z * 0.05,
|
|
0.6 + Math.random() * 0.4,
|
|
(Math.random() - 0.5) * 1.2
|
|
),
|
|
fleeDir,
|
|
state: 'falling',
|
|
timer: 0,
|
|
});
|
|
}
|
|
|
|
function updateBasketCounter(){
|
|
basketCountEl.textContent = `${basketCount} / ${BASKET_FULL}`;
|
|
}
|
|
updateBasketCounter();
|
|
|
|
// ---------- 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;
|
|
let lastLeafAt = -10;
|
|
let lastSquirrelAt = -999;
|
|
|
|
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 knockOffRandomApple(){
|
|
const candidates = treeSlots.filter(s => s.state === 'attached');
|
|
if (!candidates.length) return;
|
|
const slot = candidates[Math.floor(Math.random() * candidates.length)];
|
|
|
|
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 growSlotApple(slot){
|
|
slot.home = randomAppleHome();
|
|
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();
|
|
}
|
|
|
|
// 명언은 축하 화면이 뜰 때만 그 자리에서 불러와 쓰고, 목록을 어디에도 저장해두지
|
|
// 않는다 — 함수가 끝나면 로컬 변수(list)라 참조가 안 남아 바로 GC 대상이 된다.
|
|
async function celebrate(){
|
|
celebrateEl.classList.add('show');
|
|
quoteEl.classList.remove('show');
|
|
spawnConfetti();
|
|
|
|
try {
|
|
const res = await fetch('./quotes.txt');
|
|
if (!res.ok) return;
|
|
const text = await res.text();
|
|
const list = text.split('\n').map(s => s.trim()).filter(Boolean);
|
|
if (list.length){
|
|
quoteEl.textContent = list[Math.floor(Math.random() * list.length)];
|
|
quoteEl.classList.add('show');
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
document.getElementById('celebrateConfirm').addEventListener('click', clearBasket);
|
|
|
|
// ---------- 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){
|
|
if (treeSlots.some(s => s.state === 'attached')){
|
|
const chance = 1 - Math.exp(-shakeEnergy * dt * 0.9);
|
|
if (Math.random() < chance){
|
|
knockOffRandomApple();
|
|
lastDetachAt = now;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shakeEnergy > 0.35 && now - lastLeafAt > 0.05){
|
|
const chance = 1 - Math.exp(-shakeEnergy * dt * 1.6);
|
|
if (Math.random() < chance){
|
|
knockOffLeaf();
|
|
lastLeafAt = now;
|
|
}
|
|
}
|
|
|
|
if (shakeEnergy > 1.1 && fallingSquirrels.length === 0 && now - lastSquirrelAt > SQUIRREL_COOLDOWN){
|
|
if (Math.random() < shakeEnergy * dt * 0.03){
|
|
knockOffSquirrel();
|
|
lastSquirrelAt = now;
|
|
}
|
|
}
|
|
|
|
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){
|
|
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 > MISS_LINGER){
|
|
apple.state = 'vanishing';
|
|
apple.timer = 0;
|
|
}
|
|
} else if (apple.state === 'vanishing'){
|
|
apple.timer += dt;
|
|
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 (let i = fallingLeaves.length - 1; i >= 0; i--){
|
|
const leaf = fallingLeaves[i];
|
|
if (leaf.state === 'falling'){
|
|
leaf.timer += dt;
|
|
leaf.velocity.y += GRAVITY * 0.28 * dt;
|
|
leaf.mesh.position.x += leaf.velocity.x * dt + Math.sin(leaf.timer * 4 + leaf.driftPhase) * 0.4 * dt;
|
|
leaf.mesh.position.z += leaf.velocity.z * dt + Math.cos(leaf.timer * 3.3 + leaf.driftPhase) * 0.4 * dt;
|
|
leaf.mesh.position.y += leaf.velocity.y * dt;
|
|
leaf.mesh.rotation.x += leaf.spin.x * dt;
|
|
leaf.mesh.rotation.y += leaf.spin.y * dt;
|
|
leaf.mesh.rotation.z += leaf.spin.z * dt;
|
|
|
|
if (leaf.mesh.position.y <= 0.01){
|
|
leaf.mesh.position.y = 0.01;
|
|
leaf.state = 'resting';
|
|
leaf.timer = 0;
|
|
}
|
|
} else if (leaf.state === 'resting'){
|
|
leaf.timer += dt;
|
|
if (leaf.timer > LEAF_LINGER){
|
|
leaf.state = 'vanishing';
|
|
leaf.timer = 0;
|
|
}
|
|
} else if (leaf.state === 'vanishing'){
|
|
leaf.timer += dt;
|
|
const t = Math.min(leaf.timer / 0.4, 1);
|
|
leaf.mesh.scale.setScalar(1 - t);
|
|
if (t >= 1){
|
|
scene.remove(leaf.mesh);
|
|
fallingLeaves.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = fallingSquirrels.length - 1; i >= 0; i--){
|
|
const sq = fallingSquirrels[i];
|
|
if (sq.state === 'falling'){
|
|
sq.velocity.y += GRAVITY * dt;
|
|
sq.mesh.position.addScaledVector(sq.velocity, dt);
|
|
sq.mesh.rotation.x += sq.velocity.z * dt;
|
|
sq.mesh.rotation.z -= sq.velocity.x * dt;
|
|
|
|
if (sq.mesh.position.y <= GROUND_Y){
|
|
sq.mesh.position.y = GROUND_Y;
|
|
sq.mesh.rotation.set(0, Math.atan2(sq.fleeDir.x, sq.fleeDir.z), 0);
|
|
sq.state = 'fleeing';
|
|
sq.timer = 0;
|
|
}
|
|
sq.shadow.position.set(sq.mesh.position.x, 0.01, sq.mesh.position.z);
|
|
const h = Math.max(sq.mesh.position.y - GROUND_Y, 0);
|
|
sq.shadow.material.opacity = THREE.MathUtils.clamp(1 - h * 0.6, 0.15, 1);
|
|
} else if (sq.state === 'fleeing'){
|
|
sq.timer += dt;
|
|
const speed = 3.4;
|
|
sq.mesh.position.addScaledVector(sq.fleeDir, speed * dt);
|
|
sq.mesh.position.y = GROUND_Y + Math.abs(Math.sin(sq.timer * 14)) * 0.05;
|
|
sq.shadow.position.set(sq.mesh.position.x, 0.01, sq.mesh.position.z);
|
|
|
|
const distFromCenter = Math.hypot(sq.mesh.position.x, sq.mesh.position.z);
|
|
if (distFromCenter > 6.2 || sq.timer > 3){
|
|
scene.remove(sq.mesh);
|
|
scene.remove(sq.shadow);
|
|
fallingSquirrels.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;
|
|
slot.mesh.scale.setScalar(s);
|
|
if (t >= 1) slot.state = 'attached';
|
|
}
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
requestAnimationFrame(tick);
|
|
}
|
|
requestAnimationFrame(tick);
|
|
</script>
|
|
</body>
|
|
</html>
|