skills/awwwards-animations/references/physics-2d.md

20 KiB

Physics 2D - Matter.js

Motor de física 2D para experiencias interactivas estilo Awwwards.

Table of Contents

  1. Setup
  2. React Integration
  3. Bodies (Cuerpos)
  4. World & Engine
  5. Constraints (Restricciones)
  6. Mouse Interaction
  7. Collision Events
  8. Scroll Physics
  9. Creative Patterns

Setup

Instalación

npm install matter-js
npm install --save-dev @types/matter-js

Importación

import Matter from 'matter-js'

// O destructurar
const { Engine, Render, Runner, Bodies, World, Mouse, MouseConstraint } = Matter

React Integration

Setup Básico con Hooks

'use client'

import { useRef, useEffect } from 'react'
import Matter from 'matter-js'

export function PhysicsScene() {
  const containerRef = useRef<HTMLDivElement>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    const { Engine, Render, Runner, Bodies, World, Composite } = Matter

    // Crear engine
    const engine = Engine.create()
    const world = engine.world

    // Configurar gravedad
    engine.gravity.y = 1
    engine.gravity.x = 0

    // Crear renderer
    const render = Render.create({
      element: containerRef.current!,
      canvas: canvasRef.current!,
      engine: engine,
      options: {
        width: 800,
        height: 600,
        wireframes: false, // Renderizado sólido
        background: '#0a0a0a',
      },
    })

    // Crear suelo y paredes
    const ground = Bodies.rectangle(400, 590, 800, 20, {
      isStatic: true,
      render: { fillStyle: '#333' },
    })

    const leftWall = Bodies.rectangle(10, 300, 20, 600, {
      isStatic: true,
      render: { fillStyle: '#333' },
    })

    const rightWall = Bodies.rectangle(790, 300, 20, 600, {
      isStatic: true,
      render: { fillStyle: '#333' },
    })

    // Crear cuerpos dinámicos
    const boxes = Array.from({ length: 10 }, (_, i) =>
      Bodies.rectangle(100 + i * 60, 100, 50, 50, {
        render: {
          fillStyle: `hsl(${i * 36}, 70%, 60%)`,
        },
      })
    )

    // Agregar al mundo
    Composite.add(world, [ground, leftWall, rightWall, ...boxes])

    // Ejecutar
    const runner = Runner.create()
    Runner.run(runner, engine)
    Render.run(render)

    // Cleanup
    return () => {
      Render.stop(render)
      Runner.stop(runner)
      World.clear(world, false)
      Engine.clear(engine)
      render.canvas.remove()
    }
  }, [])

  return (
    <div ref={containerRef}>
      <canvas ref={canvasRef} />
    </div>
  )
}

Hook Reutilizable: useMatter

'use client'

import { useRef, useEffect, useCallback } from 'react'
import Matter from 'matter-js'

interface UseMatterOptions {
  width?: number
  height?: number
  gravity?: { x: number; y: number }
  wireframes?: boolean
  background?: string
}

export function useMatter(options: UseMatterOptions = {}) {
  const {
    width = 800,
    height = 600,
    gravity = { x: 0, y: 1 },
    wireframes = false,
    background = '#0a0a0a',
  } = options

  const containerRef = useRef<HTMLDivElement>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const engineRef = useRef<Matter.Engine | null>(null)
  const renderRef = useRef<Matter.Render | null>(null)
  const runnerRef = useRef<Matter.Runner | null>(null)

  useEffect(() => {
    const { Engine, Render, Runner } = Matter

    engineRef.current = Engine.create()
    engineRef.current.gravity.x = gravity.x
    engineRef.current.gravity.y = gravity.y

    renderRef.current = Render.create({
      element: containerRef.current!,
      canvas: canvasRef.current!,
      engine: engineRef.current,
      options: { width, height, wireframes, background },
    })

    runnerRef.current = Runner.create()
    Runner.run(runnerRef.current, engineRef.current)
    Render.run(renderRef.current)

    return () => {
      if (renderRef.current) Render.stop(renderRef.current)
      if (runnerRef.current) Runner.stop(runnerRef.current)
      if (engineRef.current) {
        Matter.World.clear(engineRef.current.world, false)
        Engine.clear(engineRef.current)
      }
    }
  }, [width, height, gravity.x, gravity.y, wireframes, background])

  const addBody = useCallback((body: Matter.Body) => {
    if (engineRef.current) {
      Matter.Composite.add(engineRef.current.world, body)
    }
  }, [])

  const removeBody = useCallback((body: Matter.Body) => {
    if (engineRef.current) {
      Matter.Composite.remove(engineRef.current.world, body)
    }
  }, [])

  const addBodies = useCallback((bodies: Matter.Body[]) => {
    if (engineRef.current) {
      Matter.Composite.add(engineRef.current.world, bodies)
    }
  }, [])

  return {
    containerRef,
    canvasRef,
    engine: engineRef,
    addBody,
    removeBody,
    addBodies,
    Matter,
  }
}

Bodies (Cuerpos)

Formas Básicas

const { Bodies } = Matter

// Rectángulo
const box = Bodies.rectangle(x, y, width, height, {
  render: { fillStyle: '#ff6b6b' },
})

// Círculo
const ball = Bodies.circle(x, y, radius, {
  restitution: 0.8, // Rebote
  friction: 0.1,
  render: { fillStyle: '#4ecdc4' },
})

// Polígono regular
const hexagon = Bodies.polygon(x, y, 6, radius, {
  render: { fillStyle: '#45b7d1' },
})

// Trapecio
const trapezoid = Bodies.trapezoid(x, y, width, height, slope, {
  render: { fillStyle: '#96ceb4' },
})

Cuerpos desde Vértices (Custom Shapes)

// Forma L (Tetris)
const lShape = Bodies.fromVertices(400, 300, [
  { x: 0, y: 0 },
  { x: 30, y: 0 },
  { x: 30, y: 60 },
  { x: 60, y: 60 },
  { x: 60, y: 90 },
  { x: 0, y: 90 },
], {
  render: { fillStyle: '#ff6b6b' },
})

// Forma T
const tShape = Bodies.fromVertices(400, 300, [
  { x: 0, y: 0 },
  { x: 90, y: 0 },
  { x: 90, y: 30 },
  { x: 60, y: 30 },
  { x: 60, y: 60 },
  { x: 30, y: 60 },
  { x: 30, y: 30 },
  { x: 0, y: 30 },
], {
  render: { fillStyle: '#4ecdc4' },
})

Propiedades de Bodies

const body = Bodies.circle(400, 300, 50, {
  // Física
  mass: 10,                    // Masa
  density: 0.001,              // Densidad
  friction: 0.1,               // Fricción (0-1)
  frictionAir: 0.01,           // Resistencia del aire
  frictionStatic: 0.5,         // Fricción estática
  restitution: 0.8,            // Rebote (0-1)

  // Estado
  isStatic: false,             // Estático (no se mueve)
  isSensor: false,             // Solo detecta colisiones, no físicas

  // Render
  render: {
    visible: true,
    fillStyle: '#ff6b6b',
    strokeStyle: '#fff',
    lineWidth: 2,
    opacity: 1,
  },

  // Identificación
  label: 'ball',

  // Colisiones
  collisionFilter: {
    category: 0x0001,
    mask: 0xFFFFFFFF,
    group: 0,
  },
})

Manipular Bodies

const { Body } = Matter

// Aplicar fuerza
Body.applyForce(body, body.position, { x: 0.05, y: -0.1 })

// Establecer velocidad
Body.setVelocity(body, { x: 10, y: -5 })

// Establecer velocidad angular
Body.setAngularVelocity(body, 0.1)

// Mover a posición
Body.setPosition(body, { x: 400, y: 300 })

// Rotar
Body.setAngle(body, Math.PI / 4)
Body.rotate(body, 0.1)

// Escalar
Body.scale(body, 1.5, 1.5)

World & Engine

Configuración del Engine

const engine = Engine.create({
  gravity: {
    x: 0,
    y: 1,
    scale: 0.001,
  },
  timing: {
    timeScale: 1, // Slow motion: < 1, Fast forward: > 1
  },
})

// Cambiar gravedad dinámicamente
engine.gravity.y = 0      // Sin gravedad
engine.gravity.y = -1     // Gravedad invertida
engine.gravity.x = 0.5    // Gravedad lateral

Bounds del World

// Crear contenedor con paredes
function createBounds(width: number, height: number, thickness = 50) {
  return [
    // Suelo
    Bodies.rectangle(width / 2, height + thickness / 2, width, thickness, {
      isStatic: true,
      label: 'ground',
    }),
    // Techo
    Bodies.rectangle(width / 2, -thickness / 2, width, thickness, {
      isStatic: true,
      label: 'ceiling',
    }),
    // Pared izquierda
    Bodies.rectangle(-thickness / 2, height / 2, thickness, height, {
      isStatic: true,
      label: 'leftWall',
    }),
    // Pared derecha
    Bodies.rectangle(width + thickness / 2, height / 2, thickness, height, {
      isStatic: true,
      label: 'rightWall',
    }),
  ]
}

Constraints (Restricciones)

Conectar Bodies

const { Constraint } = Matter

// Constraint punto a punto
const rope = Constraint.create({
  bodyA: ball1,
  bodyB: ball2,
  length: 100,
  stiffness: 0.9,
  render: {
    strokeStyle: '#fff',
    lineWidth: 2,
  },
})

// Constraint a punto fijo
const pendulum = Constraint.create({
  pointA: { x: 400, y: 0 },  // Punto fijo en el mundo
  bodyB: ball,
  length: 200,
  stiffness: 1,
})

// Spring (resorte)
const spring = Constraint.create({
  bodyA: ball1,
  bodyB: ball2,
  stiffness: 0.01,  // Más bajo = más elástico
  damping: 0.1,
})

Composite.add(world, [rope, pendulum, spring])

Cadena de Bodies

function createChain(x: number, y: number, links: number, linkSize: number) {
  const bodies: Matter.Body[] = []
  const constraints: Matter.Constraint[] = []

  // Punto de anclaje
  const anchor = Bodies.circle(x, y, 5, { isStatic: true })
  bodies.push(anchor)

  let prevBody = anchor
  for (let i = 0; i < links; i++) {
    const link = Bodies.rectangle(
      x,
      y + (i + 1) * linkSize,
      linkSize * 0.8,
      linkSize * 0.3,
      { render: { fillStyle: '#4ecdc4' } }
    )
    bodies.push(link)

    constraints.push(
      Constraint.create({
        bodyA: prevBody,
        bodyB: link,
        length: linkSize * 0.5,
        stiffness: 0.9,
      })
    )

    prevBody = link
  }

  return { bodies, constraints }
}

Mouse Interaction

Mouse Constraint

'use client'

import { useEffect } from 'react'
import Matter from 'matter-js'

export function DraggablePhysics() {
  useEffect(() => {
    const { Engine, Render, Runner, Bodies, Composite, Mouse, MouseConstraint } = Matter

    const engine = Engine.create()
    const render = Render.create({
      element: document.body,
      engine: engine,
      options: { width: 800, height: 600, wireframes: false },
    })

    // Crear bodies arrastrables
    const boxes = Array.from({ length: 5 }, (_, i) =>
      Bodies.rectangle(200 + i * 100, 200, 60, 60, {
        render: { fillStyle: `hsl(${i * 60}, 70%, 60%)` },
      })
    )

    const ground = Bodies.rectangle(400, 580, 800, 40, { isStatic: true })

    // Configurar mouse
    const mouse = Mouse.create(render.canvas)
    const mouseConstraint = MouseConstraint.create(engine, {
      mouse: mouse,
      constraint: {
        stiffness: 0.2,
        render: { visible: false },
      },
    })

    // Sincronizar mouse con scroll
    render.mouse = mouse

    Composite.add(engine.world, [...boxes, ground, mouseConstraint])

    Runner.run(Runner.create(), engine)
    Render.run(render)
  }, [])

  return null
}

Hover Detection

import Matter from 'matter-js'

const { Query } = Matter

function getBodyAtPosition(engine: Matter.Engine, x: number, y: number) {
  const bodies = Matter.Composite.allBodies(engine.world)
  const point = { x, y }

  // Encontrar body bajo el punto
  const found = Query.point(bodies, point)
  return found[0] || null
}

// Uso en event listener
canvas.addEventListener('mousemove', (e) => {
  const rect = canvas.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top

  const body = getBodyAtPosition(engine, x, y)
  if (body) {
    // Hover sobre body
    body.render.fillStyle = '#ff0000'
  }
})

Collision Events

Detectar Colisiones

import Matter from 'matter-js'

const { Events } = Matter

// Collision start
Events.on(engine, 'collisionStart', (event) => {
  event.pairs.forEach((pair) => {
    const { bodyA, bodyB } = pair

    // Verificar labels
    if (bodyA.label === 'ball' && bodyB.label === 'ground') {
      console.log('Ball hit ground!')
      // Reproducir sonido, cambiar color, etc.
      bodyA.render.fillStyle = '#ff0000'
    }
  })
})

// Collision active (mientras colisionan)
Events.on(engine, 'collisionActive', (event) => {
  // ...
})

// Collision end
Events.on(engine, 'collisionEnd', (event) => {
  event.pairs.forEach((pair) => {
    pair.bodyA.render.fillStyle = '#4ecdc4'
    pair.bodyB.render.fillStyle = '#4ecdc4'
  })
})

Colisiones con Audio

'use client'

import { useEffect, useRef } from 'react'
import Matter from 'matter-js'
import * as Tone from 'tone'

export function PhysicsWithSound() {
  const synthRef = useRef<Tone.MembraneSynth | null>(null)

  useEffect(() => {
    synthRef.current = new Tone.MembraneSynth().toDestination()
    synthRef.current.volume.value = -10

    const engine = Matter.Engine.create()
    // ... setup render, bodies

    Matter.Events.on(engine, 'collisionStart', async (event) => {
      await Tone.start()

      event.pairs.forEach((pair) => {
        // Calcular intensidad del impacto
        const velocity = Math.sqrt(
          Math.pow(pair.bodyA.velocity.x - pair.bodyB.velocity.x, 2) +
          Math.pow(pair.bodyA.velocity.y - pair.bodyB.velocity.y, 2)
        )

        if (velocity > 2) {
          // Mapear velocidad a nota
          const note = Math.min(Math.floor(velocity * 10) + 30, 80)
          const freq = Tone.Frequency(note, 'midi').toFrequency()
          synthRef.current?.triggerAttackRelease(freq, '16n')
        }
      })
    })

    return () => synthRef.current?.dispose()
  }, [])

  return null
}

Scroll Physics

Gravedad Reactiva al Scroll

'use client'

import { useEffect, useRef } from 'react'
import Matter from 'matter-js'
import { gsap, ScrollTrigger } from '@/lib/gsap'

export function ScrollGravity() {
  const engineRef = useRef<Matter.Engine | null>(null)

  useEffect(() => {
    engineRef.current = Matter.Engine.create()
    // ... setup

    // Cambiar gravedad con scroll
    ScrollTrigger.create({
      trigger: 'body',
      start: 'top top',
      end: 'bottom bottom',
      onUpdate: (self) => {
        if (engineRef.current) {
          // Gravedad basada en dirección del scroll
          const direction = self.direction
          engineRef.current.gravity.y = direction > 0 ? 1 : -1
        }
      },
    })

    // O gravedad basada en posición del scroll
    ScrollTrigger.create({
      trigger: 'body',
      start: 'top top',
      end: 'bottom bottom',
      onUpdate: (self) => {
        if (engineRef.current) {
          // Rotar gravedad 360° durante el scroll
          const angle = self.progress * Math.PI * 2
          engineRef.current.gravity.x = Math.sin(angle)
          engineRef.current.gravity.y = Math.cos(angle)
        }
      },
    })
  }, [])

  return null
}

Spawn Bodies on Scroll

'use client'

import { useEffect, useRef } from 'react'
import Matter from 'matter-js'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

export function ScrollSpawnBodies() {
  const engineRef = useRef<Matter.Engine | null>(null)
  const lastSpawnRef = useRef(0)

  useEffect(() => {
    // ... setup engine

    ScrollTrigger.create({
      trigger: 'body',
      start: 'top top',
      end: 'bottom bottom',
      onUpdate: (self) => {
        // Spawn body cada 5% de scroll
        const spawnPoint = Math.floor(self.progress * 20)

        if (spawnPoint > lastSpawnRef.current) {
          lastSpawnRef.current = spawnPoint

          const body = Matter.Bodies.circle(
            Math.random() * 600 + 100,
            50,
            20 + Math.random() * 30,
            {
              restitution: 0.7,
              render: {
                fillStyle: `hsl(${Math.random() * 360}, 70%, 60%)`,
              },
            }
          )

          Matter.Composite.add(engineRef.current!.world, body)
        }
      },
    })
  }, [])

  return null
}

Creative Patterns

Particle Explosion on Click

'use client'

import { useEffect, useRef } from 'react'
import Matter from 'matter-js'

export function ClickExplosion() {
  const engineRef = useRef<Matter.Engine | null>(null)
  const renderRef = useRef<Matter.Render | null>(null)

  useEffect(() => {
    const engine = Matter.Engine.create()
    engineRef.current = engine

    const render = Matter.Render.create({
      element: document.getElementById('physics-container')!,
      engine: engine,
      options: { width: 800, height: 600, wireframes: false, background: '#0a0a0a' },
    })
    renderRef.current = render

    // Ground
    const ground = Matter.Bodies.rectangle(400, 590, 800, 20, { isStatic: true })
    Matter.Composite.add(engine.world, ground)

    // Click handler
    const handleClick = (e: MouseEvent) => {
      const rect = render.canvas.getBoundingClientRect()
      const x = e.clientX - rect.left
      const y = e.clientY - rect.top

      // Crear explosión de partículas
      const particles = Array.from({ length: 20 }, () => {
        const angle = Math.random() * Math.PI * 2
        const speed = 5 + Math.random() * 10

        const particle = Matter.Bodies.circle(x, y, 5 + Math.random() * 10, {
          restitution: 0.6,
          friction: 0.1,
          render: {
            fillStyle: `hsl(${Math.random() * 60 + 10}, 100%, 60%)`,
          },
        })

        Matter.Body.setVelocity(particle, {
          x: Math.cos(angle) * speed,
          y: Math.sin(angle) * speed,
        })

        return particle
      })

      Matter.Composite.add(engine.world, particles)

      // Remover después de 3 segundos
      setTimeout(() => {
        particles.forEach((p) => Matter.Composite.remove(engine.world, p))
      }, 3000)
    }

    render.canvas.addEventListener('click', handleClick)

    Matter.Runner.run(Matter.Runner.create(), engine)
    Matter.Render.run(render)

    return () => {
      render.canvas.removeEventListener('click', handleClick)
    }
  }, [])

  return <div id="physics-container" />
}

Soft Body (Blob)

function createSoftBody(x: number, y: number, columns: number, rows: number, spacing: number) {
  const particles: Matter.Body[] = []
  const constraints: Matter.Constraint[] = []

  // Crear partículas en grid
  for (let row = 0; row < rows; row++) {
    for (let col = 0; col < columns; col++) {
      const particle = Matter.Bodies.circle(
        x + col * spacing,
        y + row * spacing,
        spacing * 0.3,
        {
          render: { fillStyle: '#4ecdc4' },
        }
      )
      particles.push(particle)
    }
  }

  // Conectar partículas adyacentes
  for (let row = 0; row < rows; row++) {
    for (let col = 0; col < columns; col++) {
      const index = row * columns + col

      // Horizontal
      if (col < columns - 1) {
        constraints.push(
          Matter.Constraint.create({
            bodyA: particles[index],
            bodyB: particles[index + 1],
            stiffness: 0.1,
            render: { visible: false },
          })
        )
      }

      // Vertical
      if (row < rows - 1) {
        constraints.push(
          Matter.Constraint.create({
            bodyA: particles[index],
            bodyB: particles[index + columns],
            stiffness: 0.1,
            render: { visible: false },
          })
        )
      }

      // Diagonal
      if (col < columns - 1 && row < rows - 1) {
        constraints.push(
          Matter.Constraint.create({
            bodyA: particles[index],
            bodyB: particles[index + columns + 1],
            stiffness: 0.05,
            render: { visible: false },
          })
        )
      }
    }
  }

  return { particles, constraints }
}

Newton's Cradle

function createNewtonsCradle(x: number, y: number, ballCount: number, ballRadius: number) {
  const balls: Matter.Body[] = []
  const constraints: Matter.Constraint[] = []

  for (let i = 0; i < ballCount; i++) {
    const ball = Matter.Bodies.circle(
      x + i * ballRadius * 2.1,
      y + 200,
      ballRadius,
      {
        restitution: 1,
        friction: 0,
        frictionAir: 0,
        render: { fillStyle: '#c0c0c0' },
      }
    )

    const constraint = Matter.Constraint.create({
      pointA: { x: x + i * ballRadius * 2.1, y },
      bodyB: ball,
      length: 200,
      stiffness: 1,
      render: { strokeStyle: '#666' },
    })

    balls.push(ball)
    constraints.push(constraint)
  }

  // Impulso inicial al primer ball
  Matter.Body.setPosition(balls[0], {
    x: balls[0].position.x - 100,
    y: balls[0].position.y - 50,
  })

  return { balls, constraints }
}

Recursos