diff --git a/src/App.svelte b/src/App.svelte index a4558e9..11b7314 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -8,22 +8,58 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; import { loadLeaderboard, saveScore, playerName, leaderboard , hasSubmitted } from './leaderboard.js'; +import { updateEnvironment, skyColors } from './environment.js'; +import { createObstacle, handleCollisions } from './obstacles.js'; +import { createSketch } from './p5overlay.js'; let showLanding = true; let lastTime = performance.now(); async function handleStart() { showLanding = false; - // Wait for Svelte to render the #wrapper and canvas + // 1. Wait for Svelte to render the div so p5Container is NOT null await tick(); + // 2. YOU FORGOT THIS: Initialize Three.js scene init(); - p5Instance = new p5(sketch, p5Container); - lastTime = performance.now();// Ensure lastTime is reset to "now" so delta doesn't jump - // Start the game logic + + // 3. Setup the Bridge Object + const gameState = { + get isPlaying() { return isPlaying; }, + get score() { return score; }, + set score(v) { score = v; }, // Allows p5 to update the score variable here + get lives() { return lives; }, + set lives(v) { lives = v; }, // Allows p5 to update the lives variable here + get multiplierTimer() { return multiplierTimer; }, + get targetType() { return targetType; }, + get instructionTimer() { return instructionTimer; }, + get gamePhase() { return gamePhase; }, + get lastStarScore() { return lastStarScore; }, + set lastStarScore(v) { lastStarScore = v; }, + targets, + scorePopups, + onHit: (t) => { + // This logic runs in App.svelte scope when p5 calls it + if (t.type === targetType) { + const gain = 100 * scoreMultiplier; + score += gain; + scorePopups.push({ x: t.x, y: t.y, opacity: 255, life: 1, val: `+${gain}` }); + } else if (t.type === "STAR") { + scoreMultiplier = 2; + multiplierTimer = BOOST_DURATION; + scorePopups.push({ x: t.x, y: t.y, opacity: 255, life: 1, val: "X2 BOOST!" }); + } else { + if (lives > 0) lives--; + } + } + }; + + // 4. Initialize p5 now that p5Container exists + p5Instance = new p5(createSketch(gameState, textures), p5Container); + + lastTime = performance.now(); startGame(); - // Start the render loop const loop = () => { animationFrame = requestAnimationFrame(loop); update(); @@ -58,10 +94,7 @@ let isDying = false, hitFlash = false; let spawnDistanceTracker = 0; const SPAWN_INTERVAL = 40; // Physical distance between obstacles let cloudGroup; -let skyColors = { - day: new THREE.Color(0x87CEFA), - night: new THREE.Color(0x02050a) -}; + let sun, moon, ambientLight, sunLight, headLight; // 2D Game Logic @@ -87,230 +120,6 @@ let multiplierTimer = 0; // Remaining seconds of boost let lastStarScore = 0; // Add this line to prevent the crash const BOOST_DURATION = 20; // 20 seconds -const sketch = (p) => { - // --- FOOLPROOF IMAGE LOADER --- - p.setup = async () => { - const w = container?.clientWidth || p.windowWidth; - const h = container?.clientHeight || p.windowHeight; - p.createCanvas(w, h); - - const loadImg = (path) => new Promise(resolve => { - p.loadImage(path, img => resolve(img), () => resolve(null)); - }); - - textures.STRAWBERRY = await loadImg('strawberry.png'); - textures.WATERMELON = await loadImg('watermelon.png'); - textures.BLUEBERRY = await loadImg('blubb.png'); - textures.STAR = await loadImg('star.png'); - }; - - const drawHeart = (x, y, size, active) => { - p.push(); - p.noStroke(); - // Use red if active, grey if dead - p.fill(active ? [255, 50, 50] : [100, 100, 100, 150]); - const s = size / 5; - p.rect(x + s, y, s, s); p.rect(x + 3 * s, y, s, s); - p.rect(x, y + s, 5 * s, s); - p.rect(x, y + 2 * s, 5 * s, s); - p.rect(x + s, y + 3 * s, 3 * s, s); - p.rect(x + 2 * s, y + 4 * s, s, s); - p.pop(); - }; - - p.draw = () => { - p.clear(); - if (!isPlaying) return; - - // --- MULTIPLIER HUD --- - if (multiplierTimer > 0) { - p.push(); - p.fill(255, 215, 0); // Golden color - p.textSize(24); - p.textStyle(p.BOLD); - p.textAlign(p.RIGHT); - p.text(`BOOST: ${Math.ceil(multiplierTimer)}s`, p.width - 20, 100); - p.pop(); - - // Optional: Add a subtle golden border to the screen - p.noFill(); - p.stroke(255, 215, 0, 100); - p.strokeWeight(10); - p.rect(0, 0, p.width, p.height); - } - - // Inside p.draw, near your other spawning logic - if (score > 0 && score % 10000 < 50 && score - lastStarScore >= 10000) { - lastStarScore = score; - targets.push({ - x: -50, // Start off-screen left - y: p.random(p.height * 0.2, p.height * 0.5), // Random vertical height - type: "STAR", - speedX: p.random(6, 9), // Fly fast horizontally - speedY: 0, - rot: 0 - }); - } - - if (gamePhase === "INSTRUCTIONS") { - p.fill(0, 200); // Darken background - p.rect(0, 0, p.width, p.height); - - p.fill(255); - p.textAlign(p.CENTER); - p.textFont('Segoe UI'); - p.textStyle(p.BOLD); - - // The Mission Text - p.textSize(28); - p.text(`MISSION: COLLECT`, p.width / 2, p.height / 2 - 100); - - // Draw the target icon to collect - const targetImg = textures[targetType]; - if (targetImg) { - p.imageMode(p.CENTER); - p.image(targetImg, p.width / 2, p.height / 2 - 20, 80, 80); - } - - p.textSize(32); - p.fill(0, 255, 200); // Cyan color for the target name - p.text(targetType, p.width / 2, p.height / 2 + 60); - - // Countdown - p.fill(255); - p.textSize(80); - p.text(Math.ceil(instructionTimer), p.width / 2, p.height / 2 + 160); - return; - } - // --- RENDER HEARTS --- - for (let i = 0; i < 5; i++) { // Always run 5 times - drawHeart(20 + (i * 35), 20, 25, i < lives); - } - - - if (p.random(1) < 0.004) { - const types = ["STRAWBERRY", "WATERMELON", "BLUEBERRY"]; - targets.push({ - x: p.random(p.width * 0.2, p.width * 0.8), - y: -50, - type: types[p.floor(p.random(types.length))], - speed: p.random(1.5, 3), - rot: 0 - }); - } - - // Inside p.draw(), top-left corner - p.push(); - p.fill(0, 150); // Translucent dark background - p.rect(10, 60, 120, 140, 15); - p.fill(255); - p.textSize(14); - p.textAlign(p.CENTER); - p.text("CURRENT TARGET", 70, 85); - - const targetImg = textures[targetType]; - if (targetImg) { - p.image(targetImg, 70, 135, 60, 60); - } - p.fill(0, 255, 200); - p.text(targetType, 70, 185); - p.pop(); - - // --- RENDER IMAGES --- - for (let i = targets.length - 1; i >= 0; i--) { - let t = targets[i]; - if (t.type === "STAR") { - t.x += t.speedX; // Move horizontal - } else { - t.y += t.speed; // Move vertical - } - - t.rot += 0.02; - - p.push(); - p.translate(t.x, t.y); - p.rotate(t.rot); - p.imageMode(p.CENTER); - - // Check if the texture exists before trying to draw it - const img = textures[t.type]; - if (img) { - // Draw the image. Scale it to 40x40 pixels (adjust as needed) - const size = t.type === "STAR" ? 120 : 100; - p.image(img, 0, 0, size, size); - } - p.pop(); - - if (t.y > p.height + 50 || t.x > p.width + 50) { - // If the one we missed was the target, lose a life - if (t.type === targetType && lives > 0) { - lives--; - } - targets.splice(i, 1); - } - } - - // --- RENDER FLOATING SCORE POPUPS --- - for (let i = scorePopups.length - 1; i >= 0; i--) { - let pop = scorePopups[i]; - if (!pop.val) continue; // Safety check: skip if value is missing - - p.push(); - p.textAlign(p.CENTER); - p.textStyle(p.BOLD); - p.textSize(32 + (1 - pop.life) * 20); // Gets bigger as it rises - - // Yellow color with fading alpha - p.fill(255, 230, 0, pop.opacity); - p.text(pop.val, pop.x, pop.y); - p.pop(); - - // Animate: Move up and fade out - pop.y -= 2; - pop.life -= 0.02; - pop.opacity = pop.life * 255; - - // Remove when faded - if (pop.life <= 0) { - scorePopups.splice(i, 1); - } - } - - // --- 2D HAMMER --- - p.push(); - p.translate(p.mouseX, p.mouseY); - p.rotate(-0.4); - p.fill(120, 80, 50); p.noStroke(); - p.rect(-5, 0, 10, 40, 2); - p.fill(100); - p.rect(-20, -10, 40, 20, 4); - p.pop(); - }; - - p.mousePressed = () => { - if (gamePhase !== "PLAYING") return; - for (let i = targets.length - 1; i >= 0; i--) { - let t = targets[i]; - if (p.dist(p.mouseX, p.mouseY, t.x, t.y) < 60) { - if (t.type === targetType) { - // Apply multiplier to normal hits - const gain = 100 * scoreMultiplier; - score += gain; - scorePopups.push({ x: t.x, y: t.y, opacity: 255, life: 1, val: `+${gain}` }); - } else if(t.type === "STAR") { - // Trigger the 2x Boost - scoreMultiplier = 2; - multiplierTimer = BOOST_DURATION; - scorePopups.push({ x: t.x, y: t.y, opacity: 255, life: 1, val: "X2 BOOST!" }); - }else { - if (lives > 0) lives--; - } - targets.splice(i, 1); - break; - } - } - }; -}; const grassVertex = ` varying vec2 vUv; @@ -466,31 +275,15 @@ function init() { } async function spawn() { -const l = Math.floor(Math.random() * 5) - 2; const isRare = Math.random() < 0.2; const modelFile = isRare ? "bird_in_a_claw_machine.glb" : "Simple computer.glb"; const source = await getCachedGLTF(modelFile); - const model = cloneSkeleton(source.scene); - const pivot = new THREE.Group(); + // Call the module function + const obstacleData = createObstacle(isRare, source, CONFIG.lane); - pivot.position.set(l * CONFIG.lane, 0, -130); - - if (isRare) { - model.position.set(0, 3.0, 0); - model.rotation.y = 0; - model.scale.setScalar(0.6); - } else { - model.position.set(0, 0.6, 0); - model.rotation.y = Math.PI; - model.scale.setScalar(5.5); - } - - pivot.add(model); - scene.add(pivot); - - // ADD THIS: Save the type so the collision logic knows it's tall - worldObjects = [...worldObjects, { mesh: pivot, lane: l, isTall: isRare }]; + scene.add(obstacleData.mesh); + worldObjects = [...worldObjects, obstacleData]; } @@ -512,28 +305,11 @@ function update() { } } - // --- DAY/NIGHT CYCLE LOGIC START --- - - // Calculate alpha (0 = Day, 1 = Night) using a sine wave based on score - // let cycleProgress = (score % (CONFIG.CYCLE_INTERVAL * 2)) / (CONFIG.CYCLE_INTERVAL * 2); - let cycleProgress = (uTime.value % 60) / 60; - let nightAlpha = Math.pow(Math.sin(cycleProgress * Math.PI), 2); - - // Interpolate Background and Fog colors - const currentSky = skyColors.day.clone().lerp(skyColors.night, nightAlpha); - scene.background.copy(currentSky); - scene.fog.color.copy(currentSky); - - // Adjust Light Intensities - ambientLight.intensity = THREE.MathUtils.lerp(1.5, 0.2, nightAlpha); - sunLight.intensity = THREE.MathUtils.lerp(1.0, 0.1, nightAlpha); - headLight.intensity = THREE.MathUtils.lerp(0, 2.5, nightAlpha); - - // Move Sun and Moon (Sun goes down, Moon comes up) - sun.position.y = THREE.MathUtils.lerp(100, -100, nightAlpha); - moon.position.y = THREE.MathUtils.lerp(-100, 100, nightAlpha); - - // --- DAY/NIGHT CYCLE LOGIC END --- + // --- NEW MODULAR ENVIRONMENT CALL --- + updateEnvironment(uTime.value, scene, + { ambientLight, sunLight, headLight }, + { sun, moon } + ); if (gamePhase === "INSTRUCTIONS") { instructionTimer -= delta; @@ -586,23 +362,14 @@ function update() { } playerAnchor.position.y = playerY; - worldObjects = worldObjects.map(obj => { - obj.mesh.position.z += moveStep; + // Update object positions + worldObjects.forEach(obj => { obj.mesh.position.z += moveStep; }); - // Collision detection - const isInLane = obj.lane === lane; - const isHitZ = Math.abs(obj.mesh.position.z) < 1.5; - - // NEW LOGIC: If it's a tall object, playerY doesn't matter. - // If it's a short object (computer), you only hit if playerY < 1.5. - const isHitHeight = obj.isTall || playerY < 1.5; + // Handle Collisions using the module + worldObjects = handleCollisions(worldObjects, lane, playerY, triggerGameOver); - if (isHitZ && isInLane && isHitHeight) { - triggerGameOver(); - } - return obj; - }).filter(obj => { - if (!obj) return false; + // Filter out-of-bounds objects + worldObjects = worldObjects.filter(obj => { const active = obj.mesh.position.z < 25; if (!active) scene.remove(obj.mesh); return active; diff --git a/src/environment.js b/src/environment.js new file mode 100644 index 0000000..bb86627 --- /dev/null +++ b/src/environment.js @@ -0,0 +1,32 @@ +// @ts-nocheck +import * as THREE from "three"; + +export const skyColors = { + day: new THREE.Color(0x87CEFA), + night: new THREE.Color(0x02050a) +}; + +export function updateEnvironment(uTime, scene, lights, celestial) { + const { ambientLight, sunLight, headLight } = lights; + const { sun, moon } = celestial; + + // Calculate cycle (0 = Day, 1 = Night) + let cycleProgress = (uTime % 100) / 100; + let nightAlpha = Math.pow(Math.sin(cycleProgress * Math.PI), 2); + + // Interpolate Background and Fog + const currentSky = skyColors.day.clone().lerp(skyColors.night, nightAlpha); + scene.background.copy(currentSky); + if (scene.fog) scene.fog.color.copy(currentSky); + + // Adjust Light Intensities + if (ambientLight) ambientLight.intensity = THREE.MathUtils.lerp(1.5, 0.2, nightAlpha); + if (sunLight) sunLight.intensity = THREE.MathUtils.lerp(1.0, 0.1, nightAlpha); + if (headLight) headLight.intensity = THREE.MathUtils.lerp(0, 2.5, nightAlpha); + + // Move Sun and Moon + if (sun) sun.position.y = THREE.MathUtils.lerp(100, -100, nightAlpha); + if (moon) moon.position.y = THREE.MathUtils.lerp(-100, 100, nightAlpha); + + return nightAlpha; // Return alpha in case you want to use it for other effects +} \ No newline at end of file diff --git a/src/obstacles.js b/src/obstacles.js new file mode 100644 index 0000000..b930a27 --- /dev/null +++ b/src/obstacles.js @@ -0,0 +1,46 @@ +// @ts-nocheck +import * as THREE from "three"; +import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js"; + +export function createObstacle(isRare, source, laneWidth) { + const model = cloneSkeleton(source.scene); + const pivot = new THREE.Group(); + const l = Math.floor(Math.random() * 5) - 2; + + pivot.position.set(l * laneWidth, 0, -130); + + if (isRare) { + // Claw Machine Settings + model.position.set(0, 3.0, 0); + model.rotation.y = 0; + model.scale.setScalar(0.6); + } else { + // Computer Settings + model.position.set(0, 0.6, 0); + model.rotation.y = Math.PI; + model.scale.setScalar(5.5); + } + + pivot.add(model); + + return { + mesh: pivot, + lane: l, + isTall: isRare + }; +} + +export function handleCollisions(worldObjects, playerLane, playerY, onCollision) { + return worldObjects.map(obj => { + // We assume movement is handled in the main loop for sync, + // but collision logic is centralized here. + const isInLane = obj.lane === playerLane; + const isHitZ = Math.abs(obj.mesh.position.z) < 1.5; + const isHitHeight = obj.isTall || playerY < 1.5; + + if (isHitZ && isInLane && isHitHeight) { + onCollision(); + } + return obj; + }); +} \ No newline at end of file diff --git a/src/p5overlay.js b/src/p5overlay.js new file mode 100644 index 0000000..500120e --- /dev/null +++ b/src/p5overlay.js @@ -0,0 +1,148 @@ +// @ts-nocheck +export const createSketch = (state, texturesRef) => { + return (p) => { + // --- PRIVATE UTILITIES --- + const drawHeart = (x, y, size, active) => { + p.push(); + p.noStroke(); + p.fill(active ? [255, 50, 50] : [100, 100, 100, 150]); + const s = size / 5; + p.rect(x + s, y, s, s); p.rect(x + 3 * s, y, s, s); + p.rect(x, y + s, 5 * s, s); + p.rect(x, y + 2 * s, 5 * s, s); + p.rect(x + s, y + 3 * s, 3 * s, s); + p.rect(x + 2 * s, y + 4 * s, s, s); + p.pop(); + }; + + p.setup = async () => { + p.createCanvas(p.windowWidth, p.windowHeight); + const loadImg = (path) => new Promise(resolve => { + p.loadImage(path, img => resolve(img), () => resolve(null)); + }); + + // Load into the reference object passed from App.svelte + texturesRef.STRAWBERRY = await loadImg('strawberry.png'); + texturesRef.WATERMELON = await loadImg('watermelon.png'); + texturesRef.BLUEBERRY = await loadImg('blubb.png'); + texturesRef.STAR = await loadImg('star.png'); + }; + + p.draw = () => { + p.clear(); + if (!state.isPlaying) return; + + // 1. Multiplier HUD + if (state.multiplierTimer > 0) { + p.push(); + p.fill(255, 215, 0); + p.textSize(24); + p.textStyle(p.BOLD); + p.textAlign(p.RIGHT); + p.text(`BOOST: ${Math.ceil(state.multiplierTimer)}s`, p.width - 20, 100); + p.noFill(); + p.stroke(255, 215, 0, 100); + p.strokeWeight(10); + p.rect(0, 0, p.width, p.height); + p.pop(); + } + + // 2. Star Spawning + if (state.score > 0 && state.score % 10000 < 50 && state.score - state.lastStarScore >= 10000) { + state.lastStarScore = state.score; + state.targets.push({ + x: -50, y: p.random(p.height * 0.2, p.height * 0.5), + type: "STAR", speedX: p.random(6, 9), rot: 0 + }); + } + + // 3. Instruction Phase + if (state.gamePhase === "INSTRUCTIONS") { + p.fill(0, 200); p.rect(0, 0, p.width, p.height); + p.fill(255); p.textAlign(p.CENTER); p.textSize(28); + p.text(`MISSION: COLLECT`, p.width / 2, p.height / 2 - 100); + const targetImg = texturesRef[state.targetType]; + if (targetImg) p.image(targetImg, p.width / 2 - 40, p.height / 2 - 60, 80, 80); + p.fill(0, 255, 200); p.text(state.targetType, p.width / 2, p.height / 2 + 60); + p.fill(255); p.textSize(80); + p.text(Math.ceil(state.instructionTimer), p.width / 2, p.height / 2 + 160); + return; + } + + // 4. Render Hearts + for (let i = 0; i < 5; i++) drawHeart(20 + (i * 35), 20, 25, i < state.lives); + + // 5. Random Target Spawning + if (p.random(1) < 0.004) { + const types = ["STRAWBERRY", "WATERMELON", "BLUEBERRY"]; + state.targets.push({ + x: p.random(p.width * 0.2, p.width * 0.8), y: -50, + type: types[p.floor(p.random(types.length))], speed: p.random(1.5, 3), rot: 0 + }); + } + + // 6. Current Target Box + p.push(); + p.fill(0, 150); p.rect(10, 60, 120, 140, 15); + p.fill(255); p.textSize(14); p.textAlign(p.CENTER); + p.text("CURRENT TARGET", 70, 85); + const boxImg = texturesRef[state.targetType]; + if (boxImg) p.image(boxImg, 40, 95, 60, 60); + p.fill(0, 255, 200); p.text(state.targetType, 70, 185); + p.pop(); + + // 7. Render/Move Targets + for (let i = state.targets.length - 1; i >= 0; i--) { + let t = state.targets[i]; + t.type === "STAR" ? t.x += t.speedX : t.y += t.speed; + t.rot += 0.02; + p.push(); + p.translate(t.x, t.y); p.rotate(t.rot); + const img = texturesRef[t.type]; + if (img) p.image(img, -50, -50, t.type === "STAR" ? 120 : 100, t.type === "STAR" ? 120 : 100); + p.pop(); + if (t.y > p.height + 50 || t.x > p.width + 50) { + if (t.type === state.targetType && state.lives > 0) state.lives--; + state.targets.splice(i, 1); + } + } + + // 8. Popups & Hammer + renderPopups(p, state.scorePopups); + drawHammer(p); + }; + + const renderPopups = (p, popups) => { + for (let i = popups.length - 1; i >= 0; i--) { + let pop = popups[i]; + p.push(); + p.fill(255, 230, 0, pop.opacity); + p.textSize(32 + (1 - pop.life) * 20); + p.text(pop.val, pop.x, pop.y); + p.pop(); + pop.y -= 2; pop.life -= 0.02; pop.opacity = pop.life * 255; + if (pop.life <= 0) popups.splice(i, 1); + } + }; + + const drawHammer = (p) => { + p.push(); + p.translate(p.mouseX, p.mouseY); p.rotate(-0.4); + p.fill(120, 80, 50); p.rect(-5, 0, 10, 40, 2); + p.fill(100); p.rect(-20, -10, 40, 20, 4); + p.pop(); + }; + + p.mousePressed = () => { + if (state.gamePhase !== "PLAYING") return; + for (let i = state.targets.length - 1; i >= 0; i--) { + let t = state.targets[i]; + if (p.dist(p.mouseX, p.mouseY, t.x, t.y) < 60) { + state.onHit(t); + state.targets.splice(i, 1); + break; + } + } + }; + }; +}; \ No newline at end of file