<!DOCTYPE html>
<html>
<head>
<title>Roll - Dark Descent</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
body { margin: 0; overflow: hidden; font-family: Arial, sans-serif; touch-action: none; background-color: #fffafa; }
#TutContainer { width: 100%; height: 100vh; }
#scoreBoard { position: absolute; top: 20px; left: 20px; font-size: 20px; font-weight: bold; color: #333; z-index: 10; pointer-events: none; }
#gameOverScreen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); display: none; flex-direction: column; justify-content: center; align-items: center; z-index: 100; color: white; }
#gameOverScreen h1 { font-size: 32px; margin-bottom: 20px; text-align: center; }
#restartBtn { padding: 15px 30px; font-size: 20px; cursor: pointer; background: #33ff33; border: none; border-radius: 5px; font-weight: bold; }
</style>
</head>
<body>
<div id="scoreBoard">Score: 0</div>
<div id="gameOverScreen">
<h1>GAME OVER</h1>
<button id="restartBtn">RUN AGAIN</button>
</div>
<div id="TutContainer"></div>
<script src="https://cdn.jsdelivr.net/npm/three@0.145.0/build/three.min.js"></script>
<script>
let sceneWidth, sceneHeight, camera, scene, renderer, dom;
let sun, heroRollingSpeed, clock, treesPool, treesInPath;
let pathAngleValues;
let globalRenderID;
const worldRadius = 26;
const heroRadius = 0.2;
const heroBaseY = 2.1;
const gravity = 0.005;
const particleCount = 20;
let rollingSpeed = 0.008;
let treeReleaseInterval = 0.5;
let rightLane = 1;
let middleLane = 0;
let leftLane = -1;
let lerpSpeed = 0.15;
let scoreText, score, hasCollided;
let jumping = false;
let bounceValue = 0;
let currentLane = middleLane;
let heroSphere;
let rollingGroundSphere;
let particles;
let particleGeometry;
let explosionPower = 1;
let sphericalHelper;
let touchStartX = 0, touchStartY = 0, touchEndX = 0, touchEndY = 0;
const minSwipeDistance = 30;
init();
function init() {
adjustSettings();
createScene();
update();
}
function adjustSettings() {
if (window.innerWidth < 768) {
rollingSpeed = 0.005;
treeReleaseInterval = 1.0;
leftLane = -0.6;
rightLane = 0.6;
lerpSpeed = 0.25;
} else {
rollingSpeed = 0.008;
treeReleaseInterval = 0.5;
leftLane = -1;
rightLane = 1;
lerpSpeed = 0.15;
}
}
function createScene() {
hasCollided = false;
score = 0;
treesPool = [];
treesInPath = [];
clock = new THREE.Clock();
clock.start();
heroRollingSpeed = (rollingSpeed * worldRadius / heroRadius) / 5;
sphericalHelper = new THREE.Spherical();
pathAngleValues = [1.52, 1.57, 1.62];
sceneWidth = window.innerWidth;
sceneHeight = window.innerHeight;
scene = new THREE.Scene();
scene.background = new THREE.Color(0xfffafa);
scene.fog = new THREE.FogExp2(0xf0fff0, 0.14);
camera = new THREE.PerspectiveCamera(60, sceneWidth / sceneHeight, 0.1, 1000);
renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setClearColor(0xfffafa, 1);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setSize(sceneWidth, sceneHeight);
dom = document.getElementById('TutContainer');
dom.appendChild(renderer.domElement);
scoreText = document.getElementById('scoreBoard');
createTreesPool();
addWorld();
addLight();
addHero();
addExplosion();
camera.position.z = 6.5;
camera.position.y = 2.5;
window.addEventListener('resize', onWindowResize, false);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('touchstart', handleTouchStart, {passive: false});
document.addEventListener('touchend', handleTouchEnd, {passive: false});
const restartBtn = document.getElementById('restartBtn');
if (restartBtn) {
restartBtn.addEventListener('click', () => { location.reload(); });
}
}
function moveLeft() {
if (hasCollided) return;
if (currentLane === middleLane) currentLane = leftLane;
else if (currentLane === rightLane) currentLane = middleLane;
triggerJumpEffect();
}
function moveRight() {
if (hasCollided) return;
if (currentLane === middleLane) currentLane = rightLane;
else if (currentLane === leftLane) currentLane = middleLane;
triggerJumpEffect();
}
function jump() {
if (hasCollided) return;
if (!jumping) {
bounceValue = 0.12;
jumping = true;
}
}
function triggerJumpEffect() {
if (!jumping) {
jumping = true;
bounceValue = 0.06;
}
}
function handleKeyDown(event) {
const key = event.key.toLowerCase();
if (key === 'a') moveLeft();
else if (key === 'd') moveRight();
else if (key === ' ') jump();
}
function handleTouchStart(e) {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
}
function handleTouchEnd(e) {
touchEndX = e.changedTouches[0].screenX;
touchEndY = e.changedTouches[0].screenY;
let dx = touchEndX - touchStartX;
let dy = touchEndY - touchStartY;
if (Math.abs(dx) > Math.abs(dy)) {
if (Math.abs(dx) > minSwipeDistance) {
if (dx < 0) moveLeft(); else moveRight();
}
} else {
if (Math.abs(dy) > minSwipeDistance) {
if (dy < 0) jump();
}
}
}
function update() {
if (hasCollided) {
doExplosionLogic();
render();
globalRenderID = requestAnimationFrame(update);
return;
}
// 1. Update Lighting and Atmosphere based on score
updateAtmosphere();
rollingGroundSphere.rotation.x += rollingSpeed;
heroSphere.rotation.x -= heroRollingSpeed;
if (heroSphere.position.y <= heroBaseY) {
jumping = false;
bounceValue = (Math.random() * 0.04) + 0.005;
}
heroSphere.position.y += bounceValue;
heroSphere.position.x = THREE.MathUtils.lerp(heroSphere.position.x, currentLane, lerpSpeed);
bounceValue -= gravity;
if (clock.getElapsedTime() > treeReleaseInterval) {
clock.start();
addPathTree();
score += 1;
if (scoreText) scoreText.innerHTML = "Score: " + score;
}
doTreeLogic();
doExplosionLogic();
render();
globalRenderID = requestAnimationFrame(update);
}
function updateAtmosphere() {
// Gradually get darker. Max darkness at score 200.
let darkness = Math.min(score / 200, 1);
// Light intensity fades from 0.9 to 0.2
sun.intensity = THREE.MathUtils.lerp(0.9, 0.2, darkness);
// Background color fades from White to Deep Midnight Blue
scene.background.setRGB(
THREE.MathUtils.lerp(1, 0.05, darkness),
THREE.MathUtils.lerp(1, 0.05, darkness),
THREE.MathUtils.lerp(1, 0.2, darkness)
);
// Fog changes color to match
scene.fog.color.setRGB(
THREE.MathUtils.lerp(0.94, 0.05, darkness),
THREE.MathUtils.lerp(1, 0.05, darkness),
THREE.MathUtils.lerp(0.94, 0.2, darkness)
);
}
function render() {
renderer.render(scene, camera);
}
function gameOver() {
const screen = document.getElementById('gameOverScreen');
if (screen) screen.style.display = 'flex';
}
function createTreesPool() {
var maxTreesInPool = 15;
for (var i = 0; i < maxTreesInPool; i++) {
treesPool.push(createTree());
}
}
function addPathTree() {
var options = [0, 1, 2];
var lane = Math.floor(Math.random() * 3);
addTree(true, lane);
options.splice(lane, 1);
if (Math.random() > 0.5) {
lane = Math.floor(Math.random() * 2);
addTree(true, options[lane]);
}
}
function addHero() {
var sphereGeometry = new THREE.DodecahedronGeometry(heroRadius, 1);
var sphereMaterial = new THREE.MeshStandardMaterial({ color: 0xe5f2f2, flatShading: true });
heroSphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
heroSphere.receiveShadow = true;
heroSphere.castShadow = true;
scene.add(heroSphere);
heroSphere.position.set(middleLane, heroBaseY, 4.8);
currentLane = middleLane;
}
function addWorld() {
var sides = 40, tiers = 40;
var sphereGeometry = new THREE.SphereGeometry(worldRadius, sides, tiers);
var sphereMaterial = new THREE.MeshStandardMaterial({ color: 0xfffafa, flatShading: true });
const pos = sphereGeometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
let x = pos.getX(i);
let y = pos.getY(i);
let z = pos.getZ(i);
let vec = new THREE.Vector3(x, y, z).normalize();
let heightValue = (Math.random() * 0.05) - 0.025;
vec.multiplyScalar(worldRadius + heightValue);
pos.setXYZ(i, vec.x, vec.y, vec.z);
}
pos.needsUpdate = true;
rollingGroundSphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
rollingGroundSphere.receiveShadow = true;
rollingGroundSphere.rotation.z = -Math.PI / 2;
scene.add(rollingGroundSphere);
rollingGroundSphere.position.set(0, -24, 2);
addWorldTrees();
}
function addLight() {
scene.add(new THREE.HemisphereLight(0xfffafa, 0x000000, .9));
sun = new THREE.DirectionalLight(0xcdc1c5, 0.9);
sun.position.set(12, 6, -7);
sun.castShadow = true;
scene.add(sun);
}
function createTree() {
var sides = 8, tiers = 6;
var scalarMultiplier = (Math.random() * 0.15) + 0.05;
var treeGeometry = new THREE.ConeGeometry(0.5, 1, sides, tiers);
var treeMaterial = new THREE.MeshStandardMaterial({ color: 0x33ff33, flatShading: true });
const pos = treeGeometry.attributes.position;
blowUpTree(pos, sides, 0, scalarMultiplier);
tightenTree(pos, sides, 1);
blowUpTree(pos, sides, 2, scalarMultiplier * 1.1, true);
tightenTree(pos, sides, 3);
blowUpTree(pos, sides, 4, scalarMultiplier * 1.2);
tightenTree(pos, sides, 5);
pos.needsUpdate = true;
var treeTop = new THREE.Mesh(treeGeometry, treeMaterial);
treeTop.castShadow = true;
treeTop.position.y = 0.9;
treeTop.rotation.y = Math.random() * Math.PI;
var treeTrunk = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 0.5), new THREE.MeshStandardMaterial({ color: 0x886633, flatShading: true }));
treeTrunk.position.y = 0.25;
var tree = new THREE.Object3D();
tree.add(treeTrunk);
tree.add(treeTop);
return tree;
}
function blowUpTree(pos, sides, currentTier, scalarMultiplier, odd) {
for (var i = 0; i < sides; i++) {
var vertexIndex = (currentTier * sides) + 1;
var idx = i + vertexIndex;
let vX = pos.getX(idx);
let vY = pos.getY(idx);
let vZ = pos.getZ(idx);
let vertexVector = new THREE.Vector3(vX, vY, vZ);
let midPointVector = new THREE.Vector3(0, vY, 0);
let offset = vertexVector.clone().sub(midPointVector);
if (odd) {
if (i % 2 === 0) offset.normalize().multiplyScalar(scalarMultiplier / 6);
else {
offset.normalize().multiplyScalar(scalarMultiplier);
let nextY = pos.getY(idx + sides);
pos.setY(idx, nextY + 0.05);
}
} else {
if (i % 2 !== 0) offset.normalize().multiplyScalar(scalarMultiplier / 6);
else {
offset.normalize().multiplyScalar(scalarMultiplier);
let nextY = pos.getY(idx + sides);
pos.setY(idx, nextY + 0.05);
}
}
let finalVec = vertexVector.add(offset);
pos.setXYZ(idx, finalVec.x, finalVec.y, finalVec.z);
}
}
function tightenTree(pos, sides, currentTier) {
for (var i = 0; i < sides; i++) {
var vertexIndex = (currentTier * sides) + 1;
var idx = i + vertexIndex;
let vX = pos.getX(idx);
let vY = pos.getY(idx);
let vZ = pos.getZ(idx);
let vertexVector = new THREE.Vector3(vX, vY, vZ);
let midPointVector = new THREE.Vector3(0, vY, 0);
let offset = vertexVector.clone().sub(midPointVector);
offset.normalize().multiplyScalar(0.06);
let finalVec = vertexVector.sub(offset);
pos.setXYZ(idx, finalVec.x, finalVec.y, finalVec.z);
}
}
function addWorldTrees() {
var numTrees = 36, gap = 6.28 / 36;
for (var i = 0; i < numTrees; i++) {
addTree(false, i * gap, true);
addTree(false, i * gap, false);
}
}
function addTree(inPath, row, isLeft) {
var newTree;
if (inPath) {
if (treesPool.length == 0) return;
newTree = treesPool.pop();
newTree.visible = true;
// Reset rotations in case it was a fallen tree
newTree.rotation.set(0,0,0);
// Randomly decide if this tree falls (30% chance)
newTree.userData.isFalling = Math.random() < 0.3;
newTree.userData.fallRotation = 0;
treesInPath.push(newTree);
sphericalHelper.set(worldRadius - 0.3, pathAngleValues[row], -rollingGroundSphere.rotation.x + 4);
} else {
newTree = createTree();
var forestAreaAngle = isLeft ? 1.68 + Math.random() * 0.1 : 1.46 - Math.random() * 0.1;
sphericalHelper.set(worldRadius - 0.3, forestAreaAngle, row);
}
newTree.position.setFromSpherical(sphericalHelper);
var rollingGroundVector = rollingGroundSphere.position.clone().normalize();
var treeVector = newTree.position.clone().normalize();
newTree.quaternion.setFromUnitVectors(treeVector, rollingGroundVector);
newTree.rotation.x += (Math.random() * (2 * Math.PI / 10)) + -Math.PI / 10;
rollingGroundSphere.add(newTree);
}
function addExplosion() {
particleGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
particleGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
var pMaterial = new THREE.PointsMaterial({ color: 0xfffafa, size: 0.2 });
particles = new THREE.Points(particleGeometry, pMaterial);
scene.add(particles);
particles.visible = false;
}
function explode() {
particles.position.set(heroSphere.position.x, 2, 4.8);
const pos = particleGeometry.attributes.position;
for (var i = 0; i < particleCount; i++) {
pos.setXYZ(i, -0.2 + Math.random() * 0.4, -0.2 + Math.random() * 0.4, -0.2 + Math.random() * 0.4);
}
pos.needsUpdate = true;
explosionPower = 1.07;
particles.visible = true;
}
function doTreeLogic() {
var treePos = new THREE.Vector3(), treesToRemove = [];
treesInPath.forEach(function (oneTree) {
treePos.setFromMatrixPosition(oneTree.matrixWorld);
// Animation: If it's a falling tree, tip it over as it gets closer
if (oneTree.userData.isFalling) {
if (oneTree.userData.fallRotation < Math.PI / 2) {
oneTree.userData.fallRotation += 0.05;
oneTree.rotation.z = oneTree.userData.fallRotation;
}
}
if (treePos.z > 6 && oneTree.visible) {
treesToRemove.push(oneTree);
} else if (treePos.distanceTo(heroSphere.position) <= 0.6) {
// SPECIAL COLLISION: If the tree is fallen, you can jump over it!
if (oneTree.userData.isFalling && heroSphere.position.y > heroBaseY + 0.2) {
// Safe! User jumped over the fallen tree
} else {
hasCollided = true;
explode();
gameOver();
}
}
});
treesToRemove.forEach(function (element) {
var fromWhere = treesInPath.indexOf(element);
treesInPath.splice(fromWhere, 1);
treesPool.push(element);
element.visible = false;
});
}
function doExplosionLogic() {
if (!particles.visible) return;
const pos = particleGeometry.attributes.position;
for (var i = 0; i < particleCount; i++) {
let x = pos.getX(i) * explosionPower;
let y = pos.getY(i) * explosionPower;
let z = pos.getZ(i) * explosionPower;
pos.setXYZ(i, x, y, z);
}
pos.needsUpdate = true;
if (explosionPower > 1.005) explosionPower -= 0.001;
else particles.visible = false;
}
function onWindowResize() {
sceneHeight = window.innerHeight;
sceneWidth = window.innerWidth;
renderer.setSize(sceneWidth, sceneHeight);
camera.aspect = sceneWidth / sceneHeight;
camera.updateProjectionMatrix();
}
</script>
</body>
</html>