Skip to content

Instantly share code, notes, and snippets.

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bouncing Ball in Tesseract Projection</title>
<style>
body {
margin: 0;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #111;
color: #eee;
font-family: sans-serif;
}
canvas {
border: 1px solid #555;
background-color: #222;
}
#info {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.5);
padding: 5px 10px;
border-radius: 5px;
}
</style>
</head>
<body>
<div id="info">
Simulating a ball bouncing in a 3D projection of a spinning tesseract.
</div>
<canvas id="tesseractCanvas"></canvas>
<script>
const canvas = document.getElementById("tesseractCanvas");
const ctx = canvas.getContext("2d");
let width = (canvas.width =
Math.min(window.innerWidth, window.innerHeight) * 0.9);
let height = (canvas.height = width);
// --- Configuration ---
const INNER_CUBE_SIZE = width * 0.2;
const OUTER_CUBE_SIZE = width * 0.4;
const BALL_RADIUS = 8;
const BALL_SPEED = 2;
const ROTATION_SPEED = 0.005;
const PERSPECTIVE = width * 0.8; // Affects how much perspective distortion
const HIGHLIGHT_DURATION = 150; // ms
const EDGE_COLOR = "#88aaff";
const FACE_COLOR = "rgba(150, 180, 255, 0.1)";
const HIGHLIGHT_COLOR = "rgba(255, 255, 0, 0.7)";
const BALL_COLOR = "#ff8888";
// --- State ---
let angleX = 0;
let angleY = 0;
let angleZ = 0;
let ball = {
x: 0,
y: 0,
z: 0,
vx: (Math.random() - 0.5) * BALL_SPEED,
vy: (Math.random() - 0.5) * BALL_SPEED,
vz: (Math.random() - 0.5) * BALL_SPEED,
};
// Face IDs for highlighting
const FACE_IDS = [
"inner_xp",
"inner_xn",
"inner_yp",
"inner_yn",
"inner_zp",
"inner_zn",
"outer_xp",
"outer_xn",
"outer_yp",
"outer_yn",
"outer_zp",
"outer_zn",
];
let faceHighlights = {}; // { faceId: timeRemaining }
FACE_IDS.forEach((id) => (faceHighlights[id] = 0));
// --- Geometry Definition (3D Projection) ---
const vertices = [];
// Inner cube vertices
for (let i = 0; i < 8; i++) {
vertices.push({
x: ((i & 1 ? 1 : -1) * INNER_CUBE_SIZE) / 2,
y: ((i & 2 ? 1 : -1) * INNER_CUBE_SIZE) / 2,
z: ((i & 4 ? 1 : -1) * INNER_CUBE_SIZE) / 2,
type: "inner",
});
}
// Outer cube vertices
for (let i = 0; i < 8; i++) {
vertices.push({
x: ((i & 1 ? 1 : -1) * OUTER_CUBE_SIZE) / 2,
y: ((i & 2 ? 1 : -1) * OUTER_CUBE_SIZE) / 2,
z: ((i & 4 ? 1 : -1) * OUTER_CUBE_SIZE) / 2,
type: "outer",
});
}
// Edges (cube edges + connecting edges)
const edges = [
// Inner cube
[0, 1],
[1, 3],
[3, 2],
[2, 0],
[4, 5],
[5, 7],
[7, 6],
[6, 4],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
// Outer cube (indices 8-15)
[8, 9],
[9, 11],
[11, 10],
[10, 8],
[12, 13],
[13, 15],
[15, 14],
[14, 12],
[8, 12],
[9, 13],
[10, 14],
[11, 15],
// Connecting edges
[0, 8],
[1, 9],
[2, 10],
[3, 11],
[4, 12],
[5, 13],
[6, 14],
[7, 15],
];
// Faces (for drawing and highlighting) - defined by vertex indices
const faces = [
// Inner faces
{
v: [1, 3, 7, 5],
id: "inner_xp",
normal: { x: 1, y: 0, z: 0 },
limit: INNER_CUBE_SIZE / 2,
}, // +x
{
v: [0, 4, 6, 2],
id: "inner_xn",
normal: { x: -1, y: 0, z: 0 },
limit: -INNER_CUBE_SIZE / 2,
}, // -x
{
v: [2, 3, 7, 6],
id: "inner_yp",
normal: { x: 0, y: 1, z: 0 },
limit: INNER_CUBE_SIZE / 2,
}, // +y
{
v: [0, 1, 5, 4],
id: "inner_yn",
normal: { x: 0, y: -1, z: 0 },
limit: -INNER_CUBE_SIZE / 2,
}, // -y
{
v: [4, 5, 7, 6],
id: "inner_zp",
normal: { x: 0, y: 0, z: 1 },
limit: INNER_CUBE_SIZE / 2,
}, // +z
{
v: [0, 2, 3, 1],
id: "inner_zn",
normal: { x: 0, y: 0, z: -1 },
limit: -INNER_CUBE_SIZE / 2,
}, // -z
// Outer faces (indices 8-15) - normals point outward from center
{
v: [9, 11, 15, 13],
id: "outer_xp",
normal: { x: 1, y: 0, z: 0 },
limit: OUTER_CUBE_SIZE / 2,
}, // +x
{
v: [8, 12, 14, 10],
id: "outer_xn",
normal: { x: -1, y: 0, z: 0 },
limit: -OUTER_CUBE_SIZE / 2,
}, // -x
{
v: [10, 11, 15, 14],
id: "outer_yp",
normal: { x: 0, y: 1, z: 0 },
limit: OUTER_CUBE_SIZE / 2,
}, // +y
{
v: [8, 9, 13, 12],
id: "outer_yn",
normal: { x: 0, y: -1, z: 0 },
limit: -OUTER_CUBE_SIZE / 2,
}, // -y
{
v: [12, 13, 15, 14],
id: "outer_zp",
normal: { x: 0, y: 0, z: 1 },
limit: OUTER_CUBE_SIZE / 2,
}, // +z
{
v: [8, 10, 11, 9],
id: "outer_zn",
normal: { x: 0, y: 0, z: -1 },
limit: -OUTER_CUBE_SIZE / 2,
}, // -z
];
// --- Projection and Rotation Functions ---
function rotateX(point, angle) {
const y = point.y * Math.cos(angle) - point.z * Math.sin(angle);
const z = point.y * Math.sin(angle) + point.z * Math.cos(angle);
return { x: point.x, y: y, z: z };
}
function rotateY(point, angle) {
const x = point.x * Math.cos(angle) + point.z * Math.sin(angle);
const z = -point.x * Math.sin(angle) + point.z * Math.cos(angle);
return { x: x, y: point.y, z: z };
}
function rotateZ(point, angle) {
const x = point.x * Math.cos(angle) - point.y * Math.sin(angle);
const y = point.x * Math.sin(angle) + point.y * Math.cos(angle);
return { x: x, y: y, z: point.z };
}
function project(point3d) {
let p = { ...point3d };
p = rotateX(p, angleX);
p = rotateY(p, angleY);
p = rotateZ(p, angleZ);
const scale = PERSPECTIVE / (PERSPECTIVE + p.z);
return {
x: width / 2 + p.x * scale,
y: height / 2 + p.y * scale,
scale: scale, // Needed for sizing the ball
};
}
// --- Update Function ---
let lastTime = 0;
function update(time = 0) {
const dt = time - lastTime;
lastTime = time;
// Update angles
angleX += ROTATION_SPEED * 1.1; // Slightly different speeds
angleY += ROTATION_SPEED * 1.0;
angleZ += ROTATION_SPEED * 0.9;
// Update ball position
ball.x += ball.vx;
ball.y += ball.vy;
ball.z += ball.vz;
// Collision detection with cube faces (defined in 3D space before rotation)
let collision = false;
// Check Outer Cube Boundaries (ball moving outwards)
if (ball.x + BALL_RADIUS > OUTER_CUBE_SIZE / 2 && ball.vx > 0) {
ball.vx *= -1;
ball.x = OUTER_CUBE_SIZE / 2 - BALL_RADIUS;
faceHighlights["outer_xp"] = HIGHLIGHT_DURATION;
collision = true;
}
if (ball.x - BALL_RADIUS < -OUTER_CUBE_SIZE / 2 && ball.vx < 0) {
ball.vx *= -1;
ball.x = -OUTER_CUBE_SIZE / 2 + BALL_RADIUS;
faceHighlights["outer_xn"] = HIGHLIGHT_DURATION;
collision = true;
}
if (ball.y + BALL_RADIUS > OUTER_CUBE_SIZE / 2 && ball.vy > 0) {
ball.vy *= -1;
ball.y = OUTER_CUBE_SIZE / 2 - BALL_RADIUS;
faceHighlights["outer_yp"] = HIGHLIGHT_DURATION;
collision = true;
}
if (ball.y - BALL_RADIUS < -OUTER_CUBE_SIZE / 2 && ball.vy < 0) {
ball.vy *= -1;
ball.y = -OUTER_CUBE_SIZE / 2 + BALL_RADIUS;
faceHighlights["outer_yn"] = HIGHLIGHT_DURATION;
collision = true;
}
if (ball.z + BALL_RADIUS > OUTER_CUBE_SIZE / 2 && ball.vz > 0) {
ball.vz *= -1;
ball.z = OUTER_CUBE_SIZE / 2 - BALL_RADIUS;
faceHighlights["outer_zp"] = HIGHLIGHT_DURATION;
collision = true;
}
if (ball.z - BALL_RADIUS < -OUTER_CUBE_SIZE / 2 && ball.vz < 0) {
ball.vz *= -1;
ball.z = -OUTER_CUBE_SIZE / 2 + BALL_RADIUS;
faceHighlights["outer_zn"] = HIGHLIGHT_DURATION;
collision = true;
}
// Check Inner Cube Boundaries (ball moving inwards)
if (
ball.x - BALL_RADIUS < INNER_CUBE_SIZE / 2 &&
ball.x > -INNER_CUBE_SIZE / 2 &&
ball.vx < 0
) {
// Approaching +x face from outside
if (
Math.abs(ball.y) < INNER_CUBE_SIZE / 2 &&
Math.abs(ball.z) < INNER_CUBE_SIZE / 2
) {
ball.vx *= -1;
ball.x = INNER_CUBE_SIZE / 2 + BALL_RADIUS;
faceHighlights["inner_xp"] = HIGHLIGHT_DURATION;
collision = true;
}
}
if (
ball.x + BALL_RADIUS > -INNER_CUBE_SIZE / 2 &&
ball.x < INNER_CUBE_SIZE / 2 &&
ball.vx > 0
) {
// Approaching -x face from outside
if (
Math.abs(ball.y) < INNER_CUBE_SIZE / 2 &&
Math.abs(ball.z) < INNER_CUBE_SIZE / 2
) {
ball.vx *= -1;
ball.x = -INNER_CUBE_SIZE / 2 - BALL_RADIUS;
faceHighlights["inner_xn"] = HIGHLIGHT_DURATION;
collision = true;
}
}
if (
ball.y - BALL_RADIUS < INNER_CUBE_SIZE / 2 &&
ball.y > -INNER_CUBE_SIZE / 2 &&
ball.vy < 0
) {
// Approaching +y face from outside
if (
Math.abs(ball.x) < INNER_CUBE_SIZE / 2 &&
Math.abs(ball.z) < INNER_CUBE_SIZE / 2
) {
ball.vy *= -1;
ball.y = INNER_CUBE_SIZE / 2 + BALL_RADIUS;
faceHighlights["inner_yp"] = HIGHLIGHT_DURATION;
collision = true;
}
}
if (
ball.y + BALL_RADIUS > -INNER_CUBE_SIZE / 2 &&
ball.y < INNER_CUBE_SIZE / 2 &&
ball.vy > 0
) {
// Approaching -y face from outside
if (
Math.abs(ball.x) < INNER_CUBE_SIZE / 2 &&
Math.abs(ball.z) < INNER_CUBE_SIZE / 2
) {
ball.vy *= -1;
ball.y = -INNER_CUBE_SIZE / 2 - BALL_RADIUS;
faceHighlights["inner_yn"] = HIGHLIGHT_DURATION;
collision = true;
}
}
if (
ball.z - BALL_RADIUS < INNER_CUBE_SIZE / 2 &&
ball.z > -INNER_CUBE_SIZE / 2 &&
ball.vz < 0
) {
// Approaching +z face from outside
if (
Math.abs(ball.x) < INNER_CUBE_SIZE / 2 &&
Math.abs(ball.y) < INNER_CUBE_SIZE / 2
) {
ball.vz *= -1;
ball.z = INNER_CUBE_SIZE / 2 + BALL_RADIUS;
faceHighlights["inner_zp"] = HIGHLIGHT_DURATION;
collision = true;
}
}
if (
ball.z + BALL_RADIUS > -INNER_CUBE_SIZE / 2 &&
ball.z < INNER_CUBE_SIZE / 2 &&
ball.vz > 0
) {
// Approaching -z face from outside
if (
Math.abs(ball.x) < INNER_CUBE_SIZE / 2 &&
Math.abs(ball.y) < INNER_CUBE_SIZE / 2
) {
ball.vz *= -1;
ball.z = -INNER_CUBE_SIZE / 2 - BALL_RADIUS;
faceHighlights["inner_zn"] = HIGHLIGHT_DURATION;
collision = true;
}
}
// Update highlight timers
for (const id in faceHighlights) {
if (faceHighlights[id] > 0) {
faceHighlights[id] -= dt;
if (faceHighlights[id] < 0) faceHighlights[id] = 0;
}
}
}
// --- Draw Function ---
function draw() {
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Project all vertices
const projectedVertices = vertices.map((v) => project(v));
// Draw faces first (so edges appear on top)
faces.forEach((face) => {
ctx.beginPath();
const firstPoint = projectedVertices[face.v[0]];
ctx.moveTo(firstPoint.x, firstPoint.y);
for (let i = 1; i < face.v.length; i++) {
const point = projectedVertices[face.v[i]];
ctx.lineTo(point.x, point.y);
}
ctx.closePath();
// Set fill style based on highlight state
ctx.fillStyle =
faceHighlights[face.id] > 0 ? HIGHLIGHT_COLOR : FACE_COLOR;
ctx.fill();
});
// Draw edges
ctx.strokeStyle = EDGE_COLOR;
ctx.lineWidth = 1.5;
edges.forEach((edge) => {
const p1 = projectedVertices[edge[0]];
const p2 = projectedVertices[edge[1]];
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
});
// Draw ball
const projectedBall = project(ball);
const ballScreenRadius = Math.max(1, BALL_RADIUS * projectedBall.scale); // Ensure radius doesn't go below 1
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(
projectedBall.x,
projectedBall.y,
ballScreenRadius,
0,
Math.PI * 2,
);
ctx.fill();
}
// --- Animation Loop ---
function animate(time) {
update(time);
draw();
requestAnimationFrame(animate);
}
// Start animation
requestAnimationFrame(animate);
// Handle window resize
window.addEventListener("resize", () => {
width = canvas.width =
Math.min(window.innerWidth, window.innerHeight) * 0.9;
height = canvas.height = width;
// Recalculate perspective if needed, or other size-dependent constants
// PERSPECTIVE = width * 0.8; // Optional: adjust perspective with size
// Note: Resizing doesn't dynamically resize the cubes or ball speed here,
// just the canvas. Reloading might be needed for a full resize effect.
});
</script>
</body>
</html>
@shricodev
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment