Rebuild as an interactive 3D apple tree with drag-to-shake
This commit is contained in:
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
Tree of Life - HTML5 page for the podman pages service
|
Tree of Life - HTML5 page for the podman pages service
|
||||||
|
|
||||||
- 순수 정적 HTML5 (`index.html` 단일 파일, 외부 의존성 없음)
|
- 정적 HTML5 + Three.js 기반 3D 사과나무 (`index.html` + `vendor/three.module.min.js`, 외부 CDN 의존성 없이 자체 호스팅)
|
||||||
- 봄/여름/가을/겨울 색상 전환이 가능한 SVG 나무 애니메이션
|
- 저사양 모바일 GPU에서도 부드럽게 동작하도록 로우폴리 지오메트리, 실시간 그림자 대신 블롭 섀도우, 픽셀비 상한(2x) 적용
|
||||||
|
- 나무를 마우스/터치로 드래그하면 스프링-댐퍼 물리로 자연스럽게 흔들리고, 일정 세기 이상 흔들면 사과(5개)가 하나씩 떨어짐
|
||||||
|
- 떨어진 사과는 바닥에 닿아 통통 튄 뒤 멈추고, 약 2.6초 후 원래 매달려 있던 자리에서 다시 자라남(pop-in 애니메이션)
|
||||||
|
|
||||||
## pages 서비스 연동
|
## pages 서비스 연동
|
||||||
|
|
||||||
|
|||||||
453
index.html
453
index.html
@@ -2,160 +2,371 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Tree of Life</title>
|
<title>Tree of Life</title>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--bg: #0b1410;
|
--sky-top: #bfe3f0;
|
||||||
--bg-2: #0f1d16;
|
--sky-bottom: #eaf6ee;
|
||||||
--trunk: #6b4a35;
|
--text: #1c2b22;
|
||||||
--trunk-dark: #4a3324;
|
--muted: #4c6155;
|
||||||
--leaf-1: #4f9d6e;
|
}
|
||||||
--leaf-2: #79c28d;
|
@media (prefers-color-scheme: dark){
|
||||||
--leaf-3: #a7dcb5;
|
:root{
|
||||||
|
--sky-top: #16222a;
|
||||||
|
--sky-bottom: #0c1712;
|
||||||
--text: #eaf3ec;
|
--text: #eaf3ec;
|
||||||
--muted: #9db3a6;
|
--muted: #9db3a6;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: light){
|
|
||||||
:root{
|
|
||||||
--bg: #f4f8f5;
|
|
||||||
--bg-2: #e7f0e9;
|
|
||||||
--text: #16241c;
|
|
||||||
--muted: #4c6155;
|
|
||||||
}
|
}
|
||||||
|
: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;
|
||||||
}
|
}
|
||||||
*{ box-sizing: border-box; }
|
|
||||||
html,body{ height:100%; }
|
|
||||||
body{
|
body{
|
||||||
margin:0;
|
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||||
min-height:100%;
|
gap:0.9rem;
|
||||||
background: radial-gradient(ellipse at 50% 20%, var(--bg-2), var(--bg));
|
padding: 1.4rem 1rem;
|
||||||
|
background: linear-gradient(180deg, var(--sky-top), var(--sky-bottom));
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Pretendard, Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Pretendard, Roboto, sans-serif;
|
||||||
display:flex;
|
|
||||||
flex-direction:column;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding: 2.5rem 1.5rem;
|
|
||||||
text-align:center;
|
text-align:center;
|
||||||
overflow-x:hidden;
|
overflow:hidden;
|
||||||
}
|
}
|
||||||
h1{
|
h1{
|
||||||
margin:0;
|
margin:0;
|
||||||
font-size: clamp(1.8rem, 5vw, 2.8rem);
|
font-size: clamp(1.5rem, 5vw, 2.2rem);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.01em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
p.tagline{
|
.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;
|
margin:0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 1rem;
|
font-size: 0.92rem;
|
||||||
max-width: 32rem;
|
|
||||||
}
|
}
|
||||||
.tree-wrap{
|
noscript{ color: var(--muted); }
|
||||||
width: min(90vw, 480px);
|
|
||||||
aspect-ratio: 4/5;
|
|
||||||
}
|
|
||||||
svg{ width:100%; height:100%; overflow:visible; }
|
|
||||||
.trunk{
|
|
||||||
stroke: var(--trunk-dark);
|
|
||||||
stroke-width: 3;
|
|
||||||
fill: var(--trunk);
|
|
||||||
}
|
|
||||||
.branch{
|
|
||||||
stroke: var(--trunk-dark);
|
|
||||||
stroke-width: 2;
|
|
||||||
fill: none;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-dasharray: 200;
|
|
||||||
stroke-dashoffset: 200;
|
|
||||||
animation: grow 1.6s ease-out forwards;
|
|
||||||
}
|
|
||||||
.leaf{
|
|
||||||
fill: var(--leaf-2);
|
|
||||||
opacity: 0;
|
|
||||||
transform-origin: center;
|
|
||||||
animation: bloom 0.9s ease-out forwards;
|
|
||||||
}
|
|
||||||
.leaf.a{ fill: var(--leaf-1); }
|
|
||||||
.leaf.c{ fill: var(--leaf-3); }
|
|
||||||
@keyframes grow{ to{ stroke-dashoffset: 0; } }
|
|
||||||
@keyframes bloom{
|
|
||||||
from{ opacity:0; transform: scale(0.3); }
|
|
||||||
to{ opacity:1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
.season-btns{ display:flex; gap:0.5rem; flex-wrap:wrap; justify-content:center; }
|
|
||||||
button{
|
|
||||||
appearance:none;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor:pointer;
|
|
||||||
transition: background 0.15s ease, transform 0.15s ease;
|
|
||||||
}
|
|
||||||
button:hover{ background: color-mix(in srgb, var(--text) 14%, transparent); transform: translateY(-1px); }
|
|
||||||
button[aria-pressed="true"]{ background: var(--leaf-1); color:#0b1410; border-color: transparent; }
|
|
||||||
footer{ color: var(--muted); font-size: 0.8rem; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Tree of Life</h1>
|
<h1>Tree of Life</h1>
|
||||||
<p class="tagline">가지마다 새로운 잎이 돋아나는, 성장을 담은 작은 나무 한 그루.</p>
|
<div class="stage" id="stage"></div>
|
||||||
|
<p class="hint">나무를 드래그해서 흔들어 보세요 🍎</p>
|
||||||
|
|
||||||
<div class="tree-wrap">
|
<script type="importmap">
|
||||||
<svg viewBox="0 0 300 360" xmlns="http://www.w3.org/2000/svg" aria-label="애니메이션으로 자라나는 나무">
|
{ "imports": { "three": "./vendor/three.module.min.js" } }
|
||||||
<rect x="132" y="300" width="36" height="40" rx="4" class="trunk"/>
|
</script>
|
||||||
<path class="branch" d="M150 300 C150 240, 150 220, 150 180" style="animation-delay:0s"/>
|
<script type="module">
|
||||||
<path class="branch" d="M150 260 C150 250, 110 230, 80 190" style="animation-delay:0.15s"/>
|
import * as THREE from "three";
|
||||||
<path class="branch" d="M150 260 C150 250, 190 230, 220 190" style="animation-delay:0.15s"/>
|
|
||||||
<path class="branch" d="M150 210 C150 200, 120 175, 95 140" style="animation-delay:0.3s"/>
|
|
||||||
<path class="branch" d="M150 210 C150 200, 180 175, 205 140" style="animation-delay:0.3s"/>
|
|
||||||
<path class="branch" d="M150 180 C150 165, 150 150, 150 120" style="animation-delay:0.45s"/>
|
|
||||||
|
|
||||||
<g class="leaves">
|
const stage = document.getElementById('stage');
|
||||||
<circle class="leaf a" cx="80" cy="185" r="26" style="animation-delay:1.1s"/>
|
|
||||||
<circle class="leaf b" cx="220" cy="185" r="26" style="animation-delay:1.2s"/>
|
|
||||||
<circle class="leaf c" cx="95" cy="135" r="24" style="animation-delay:1.3s"/>
|
|
||||||
<circle class="leaf a" cx="205" cy="135" r="24" style="animation-delay:1.4s"/>
|
|
||||||
<circle class="leaf b" cx="150" cy="110" r="30" style="animation-delay:1.5s"/>
|
|
||||||
<circle class="leaf c" cx="150" cy="185" r="22" style="animation-delay:1.6s"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="season-btns" role="group" aria-label="계절 선택">
|
// ---------- renderer / scene / camera ----------
|
||||||
<button data-season="spring" aria-pressed="true">봄</button>
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'low-power' });
|
||||||
<button data-season="summer">여름</button>
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
<button data-season="autumn">가을</button>
|
stage.appendChild(renderer.domElement);
|
||||||
<button data-season="winter">겨울</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>tree-of-life · static HTML5 page</footer>
|
const scene = new THREE.Scene();
|
||||||
|
const skyColor = new THREE.Color(0xbfe3f0);
|
||||||
|
scene.background = skyColor;
|
||||||
|
scene.fog = new THREE.Fog(skyColor.getHex(), 9, 16);
|
||||||
|
|
||||||
<script>
|
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 50);
|
||||||
const palettes = {
|
camera.position.set(0, 3.1, 7.2);
|
||||||
spring: ['#79c28d', '#4f9d6e', '#a7dcb5'],
|
camera.lookAt(0, 2.0, 0);
|
||||||
summer: ['#2f8f4f', '#1f6e3a', '#4fae6d'],
|
|
||||||
autumn: ['#d99a3d', '#c46a2b', '#e8c15a'],
|
function syncSky(){
|
||||||
winter: ['#cfe3e0', '#9fb8b4', '#ffffff'],
|
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,
|
||||||
};
|
};
|
||||||
const buttons = document.querySelectorAll('.season-btns button');
|
});
|
||||||
const leafClasses = { a: 0, b: 1, c: 2 };
|
|
||||||
|
|
||||||
function applySeason(season){
|
// ---------- drag-to-shake interaction ----------
|
||||||
const colors = palettes[season];
|
const raycaster = new THREE.Raycaster();
|
||||||
document.querySelectorAll('.leaf').forEach(leaf => {
|
const pointerNdc = new THREE.Vector2();
|
||||||
const key = ['a','b','c'].find(k => leaf.classList.contains(k)) || 'b';
|
let dragging = false;
|
||||||
leaf.style.fill = colors[leafClasses[key]];
|
let lastX = 0, lastY = 0;
|
||||||
});
|
|
||||||
buttons.forEach(btn => btn.setAttribute('aria-pressed', String(btn.dataset.season === season)));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons.forEach(btn => btn.addEventListener('click', () => applySeason(btn.dataset.season)));
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
6
vendor/three.module.min.js
vendored
Normal file
6
vendor/three.module.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user