413 lines
16 KiB
HTML
413 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>3D Guinea Pig Garden</title>
|
|
<!-- Load Tailwind CSS for modern styling -->
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<!-- Load Three.js library -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
<style>
|
|
/* Custom CSS to ensure the canvas fills the viewport */
|
|
body {
|
|
margin: 0;
|
|
overflow: hidden;
|
|
font-family: 'Inter', sans-serif;
|
|
color: #333;
|
|
}
|
|
canvas {
|
|
display: block;
|
|
}
|
|
#info-box {
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 10px;
|
|
padding: 10px 15px;
|
|
background-color: rgba(255, 255, 255, 0.9);
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
z-index: 10;
|
|
border-left: 4px solid #38A169; /* Garden color accent */
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="container"></div>
|
|
<div id="info-box">
|
|
<h2 class="font-bold text-lg mb-1">Guinea Pig Garden Frolic</h2>
|
|
<p class="text-sm">Click and drag to look around the field.</p>
|
|
</div>
|
|
|
|
<script>
|
|
// Global variables for Three.js
|
|
let scene, camera, renderer, controls;
|
|
const guineaPigs = [];
|
|
const NUM_PIGS = 80;
|
|
const ROOM_SIZE = 15; // Defines the half-width/depth of the movement area (now a field)
|
|
|
|
// --- Utility Functions (TTS Helpers kept for completeness) ---
|
|
|
|
function base64ToArrayBuffer(base64) {
|
|
const binaryString = atob(base64);
|
|
const len = binaryString.length;
|
|
const bytes = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes.buffer;
|
|
}
|
|
|
|
function pcmToWav(int16Array, sampleRate) {
|
|
const numChannels = 1;
|
|
const bytesPerSample = 2;
|
|
const blockAlign = numChannels * bytesPerSample;
|
|
const dataSize = int16Array.length * bytesPerSample;
|
|
const buffer = new ArrayBuffer(44 + dataSize);
|
|
const view = new DataView(buffer);
|
|
|
|
writeString(view, 0, 'RIFF');
|
|
view.setUint32(4, 36 + dataSize, true);
|
|
writeString(view, 8, 'WAVE');
|
|
writeString(view, 12, 'fmt ');
|
|
view.setUint32(16, 16, true);
|
|
view.setUint16(20, 1, true);
|
|
view.setUint16(22, numChannels, true);
|
|
view.setUint32(24, sampleRate, true);
|
|
view.setUint32(28, sampleRate * blockAlign, true);
|
|
view.setUint16(32, blockAlign, true);
|
|
view.setUint16(34, 16, true);
|
|
writeString(view, 36, 'data');
|
|
view.setUint32(40, dataSize, true);
|
|
|
|
for (let i = 0; i < int16Array.length; i++) {
|
|
view.setInt16(44 + i * 2, int16Array[i], true);
|
|
}
|
|
|
|
return new Blob([buffer], { type: 'audio/wav' });
|
|
|
|
function writeString(view, offset, string) {
|
|
for (let i = 0; i < string.length; i++) {
|
|
view.setUint8(offset + i, string.charCodeAt(i));
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// --- Three.js Setup and Geometry ---
|
|
|
|
function init() {
|
|
// 1. Scene Setup
|
|
scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x87ceeb); // Lighter Sky Blue
|
|
|
|
// 2. Camera Setup
|
|
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
camera.position.set(0, 5, 20);
|
|
|
|
// 3. Renderer Setup
|
|
renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.shadowMap.enabled = true; // Enable shadows
|
|
document.getElementById('container').appendChild(renderer.domElement);
|
|
|
|
// 4. Lighting (Sunlight simulation)
|
|
const ambientLight = new THREE.AmbientLight(0x404040, 1.5); // Soft white light
|
|
scene.add(ambientLight);
|
|
|
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 2.5); // Brighter sun
|
|
directionalLight.position.set(5, 20, 10);
|
|
directionalLight.castShadow = true;
|
|
directionalLight.shadow.mapSize.width = 2048;
|
|
directionalLight.shadow.mapSize.height = 2048;
|
|
directionalLight.shadow.camera.near = 0.5;
|
|
directionalLight.shadow.camera.far = 50;
|
|
directionalLight.shadow.camera.left = -ROOM_SIZE * 2;
|
|
directionalLight.shadow.camera.right = ROOM_SIZE * 2;
|
|
directionalLight.shadow.camera.top = ROOM_SIZE * 2;
|
|
directionalLight.shadow.camera.bottom = -ROOM_SIZE * 2;
|
|
scene.add(directionalLight);
|
|
|
|
// 5. Garden Setup
|
|
createGarden();
|
|
|
|
// 6. Guinea Pigs
|
|
createGuineaPigs();
|
|
|
|
// 7. Camera Controls (using custom mouse drag controls)
|
|
setupControls();
|
|
|
|
// Event Listeners
|
|
window.addEventListener('resize', onWindowResize, false);
|
|
}
|
|
|
|
/**
|
|
* Creates a simple, low-poly geometry for a single guinea pig.
|
|
* The pig is returned as a THREE.Group.
|
|
*/
|
|
function createGuineaPigMesh() {
|
|
const pigGroup = new THREE.Group();
|
|
|
|
// Body (Capsule/Cylinder)
|
|
const bodyGeometry = new THREE.CylinderGeometry(0.5, 0.6, 1.5, 8);
|
|
const color = new THREE.Color(Math.random(), Math.random(), Math.random()).lerp(new THREE.Color(0.6, 0.6, 0.6), 0.5); // Mixed fur colors
|
|
const bodyMaterial = new THREE.MeshLambertMaterial({ color: color });
|
|
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
|
|
body.rotation.z = Math.PI / 2; // Lay it flat along the X-axis
|
|
body.position.y = 0.3; // Lift off floor
|
|
body.castShadow = true;
|
|
body.receiveShadow = true;
|
|
pigGroup.add(body);
|
|
|
|
// Head (Sphere)
|
|
const headGeometry = new THREE.SphereGeometry(0.4, 8, 8);
|
|
const headMaterial = new THREE.MeshLambertMaterial({ color: color.clone().offsetHSL(0, 0, 0.1) });
|
|
const head = new THREE.Mesh(headGeometry, headMaterial);
|
|
head.position.set(0.8, 0.3, 0); // Head positioned along the positive X-axis
|
|
head.castShadow = true;
|
|
pigGroup.add(head);
|
|
|
|
// Ears (Cones)
|
|
const earGeometry = new THREE.ConeGeometry(0.1, 0.2, 4);
|
|
const earMaterial = new THREE.MeshLambertMaterial({ color: 0xcb997e }); // Pinkish
|
|
const earL = new THREE.Mesh(earGeometry, earMaterial);
|
|
earL.position.set(0.8, 0.5, 0.3);
|
|
earL.rotation.x = Math.PI / 2;
|
|
earL.rotation.y = -Math.PI / 8;
|
|
earL.rotation.z = -Math.PI / 4;
|
|
pigGroup.add(earL);
|
|
|
|
const earR = new THREE.Mesh(earGeometry, earMaterial);
|
|
earR.position.set(0.8, 0.5, -0.3);
|
|
earR.rotation.x = Math.PI / 2;
|
|
earR.rotation.y = Math.PI / 8;
|
|
earR.rotation.z = Math.PI / 4;
|
|
pigGroup.add(earR);
|
|
|
|
// FIX: Rotate the entire pig group -90 degrees around the Y-axis.
|
|
// This aligns the pig's "forward" direction (originally +X) with the scene's +Z axis,
|
|
// which correctly matches the orientation calculated by the movement logic.
|
|
pigGroup.rotation.y = -Math.PI / 2;
|
|
|
|
return pigGroup;
|
|
}
|
|
|
|
/**
|
|
* Creates and adds NUM_PIGS instances to the scene with initial random positions.
|
|
*/
|
|
function createGuineaPigs() {
|
|
for (let i = 0; i < NUM_PIGS; i++) {
|
|
const pigMesh = createGuineaPigMesh();
|
|
|
|
// Initial random position within the room bounds
|
|
pigMesh.position.x = (Math.random() - 0.5) * ROOM_SIZE * 1.8;
|
|
pigMesh.position.z = (Math.random() - 0.5) * ROOM_SIZE * 1.8;
|
|
pigMesh.rotation.y = Math.random() * Math.PI * 2; // Random initial direction
|
|
|
|
// Attach movement properties to the mesh
|
|
pigMesh.velocity = new THREE.Vector3(
|
|
(Math.random() - 0.5) * 0.05,
|
|
0,
|
|
(Math.random() - 0.5) * 0.05
|
|
);
|
|
pigMesh.turnCooldown = Math.random() * 60 + 60; // Pigs turn every 60-120 frames
|
|
pigMesh.turnTimer = 0;
|
|
pigMesh.runAnimation = 0;
|
|
|
|
scene.add(pigMesh);
|
|
guineaPigs.push(pigMesh);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds randomized grass and flower meshes to the garden.
|
|
*/
|
|
function addFoliage() {
|
|
const FOLIAGE_COUNT = 300;
|
|
const MAX_BOUND = ROOM_SIZE * 1.8; // Area where foliage can spawn
|
|
|
|
// Simple Grass Mesh (tall, thin cylinder)
|
|
const grassGeometry = new THREE.CylinderGeometry(0.05, 0.05, 1, 4);
|
|
const grassMaterial = new THREE.MeshLambertMaterial({ color: 0x68a87b }); // Slightly different green
|
|
|
|
// Simple Flower Mesh (stem + petal sphere)
|
|
const stemGeometry = new THREE.CylinderGeometry(0.03, 0.03, 0.5, 4);
|
|
const petalGeometry = new THREE.SphereGeometry(0.15, 6, 6);
|
|
const flowerMaterials = [
|
|
new THREE.MeshLambertMaterial({ color: 0xffd700 }), // Yellow
|
|
new THREE.MeshLambertMaterial({ color: 0xff69b4 }), // Pink
|
|
new THREE.MeshLambertMaterial({ color: 0x7b68ee }) // Lavender
|
|
];
|
|
const stemMaterial = new THREE.MeshLambertMaterial({ color: 0x8fbc8f });
|
|
|
|
for (let i = 0; i < FOLIAGE_COUNT; i++) {
|
|
const x = (Math.random() - 0.5) * MAX_BOUND;
|
|
const z = (Math.random() - 0.5) * MAX_BOUND;
|
|
|
|
// Alternate between grass and flowers
|
|
if (i % 3 === 0) {
|
|
// Add Flower
|
|
const stem = new THREE.Mesh(stemGeometry, stemMaterial);
|
|
stem.position.set(x, 0.25, z);
|
|
stem.receiveShadow = true;
|
|
scene.add(stem);
|
|
|
|
const petals = new THREE.Mesh(petalGeometry, flowerMaterials[i % 3]);
|
|
petals.position.set(x, 0.5 + Math.random() * 0.1, z);
|
|
petals.castShadow = true;
|
|
scene.add(petals);
|
|
} else {
|
|
// Add Grass
|
|
const grass = new THREE.Mesh(grassGeometry, grassMaterial);
|
|
const height = Math.random() * 0.5 + 0.5;
|
|
grass.scale.y = height;
|
|
grass.position.set(x, height / 2, z);
|
|
grass.rotation.y = Math.random() * Math.PI * 2;
|
|
grass.receiveShadow = true;
|
|
scene.add(grass);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates the garden ground (replaces the room walls and floor).
|
|
*/
|
|
function createGarden() {
|
|
const grassMaterial = new THREE.MeshLambertMaterial({ color: 0x4f8e5b }); // Rich Grass Green
|
|
|
|
// Ground
|
|
const groundGeometry = new THREE.PlaneGeometry(ROOM_SIZE * 4, ROOM_SIZE * 4); // Large ground plane
|
|
const ground = new THREE.Mesh(groundGeometry, grassMaterial);
|
|
ground.rotation.x = -Math.PI / 2;
|
|
ground.position.y = 0;
|
|
ground.receiveShadow = true;
|
|
scene.add(ground);
|
|
|
|
// Add flowers and grass
|
|
addFoliage();
|
|
}
|
|
|
|
// --- Interaction / Controls ---
|
|
|
|
function setupControls() {
|
|
let isDragging = false;
|
|
let previousMousePosition = { x: 0, y: 0 };
|
|
const rotationSpeed = 0.005;
|
|
|
|
// Create a target for the camera to look at, placed roughly in the center of the scene.
|
|
const pivot = new THREE.Group();
|
|
pivot.position.set(0, 0, 0);
|
|
scene.add(pivot);
|
|
pivot.add(camera);
|
|
|
|
const mouseDownHandler = (e) => {
|
|
isDragging = true;
|
|
previousMousePosition.x = e.clientX;
|
|
previousMousePosition.y = e.clientY;
|
|
};
|
|
|
|
const mouseUpHandler = () => {
|
|
isDragging = false;
|
|
};
|
|
|
|
const mouseMoveHandler = (e) => {
|
|
if (!isDragging) return;
|
|
|
|
const deltaX = e.clientX - previousMousePosition.x;
|
|
const deltaY = e.clientY - previousMousePosition.y;
|
|
|
|
// Rotate the pivot around the Y axis for horizontal movement
|
|
pivot.rotation.y += deltaX * rotationSpeed;
|
|
|
|
// Calculate vertical rotation (tilt the camera up/down)
|
|
let newXRotation = pivot.rotation.x + deltaY * rotationSpeed;
|
|
|
|
// Clamp the vertical rotation to prevent flipping
|
|
const PI_HALF = Math.PI / 2;
|
|
newXRotation = Math.max(-PI_HALF + 0.1, Math.min(PI_HALF - 0.1, newXRotation));
|
|
pivot.rotation.x = newXRotation;
|
|
|
|
previousMousePosition.x = e.clientX;
|
|
previousMousePosition.y = e.clientY;
|
|
};
|
|
|
|
renderer.domElement.addEventListener('mousedown', mouseDownHandler, false);
|
|
renderer.domElement.addEventListener('mouseup', mouseUpHandler, false);
|
|
renderer.domElement.addEventListener('mousemove', mouseMoveHandler, false);
|
|
renderer.domElement.addEventListener('mouseout', mouseUpHandler, false); // Stop dragging if mouse leaves
|
|
}
|
|
|
|
// --- Animation Loop ---
|
|
|
|
function updateGuineaPigs() {
|
|
const boundary = ROOM_SIZE * 0.9;
|
|
const speed = 0.05;
|
|
|
|
guineaPigs.forEach(pig => {
|
|
// 1. Movement and Rotation
|
|
pig.position.x += pig.velocity.x * speed;
|
|
pig.position.z += pig.velocity.z * speed;
|
|
|
|
// 2. Bound Checking (Check if near edges of the field)
|
|
let needsTurn = false;
|
|
if (pig.position.x > boundary || pig.position.x < -boundary) {
|
|
pig.velocity.x *= -1;
|
|
needsTurn = true;
|
|
pig.position.x = Math.max(-boundary, Math.min(boundary, pig.position.x));
|
|
}
|
|
if (pig.position.z > boundary || pig.position.z < -boundary) {
|
|
pig.velocity.z *= -1;
|
|
needsTurn = true;
|
|
pig.position.z = Math.max(-boundary, Math.min(boundary, pig.position.z));
|
|
}
|
|
|
|
// 3. Random Turning
|
|
pig.turnTimer++;
|
|
if (pig.turnTimer > pig.turnCooldown || needsTurn) {
|
|
pig.turnTimer = 0;
|
|
// Calculate new random velocity and rotation
|
|
pig.velocity.x = (Math.random() - 0.5) * 2;
|
|
pig.velocity.z = (Math.random() - 0.5) * 2;
|
|
pig.velocity.normalize(); // Keep consistent speed
|
|
|
|
// Set rotation based on new velocity direction (no offset needed as the mesh is pre-rotated)
|
|
pig.rotation.y = Math.atan2(pig.velocity.x, pig.velocity.z)-Math.PI/2;
|
|
|
|
pig.turnCooldown = Math.random() * 60 + 60;
|
|
}
|
|
|
|
// 4. Simple 'Run' Animation (wobbling)
|
|
pig.runAnimation += 0.1;
|
|
pig.rotation.x = Math.sin(pig.runAnimation) * 0.05; // Head wobble
|
|
pig.position.y = Math.abs(Math.sin(pig.runAnimation * 0.5)) * 0.05 + 0.3; // Slight bounce
|
|
});
|
|
}
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
updateGuineaPigs(); // Move and update the pigs
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
function onWindowResize() {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
}
|
|
|
|
// Start the application when the window loads
|
|
window.onload = function () {
|
|
// Check if Three.js is loaded
|
|
if (typeof THREE === 'undefined') {
|
|
console.error("Three.js not loaded. Please ensure the CDN link is correct.");
|
|
return;
|
|
}
|
|
init();
|
|
animate();
|
|
};
|
|
|
|
</script>
|
|
</body>
|
|
</html> |