1. Project Overview & Features
The 3D Interactive Birthday Heart Tree Surprise is an essential frontend project designed to help you bridge the gap between learning JavaScript syntax and building interactive, responsive web applications.
Key Features Implemented:
- Interactive slingshot with elastic physics, drag-to-aim tension, and sound effects
- Procedural 3D organic branch growth forming a romantic heart silhouette in real-time
- 600+ vibrant 3D heart-shaped petals that bloom dynamically with depth & lighting
- Drifting 3D falling petals simulation with rotational physics and soft wind turbulence
- Cinematic romantic greeting card with live milestone clock and customizable dedication
- Web Audio API acoustic music box synthesizer playing Happy Birthday
- 3D mouse parallax and orbit controls allowing 360Β° inspection
- Direct animated GIF recorder and complete multi-file ZIP export
2. Step-by-Step Code Walkthrough
A Step 1: Semantic HTML Structure
We structure the interface using clean, semantic HTML elements. This ensures maximum accessibility and provides intuitive hooks for CSS classes and JavaScript query selectors.
<div class="bday-experience" id="bdayApp">
<!-- Three.js 3D WebGL Viewport -->
<canvas id="threeCanvas" class="three-canvas"></canvas>
<!-- Slingshot Interactive 2D Overlay -->
<canvas id="slingshotCanvas" class="slingshot-canvas"></canvas>
<!-- Ambient Light Atmosphere -->
<div class="ambient-glow"></div>
<!-- Stage 1: Intro Callout & Slingshot Launcher -->
<div class="stage-intro" id="stageIntro">
<div class="intro-pill"><i class="fas fa-heart text-danger"></i> A Special 3D Surprise</div>
<h1 class="intro-title">a little something, for you</h1>
<div class="pulsing-heart-wrap">
<div class="pulsing-heart">
<i class="fas fa-heart"></i>
</div>
</div>
<div class="slingshot-panel" id="slingshotPanel">
<div class="slingshot-badge"><i class="fas fa-hand-rock"></i> PULL & RELEASE</div>
<p class="slingshot-sub">Drag the heart seed at the bottom-left and shoot to plant your tree!</p>
<button type="button" class="btn-auto-shoot" id="btnAutoShoot">
<i class="fas fa-magic me-1"></i> Quick Launch & Bloom β¨
</button>
</div>
</div>
<!-- Stage 2: Cinematic Banner -->
<div class="cinematic-banner hidden" id="cinematicBanner">
<span class="banner-sub">and... make a wish...</span>
<h2 class="banner-title">Happy Birthday</h2>
<div class="gold-brush-stroke"></div>
</div>
<!-- Stage 3 & 4: Romantic Birthday Card & Live Milestone Clock -->
<div class="bday-card-wrap hidden" id="bdayCard">
<div class="card-glass">
<button type="button" class="card-close-btn" id="btnCloseCard" title="Hide Birthday Card">×</button>
<div class="card-top-tag">
<i class="fas fa-sparkles text-warning me-1"></i> <span id="cardDedication">Made with Love & Code π</span>
</div>
<h2 class="card-greeting">Happy Birthday, <span class="recipient-name" id="cardRecipient">Sweetheart</span> β¨</h2>
<div class="card-quote-box">
<p class="card-quote">
“I turned HTML, CSS, and JavaScript into a special birthday surprise.<br>
A special gift, made with time, effort, and a little bit of code. ⨔
</p>
</div>
<div class="card-message" id="cardCustomMsg">
May every branch of your future blossom with peace, unbounded joy, and heartwarming love!
</div>
<!-- Live Milestone Counter -->
<div class="milestone-box">
<div class="milestone-header">Celebrating your journey of joy for:</div>
<div class="milestone-grid">
<div class="milestone-unit">
<span class="m-val" id="cDays">8,421</span>
<span class="m-label">Days</span>
</div>
<div class="milestone-unit">
<span class="m-val" id="cHours">14</span>
<span class="m-label">Hours</span>
</div>
<div class="milestone-unit">
<span class="m-val" id="cMins">32</span>
<span class="m-label">Mins</span>
</div>
<div class="milestone-unit">
<span class="m-val" id="cSecs">45</span>
<span class="m-label">Secs</span>
</div>
</div>
</div>
<div class="card-actions">
<button type="button" class="card-btn btn-wish" id="btnOpenWish">
<i class="fas fa-birthday-cake me-1"></i> Make a Wish π
</button>
<button type="button" class="card-btn btn-customize" id="btnOpenCustom">
<i class="fas fa-sliders-h me-1"></i> Personalize π
</button>
</div>
</div>
</div>
<!-- Subtle Bottom-Left Pill to Show Card When Hidden -->
<button type="button" class="card-toggle-pill hidden" id="btnShowCardPill" title="Open Birthday Greeting Card">
<i class="fas fa-envelope-open-text text-danger me-1"></i> <span>Greeting Card & Milestone</span>
</button>
<!-- Floating Controls Dock -->
<div class="controls-dock">
<button type="button" class="dock-btn" id="btnToggleCard" title="Show/Hide Greeting Card">
<i class="fas fa-envelope"></i>
</button>
<button type="button" class="dock-btn" id="btnAudioToggle" title="Toggle Music Box">
<i class="fas fa-volume-up"></i>
</button>
<button type="button" class="dock-btn" id="btnReplay" title="Replay Slingshot & Blossom">
<i class="fas fa-redo-alt"></i>
</button>
<button type="button" class="dock-btn" id="btnPalette" title="Cycle Color Themes">
<i class="fas fa-palette"></i>
</button>
<button type="button" class="dock-btn" id="btnRecordGif" title="Record 3D Canvas as Animated GIF">
<i class="fas fa-film"></i>
</button>
</div>
<!-- 3D Parallax Hint -->
<div class="orbit-hint" id="orbitHint">
<i class="fas fa-cube me-1"></i> Move cursor / touch to rotate in 3D
</div>
<!-- Interactive Birthday Cake & Candle Modal -->
<div class="modal-overlay hidden" id="wishModal">
<div class="modal-card">
<button type="button" class="btn-close-modal" id="btnCloseWish">×</button>
<div class="cake-emoji">π</div>
<h3 class="modal-h3">Make a Birthday Wish</h3>
<p class="modal-p">Close your eyes, make a heartfelt wish, then tap the candle flame to blow it out!</p>
<div class="candle-stage" id="candleStage">
<div class="candle-item">
<div class="flame-glow" id="candleFlame"></div>
<div class="wick"></div>
<div class="wax"></div>
</div>
</div>
<div class="tap-hint" id="tapFlameHint">β¨ Tap the glowing flame to blow! β¨</div>
<div class="wish-congrats hidden" id="wishCongrats">
<i class="fas fa-sparkles fa-2x text-warning mb-2"></i>
<h4>Happy Birthday! May all your wishes come true! πβ¨</h4>
<p>A cascade of fireworks and blooming blessings for you!</p>
</div>
</div>
</div>
<!-- Personalize Modal -->
<div class="modal-overlay hidden" id="customModal">
<div class="modal-card">
<button type="button" class="btn-close-modal" id="btnCloseCustom">×</button>
<div class="cake-emoji">π</div>
<h3 class="modal-h3">Personalize Birthday Surprise</h3>
<div class="form-row">
<label>Recipient Name</label>
<input type="text" id="inpName" value="Sweetheart" placeholder="e.g. Sarah, Emma, Alex">
</div>
<div class="form-row">
<label>Birth Date (For Live Milestone Clock)</label>
<input type="date" id="inpDate" value="2000-09-21">
</div>
<div class="form-row">
<label>Blossom Color Palette</label>
<select id="inpTheme">
<option value="rose">πΈ Romantic Rose Blossom</option>
<option value="sakura">β¨ Sakura Gold & Amber</option>
<option value="sunset">π
Coral Peach & Ruby</option>
<option value="twilight">π Violet Aurora & Magenta</option>
</select>
</div>
<div class="form-row">
<label>Custom Dedication Message</label>
<textarea id="inpMsg" rows="3">May every branch of your future blossom with peace, unbounded joy, and heartwarming love!</textarea>
</div>
<button type="button" class="btn-save-custom" id="btnSaveCustom">
<i class="fas fa-check me-1"></i> Apply & Re-Blossom
</button>
</div>
</div>
</div>B Step 2: Styling & Responsive CSS
Modern CSS flexbox and grid layouts are used to make the UI look polished, centered, and responsive across desktop, tablet, and mobile screens.
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
}
body, html {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
background: #fdf5f0;
}
.bday-experience {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: radial-gradient(circle at 50% 45%, #fff0f3 0%, #fae1dd 50%, #f7d6cd 100%);
}
.three-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
z-index: 1;
}
.slingshot-canvas {
position: absolute;
bottom: 0;
left: 0;
width: 320px;
height: 300px;
z-index: 10;
pointer-events: auto;
cursor: grab;
}
.slingshot-canvas:active {
cursor: grabbing;
}
.ambient-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(255, 182, 193, 0.4) 0%, rgba(255, 228, 225, 0) 70%);
border-radius: 50%;
pointer-events: none;
z-index: 2;
filter: blur(40px);
animation: pulseGlow 6s ease-in-out infinite alternate;
}
@keyframes pulseGlow {
0% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.5; }
100% { transform: translate(-50%, -50%) scale(1.15); opacity: 0.85; }
}
/* Stage 1: Intro */
.stage-intro {
position: absolute;
top: 40px;
left: 50%;
transform: translateX(-50%);
text-align: center;
z-index: 5;
pointer-events: none;
transition: opacity 0.8s ease, transform 0.8s ease;
}
.stage-intro.fade-out {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
pointer-events: none;
}
.intro-pill {
display: inline-block;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
padding: 6px 16px;
border-radius: 30px;
font-size: 0.85rem;
font-weight: 600;
color: #e11d48;
box-shadow: 0 4px 15px rgba(225, 29, 72, 0.12);
margin-bottom: 12px;
}
.intro-title {
font-family: 'Playfair Display', Georgia, serif;
font-style: italic;
font-size: 2.2rem;
color: #4a2828;
letter-spacing: 0.5px;
text-shadow: 0 2px 10px rgba(255, 255, 255, 0.8);
}
.pulsing-heart-wrap {
margin-top: 14px;
}
.pulsing-heart {
display: inline-flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
background: radial-gradient(circle at 35% 35%, #ff758c 0%, #ff477e 100%);
border-radius: 50%;
color: #fff;
font-size: 1.4rem;
box-shadow: 0 0 25px rgba(255, 71, 126, 0.6), inset 0 2px 4px rgba(255, 255, 255, 0.6);
animation: heartThrob 1.6s ease-in-out infinite;
}
@keyframes heartThrob {
0%, 100% { transform: scale(1); box-shadow: 0 0 20px rgba(255, 71, 126, 0.5); }
50% { transform: scale(1.18); box-shadow: 0 0 35px rgba(255, 71, 126, 0.85); }
}
.slingshot-panel {
position: absolute;
bottom: -220px;
left: 20px;
text-align: left;
pointer-events: auto;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(10px);
padding: 10px 16px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.06);
max-width: 280px;
}
.slingshot-badge {
font-weight: 700;
font-size: 0.85rem;
color: #b91c1c;
letter-spacing: 0.8px;
margin-bottom: 4px;
}
.slingshot-sub {
font-size: 0.78rem;
color: #64748b;
margin-bottom: 8px;
line-height: 1.3;
}
.btn-auto-shoot {
background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%);
color: white;
border: none;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(225, 29, 72, 0.3);
transition: all 0.2s;
}
.btn-auto-shoot:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(225, 29, 72, 0.45);
}
/* Stage 2: Cinematic Banner */
.cinematic-banner {
position: absolute;
top: 48%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 6;
pointer-events: none;
opacity: 0;
transition: opacity 1s ease, transform 1s cubic-bezier(0.16, 1, 0.3, 1);
}
.cinematic-banner.show {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.banner-sub {
font-family: 'Playfair Display', Georgia, serif;
font-style: italic;
font-size: 1.4rem;
color: #be123c;
display: block;
margin-bottom: 6px;
}
.banner-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: 3.5rem;
font-weight: 700;
color: #881337;
letter-spacing: 1px;
text-shadow: 0 4px 20px rgba(255, 255, 255, 0.9);
}
.gold-brush-stroke {
width: 220px;
height: 4px;
background: linear-gradient(90deg, transparent, #f59e0b, #fbbf24, transparent);
margin: 10px auto 0;
border-radius: 2px;
}
/* Stage 3 & 4: Romantic Birthday Card */
.bday-card-wrap {
position: absolute;
bottom: 30px;
left: 30px;
z-index: 15;
max-width: 440px;
width: calc(100% - 60px);
pointer-events: auto;
opacity: 0;
transform: translateY(30px);
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.bday-card-wrap.show {
opacity: 1;
transform: translateY(0);
}
.card-glass {
position: relative;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.95);
border-radius: 24px;
padding: 22px 24px;
box-shadow: 0 20px 45px rgba(225, 29, 72, 0.14), 0 4px 12px rgba(0, 0, 0, 0.04);
}
.card-close-btn {
position: absolute;
top: 14px;
right: 16px;
background: rgba(241, 245, 249, 0.9);
border: 1px solid rgba(203, 213, 225, 0.6);
font-size: 1.15rem;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: all 0.2s;
}
.card-close-btn:hover {
background: #fecdd3;
color: #e11d48;
transform: scale(1.1);
}
.card-toggle-pill {
position: absolute;
bottom: 24px;
left: 24px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.95);
padding: 8px 18px;
border-radius: 30px;
font-size: 0.85rem;
font-weight: 700;
color: #475569;
cursor: pointer;
box-shadow: 0 8px 25px rgba(225, 29, 72, 0.15);
z-index: 12;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.card-toggle-pill:hover {
background: #ffffff;
color: #e11d48;
transform: translateY(-2px);
box-shadow: 0 12px 30px rgba(225, 29, 72, 0.25);
}
.card-top-tag {
font-size: 0.78rem;
font-weight: 700;
color: #e11d48;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 6px;
}
.card-greeting {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.85rem;
font-weight: 700;
color: #4c0519;
line-height: 1.2;
margin-bottom: 12px;
}
.recipient-name {
color: #e11d48;
border-bottom: 2px dashed #f43f5e;
}
.card-quote-box {
background: rgba(255, 241, 242, 0.75);
border-left: 3px solid #f43f5e;
border-radius: 0 12px 12px 0;
padding: 10px 14px;
margin-bottom: 12px;
}
.card-quote {
font-size: 0.88rem;
font-style: italic;
color: #881337;
line-height: 1.5;
}
.card-message {
font-size: 0.86rem;
color: #475569;
line-height: 1.5;
margin-bottom: 14px;
}
/* Milestone Grid */
.milestone-box {
background: rgba(255, 255, 255, 0.9);
border-radius: 14px;
padding: 10px 12px;
margin-bottom: 16px;
border: 1px solid #ffe4e6;
}
.milestone-header {
font-size: 0.72rem;
font-weight: 600;
color: #9f1239;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
text-align: center;
}
.milestone-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
text-align: center;
}
.milestone-unit {
background: #fff1f2;
border-radius: 8px;
padding: 6px 2px;
}
.m-val {
display: block;
font-size: 1.1rem;
font-weight: 800;
color: #be123c;
font-family: monospace;
}
.m-label {
display: block;
font-size: 0.65rem;
font-weight: 600;
color: #9f1239;
text-transform: uppercase;
}
.card-actions {
display: flex;
gap: 10px;
}
.card-btn {
flex: 1;
padding: 10px 14px;
border-radius: 14px;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
border: none;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-wish {
background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%);
color: white;
box-shadow: 0 4px 14px rgba(225, 29, 72, 0.35);
}
.btn-wish:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(225, 29, 72, 0.45);
}
.btn-customize {
background: #f1f5f9;
color: #334155;
border: 1px solid #cbd5e1;
}
.btn-customize:hover {
background: #e2e8f0;
}
/* Floating Dock */
.controls-dock {
position: absolute;
top: 20px;
right: 20px;
display: flex;
gap: 8px;
z-index: 20;
}
.dock-btn {
width: 42px;
height: 42px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.9);
color: #475569;
font-size: 0.95rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
transition: all 0.2s;
}
.dock-btn:hover {
background: #fff;
color: #e11d48;
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(225, 29, 72, 0.25);
}
.dock-btn.active {
background: #e11d48;
color: #fff;
}
/* Orbit Hint */
.orbit-hint {
position: absolute;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
color: #fff;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
pointer-events: none;
z-index: 5;
opacity: 0.8;
}
/* Modals */
.modal-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
transition: opacity 0.3s ease;
}
.modal-overlay.hidden {
display: none;
}
.modal-card {
background: #ffffff;
border-radius: 24px;
padding: 30px;
max-width: 440px;
width: 100%;
position: relative;
text-align: center;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.3);
animation: modalPop 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalPop {
0% { transform: scale(0.9); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.btn-close-modal {
position: absolute;
top: 16px;
right: 18px;
background: #f1f5f9;
border: none;
font-size: 1.2rem;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
}
.btn-close-modal:hover { background: #e2e8f0; color: #0f172a; }
.cake-emoji { font-size: 3rem; margin-bottom: 8px; }
.modal-h3 { font-family: 'Playfair Display', Georgia, serif; font-size: 1.6rem; color: #1e293b; margin-bottom: 6px; }
.modal-p { font-size: 0.85rem; color: #64748b; margin-bottom: 16px; line-height: 1.4; }
/* Interactive Candle */
.candle-stage {
height: 120px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
}
.candle-item {
position: relative;
width: 24px;
height: 70px;
background: linear-gradient(to right, #fb7185, #f43f5e);
border-radius: 6px;
box-shadow: 0 4px 10px rgba(244, 63, 94, 0.3);
}
.wick {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
width: 3px;
height: 10px;
background: #334155;
border-radius: 2px;
}
.flame-glow {
position: absolute;
top: -32px;
left: 50%;
transform: translateX(-50%);
width: 18px;
height: 26px;
background: radial-gradient(ellipse at 50% 80%, #fff 0%, #fef08a 40%, #f97316 80%, transparent 100%);
border-radius: 50% 50% 20% 20%;
box-shadow: 0 0 20px #f97316, 0 0 35px #fbbf24;
cursor: pointer;
animation: flicker 0.15s ease-in-out infinite alternate;
}
.flame-glow.blown-out {
display: none;
}
@keyframes flicker {
0% { transform: translateX(-50%) scale(1) rotate(-1deg); }
100% { transform: translateX(-50%) scale(1.08) rotate(1.5deg); }
}
.tap-hint { font-size: 0.8rem; font-weight: 600; color: #f43f5e; margin-bottom: 10px; }
.wish-congrats {
background: #fff1f2;
border: 1px solid #fecdd3;
border-radius: 14px;
padding: 16px;
margin-top: 10px;
}
.wish-congrats h4 { color: #9f1239; font-size: 1.1rem; margin-bottom: 4px; }
.wish-congrats p { color: #be123c; font-size: 0.82rem; margin: 0; }
/* Custom Form */
.form-row { text-align: left; margin-bottom: 12px; }
.form-row label { display: block; font-size: 0.78rem; font-weight: 600; color: #334155; margin-bottom: 4px; }
.form-row input, .form-row select, .form-row textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #cbd5e1;
border-radius: 10px;
font-size: 0.85rem;
outline: none;
font-family: inherit;
transition: border-color 0.2s;
}
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
border-color: #f43f5e;
}
.btn-save-custom {
width: 100%;
background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%);
color: white;
border: none;
padding: 10px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 14px rgba(225, 29, 72, 0.3);
}
.hidden { display: none !important; }
@media (max-width: 768px) {
.intro-title { font-size: 1.6rem; }
.bday-card-wrap { bottom: 15px; left: 15px; width: calc(100% - 30px); }
.banner-title { font-size: 2.4rem; }
.slingshot-panel { bottom: -200px; max-width: 240px; }
}C Step 3: JavaScript Logic & Event Handling
Here is the complete JavaScript code handling the state, event listeners, mathematical logic, and dynamic DOM rendering:
(() => {
// DOM Elements
const threeCanvas = document.getElementById('threeCanvas');
const slingshotCanvas = document.getElementById('slingshotCanvas');
const stageIntro = document.getElementById('stageIntro');
const cinematicBanner = document.getElementById('cinematicBanner');
const bdayCard = document.getElementById('bdayCard');
const btnCloseCard = document.getElementById('btnCloseCard');
const btnToggleCard = document.getElementById('btnToggleCard');
const btnShowCardPill = document.getElementById('btnShowCardPill');
const btnAutoShoot = document.getElementById('btnAutoShoot');
const btnAudioToggle = document.getElementById('btnAudioToggle');
const btnReplay = document.getElementById('btnReplay');
const btnPalette = document.getElementById('btnPalette');
const btnRecordGif = document.getElementById('btnRecordGif');
const btnOpenWish = document.getElementById('btnOpenWish');
const btnOpenCustom = document.getElementById('btnOpenCustom');
const wishModal = document.getElementById('wishModal');
const btnCloseWish = document.getElementById('btnCloseWish');
const candleFlame = document.getElementById('candleFlame');
const tapFlameHint = document.getElementById('tapFlameHint');
const wishCongrats = document.getElementById('wishCongrats');
const customModal = document.getElementById('customModal');
const btnCloseCustom = document.getElementById('btnCloseCustom');
const btnSaveCustom = document.getElementById('btnSaveCustom');
// Milestone Counter Elements
const cDays = document.getElementById('cDays');
const cHours = document.getElementById('cHours');
const cMins = document.getElementById('cMins');
const cSecs = document.getElementById('cSecs');
// State
let recipientName = 'Sweetheart';
let birthDate = new Date('2000-09-21T00:00:00');
let customMsg = 'May every branch of your future blossom with peace, unbounded joy, and heartwarming love!';
let themeIndex = 0;
const themes = [
{ name: 'rose', colors: [0xff6b8b, 0xff8da1, 0xffa8a8, 0xffd166, 0xf78fb3, 0xff477e] },
{ name: 'sakura', colors: [0xff9ebb, 0xffc2d1, 0xffe5ec, 0xfcd34d, 0xf59e0b, 0xffb703] },
{ name: 'sunset', colors: [0xfb7185, 0xf43f5e, 0xf97316, 0xfbbf24, 0xf87171, 0xef4444] },
{ name: 'twilight', colors: [0xc084fc, 0xa855f7, 0xe879f9, 0xf472b6, 0xec4899, 0x7c3aed] }
];
let isPlanted = false;
let treeProgress = 0.0; // 0 to 1
let targetProgress = 0.0;
let audioEnabled = false;
let audioCtx = null;
// --- 1. Three.js 3D WebGL Setup ---
let scene, camera, renderer;
let treeGroup = null;
let branchSegments = [];
let petalMeshes = [];
let fallingPetals = [];
let bgParticles = [];
let mouseX = 0, mouseY = 0;
let targetCamX = 0, targetCamY = 3.5;
let isDraggingOrbit = false;
let prevMouseX = 0, prevMouseY = 0;
let orbitAngleX = 0, orbitAngleY = 0;
function initThree() {
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0xfbe7df, 0.015);
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 3.5, 26);
renderer = new THREE.WebGLRenderer({
canvas: threeCanvas,
alpha: true,
antialias: true,
preserveDrawingBuffer: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
// Lighting
const ambientLight = new THREE.AmbientLight(0xfff5f5, 1.2);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
dirLight.position.set(10, 20, 15);
scene.add(dirLight);
const pointLight = new THREE.PointLight(0xff477e, 2.5, 45);
pointLight.position.set(0, 6, 4);
scene.add(pointLight);
// Floating Background Ambient Dust Motes
const dustGeo = new THREE.BufferGeometry();
const dustCount = 140;
const dustPositions = new Float32Array(dustCount * 3);
for (let i = 0; i < dustCount * 3; i += 3) {
dustPositions[i] = (Math.random() - 0.5) * 40;
dustPositions[i + 1] = Math.random() * 25 - 5;
dustPositions[i + 2] = (Math.random() - 0.5) * 30;
}
dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPositions, 3));
const dustMat = new THREE.PointsMaterial({
color: 0xffd1dc,
size: 0.35,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending
});
const dustPoints = new THREE.Points(dustGeo, dustMat);
scene.add(dustPoints);
bgParticles.push(dustPoints);
// Initial Tree Construction
buildProceduralTree();
initFallingPetals();
window.addEventListener('resize', onWindowResize);
threeCanvas.addEventListener('mousedown', onPointerDown);
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
threeCanvas.addEventListener('touchstart', onTouchStart, { passive: false });
window.addEventListener('touchmove', onTouchMove, { passive: false });
window.addEventListener('touchend', onTouchEnd);
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
initSlingshotCanvas();
}
// --- Heart Shape Geometry Helper ---
function createHeartShape() {
const shape = new THREE.Shape();
const x = 0, y = 0;
shape.moveTo(x, y + 0.3);
shape.bezierCurveTo(x, y + 0.6, x - 0.5, y + 0.85, x - 0.7, y + 0.55);
shape.bezierCurveTo(x - 0.9, y + 0.25, x - 0.6, y - 0.25, x, y - 0.75);
shape.bezierCurveTo(x + 0.6, y - 0.25, x + 0.9, y + 0.25, x + 0.7, y + 0.55);
shape.bezierCurveTo(x + 0.5, y + 0.85, x, y + 0.6, x, y + 0.3);
return shape;
}
const heartGeo = new THREE.ShapeGeometry(createHeartShape(), 12);
// --- 2. Procedural 3D Heart Tree ---
function buildProceduralTree() {
if (treeGroup) scene.remove(treeGroup);
treeGroup = new THREE.Group();
branchSegments = [];
petalMeshes = [];
const barkMat = new THREE.MeshStandardMaterial({
color: 0x3d231e,
roughness: 0.85,
metalness: 0.1
});
// 1. Trunk
const trunkCurve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, -6, 0),
new THREE.Vector3(0.1, -2, 0),
new THREE.Vector3(-0.1, 1.5, 0),
new THREE.Vector3(0, 3.5, 0)
]);
const trunkGeo = new THREE.TubeGeometry(trunkCurve, 20, 0.45, 8, false);
const trunkMesh = new THREE.Mesh(trunkGeo, barkMat);
treeGroup.add(trunkMesh);
branchSegments.push({ mesh: trunkMesh, minP: 0.0, maxP: 0.3 });
// 2. Heart-Shaped Branch Array (Left & Right Main Arcs)
const branchConfigs = [
// Left Heart Arc
{
points: [
new THREE.Vector3(0, 3.5, 0),
new THREE.Vector3(-1.8, 5.2, 0.4),
new THREE.Vector3(-4.2, 7.5, 0.6),
new THREE.Vector3(-5.5, 9.8, 0.2),
new THREE.Vector3(-4.5, 12.2, -0.2),
new THREE.Vector3(-2.2, 12.8, -0.4),
new THREE.Vector3(0, 10.5, 0)
],
radius: 0.3,
minP: 0.15, maxP: 0.7
},
// Right Heart Arc
{
points: [
new THREE.Vector3(0, 3.5, 0),
new THREE.Vector3(1.8, 5.2, -0.4),
new THREE.Vector3(4.2, 7.5, -0.6),
new THREE.Vector3(5.5, 9.8, -0.2),
new THREE.Vector3(4.5, 12.2, 0.2),
new THREE.Vector3(2.2, 12.8, 0.4),
new THREE.Vector3(0, 10.5, 0)
],
radius: 0.3,
minP: 0.15, maxP: 0.7
},
// Inner Sub-branches
{
points: [
new THREE.Vector3(-1.8, 5.2, 0.4),
new THREE.Vector3(-2.8, 8.0, 1.2),
new THREE.Vector3(-1.5, 9.5, 0.8)
],
radius: 0.18,
minP: 0.35, maxP: 0.8
},
{
points: [
new THREE.Vector3(1.8, 5.2, -0.4),
new THREE.Vector3(2.8, 8.0, -1.2),
new THREE.Vector3(1.5, 9.5, -0.8)
],
radius: 0.18,
minP: 0.35, maxP: 0.8
},
{
points: [
new THREE.Vector3(-4.2, 7.5, 0.6),
new THREE.Vector3(-5.8, 8.5, 0.8),
new THREE.Vector3(-6.2, 10.5, 0.3)
],
radius: 0.16,
minP: 0.4, maxP: 0.85
},
{
points: [
new THREE.Vector3(4.2, 7.5, -0.6),
new THREE.Vector3(5.8, 8.5, -0.8),
new THREE.Vector3(6.2, 10.5, -0.3)
],
radius: 0.16,
minP: 0.4, maxP: 0.85
}
];
branchConfigs.forEach(bc => {
const curve = new THREE.CatmullRomCurve3(bc.points);
const geo = new THREE.TubeGeometry(curve, 18, bc.radius, 6, false);
const mesh = new THREE.Mesh(geo, barkMat);
treeGroup.add(mesh);
branchSegments.push({ mesh, minP: bc.minP, maxP: bc.maxP });
});
// 3. 600+ Blooming 3D Heart Petals
const curColors = themes[themeIndex].colors;
const petalCount = 680;
for (let i = 0; i < petalCount; i++) {
// Parametric Heart Volume Distribution
const t = Math.PI * 2 * Math.random();
// Heart formula: x = 16 sin^3(t), y = 13 cos(t) - 5 cos(2t) - 2 cos(3t) - cos(4t)
const hScale = 0.36 + Math.random() * 0.12;
const hx = 16 * Math.pow(Math.sin(t), 3) * hScale * (0.65 + Math.random() * 0.38);
const hy = (13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t)) * hScale * (0.65 + Math.random() * 0.38) + 9.6;
const hz = (Math.random() - 0.5) * 4.5;
const col = curColors[Math.floor(Math.random() * curColors.length)];
const pMat = new THREE.MeshStandardMaterial({
color: col,
roughness: 0.4,
metalness: 0.15,
side: THREE.DoubleSide
});
const pMesh = new THREE.Mesh(heartGeo, pMat);
const baseScale = 0.35 + Math.random() * 0.32;
pMesh.position.set(hx, hy, hz);
pMesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
pMesh.scale.set(0.001, 0.001, 0.001); // starts invisible
treeGroup.add(pMesh);
petalMeshes.push({
mesh: pMesh,
targetScale: baseScale,
bloomThreshold: 0.35 + Math.random() * 0.55,
rotSpeed: (Math.random() - 0.5) * 0.02
});
}
treeGroup.scale.set(0.001, 0.001, 0.001);
scene.add(treeGroup);
}
// --- 3. Drifting 3D Falling Petals ---
function initFallingPetals() {
fallingPetals.forEach(p => scene.remove(p.mesh));
fallingPetals = [];
const curColors = themes[themeIndex].colors;
const count = 120;
for (let i = 0; i < count; i++) {
const col = curColors[Math.floor(Math.random() * curColors.length)];
const mat = new THREE.MeshStandardMaterial({
color: col,
roughness: 0.35,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.85
});
const mesh = new THREE.Mesh(heartGeo, mat);
const s = 0.22 + Math.random() * 0.24;
mesh.scale.set(s, s, s);
mesh.position.set(
(Math.random() - 0.5) * 26,
15 + Math.random() * 12,
(Math.random() - 0.5) * 16
);
mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
scene.add(mesh);
fallingPetals.push({
mesh,
vy: 0.025 + Math.random() * 0.035,
vx: (Math.random() - 0.5) * 0.015,
vz: (Math.random() - 0.5) * 0.015,
rotX: (Math.random() - 0.5) * 0.03,
rotY: (Math.random() - 0.5) * 0.03,
rotZ: (Math.random() - 0.5) * 0.02,
swayOffset: Math.random() * 10
});
}
}
// --- 4. Interactive 2D Slingshot Engine ---
let sCtx = null;
let sWidth = 320, sHeight = 300;
const slingAnchorLeft = { x: 80, y: 190 };
const slingAnchorRight = { x: 170, y: 180 };
const seedOrigin = { x: 125, y: 185 };
let seedCurrent = { x: 125, y: 185 };
let isDraggingSeed = false;
let seedProjectile = null;
function initSlingshotCanvas() {
slingshotCanvas.width = 320;
slingshotCanvas.height = 300;
sCtx = slingshotCanvas.getContext('2d');
sWidth = slingshotCanvas.width;
sHeight = slingshotCanvas.height;
}
function renderSlingshot() {
if (!sCtx) return;
sCtx.clearRect(0, 0, sWidth, sHeight);
if (isPlanted && !seedProjectile) return;
// Draw Slingshot Wooden Fork
sCtx.lineWidth = 14;
sCtx.lineCap = 'round';
sCtx.lineJoin = 'round';
sCtx.strokeStyle = '#5c2c16';
// Base Post
sCtx.beginPath();
sCtx.moveTo(125, 290);
sCtx.lineTo(125, 220);
sCtx.stroke();
// Left Prong
sCtx.beginPath();
sCtx.moveTo(125, 220);
sCtx.quadraticCurveTo(95, 210, slingAnchorLeft.x, slingAnchorLeft.y);
sCtx.stroke();
// Right Prong
sCtx.beginPath();
sCtx.moveTo(125, 220);
sCtx.quadraticCurveTo(155, 205, slingAnchorRight.x, slingAnchorRight.y);
sCtx.stroke();
// Rubber Bands & Pocket
if (!seedProjectile) {
sCtx.lineWidth = 4;
sCtx.strokeStyle = '#e11d48';
// Left Band
sCtx.beginPath();
sCtx.moveTo(slingAnchorLeft.x, slingAnchorLeft.y);
sCtx.lineTo(seedCurrent.x, seedCurrent.y);
sCtx.stroke();
// Right Band
sCtx.beginPath();
sCtx.moveTo(slingAnchorRight.x, slingAnchorRight.y);
sCtx.lineTo(seedCurrent.x, seedCurrent.y);
sCtx.stroke();
// Heart Seed in Pocket
drawHeartSeed(seedCurrent.x, seedCurrent.y, 16);
// Trajectory Prediction Line if dragging
if (isDraggingSeed) {
const dx = seedOrigin.x - seedCurrent.x;
const dy = seedOrigin.y - seedCurrent.y;
sCtx.setLineDash([5, 5]);
sCtx.lineWidth = 2;
sCtx.strokeStyle = 'rgba(244, 63, 94, 0.6)';
sCtx.beginPath();
sCtx.moveTo(seedCurrent.x, seedCurrent.y);
sCtx.quadraticCurveTo(
seedOrigin.x + dx * 2,
seedOrigin.y + dy * 2 - 60,
seedOrigin.x + dx * 3.5,
seedOrigin.y + dy * 3.5 - 180
);
sCtx.stroke();
sCtx.setLineDash([]);
}
}
// Flying Projectile Animation
if (seedProjectile) {
seedProjectile.progress += 0.035;
const t = seedProjectile.progress;
const px = Math.pow(1 - t, 2) * seedProjectile.p0.x + 2 * (1 - t) * t * seedProjectile.p1.x + Math.pow(t, 2) * seedProjectile.p2.x;
const py = Math.pow(1 - t, 2) * seedProjectile.p0.y + 2 * (1 - t) * t * seedProjectile.p1.y + Math.pow(t, 2) * seedProjectile.p2.y;
drawHeartSeed(px, py, 14 * (1 - t * 0.4));
if (t >= 1) {
seedProjectile = null;
triggerPlantAndBloom();
}
}
}
function drawHeartSeed(x, y, size) {
sCtx.save();
sCtx.translate(x, y);
sCtx.fillStyle = '#ff2a6d';
sCtx.shadowColor = 'rgba(255, 42, 109, 0.8)';
sCtx.shadowBlur = 12;
sCtx.beginPath();
const d = size / 2;
sCtx.moveTo(0, d * 0.4);
sCtx.bezierCurveTo(0, d * 0.9, -d, d * 1.2, -d * 1.2, d * 0.6);
sCtx.bezierCurveTo(-d * 1.4, 0, -d * 0.6, -d * 0.5, 0, -d * 1.2);
sCtx.bezierCurveTo(d * 0.6, -d * 0.5, d * 1.4, 0, d * 1.2, d * 0.6);
sCtx.bezierCurveTo(d, d * 1.2, 0, d * 0.9, 0, d * 0.4);
sCtx.fill();
sCtx.restore();
}
// Slingshot Events
function getSlingshotPos(e) {
const rect = slingshotCanvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return {
x: (clientX - rect.left) * (sWidth / rect.width),
y: (clientY - rect.top) * (sHeight / rect.height)
};
}
slingshotCanvas.addEventListener('mousedown', (e) => {
if (isPlanted) return;
const pos = getSlingshotPos(e);
const dist = Math.hypot(pos.x - seedCurrent.x, pos.y - seedCurrent.y);
if (dist < 50) {
isDraggingSeed = true;
playTwangSound();
}
});
window.addEventListener('mousemove', (e) => {
if (!isDraggingSeed) return;
const pos = getSlingshotPos(e);
const dx = pos.x - seedOrigin.x;
const dy = pos.y - seedOrigin.y;
const dist = Math.hypot(dx, dy);
const maxPull = 90;
if (dist > maxPull) {
seedCurrent.x = seedOrigin.x + (dx / dist) * maxPull;
seedCurrent.y = seedOrigin.y + (dy / dist) * maxPull;
} else {
seedCurrent.x = pos.x;
seedCurrent.y = pos.y;
}
});
window.addEventListener('mouseup', () => {
if (!isDraggingSeed) return;
isDraggingSeed = false;
const pullDist = Math.hypot(seedCurrent.x - seedOrigin.x, seedCurrent.y - seedOrigin.y);
if (pullDist > 25) {
launchSlingshot();
} else {
seedCurrent = { ...seedOrigin };
}
});
slingshotCanvas.addEventListener('touchstart', (e) => {
if (isPlanted) return;
const pos = getSlingshotPos(e);
const dist = Math.hypot(pos.x - seedCurrent.x, pos.y - seedCurrent.y);
if (dist < 60) {
isDraggingSeed = true;
playTwangSound();
}
}, { passive: true });
window.addEventListener('touchmove', (e) => {
if (!isDraggingSeed) return;
const pos = getSlingshotPos(e);
const dx = pos.x - seedOrigin.x;
const dy = pos.y - seedOrigin.y;
const dist = Math.hypot(dx, dy);
const maxPull = 90;
if (dist > maxPull) {
seedCurrent.x = seedOrigin.x + (dx / dist) * maxPull;
seedCurrent.y = seedOrigin.y + (dy / dist) * maxPull;
} else {
seedCurrent.x = pos.x;
seedCurrent.y = pos.y;
}
}, { passive: true });
window.addEventListener('touchend', () => {
if (!isDraggingSeed) return;
isDraggingSeed = false;
const pullDist = Math.hypot(seedCurrent.x - seedOrigin.x, seedCurrent.y - seedOrigin.y);
if (pullDist > 25) {
launchSlingshot();
} else {
seedCurrent = { ...seedOrigin };
}
});
function launchSlingshot() {
playWhooshSound();
seedProjectile = {
progress: 0,
p0: { x: seedCurrent.x, y: seedCurrent.y },
p1: { x: seedOrigin.x + (seedOrigin.x - seedCurrent.x) * 2.5, y: -60 },
p2: { x: sWidth + 120, y: -100 }
};
seedCurrent = { ...seedOrigin };
}
btnAutoShoot.addEventListener('click', () => {
launchSlingshot();
});
// --- 5. Tree Growth & Milestone Sequence ---
function triggerPlantAndBloom() {
isPlanted = true;
targetProgress = 1.0;
stageIntro.classList.add('fade-out');
// Play chime sound
playChimeSound();
if (!audioEnabled) {
toggleMusicBox(true);
}
// Show Cinematic Banner
setTimeout(() => {
cinematicBanner.classList.remove('hidden');
cinematicBanner.classList.add('show');
}, 1200);
// Transition banner & make greeting card pill available (keeps 3D view clean)
setTimeout(() => {
cinematicBanner.classList.remove('show');
setTimeout(() => cinematicBanner.classList.add('hidden'), 800);
if (btnShowCardPill) {
btnShowCardPill.classList.remove('hidden');
}
}, 3800);
}
// --- 6. Web Audio API Acoustic Music Box Synthesizer ---
function initAudio() {
if (!audioCtx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioCtx = new AudioContext();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
const bdayNotes = [
{ note: 261.63, dur: 0.35, pause: 0.1 }, // C4
{ note: 261.63, dur: 0.35, pause: 0.1 }, // C4
{ note: 293.66, dur: 0.6, pause: 0.1 }, // D4
{ note: 261.63, dur: 0.6, pause: 0.1 }, // C4
{ note: 349.23, dur: 0.6, pause: 0.1 }, // F4
{ note: 329.63, dur: 1.0, pause: 0.3 }, // E4
{ note: 261.63, dur: 0.35, pause: 0.1 }, // C4
{ note: 261.63, dur: 0.35, pause: 0.1 }, // C4
{ note: 293.66, dur: 0.6, pause: 0.1 }, // D4
{ note: 261.63, dur: 0.6, pause: 0.1 }, // C4
{ note: 392.00, dur: 0.6, pause: 0.1 }, // G4
{ note: 349.23, dur: 1.0, pause: 0.3 }, // F4
{ note: 261.63, dur: 0.35, pause: 0.1 }, // C4
{ note: 261.63, dur: 0.35, pause: 0.1 }, // C4
{ note: 523.25, dur: 0.6, pause: 0.1 }, // C5
{ note: 440.00, dur: 0.6, pause: 0.1 }, // A4
{ note: 349.23, dur: 0.6, pause: 0.1 }, // F4
{ note: 329.63, dur: 0.6, pause: 0.1 }, // E4
{ note: 293.66, dur: 0.9, pause: 0.3 }, // D4
{ note: 466.16, dur: 0.35, pause: 0.1 }, // Bb4
{ note: 466.16, dur: 0.35, pause: 0.1 }, // Bb4
{ note: 440.00, dur: 0.6, pause: 0.1 }, // A4
{ note: 349.23, dur: 0.6, pause: 0.1 }, // F4
{ note: 392.00, dur: 0.6, pause: 0.1 }, // G4
{ note: 349.23, dur: 1.2, pause: 0.8 } // F4
];
let musicInterval = null;
let noteIndex = 0;
function playTone(freq, duration) {
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
// Music Box Exponential Pluck
gain.gain.setValueAtTime(0.001, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.3, audioCtx.currentTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playTwangSound() {
initAudio();
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(160, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(280, audioCtx.currentTime + 0.12);
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0.001, audioCtx.currentTime + 0.12);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.12);
}
function playWhooshSound() {
initAudio();
if (!audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(450, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(150, audioCtx.currentTime + 0.25);
gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0.001, audioCtx.currentTime + 0.25);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.25);
}
function playChimeSound() {
initAudio();
if (!audioCtx) return;
[523.25, 659.25, 783.99, 1046.50].forEach((f, i) => {
setTimeout(() => playTone(f, 1.2), i * 140);
});
}
function startMusicLoop() {
if (musicInterval) clearInterval(musicInterval);
noteIndex = 0;
function step() {
if (!audioEnabled) return;
const n = bdayNotes[noteIndex];
playTone(n.note, n.dur);
noteIndex = (noteIndex + 1) % bdayNotes.length;
musicInterval = setTimeout(step, (n.dur + n.pause) * 1000);
}
step();
}
function toggleMusicBox(forceState) {
initAudio();
audioEnabled = forceState !== undefined ? forceState : !audioEnabled;
if (audioEnabled) {
btnAudioToggle.innerHTML = '<i class="fas fa-volume-up"></i>';
btnAudioToggle.classList.add('active');
startMusicLoop();
} else {
btnAudioToggle.innerHTML = '<i class="fas fa-volume-mute"></i>';
btnAudioToggle.classList.remove('active');
if (musicInterval) clearTimeout(musicInterval);
}
}
btnAudioToggle.addEventListener('click', () => toggleMusicBox());
// --- 7. Milestone Clock Calculation ---
function updateMilestoneClock() {
const now = new Date();
const diffMs = Math.max(0, now - birthDate);
const totalSecs = Math.floor(diffMs / 1000);
const days = Math.floor(totalSecs / 86400);
const hours = Math.floor((totalSecs % 86400) / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = totalSecs % 60;
cDays.textContent = days.toLocaleString();
cHours.textContent = String(hours).padStart(2, '0');
cMins.textContent = String(mins).padStart(2, '0');
cSecs.textContent = String(secs).padStart(2, '0');
}
setInterval(updateMilestoneClock, 1000);
updateMilestoneClock();
// --- 8. Mouse / Touch Parallax & Orbit ---
function onPointerDown(e) {
isDraggingOrbit = true;
prevMouseX = e.clientX;
prevMouseY = e.clientY;
}
function onPointerMove(e) {
const nx = (e.clientX / window.innerWidth) * 2 - 1;
const ny = -(e.clientY / window.innerHeight) * 2 + 1;
if (isDraggingOrbit) {
const dx = e.clientX - prevMouseX;
const dy = e.clientY - prevMouseY;
orbitAngleX += dx * 0.005;
orbitAngleY = Math.max(-0.6, Math.min(0.6, orbitAngleY + dy * 0.005));
prevMouseX = e.clientX;
prevMouseY = e.clientY;
} else {
targetCamX = nx * 3.5;
targetCamY = 3.5 + ny * 2.0;
}
}
function onPointerUp() {
isDraggingOrbit = false;
}
function onTouchStart(e) {
if (e.touches.length === 1) {
isDraggingOrbit = true;
prevMouseX = e.touches[0].clientX;
prevMouseY = e.touches[0].clientY;
}
}
function onTouchMove(e) {
if (isDraggingOrbit && e.touches.length === 1) {
const dx = e.touches[0].clientX - prevMouseX;
const dy = e.touches[0].clientY - prevMouseY;
orbitAngleX += dx * 0.006;
orbitAngleY = Math.max(-0.6, Math.min(0.6, orbitAngleY + dy * 0.006));
prevMouseX = e.touches[0].clientX;
prevMouseY = e.touches[0].clientY;
}
}
function onTouchEnd() {
isDraggingOrbit = false;
}
// --- 9. Card Visibility & Replay Handlers ---
function hideCard() {
if (!bdayCard) return;
bdayCard.classList.remove('show');
setTimeout(() => {
bdayCard.classList.add('hidden');
if (isPlanted && btnShowCardPill) {
btnShowCardPill.classList.remove('hidden');
}
}, 350);
if (btnToggleCard) btnToggleCard.classList.remove('active');
}
function showCard() {
if (!bdayCard) return;
if (btnShowCardPill) btnShowCardPill.classList.add('hidden');
bdayCard.classList.remove('hidden');
setTimeout(() => bdayCard.classList.add('show'), 50);
if (btnToggleCard) btnToggleCard.classList.add('active');
}
function toggleCard() {
if (!bdayCard) return;
if (bdayCard.classList.contains('hidden') || !bdayCard.classList.contains('show')) {
showCard();
} else {
hideCard();
}
}
if (btnCloseCard) btnCloseCard.addEventListener('click', hideCard);
if (btnToggleCard) btnToggleCard.addEventListener('click', toggleCard);
if (btnShowCardPill) btnShowCardPill.addEventListener('click', showCard);
btnReplay.addEventListener('click', () => {
isPlanted = false;
treeProgress = 0.0;
targetProgress = 0.0;
stageIntro.classList.remove('fade-out');
cinematicBanner.classList.remove('show');
cinematicBanner.classList.add('hidden');
bdayCard.classList.remove('show');
bdayCard.classList.add('hidden');
if (btnShowCardPill) btnShowCardPill.classList.add('hidden');
if (btnToggleCard) btnToggleCard.classList.remove('active');
seedCurrent = { ...seedOrigin };
seedProjectile = null;
buildProceduralTree();
initFallingPetals();
});
btnPalette.addEventListener('click', () => {
themeIndex = (themeIndex + 1) % themes.length;
buildProceduralTree();
initFallingPetals();
});
// Make a Wish Modal
btnOpenWish.addEventListener('click', () => {
wishModal.classList.remove('hidden');
candleFlame.classList.remove('blown-out');
tapFlameHint.classList.remove('hidden');
wishCongrats.classList.add('hidden');
});
btnCloseWish.addEventListener('click', () => wishModal.classList.add('hidden'));
candleFlame.addEventListener('click', () => {
candleFlame.classList.add('blown-out');
tapFlameHint.classList.add('hidden');
wishCongrats.classList.remove('hidden');
playChimeSound();
});
// Customize Modal
btnOpenCustom.addEventListener('click', () => {
document.getElementById('inpName').value = recipientName;
document.getElementById('inpMsg').value = customMsg;
customModal.classList.remove('hidden');
});
btnCloseCustom.addEventListener('click', () => customModal.classList.add('hidden'));
btnSaveCustom.addEventListener('click', () => {
const name = document.getElementById('inpName').value.trim();
const dateVal = document.getElementById('inpDate').value;
const themeVal = document.getElementById('inpTheme').value;
const msgVal = document.getElementById('inpMsg').value.trim();
if (name) {
recipientName = name;
document.getElementById('cardRecipient').textContent = recipientName;
}
if (dateVal) {
birthDate = new Date(dateVal + 'T00:00:00');
updateMilestoneClock();
}
if (msgVal) {
customMsg = msgVal;
document.getElementById('cardCustomMsg').textContent = customMsg;
}
if (themeVal) {
const idx = themes.findIndex(t => t.name === themeVal);
if (idx !== -1) themeIndex = idx;
}
customModal.classList.add('hidden');
buildProceduralTree();
initFallingPetals();
});
// Record GIF
if (btnRecordGif) {
btnRecordGif.addEventListener('click', () => {
const origHtml = btnRecordGif.innerHTML;
btnRecordGif.disabled = true;
btnRecordGif.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 0/24';
const frames = [];
const totalFrames = 24;
let count = 0;
const recordTimer = setInterval(() => {
count++;
btnRecordGif.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${count}/${totalFrames}`;
try {
frames.push(threeCanvas.toDataURL('image/jpeg', 0.8));
} catch(e) {}
if (count >= totalFrames) {
clearInterval(recordTimer);
btnRecordGif.innerHTML = '<i class="fas fa-cog fa-spin"></i> GIF...';
if (typeof gifshot !== 'undefined' && frames.length > 0) {
gifshot.createGIF({
images: frames,
gifWidth: Math.min(window.innerWidth, 500),
gifHeight: Math.min(window.innerHeight, 420),
interval: 0.1,
numFrames: totalFrames,
sampleInterval: 10
}, function(obj) {
btnRecordGif.disabled = false;
if (!obj.error) {
const link = document.createElement('a');
link.href = obj.image;
link.download = `${recipientName.toLowerCase()}-3d-birthday-surprise.gif`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
btnRecordGif.innerHTML = '<i class="fas fa-check text-success"></i> Saved!';
setTimeout(() => { btnRecordGif.innerHTML = origHtml; }, 2500);
} else {
btnRecordGif.innerHTML = origHtml;
}
});
} else {
btnRecordGif.disabled = false;
btnRecordGif.innerHTML = origHtml;
}
}
}, 100);
});
}
// --- 10. Main Animation Loop (60 FPS) ---
function animate() {
requestAnimationFrame(animate);
// Tree Growth Interpolation
if (treeProgress < targetProgress) {
treeProgress += 0.008;
if (treeProgress > targetProgress) treeProgress = targetProgress;
}
if (treeGroup) {
// Scale tree with smooth spring easing
const currentScale = Math.min(1.0, treeProgress * 1.15);
treeGroup.scale.set(currentScale, currentScale, currentScale);
// Petals Blossom
petalMeshes.forEach(p => {
if (treeProgress > p.bloomThreshold) {
const petalGrowth = Math.min(1.0, (treeProgress - p.bloomThreshold) * 3.5);
const s = p.targetScale * petalGrowth;
p.mesh.scale.set(s, s, s);
p.mesh.rotation.z += p.rotSpeed;
} else {
p.mesh.scale.set(0.001, 0.001, 0.001);
}
});
}
// Drifting Falling Petals
if (isPlanted && treeProgress > 0.4) {
const time = Date.now() * 0.002;
fallingPetals.forEach(p => {
p.mesh.position.y -= p.vy;
p.mesh.position.x += Math.sin(time + p.swayOffset) * 0.02 + p.vx;
p.mesh.position.z += Math.cos(time + p.swayOffset) * 0.015 + p.vz;
p.mesh.rotation.x += p.rotX;
p.mesh.rotation.y += p.rotY;
p.mesh.rotation.z += p.rotZ;
// Reset when falling below floor
if (p.mesh.position.y < -7) {
p.mesh.position.y = 15 + Math.random() * 5;
p.mesh.position.x = (Math.random() - 0.5) * 24;
p.mesh.position.z = (Math.random() - 0.5) * 14;
}
});
}
// Camera Lerping
const radius = 26;
const targetX = radius * Math.sin(orbitAngleX) + targetCamX;
const targetZ = radius * Math.cos(orbitAngleX);
const targetY = targetCamY + orbitAngleY * 10;
camera.position.x += (targetX - camera.position.x) * 0.05;
camera.position.y += (targetY - camera.position.y) * 0.05;
camera.position.z += (targetZ - camera.position.z) * 0.05;
camera.lookAt(0, 4.5, 0);
// Render WebGL
renderer.render(scene, camera);
// Render 2D Slingshot
renderSlingshot();
}
// --- Boot Experience ---
initThree();
initSlingshotCanvas();
animate();
})();3. Core JavaScript Concepts You Learned
Crucial concept utilized to power the dynamic state, user interactions, and styling of this project.
Crucial concept utilized to power the dynamic state, user interactions, and styling of this project.
Crucial concept utilized to power the dynamic state, user interactions, and styling of this project.
Crucial concept utilized to power the dynamic state, user interactions, and styling of this project.
Crucial concept utilized to power the dynamic state, user interactions, and styling of this project.
Crucial concept utilized to power the dynamic state, user interactions, and styling of this project.
4. How to Run Locally on Your Computer
- Download the project ZIP: Click the Download Project (.ZIP) button at the top or in the live runner to save
3d-birthday-surprise-project.zip. - Extract the archive: Unzip the downloaded file. Inside you will find separate
index.html,style.css,script.js,index-standalone.html, and a comprehensiveREADME.md. - Open in any browser: Simply double-click
index.html(orindex-standalone.html) to open and test immediately in Chrome, Firefox, Safari, or Edge. - Edit in Visual Studio Code: Open the extracted folder in VS Code. Right-click
index.htmland select "Open with Live Server" to enjoy instant live reloading as you modify HTML, CSS, or JS!
Bonus Challenges for Learners
Ready to level up? Try adding these extra features on your own:
- Add sound effects or audio feedback for user button clicks.
- Add custom theme color customizer with CSS variables.
- Export the results to PDF or copy as a formatted text summary.