Created
July 7, 2025 03:15
-
-
Save boxabirds/171f8e3e09c5d1a3cd7940e3cc9f232e to your computer and use it in GitHub Desktop.
Squish
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Groovy Glob Sandbox</title> | |
<style> | |
@import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@500;700&display=swap'); | |
body { | |
margin: 0; | |
font-family: 'Quicksand', sans-serif; | |
color: white; | |
background-color: #6a7b6a; | |
background-image: repeating-linear-gradient( | |
90deg, | |
rgba(0,0,0,0.05) 0px, | |
rgba(0,0,0,0.05) 8px, | |
transparent 8px, | |
transparent 16px | |
); | |
overflow: hidden; | |
} | |
#info { | |
position: absolute; | |
top: 20px; | |
width: 100%; | |
text-align: center; | |
z-index: 100; | |
text-shadow: 0 2px 5px rgba(0,0,0,0.2); | |
} | |
h1 { margin: 0; font-weight: 700; } | |
p { margin: 5px 0 0; font-weight: 500; opacity: 0.9; } | |
canvas { display: block; } | |
</style> | |
</head> | |
<body> | |
<div id="info"> | |
<h1>Groovy Glob Sandbox</h1> | |
<p>Use tools. Hold [Shift] to multi-select. Hold [Space] for camera.</p> | |
</div> | |
<!-- ES Module Shims for browser compatibility --> | |
<script async src="https://unpkg.com/[email protected]/dist/es-module-shims.js"></script> | |
<script type="importmap"> | |
{ | |
"imports": { | |
"three": "https://unpkg.com/[email protected]/build/three.module.js", | |
"three/addons/": "https://unpkg.com/[email protected]/examples/jsm/", | |
"cannon-es": "https://unpkg.com/[email protected]/dist/cannon-es.js", | |
"lil-gui": "https://unpkg.com/[email protected]/dist/lil-gui.esm.js" | |
} | |
} | |
</script> | |
<script type="module"> | |
import * as THREE from 'three'; | |
import * as CANNON from 'cannon-es'; | |
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'; | |
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
import GUI from 'lil-gui'; | |
// --- SETUP --- | |
const scene = new THREE.Scene(); | |
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000); | |
camera.position.set(8, 7, 12); | |
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
renderer.shadowMap.enabled = true; | |
renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
document.body.appendChild(renderer.domElement); | |
// --- CAMERA CONTROLS --- | |
const controls = new OrbitControls(camera, renderer.domElement); | |
controls.enableDamping = true; | |
controls.dampingFactor = 0.05; | |
controls.minDistance = 5; | |
controls.maxDistance = 50; | |
controls.maxPolarAngle = Math.PI / 2 - 0.05; | |
controls.target.set(0, 1, 0); | |
controls.listenToKeyEvents(window); | |
controls.update(); | |
// --- LIGHTING & SHADOWS --- | |
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 2.0)); | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 2.0); | |
directionalLight.position.set(5, 10, 7.5); | |
directionalLight.castShadow = true; | |
directionalLight.shadow.mapSize.width = 2048; | |
directionalLight.shadow.mapSize.height = 2048; | |
const shadowFrustumSize = 25; | |
directionalLight.shadow.camera.left = -shadowFrustumSize; | |
directionalLight.shadow.camera.right = shadowFrustumSize; | |
directionalLight.shadow.camera.top = shadowFrustumSize; | |
directionalLight.shadow.camera.bottom = -shadowFrustumSize; | |
directionalLight.shadow.camera.near = 0.5; | |
directionalLight.shadow.camera.far = 50; | |
directionalLight.shadow.radius = 4; | |
directionalLight.shadow.bias = -0.0001; | |
scene.add(directionalLight); | |
// --- PHYSICS --- | |
const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -20, 0) }); | |
const groundMaterial = new CANNON.Material('ground'); | |
const cubeMaterial = new CANNON.Material('cube'); | |
const projectileMaterial = new CANNON.Material('projectile'); | |
const shrapnelMaterial = new CANNON.Material('shrapnel'); | |
world.addContactMaterial(new CANNON.ContactMaterial(groundMaterial, cubeMaterial, { friction: 0.6, restitution: 0.4 })); | |
world.addContactMaterial(new CANNON.ContactMaterial(cubeMaterial, cubeMaterial, { friction: 0.5, restitution: 0.4 })); | |
world.addContactMaterial(new CANNON.ContactMaterial(groundMaterial, projectileMaterial, { friction: 0.5, restitution: 0.7 })); | |
world.addContactMaterial(new CANNON.ContactMaterial(cubeMaterial, projectileMaterial, { friction: 0.1, restitution: 0.5 })); | |
world.addContactMaterial(new CANNON.ContactMaterial(groundMaterial, shrapnelMaterial, { friction: 0.8, restitution: 0.5 })); | |
world.addContactMaterial(new CANNON.ContactMaterial(cubeMaterial, shrapnelMaterial, { friction: 0.2, restitution: 0.4 })); | |
world.addContactMaterial(new CANNON.ContactMaterial(shrapnelMaterial, shrapnelMaterial, { friction: 0.5, restitution: 0.4 })); | |
// --- VISUAL & PHYSICS GROUND --- | |
const groundBody = new CANNON.Body({ type: CANNON.Body.STATIC, shape: new CANNON.Plane(), material: groundMaterial }); | |
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0); | |
world.addBody(groundBody); | |
const groundMesh = new THREE.Mesh( | |
new THREE.PlaneGeometry(100, 100), | |
new THREE.MeshStandardMaterial({ color: 0x5a6b5a, roughness: 0.8, metalness: 0.2 }) | |
); | |
groundMesh.rotation.x = -Math.PI / 2; | |
groundMesh.receiveShadow = true; | |
scene.add(groundMesh); | |
// --- GAME LOGIC & STATE --- | |
let activeTool = 'SHAPE'; | |
let previousTool = 'SHAPE'; | |
const objectsToUpdate = []; | |
const objectsToRemove = []; | |
const selectedObjects = []; | |
let isDraggingObjects = false; | |
const dragStartOffsets = new Map(); | |
const selectionHighlight = new THREE.Color(0xffaa00); | |
const cubeSize = 1.5; | |
const cubeGeometry = new RoundedBoxGeometry(cubeSize, cubeSize, cubeSize, 18, 0.2); | |
const cubeShape = new CANNON.Box(new CANNON.Vec3(cubeSize * 0.5, cubeSize * 0.5, cubeSize * 0.5)); | |
const colorPalette = [0x4ab3c4, 0xf28b82, 0xfbbc04, 0x34a853, 0x8ab4f8, 0xfd7099]; | |
const projectileGeometry = new THREE.SphereGeometry(0.2, 16, 16); | |
const projectileShape = new CANNON.Sphere(0.2); | |
const particleGeometry = new THREE.BoxGeometry(0.1, 0.1, 0.1); | |
const particleShape = new CANNON.Box(new CANNON.Vec3(0.05, 0.05, 0.05)); | |
function createCube(position) { | |
const color = colorPalette[Math.floor(Math.random() * colorPalette.length)]; | |
const isTransparent = settings.opacity < 1.0; | |
const transmissionValue = isTransparent ? 0.9 : 0; | |
const material = new THREE.MeshPhysicalMaterial({ | |
color: color, metalness: 0.1, roughness: 0.2, ior: 1.5, thickness: 1.5, | |
transmission: transmissionValue, opacity: settings.opacity, transparent: isTransparent, | |
}); | |
const mesh = new THREE.Mesh(cubeGeometry, material); | |
updateShadowForMesh(mesh); | |
mesh.position.copy(position); | |
scene.add(mesh); | |
const body = new CANNON.Body({ mass: 1, shape: cubeShape, position: new CANNON.Vec3().copy(position), material: cubeMaterial }); | |
world.addBody(body); | |
const objectData = { mesh, body, health: 3, targetScale: new THREE.Vector3(1, 1, 1), originalEmissive: new THREE.Color().copy(material.emissive) }; | |
objectsToUpdate.push(objectData); | |
body.addEventListener('collide', (event) => { | |
if (event.target.type === CANNON.Body.KINEMATIC) return; | |
if (event.body.isProjectile) { | |
dealDamage(objectData, event.body, 1); | |
} else if (event.body.isShrapnel) { | |
dealDamage(objectData, event.body, 1); | |
} else { | |
const impactVelocity = event.contact.getImpactVelocityAlongNormal(); | |
if (impactVelocity > 1.5) triggerCollisionSquish(objectData, impactVelocity); | |
} | |
}); | |
updateCubeCount(); | |
} | |
function dealDamage(cubeData, impactingBody, damageAmount) { | |
if (!cubeData.health) return; | |
cubeData.health -= damageAmount; | |
triggerCollisionSquish(cubeData, 15); | |
if (cubeData.health <= 0) { | |
createExplosion(cubeData.body.position, cubeData.mesh.material.color); | |
queueForRemoval(cubeData); | |
} | |
const impactingObject = objectsToUpdate.find(o => o.body === impactingBody); | |
if (impactingObject) queueForRemoval(impactingObject); | |
} | |
function createProjectile(ray) { | |
const material = new THREE.MeshStandardMaterial({ color: 0xeeeeee, roughness: 0.2, metalness: 0.5 }); | |
const mesh = new THREE.Mesh(projectileGeometry, material); | |
mesh.castShadow = true; | |
const body = new CANNON.Body({ mass: 0.5, shape: projectileShape, material: projectileMaterial }); | |
body.isProjectile = true; | |
const startPosition = ray.origin.clone().add(ray.direction.clone().multiplyScalar(2)); | |
const velocity = ray.direction.clone().multiplyScalar(80); | |
body.position.copy(startPosition); | |
body.velocity.copy(velocity); | |
scene.add(mesh); | |
world.addBody(body); | |
const objectData = { mesh, body, isProjectile: true }; | |
objectsToUpdate.push(objectData); | |
setTimeout(() => queueForRemoval(objectData), 3000); | |
} | |
function createExplosion(position, color) { | |
const flash = new THREE.PointLight(0xffffff, 100, 50, 2); | |
flash.position.copy(position); | |
scene.add(flash); | |
setTimeout(() => scene.remove(flash), 200); | |
const count = 50; | |
for (let i = 0; i < count; i++) { | |
const material = new THREE.MeshStandardMaterial({ color: color }); | |
const mesh = new THREE.Mesh(particleGeometry, material); | |
mesh.castShadow = true; | |
const body = new CANNON.Body({ mass: 0.1, shape: particleShape, material: shrapnelMaterial }); | |
body.isShrapnel = true; | |
body.position.copy(position); | |
const force = new CANNON.Vec3((Math.random() - 0.5) * 25, (Math.random()) * 15, (Math.random() - 0.5) * 25); | |
body.applyImpulse(force, body.position); | |
scene.add(mesh); | |
world.addBody(body); | |
const objectData = { mesh, body, isShrapnel: true }; | |
objectsToUpdate.push(objectData); | |
setTimeout(() => queueForRemoval(objectData), 3000); | |
} | |
} | |
function queueForRemoval(objectData) { | |
if (!objectsToRemove.includes(objectData)) { | |
objectsToRemove.push(objectData); | |
} | |
} | |
function _removeObjectNow(objectData) { | |
if (objectData.mesh) scene.remove(objectData.mesh); | |
if (objectData.body) world.removeBody(objectData.body); | |
const index = objectsToUpdate.indexOf(objectData); | |
if (index > -1) objectsToUpdate.splice(index, 1); | |
if (objectData.health) updateCubeCount(); | |
} | |
function triggerCollisionSquish(objectData, intensity) { | |
const squishFactor = settings.squishiness; | |
const effectiveIntensity = Math.min(intensity / 15, 1.0); | |
let scaleY = 1 - (0.1 * squishFactor * effectiveIntensity); | |
scaleY = Math.max(0.05, scaleY); | |
const scaleXZ = 1 / Math.sqrt(scaleY); | |
objectData.targetScale.set(scaleXZ, scaleY, scaleXZ); | |
setTimeout(() => { | |
if (objectData.targetScale) objectData.targetScale.set(1, 1, 1); | |
}, 150); | |
} | |
// --- GUI & TOOLS --- | |
const gui = new GUI(); | |
const settings = { | |
shadowMode: 'Realistic', squishiness: 0.6, opacity: 1.0, cubeCount: 0, | |
clearScene: () => { | |
deselectAll(); | |
[...objectsToUpdate].forEach(o => queueForRemoval(o)); | |
} | |
}; | |
const toolsFolder = gui.addFolder('Tools'); | |
toolsFolder.add({ tool: () => setTool('SHAPE') }, 'tool').name('Shape Tool'); | |
toolsFolder.add({ tool: () => setTool('SHOOT') }, 'tool').name('Shoot Tool'); | |
toolsFolder.add({ tool: () => setTool('MOVE') }, 'tool').name('Move Tool'); | |
toolsFolder.add({ tool: () => setTool('GRABBER') }, 'tool').name('Grabber Tool'); | |
const settingsFolder = gui.addFolder('Settings'); | |
settingsFolder.add(settings, 'squishiness', 0.1, 10.0).name('Squishiness').onChange(value => { | |
const newRestitution = Math.min(0.3 + value * 0.06, 0.95); | |
groundContactMaterial.restitution = newRestitution; | |
cubeContactMaterial.restitution = newRestitution; | |
}); | |
settingsFolder.add(settings, 'opacity', 0.1, 1.0).name('Opacity'); | |
const shadowFolder = gui.addFolder('Shadows'); | |
shadowFolder.add(settings, 'shadowMode', ['Realistic', 'Artistic']).name('Shadow Mode').onChange(() => { | |
for (const object of objectsToUpdate) if(object.health) updateShadowForMesh(object.mesh); | |
}); | |
const infoFolder = gui.addFolder('Info'); | |
const cubeCountController = infoFolder.add(settings, 'cubeCount').name('Cube Count').listen(); | |
cubeCountController.domElement.style.pointerEvents = 'none'; | |
infoFolder.add(settings, 'clearScene').name('Clear Scene'); | |
function updateShadowForMesh(mesh) { | |
if (settings.shadowMode === 'Realistic') mesh.castShadow = mesh.material.opacity === 1.0; | |
else mesh.castShadow = true; | |
} | |
function setTool(toolName) { | |
activeTool = toolName; | |
controls.enabled = false; | |
if (toolName === 'GRABBER') { | |
controls.enabled = true; | |
renderer.domElement.style.cursor = 'grab'; | |
} else if (toolName === 'SHAPE' || toolName === 'SHOOT') { | |
renderer.domElement.style.cursor = 'crosshair'; | |
} else if (toolName === 'MOVE') { | |
renderer.domElement.style.cursor = 'move'; | |
} | |
} | |
setTool('SHAPE'); | |
function updateCubeCount() { settings.cubeCount = objectsToUpdate.filter(o => o.health).length; } | |
// --- SELECTION & MOVEMENT LOGIC --- | |
const raycaster = new THREE.Raycaster(); | |
const mouse = new THREE.Vector2(); | |
function selectObject(objectData) { | |
if (!selectedObjects.includes(objectData)) { | |
selectedObjects.push(objectData); | |
objectData.mesh.material.emissive.copy(selectionHighlight); | |
} | |
} | |
function deselectObject(objectData) { | |
const index = selectedObjects.indexOf(objectData); | |
if (index > -1) { | |
selectedObjects.splice(index, 1); | |
objectData.mesh.material.emissive.copy(objectData.originalEmissive); | |
} | |
} | |
function deselectAll() { | |
[...selectedObjects].forEach(deselectObject); | |
} | |
function onPointerDown(event) { | |
if (event.target.closest('.lil-gui')) return; | |
mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
raycaster.setFromCamera(mouse, camera); | |
if (activeTool === 'SHAPE') { | |
const intersects = raycaster.intersectObject(groundMesh); | |
if (intersects.length > 0) createCube(new THREE.Vector3(intersects[0].point.x, 10, intersects[0].point.z)); | |
} else if (activeTool === 'SHOOT') { | |
createProjectile(raycaster.ray); | |
} else if (activeTool === 'MOVE') { | |
const intersects = raycaster.intersectObjects(objectsToUpdate.filter(o => o.health).map(o => o.mesh)); | |
if (intersects.length > 0) { | |
const hitObject = objectsToUpdate.find(o => o.mesh === intersects[0].object); | |
if (event.shiftKey) { | |
if (selectedObjects.includes(hitObject)) { | |
deselectObject(hitObject); | |
} else { | |
selectObject(hitObject); | |
} | |
} else { | |
if (!selectedObjects.includes(hitObject)) { | |
deselectAll(); | |
selectObject(hitObject); | |
} | |
} | |
isDraggingObjects = true; | |
controls.enabled = false; | |
dragStartOffsets.clear(); | |
const groundIntersectPoint = new THREE.Vector3(); | |
raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), -intersects[0].point.y), groundIntersectPoint); | |
for (const obj of selectedObjects) { | |
obj.body.type = CANNON.Body.KINEMATIC; | |
const offset = new THREE.Vector3().copy(obj.mesh.position).sub(groundIntersectPoint); | |
dragStartOffsets.set(obj, offset); | |
} | |
} else { | |
if (!event.shiftKey) deselectAll(); | |
} | |
} | |
} | |
function onPointerMove(event) { | |
if (isDraggingObjects && selectedObjects.length > 0) { | |
mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
raycaster.setFromCamera(mouse, camera); | |
const firstObject = selectedObjects[0]; | |
const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -firstObject.mesh.position.y); | |
const groundIntersectPoint = new THREE.Vector3(); | |
raycaster.ray.intersectPlane(dragPlane, groundIntersectPoint); | |
if (groundIntersectPoint) { | |
for (const obj of selectedObjects) { | |
const offset = dragStartOffsets.get(obj); | |
obj.body.position.set( | |
groundIntersectPoint.x + offset.x, | |
obj.body.position.y, | |
groundIntersectPoint.z + offset.z | |
); | |
} | |
} | |
} | |
} | |
function onPointerUp() { | |
isDraggingObjects = false; | |
for (const obj of selectedObjects) { | |
obj.body.type = CANNON.Body.DYNAMIC; | |
obj.body.wakeUp(); | |
} | |
if (activeTool === 'GRABBER') controls.enabled = true; | |
} | |
function onWheel(event) { | |
if (isDraggingObjects && selectedObjects.length > 0) { | |
event.preventDefault(); | |
const scrollAmount = event.deltaY > 0 ? -0.2 : 0.2; | |
for (const obj of selectedObjects) { | |
obj.body.position.y += scrollAmount; | |
} | |
} | |
} | |
window.addEventListener('pointerdown', onPointerDown); | |
window.addEventListener('pointermove', onPointerMove); | |
window.addEventListener('pointerup', onPointerUp); | |
window.addEventListener('wheel', onWheel, { passive: false }); | |
window.addEventListener('keydown', (event) => { | |
if (event.code === 'Space' && !event.repeat) { | |
if (activeTool !== 'GRABBER') { | |
previousTool = activeTool; | |
setTool('GRABBER'); | |
} | |
} | |
}); | |
window.addEventListener('keyup', (event) => { | |
if (event.code === 'Space') setTool(previousTool); | |
}); | |
// --- ANIMATION LOOP --- | |
const clock = new THREE.Clock(); | |
let oldElapsedTime = 0; | |
function animate() { | |
const elapsedTime = clock.getElapsedTime(); | |
const deltaTime = elapsedTime - oldElapsedTime; | |
oldElapsedTime = elapsedTime; | |
controls.update(); | |
world.step(1 / 60, deltaTime, 3); | |
for (const object of objectsToRemove) { | |
_removeObjectNow(object); | |
} | |
objectsToRemove.length = 0; | |
for (const object of objectsToUpdate) { | |
if (object.mesh && object.body) { | |
if (!selectedObjects.includes(object) || !isDraggingObjects) { | |
object.mesh.position.copy(object.body.position); | |
object.mesh.quaternion.copy(object.body.quaternion); | |
} else { | |
object.mesh.position.copy(object.body.position); | |
object.mesh.quaternion.copy(object.body.quaternion); | |
} | |
if (object.targetScale) object.mesh.scale.lerp(object.targetScale, 0.2); | |
} | |
} | |
renderer.render(scene, camera); | |
requestAnimationFrame(animate); | |
} | |
animate(); | |
// --- RESIZE HANDLER --- | |
window.addEventListener('resize', () => { | |
camera.aspect = window.innerWidth / window.innerHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment