added new obstacle type so change in lanes are inevitable, added new special target that boosts score
@@ -1,3 +1,4 @@
|
|||||||
Commodore 64 by Jason Toff [CC-BY] via Poly Pizza
|
Commodore 64 by Jason Toff [CC-BY] via Poly Pizza
|
||||||
https://freefrontend.com/code/procedural-3d-endless-runner-game-2026-02-24/?utm_source=chatgpt.com
|
https://freefrontend.com/code/procedural-3d-endless-runner-game-2026-02-24/?utm_source=chatgpt.com
|
||||||
using perplexity, gemini, google ai studio
|
using perplexity, gemini, google ai studio
|
||||||
|
"Bird in a claw machine" (https://skfb.ly/otMUN) by Tin Pui-yiu is licensed under CC Attribution-NonCommercial-ShareAlike (http://creativecommons.org/licenses/by-nc-sa/4.0/).
|
||||||
|
|||||||
BIN
banana.png
|
Before Width: | Height: | Size: 743 KiB After Width: | Height: | Size: 614 KiB |
BIN
bird_in_a_claw_machine.glb
Normal file
BIN
blubb.png
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 717 KiB |
177
src/App.svelte
@@ -22,7 +22,7 @@ async function handleStart() {
|
|||||||
|
|
||||||
init();
|
init();
|
||||||
p5Instance = new p5(sketch, p5Container);
|
p5Instance = new p5(sketch, p5Container);
|
||||||
|
lastTime = performance.now();// Ensure lastTime is reset to "now" so delta doesn't jump
|
||||||
// Start the game logic
|
// Start the game logic
|
||||||
startGame();
|
startGame();
|
||||||
|
|
||||||
@@ -120,6 +120,11 @@ const glbCache = new Map();
|
|||||||
|
|
||||||
let textures = {};
|
let textures = {};
|
||||||
|
|
||||||
|
let scoreMultiplier = 1;
|
||||||
|
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) => {
|
const sketch = (p) => {
|
||||||
// --- FOOLPROOF IMAGE LOADER ---
|
// --- FOOLPROOF IMAGE LOADER ---
|
||||||
p.setup = async () => {
|
p.setup = async () => {
|
||||||
@@ -132,8 +137,9 @@ const sketch = (p) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
textures.STRAWBERRY = await loadImg('strawberry.png');
|
textures.STRAWBERRY = await loadImg('strawberry.png');
|
||||||
textures.BANANA = await loadImg('banana.png');
|
textures.WATERMELON = await loadImg('watermelon.png');
|
||||||
textures.BLUEBERRY = await loadImg('blubb.png');
|
textures.BLUEBERRY = await loadImg('blubb.png');
|
||||||
|
textures.STAR = await loadImg('star.png');
|
||||||
};
|
};
|
||||||
|
|
||||||
const drawHeart = (x, y, size, active) => {
|
const drawHeart = (x, y, size, active) => {
|
||||||
@@ -153,6 +159,37 @@ const sketch = (p) => {
|
|||||||
p.draw = () => {
|
p.draw = () => {
|
||||||
p.clear();
|
p.clear();
|
||||||
if (!isPlaying) return;
|
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") {
|
if (gamePhase === "INSTRUCTIONS") {
|
||||||
p.fill(0, 200); // Darken background
|
p.fill(0, 200); // Darken background
|
||||||
p.rect(0, 0, p.width, p.height);
|
p.rect(0, 0, p.width, p.height);
|
||||||
@@ -190,7 +227,7 @@ const sketch = (p) => {
|
|||||||
|
|
||||||
|
|
||||||
if (p.random(1) < 0.004) {
|
if (p.random(1) < 0.004) {
|
||||||
const types = ["STRAWBERRY", "BANANA", "BLUEBERRY"];
|
const types = ["STRAWBERRY", "WATERMELON", "BLUEBERRY"];
|
||||||
targets.push({
|
targets.push({
|
||||||
x: p.random(p.width * 0.2, p.width * 0.8),
|
x: p.random(p.width * 0.2, p.width * 0.8),
|
||||||
y: -50,
|
y: -50,
|
||||||
@@ -200,10 +237,32 @@ const sketch = (p) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ---
|
// --- RENDER IMAGES ---
|
||||||
for (let i = targets.length - 1; i >= 0; i--) {
|
for (let i = targets.length - 1; i >= 0; i--) {
|
||||||
let t = targets[i];
|
let t = targets[i];
|
||||||
t.y += t.speed;
|
if (t.type === "STAR") {
|
||||||
|
t.x += t.speedX; // Move horizontal
|
||||||
|
} else {
|
||||||
|
t.y += t.speed; // Move vertical
|
||||||
|
}
|
||||||
|
|
||||||
t.rot += 0.02;
|
t.rot += 0.02;
|
||||||
|
|
||||||
p.push();
|
p.push();
|
||||||
@@ -215,15 +274,12 @@ const sketch = (p) => {
|
|||||||
const img = textures[t.type];
|
const img = textures[t.type];
|
||||||
if (img) {
|
if (img) {
|
||||||
// Draw the image. Scale it to 40x40 pixels (adjust as needed)
|
// Draw the image. Scale it to 40x40 pixels (adjust as needed)
|
||||||
p.image(img, 0, 0, 60, 60);
|
const size = t.type === "STAR" ? 120 : 100;
|
||||||
} else {
|
p.image(img, 0, 0, size, size);
|
||||||
// Fallback: draw a small circle if image fails to load
|
|
||||||
p.fill(255);
|
|
||||||
p.ellipse(0, 0, 10);
|
|
||||||
}
|
}
|
||||||
p.pop();
|
p.pop();
|
||||||
|
|
||||||
if (t.y > p.height + 50) {
|
if (t.y > p.height + 50 || t.x > p.width + 50) {
|
||||||
// If the one we missed was the target, lose a life
|
// If the one we missed was the target, lose a life
|
||||||
if (t.type === targetType && lives > 0) {
|
if (t.type === targetType && lives > 0) {
|
||||||
lives--;
|
lives--;
|
||||||
@@ -235,6 +291,7 @@ const sketch = (p) => {
|
|||||||
// --- RENDER FLOATING SCORE POPUPS ---
|
// --- RENDER FLOATING SCORE POPUPS ---
|
||||||
for (let i = scorePopups.length - 1; i >= 0; i--) {
|
for (let i = scorePopups.length - 1; i >= 0; i--) {
|
||||||
let pop = scorePopups[i];
|
let pop = scorePopups[i];
|
||||||
|
if (!pop.val) continue; // Safety check: skip if value is missing
|
||||||
|
|
||||||
p.push();
|
p.push();
|
||||||
p.textAlign(p.CENTER);
|
p.textAlign(p.CENTER);
|
||||||
@@ -243,7 +300,7 @@ const sketch = (p) => {
|
|||||||
|
|
||||||
// Yellow color with fading alpha
|
// Yellow color with fading alpha
|
||||||
p.fill(255, 230, 0, pop.opacity);
|
p.fill(255, 230, 0, pop.opacity);
|
||||||
p.text("+100", pop.x, pop.y);
|
p.text(pop.val, pop.x, pop.y);
|
||||||
p.pop();
|
p.pop();
|
||||||
|
|
||||||
// Animate: Move up and fade out
|
// Animate: Move up and fade out
|
||||||
@@ -272,16 +329,17 @@ const sketch = (p) => {
|
|||||||
if (gamePhase !== "PLAYING") return;
|
if (gamePhase !== "PLAYING") return;
|
||||||
for (let i = targets.length - 1; i >= 0; i--) {
|
for (let i = targets.length - 1; i >= 0; i--) {
|
||||||
let t = targets[i];
|
let t = targets[i];
|
||||||
if (p.dist(p.mouseX, p.mouseY, t.x, t.y) < 40) {
|
if (p.dist(p.mouseX, p.mouseY, t.x, t.y) < 60) {
|
||||||
if (t.type === targetType) {
|
if (t.type === targetType) {
|
||||||
score += 100;
|
// Apply multiplier to normal hits
|
||||||
// Create a floating score popup
|
const gain = 100 * scoreMultiplier;
|
||||||
scorePopups.push({
|
score += gain;
|
||||||
x: t.x,
|
scorePopups.push({ x: t.x, y: t.y, opacity: 255, life: 1, val: `+${gain}` });
|
||||||
y: t.y,
|
} else if(t.type === "STAR") {
|
||||||
opacity: 255,
|
// Trigger the 2x Boost
|
||||||
life: 1
|
scoreMultiplier = 2;
|
||||||
});
|
multiplierTimer = BOOST_DURATION;
|
||||||
|
scorePopups.push({ x: t.x, y: t.y, opacity: 255, life: 1, val: "X2 BOOST!" });
|
||||||
}else {
|
}else {
|
||||||
if (lives > 0) lives--;
|
if (lives > 0) lives--;
|
||||||
}
|
}
|
||||||
@@ -384,9 +442,9 @@ function init() {
|
|||||||
scene.background = new THREE.Color(skyColor);
|
scene.background = new THREE.Color(skyColor);
|
||||||
scene.fog = new THREE.Fog(skyColor, 150, 350);
|
scene.fog = new THREE.Fog(skyColor, 150, 350);
|
||||||
|
|
||||||
camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 1000);
|
camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.2, 1000);
|
||||||
camera.position.set(0, 4.5, 13);
|
camera.position.set(0, 5.5, 13);
|
||||||
camera.lookAt(0, 1, -5);
|
camera.lookAt(0, -1, -5);
|
||||||
|
|
||||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||||
@@ -447,16 +505,40 @@ function init() {
|
|||||||
|
|
||||||
async function spawn() {
|
async function spawn() {
|
||||||
const l = Math.floor(Math.random() * 5) - 2;
|
const l = Math.floor(Math.random() * 5) - 2;
|
||||||
const source = await getCachedGLTF("Simple computer.glb");
|
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 model = cloneSkeleton(source.scene);
|
||||||
const pivot = new THREE.Group();
|
const pivot = new THREE.Group();
|
||||||
|
|
||||||
pivot.position.set(l * CONFIG.lane, 0, -130);
|
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.position.set(0, 0.6, 0);
|
||||||
model.rotation.y = Math.PI;
|
model.rotation.y = Math.PI;
|
||||||
model.scale.setScalar(5.5);
|
model.scale.setScalar(5.5);
|
||||||
|
}
|
||||||
|
|
||||||
pivot.add(model);
|
pivot.add(model);
|
||||||
scene.add(pivot);
|
scene.add(pivot);
|
||||||
worldObjects = [...worldObjects, { mesh: pivot, lane: l }];
|
|
||||||
|
// ADD THIS: Save the type so the collision logic knows it's tall
|
||||||
|
worldObjects = [...worldObjects, { mesh: pivot, lane: l, isTall: isRare }];
|
||||||
|
// const source = await getCachedGLTF("Simple computer.glb");
|
||||||
|
// const model = cloneSkeleton(source.scene);
|
||||||
|
// const pivot = new THREE.Group();
|
||||||
|
// pivot.position.set(l * CONFIG.lane, 0, -130);
|
||||||
|
// model.position.set(0, 0.6, 0);
|
||||||
|
// model.rotation.y = Math.PI;
|
||||||
|
// model.scale.setScalar(5.5);
|
||||||
|
// pivot.add(model);
|
||||||
|
// scene.add(pivot);
|
||||||
|
// worldObjects = [...worldObjects, { mesh: pivot, lane: l }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -469,6 +551,15 @@ function update() {
|
|||||||
if (currentMixer) currentMixer.update(delta);
|
if (currentMixer) currentMixer.update(delta);
|
||||||
if (!isPlaying) return;
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
// --- MULTIPLIER COUNTDOWN ---
|
||||||
|
if (multiplierTimer > 0) {
|
||||||
|
multiplierTimer -= delta;
|
||||||
|
if (multiplierTimer <= 0) {
|
||||||
|
multiplierTimer = 0;
|
||||||
|
scoreMultiplier = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- DAY/NIGHT CYCLE LOGIC START ---
|
// --- DAY/NIGHT CYCLE LOGIC START ---
|
||||||
|
|
||||||
// Calculate alpha (0 = Day, 1 = Night) using a sine wave based on score
|
// Calculate alpha (0 = Day, 1 = Night) using a sine wave based on score
|
||||||
@@ -503,7 +594,11 @@ function update() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const moveStep = currentSpeed * delta;
|
const moveStep = currentSpeed * delta;
|
||||||
score += Math.floor(currentSpeed / 40);
|
// --- BOOSTED DISTANCE SCORE ---
|
||||||
|
// We apply the multiplier to the floor calculation
|
||||||
|
score += Math.floor((currentSpeed / 40) * scoreMultiplier);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (cloudGroup) {
|
if (cloudGroup) {
|
||||||
// Moving at 40% speed (moveStep * 0.4) creates a nice parallax depth
|
// Moving at 40% speed (moveStep * 0.4) creates a nice parallax depth
|
||||||
@@ -539,11 +634,33 @@ function update() {
|
|||||||
}
|
}
|
||||||
playerAnchor.position.y = playerY;
|
playerAnchor.position.y = playerY;
|
||||||
|
|
||||||
|
// worldObjects = worldObjects.map(obj => {
|
||||||
|
// obj.mesh.position.z += moveStep;
|
||||||
|
// if (Math.abs(obj.mesh.position.z) < 1.5 && obj.lane === lane && playerY < 1.5) triggerGameOver();
|
||||||
|
// return obj;
|
||||||
|
// }).filter(obj => {
|
||||||
|
// const active = obj.mesh.position.z < 25;
|
||||||
|
// if (!active) scene.remove(obj.mesh);
|
||||||
|
// return active;
|
||||||
|
// });
|
||||||
|
|
||||||
worldObjects = worldObjects.map(obj => {
|
worldObjects = worldObjects.map(obj => {
|
||||||
obj.mesh.position.z += moveStep;
|
obj.mesh.position.z += moveStep;
|
||||||
if (Math.abs(obj.mesh.position.z) < 1.5 && obj.lane === lane && playerY < 1.5) triggerGameOver();
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (isHitZ && isInLane && isHitHeight) {
|
||||||
|
triggerGameOver();
|
||||||
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}).filter(obj => {
|
}).filter(obj => {
|
||||||
|
if (!obj) return false;
|
||||||
const active = obj.mesh.position.z < 25;
|
const active = obj.mesh.position.z < 25;
|
||||||
if (!active) scene.remove(obj.mesh);
|
if (!active) scene.remove(obj.mesh);
|
||||||
return active;
|
return active;
|
||||||
@@ -571,7 +688,7 @@ async function startGame() {
|
|||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
currentSpeed = CONFIG.START_SPEED;
|
currentSpeed = CONFIG.START_SPEED;
|
||||||
// Choose a random target type for this mission
|
// Choose a random target type for this mission
|
||||||
const types = ["STRAWBERRY", "BANANA", "BLUEBERRY"];
|
const types = ["STRAWBERRY", "WATERMELON", "BLUEBERRY"];
|
||||||
targetType = types[Math.floor(Math.random() * types.length)];
|
targetType = types[Math.floor(Math.random() * types.length)];
|
||||||
worldObjects.forEach(obj => scene.remove(obj.mesh));
|
worldObjects.forEach(obj => scene.remove(obj.mesh));
|
||||||
worldObjects = [];
|
worldObjects = [];
|
||||||
@@ -715,7 +832,7 @@ onMount(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
color: #00d2ff; /* Change color to cyan to match your theme */
|
color: hsl(308, 100%, 87%); /* Change color to cyan to match your theme */
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,7 +847,7 @@ onMount(() => {
|
|||||||
ul { list-style: none; padding: 0; margin: 0; }
|
ul { list-style: none; padding: 0; margin: 0; }
|
||||||
|
|
||||||
button { width: 100%; padding: 15px; border: none; border-radius: 12px; cursor: pointer; font-weight: bold; font-size: 1rem; transition: all 0.2s; }
|
button { width: 100%; padding: 15px; border: none; border-radius: 12px; cursor: pointer; font-weight: bold; font-size: 1rem; transition: all 0.2s; }
|
||||||
.save-btn { background: #00d2ff; color: white; margin-bottom: 5px; }
|
.save-btn { background: #ff4ed3; color: white; margin-bottom: 5px; }
|
||||||
.retry-btn { background: #1e2b21; color: white; margin-top: 10px; }
|
.retry-btn { background: #1e2b21; color: white; margin-top: 10px; }
|
||||||
button:hover { transform: translateY(-2px); filter: brightness(1.1); }
|
button:hover { transform: translateY(-2px); filter: brightness(1.1); }
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 743 KiB After Width: | Height: | Size: 614 KiB |
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 717 KiB |
|
Before Width: | Height: | Size: 852 KiB After Width: | Height: | Size: 819 KiB |
BIN
strawberry.png
|
Before Width: | Height: | Size: 852 KiB After Width: | Height: | Size: 819 KiB |
BIN
watermelon.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |