Skip to content

Instantly share code, notes, and snippets.

@boxabirds
Created July 7, 2025 03:15
Show Gist options
  • Save boxabirds/171f8e3e09c5d1a3cd7940e3cc9f232e to your computer and use it in GitHub Desktop.
Save boxabirds/171f8e3e09c5d1a3cd7940e3cc9f232e to your computer and use it in GitHub Desktop.
Squish
<!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