|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Plinko Vows to Sam - Golden Ticket Edition</title> |
|
<style> |
|
body { |
|
margin: 0; |
|
overflow: hidden; |
|
font-family: 'Garamond', serif; |
|
background: linear-gradient(to bottom, #0d121a, #1a2330); |
|
color: #f0f0f0; |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
min-height: 100vh; |
|
} |
|
#container { |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
width: 100%; |
|
height: 100vh; |
|
padding: 10px; |
|
box-sizing: border-box; |
|
} |
|
#vows-container { |
|
width: 100%; |
|
max-width: 800px; |
|
height: 150px; |
|
overflow: hidden; |
|
position: relative; |
|
background-color: rgba(0, 0, 0, 0.2); |
|
border-radius: 15px; |
|
margin-bottom: 5px; |
|
box-shadow: 0 4px 15px rgba(0,0,0,0.4); |
|
border: 1px solid rgba(255,255,255,0.1); |
|
} |
|
#vows-content { |
|
position: absolute; |
|
top: 100%; |
|
width: 100%; |
|
text-align: center; |
|
animation: scroll-vows 30s linear infinite; |
|
} |
|
#vows-content h2 { font-size: 1.6em; margin-bottom: 15px; color: #ffd700; } |
|
#vows-content p { font-size: 1.2em; line-height: 1.6; margin: 0 20px 20px; } |
|
@keyframes scroll-vows { |
|
0% { top: 100%; } |
|
100% { top: -250%; } |
|
} |
|
#life-bar-container { |
|
width: 80%; |
|
max-width: 400px; |
|
height: 30px; |
|
background-color: rgba(0, 0, 0, 0.4); |
|
border-radius: 15px; |
|
border: 2px solid #ffd700; |
|
padding: 3px; |
|
box-shadow: 0 0 15px rgba(255, 215, 0, 0.5); |
|
margin-bottom: 5px; |
|
} |
|
#life-bar { |
|
width: 0%; |
|
height: 100%; |
|
background: linear-gradient(90deg, #ff8c00, #ffd700); |
|
border-radius: 12px; |
|
transition: width 0.5s ease-out; |
|
text-align: right; |
|
color: #333; |
|
font-weight: bold; |
|
line-height: 24px; |
|
padding-right: 10px; |
|
box-sizing: border-box; |
|
} |
|
#game-area { |
|
width: 100%; |
|
flex-grow: 1; |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
position: relative; |
|
} |
|
#game-container { |
|
width: 100%; |
|
flex-grow: 1; |
|
position: relative; |
|
} |
|
canvas { display: block; width: 100%; height: 100%; } |
|
#score-slots { |
|
display: flex; |
|
justify-content: center; |
|
width: 100%; |
|
max-width: 500px; |
|
padding: 5px 0; |
|
} |
|
.slot { flex: 1; text-align: center; font-size: 1.2em; font-weight: bold; color: #f0f0f0; } |
|
.slot.jackpot { color: #ffd700; font-size: 1.4em; animation: glow 2s ease-in-out infinite; } |
|
@keyframes glow { |
|
0%, 100% { text-shadow: 0 0 5px #ffd700, 0 0 10px #ff0; } |
|
50% { text-shadow: 0 0 15px #ffd700, 0 0 25px #ff0; } |
|
} |
|
#ticket-controls { |
|
position: absolute; |
|
bottom: 30px; |
|
left: 50%; |
|
transform: translateX(-50%); |
|
display: flex; |
|
gap: 10px; |
|
padding: 10px; |
|
background-color: rgba(0,0,0,0.3); |
|
border-radius: 15px; |
|
z-index: 100; |
|
} |
|
.ticket-button { |
|
background: linear-gradient(145deg, #fceabb, #f8b500); |
|
border: 2px solid #c79100; |
|
color: #4a2c00; |
|
font-family: 'Garamond', serif; |
|
font-size: 18px; |
|
font-weight: bold; |
|
padding: 10px 15px; |
|
border-radius: 10px; |
|
cursor: pointer; |
|
box-shadow: 0 4px 6px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.5); |
|
transition: all 0.2s ease-in-out; |
|
} |
|
.ticket-button:hover { |
|
transform: translateY(-2px); |
|
box-shadow: 0 6px 10px rgba(0,0,0,0.6), inset 0 1px 1px rgba(255,255,255,0.5); |
|
} |
|
.ticket-button:active { |
|
transform: translateY(0); |
|
} |
|
</style> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js"></script> |
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.7.77/Tone.js"></script> |
|
</head> |
|
<body> |
|
<div id="container"> |
|
|
|
<div id="vows-container"> |
|
<div id="vows-content"> |
|
<h2>A Prayer for My Love, Sam</h2> |
|
<p>Dear Lord, help me this day to be...</p> |
|
<p><strong>1. Physical:</strong> to walk with you, hand in hand, in pleasure and intimacy as we adventure.</p> |
|
<p><strong>2. Emotion:</strong> to support you, make you smile and laugh, and be your biggest fan.</p> |
|
<p><strong>3. Spirit:</strong> To lift your heart, help you dream, and help you be your best.</p> |
|
<p><strong>4. Pure:</strong> To be selfless, being the best version of myself to be worthy of your love.</p> |
|
<p><strong>5. Family:</strong> To be dependable, smart, safe, and responsible so we can grow together.</p> |
|
<p><strong>6. Love:</strong> To satisfy you, make your eyes sparkle, to wipe your tears in happiness of love.</p> |
|
<p><strong>7. Respect:</strong> To be your best friend, honoring your wants, needs and dreams.</p> |
|
<p><strong>8. Trust:</strong> To be an open book to you, one you can always depend on to be there when you need me.</p> |
|
<p><strong>9. Commit:</strong> To be hopelessly devoted favorite sunrise to sunset.</p> |
|
<p><strong>10. Bond:</strong> To communicate with you even without words.</p> |
|
</div> |
|
</div> |
|
|
|
<div id="game-area"> |
|
<div id="life-bar-container"><div id="life-bar"></div></div> |
|
<div id="game-container"> |
|
<div id="ticket-controls"> |
|
<button class="ticket-button" data-count="1">Drop 1 Ball</button> |
|
<button class="ticket-button" data-count="5">Drop 5 Balls</button> |
|
<button class="ticket-button" data-count="10">Drop 10 Balls</button> |
|
</div> |
|
</div> |
|
<div id="score-slots"> |
|
<div class="slot jackpot">1000</div><div class="slot">250</div><div class="slot">100</div> |
|
<div class="slot">500</div> |
|
<div class="slot">100</div><div class="slot">250</div><div class="slot jackpot">1000</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
window.onload = function() { |
|
|
|
let score = 0; |
|
const targetScore = 50000; |
|
const lifeBar = document.getElementById('life-bar'); |
|
const gameContainer = document.getElementById('game-container'); |
|
const scene = new THREE.Scene(); |
|
const world = new CANNON.World(); |
|
world.gravity.set(0, -30, 0); |
|
world.broadphase = new CANNON.NaiveBroadphase(); |
|
world.solver.iterations = 10; |
|
const camera = new THREE.PerspectiveCamera(75, gameContainer.clientWidth / gameContainer.clientHeight, 0.1, 1000); |
|
camera.position.set(0, 2, 32); |
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); |
|
renderer.setSize(gameContainer.clientWidth, gameContainer.clientHeight); |
|
renderer.setPixelRatio(window.devicePixelRatio); |
|
gameContainer.appendChild(renderer.domElement); |
|
scene.add(new THREE.AmbientLight(0xffffff, 0.7)); |
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9); |
|
directionalLight.position.set(10, 20, 30); |
|
scene.add(directionalLight); |
|
|
|
|
|
let soundsReady = false; |
|
|
|
const pegHitSynth = new Tone.PolySynth(Tone.Synth, { |
|
oscillator: { type: 'sine' }, |
|
envelope: { attack: 0.001, decay: 0.1, sustain: 0.01, release: 0.1 }, |
|
volume: -12 |
|
}).toDestination(); |
|
|
|
const scoreSynth = new Tone.Synth({ |
|
oscillator: { type: 'triangle8' }, |
|
envelope: { attack: 0.01, decay: 0.2, sustain: 0.2, release: 0.5 }, |
|
volume: -5 |
|
}).toDestination(); |
|
const scoreLfo = new Tone.LFO("4n", 400, 1000).connect(scoreSynth.frequency); |
|
|
|
const dropSynth = new Tone.NoiseSynth({ |
|
noise: { type: 'white' }, |
|
envelope: { attack: 0.005, decay: 0.1, sustain: 0 }, |
|
volume: -10 |
|
}).toDestination(); |
|
|
|
|
|
|
|
const ballMaterial = new THREE.MeshPhongMaterial({ color: 0xff4500, emissive: 0xcc3700, shininess: 100 }); |
|
const pegMaterial = new THREE.MeshPhongMaterial({ color: 0x87ceeb, emissive: 0x1f5c7a, shininess: 80 }); |
|
const wallMaterial = new THREE.MeshPhongMaterial({ color: 0x6a5acd, transparent: true, opacity: 0.4 }); |
|
const goldenTicketMaterial = new THREE.MeshStandardMaterial({ color: 0xfdd835, metalness: 0.8, roughness: 0.2, emissive: 0xc19300 }); |
|
const ballPhysicsMaterial = new CANNON.Material('ball'); |
|
const pegPhysicsMaterial = new CANNON.Material('peg'); |
|
const staticPhysicsMaterial = new CANNON.Material('static'); |
|
world.addContactMaterial(new CANNON.ContactMaterial(ballPhysicsMaterial, pegPhysicsMaterial, { friction: 0.2, restitution: 0.5 })); |
|
world.addContactMaterial(new CANNON.ContactMaterial(ballPhysicsMaterial, staticPhysicsMaterial, { friction: 0.1, restitution: 0.4 })); |
|
world.addContactMaterial(new CANNON.ContactMaterial(ballPhysicsMaterial, ballPhysicsMaterial, { friction: 0.05, restitution: 0.9 })); |
|
|
|
const gameElements = []; |
|
const boardWidth = 28; |
|
const boardHeight = 38; |
|
|
|
|
|
const createStaticBox = (w, h, d, pos, rot = {x:0,y:0,z:0}) => { |
|
const shape = new CANNON.Box(new CANNON.Vec3(w/2, h/2, d/2)); |
|
const body = new CANNON.Body({mass:0, material: staticPhysicsMaterial}); |
|
body.addShape(shape); |
|
body.position.copy(pos); |
|
body.quaternion.setFromEuler(rot.x, rot.y, rot.z, 'XYZ'); |
|
world.addBody(body); |
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w,h,d), wallMaterial); |
|
mesh.position.copy(body.position); |
|
mesh.quaternion.copy(body.quaternion); |
|
scene.add(mesh); |
|
}; |
|
const createPeg = (x, y, z) => { |
|
const r = 0.4, h = 2; |
|
const body = new CANNON.Body({mass: 0, material: pegPhysicsMaterial}); |
|
|
|
body.isPeg = true; |
|
const shape = new CANNON.Cylinder(r, r, h, 16); |
|
const quat = new CANNON.Quaternion(); |
|
quat.setFromAxisAngle(new CANNON.Vec3(1,0,0), -Math.PI/2); |
|
body.addShape(shape, new CANNON.Vec3(), quat); |
|
body.position.set(x,y,z); |
|
world.addBody(body); |
|
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(r,r,h,16), pegMaterial); |
|
mesh.position.copy(body.position); |
|
mesh.quaternion.copy(body.quaternion); |
|
scene.add(mesh); |
|
}; |
|
|
|
|
|
const setupBoard = () => { |
|
|
|
createStaticBox(1, boardHeight, 2, { x: -boardWidth / 2, y: 0, z: 0 }); |
|
createStaticBox(1, boardHeight, 2, { x: boardWidth / 2, y: 0, z: 0 }); |
|
createStaticBox(boardWidth, 1, 2, { x: 0, y: -boardHeight / 2 - 1, z: 0 }); |
|
createStaticBox(12, 1, 2, {x: -8, y: 19, z: 0}, {x:0, y:0, z: 0.7}); |
|
createStaticBox(12, 1, 2, {x: 8, y: 19, z: 0}, {x:0, y:0, z: -0.7}); |
|
const rows = 12, spacingY = 2.9, spacingX = 2.9; |
|
for (let i = 0; i < rows; i++) { |
|
const numPegs = i % 2 === 0 ? 8 : 7; |
|
const rowWidth = (numPegs - 1) * spacingX; |
|
for (let j = 0; j < numPegs; j++) { |
|
const x = -rowWidth/2 + j * spacingX; |
|
const y = 14 - i * spacingY; |
|
createPeg(x, y, 0); |
|
} |
|
} |
|
const scoreSlotsData = [1000, 250, 100, 500, 100, 250, 1000]; |
|
const slotWidth = boardWidth / scoreSlotsData.length; |
|
const slotY = -boardHeight / 2 + 1; |
|
for (let i = 0; i < scoreSlotsData.length - 1; i++) { |
|
createStaticBox(0.5, 4, 2, {x: -boardWidth/2 + (i + 1) * slotWidth, y: slotY + 1, z: 0}); |
|
} |
|
scoreSlotsData.forEach((points, i) => { |
|
const x = -boardWidth/2 + (i + 0.5) * slotWidth; |
|
const triggerBody = new CANNON.Body({isTrigger: true}); |
|
triggerBody.addShape(new CANNON.Box(new CANNON.Vec3(slotWidth/2, 2, 2))); |
|
triggerBody.position.set(x, slotY, 0); |
|
triggerBody.points = points; |
|
triggerBody.isScoreZone = true; |
|
world.addBody(triggerBody); |
|
}); |
|
}; |
|
const goldenTicketMesh = new THREE.Mesh(new THREE.PlaneGeometry(16, 6, 1, 1), goldenTicketMaterial); |
|
goldenTicketMesh.position.set(0, -20, 0); |
|
scene.add(goldenTicketMesh); |
|
|
|
|
|
const updateScore = (points, scoreZoneBody) => { |
|
score += points; |
|
const lifeBarPercent = Math.min((score / targetScore) * 100, 100); |
|
lifeBar.style.width = `${lifeBarPercent}%`; |
|
lifeBar.innerText = score; |
|
|
|
if (soundsReady) { |
|
let note = "C4"; |
|
if (points >= 1000) note = "G5"; |
|
else if (points >= 500) note = "E5"; |
|
else if (points >= 250) note = "C5"; |
|
scoreSynth.triggerAttackRelease(note, "8n"); |
|
} |
|
}; |
|
|
|
const dropBalls = (count) => { |
|
if (soundsReady) dropSynth.triggerAttackRelease("8n"); |
|
|
|
for (let i = 0; i < count; i++) { |
|
const radius = 0.5; |
|
const spawnX = (Math.random() - 0.5) * count * 0.2; |
|
const spawnZ = (Math.random() - 0.5) * count * 0.2; |
|
|
|
const body = new CANNON.Body({ mass: 1, position: new CANNON.Vec3(spawnX, 25, spawnZ), shape: new CANNON.Sphere(radius), material: ballPhysicsMaterial }); |
|
body.angularVelocity.set((Math.random()-0.5)*8, (Math.random()-0.5)*8, (Math.random()-0.5)*8); |
|
body.hasScored = false; |
|
|
|
body.addEventListener("collide", (event) => { |
|
|
|
if (event.body.isScoreZone && !body.hasScored) { |
|
body.hasScored = true; |
|
updateScore(event.body.points, event.body); |
|
const elementToRemove = gameElements.find(el => el.body === body); |
|
if(elementToRemove) { |
|
elementToRemove.mesh.material.emissive.setHex(0xffffff); |
|
setTimeout(() => { |
|
scene.remove(elementToRemove.mesh); |
|
world.removeBody(elementToRemove.body); |
|
gameElements.splice(gameElements.indexOf(elementToRemove), 1); |
|
}, 200); |
|
} |
|
} |
|
|
|
if (event.body.isPeg && soundsReady) { |
|
const impactVelocity = event.contact.getImpactVelocityAlongNormal(); |
|
if (impactVelocity > 1.5) { |
|
const randomNote = ['C5', 'E5', 'G5'][Math.floor(Math.random() * 3)]; |
|
|
|
const volume = Math.min(-12 + impactVelocity, 0); |
|
pegHitSynth.triggerAttackRelease(randomNote, "32n", Tone.now(), volume); |
|
} |
|
} |
|
}); |
|
|
|
const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 32, 32), ballMaterial.clone()); |
|
world.addBody(body); |
|
scene.add(mesh); |
|
gameElements.push({ mesh, body }); |
|
} |
|
}; |
|
|
|
|
|
const clock = new THREE.Clock(); |
|
let ticketAnimationDone = false; |
|
function animate() { |
|
requestAnimationFrame(animate); |
|
const deltaTime = Math.min(clock.getDelta(), 0.1); |
|
world.step(1 / 60, deltaTime, 3); |
|
if (!ticketAnimationDone && goldenTicketMesh.position.y < -15) { |
|
goldenTicketMesh.position.y += 20 * deltaTime; |
|
} else { |
|
ticketAnimationDone = true; |
|
} |
|
gameElements.forEach(el => { |
|
el.mesh.position.copy(el.body.position); |
|
el.mesh.quaternion.copy(el.body.quaternion); |
|
if (el.mesh.position.y < -boardHeight / 2 - 5) { |
|
scene.remove(el.mesh); |
|
world.removeBody(el.body); |
|
gameElements.splice(gameElements.indexOf(el), 1); |
|
} |
|
}); |
|
renderer.render(scene, camera); |
|
} |
|
|
|
|
|
document.querySelectorAll('.ticket-button').forEach(button => { |
|
button.addEventListener('click', async (e) => { |
|
|
|
if (!soundsReady) { |
|
await Tone.start(); |
|
soundsReady = true; |
|
console.log('Audio context started!'); |
|
} |
|
const count = parseInt(e.target.getAttribute('data-count'), 10); |
|
dropBalls(count); |
|
}); |
|
}); |
|
window.addEventListener('resize', () => { |
|
camera.aspect = gameContainer.clientWidth / gameContainer.clientHeight; |
|
camera.updateProjectionMatrix(); |
|
renderer.setSize(gameContainer.clientWidth, gameContainer.clientHeight); |
|
}, false); |
|
|
|
|
|
setupBoard(); |
|
animate(); |
|
}; |
|
</script> |
|
</body> |
|
</html> |
|
|