¡Splatchemy!

Combina objetos cotidianos para crear un entorno acogedor y lleno de plantas en este juego WebXR interactivo creado con Niantic Studio.


Hazlo tuyo con estos proyectos de muestra

thumbnail: detect object sample project

Estudio: Detectar tipo de objeto al toque checkmark bullet

Este proyecto de ejemplo muestra la posibilidad de etiquetar un objeto y acceder a él mediante un clic. Esto permite agrupar y etiquetar diferentes objetos.

Ver ejemplo de proyecto
splatchemysampletwo

Estudio: Fusionar objetos checkmark bullet

Este proyecto muestra la funcionalidad básica de fusión, que permite a los jugadores instanciar diferentes objetos en una colisión.

Ver ejemplo de proyecto

Behind the Build: Splatchemy!

Written by Jessica Sheng

March 7, 2025


Introduction

Esta experiencia es un juego Web3D creado con Niantic Studio Beta, en noviembre de 2024.

¡Splatchemy! es un juego de interior en el que los jugadores combinan objetos cotidianos para crear otros nuevos y transformar un dormitorio cualquiera en un espacio mágico. ¡Splatchemy! permite a los jugadores experimentar con combinaciones de distintos objetos para cultivar plantas, crear luces de hadas y mucho más. A medida que los jugadores progresan, pueden desbloquear nuevos objetos para colocar en el entorno y dar vida a la habitación.

El juego gira en torno a descubrir combinaciones creativas y aplicarlas a la escena para dar vida a la habitación. Hay un total de 9 objetos que se pueden aplicar a la habitación. Tu objetivo es encontrar las combinaciones para crear los 9 objetos y transformar por completo el sencillo dormitorio en un acogedor espacio verde.

Utiliza los botones para crear los objetos básicos y, al chocar, se fusionarán en otros nuevos.

Project Structure

Escena
3D
  • Entidades Base: Incluye la cámara AR de Perspectiva y las Luces de Ambiente/Direccionales
  • Entidades Interactuables: Objetos con los que los jugadores pueden interactuar o combinar (por ejemplo, agua, taza, tierra).
  • Entidades del entorno: Objetos que aparecen en el entorno como resultado de una fusión (por ejemplo, enredaderas, macetas, luces de colores).
  • Entidades de interfaz de usuario: Comprende todos los elementos de la interfaz de usuario que aparecen en la pantalla y que proporcionan información al jugador. Al principio se muestra un flujo de incorporación.

Activos

Incluye todos los modelos 3D, archivos de audio e imágenes utilizados a lo largo del juego.
Los activos se dividen en dos carpetas para su organización.

 

  • Entorno: Contiene todos los activos utilizados para decorar el entorno.
  • Interactable: Contiene todos los activos que se generan a través de botones o fusionando otros.
Scripts

Sólo hay unos pocos scripts en este juego, compuestos únicamente por componentes.

  • Componentes: Contiene la lógica principal del juego. Esto incluye las pantallas de interfaz de usuario, el movimiento del jugador, la lógica de generación de monedas en el suelo congelado, los comportamientos de los hongos y la lógica de seguimiento, y el gestor de juego, que maneja los eventos durante la sesión de juego.
  • MergeableComponent: Define objetos que pueden combinarse con otros.
  • MergeHandler: Maneja la lógica para combinar dos objetos y generar nuevos.
  • UIComponent: Gestiona las interacciones de la interfaz de usuario para generar primitivas y mostrar mensajes.
  • Detección de clics: Detecta cuando un jugador hace clic o toca un objeto en la escena. Este componente determina qué generar o destruir cuando el usuario interactúa con un objeto.

Implementation

Objetos interactuables
Estos son los objetos primitivos con los que los jugadores pueden interactuar o combinar:
Agua
Taza
Tierra
Alambre
Objetos derivados como:
Regadera
Maceta
Planta en maceta
Plantas en cesta
Enredaderas
Lámpara
Luces colgantes
Ladrillo
Leña
Luces de hadas

Los objetos se definen utilizando el MergeableComponent, que rastrea su estado actual (nivel) para la lógica de combinación. 

         
const MergeableComponent = ecs.registerComponent({
 name: 'Mergeable',
 schema: {
   level: ecs.string,
   // Add data that can be configured on the component.
 },

      

Luego, MergeHandler maneja toda la lógica detrás de qué niveles pueden ser fusionados.

 

MergeHandler.js

         
if (levelA === mergeLevelA && levelB === mergeLevelB) {
       console.log('Merging objects with levels:', levelA, 'and', levelB)
       // Spawn a new object if levels match
       if (MergeableComponent.get(world, entityB)?.level === mergeWith1) {
         spawnObject(component.schema.entityToSpawn1, spawnX, spawnY1, spawnZ)
         if (MergeHandler.has(world, component.schema.entityToSpawn1)) {
           const properties = MergeHandler.get(world, component.schema.entityToSpawn1)
           MergeHandler.set(world, component.newSpawnedEntity, {...properties})
           console.log('set Merge handler')
         }
       }
       world.deleteEntity(component.eid)  // Removes the current entity
       world.deleteEntity(entityB)        // Removes the other collided entity
     }


      

Esta parte se encarga de la comprobación de colisiones y niveles. Si el nivel del objeto actual y el del objeto colisionado coinciden con los niveles especificados, sólo entonces podrá añadirse un nuevo objeto a la escena.

         
const spawnObject = (sourceEntityId, x, y, z) => {
     if (!sourceEntityId) {
       console.warn('No source entity ID provided for spawning')
       return
     }


     const newEid = world.createEntity()
     cloneComponents(sourceEntityId, newEid, world)


     const {spawnedScale} = component.schema
     const spawnPosition = vec3.xyz(x, y, z)


     Position.set(world, newEid, spawnPosition)
     Scale.set(world, newEid, vec3.xyz(0.1 * spawnedScale))


     ScaleAnimation.set(world, newEid, {
       fromX: 0,
       fromY: 0,
       fromZ: 0,
       toX: spawnedScale,
       toY: spawnedScale,
       toZ: spawnedScale,
       duration: 500,
       loop: false,
       easeIn: true,
       easeOut: true,
     })


     console.log('Object spawned with new entity ID:', newEid)
     component.newSpawnedEntity = newEid
   }

      

Esta parte de la secuencia de comandos gestiona la creación de entidades clonando los datos de un componente existente que está oculto.

Ambas secciones de la secuencia de comandos se ejecutan cada vez que dos objetos con MergeableComponents colisionan entre sí.

 

Objetos del entorno

Estos se generan en la escena cuando se hacen combinaciones válidas:
Enredaderas
Plantas en macetas
Luces de hadas
Chimenea
Lámparas
Luces colgantes
Plantas en cestas colgantes
Jaula de pájaros
Capullos de flores

Los objetos de entorno se manejan utilizando ClickDetection, que rastrea qué objeto de nivel se ha pulsado, y escala el objeto de entorno correspondiente si se ha pulsado el objeto de nivel correcto.

         
name: 'ClickDetection',
 schema: {
   flowerBuds: ecs.eid,
   pottedPlants: ecs.eid,
   vines: ecs.eid,
   hangingPlants: ecs.eid,
   lamps: ecs.eid,
   stringLights: ecs.eid,
   hangingLights: ecs.eid,
   fireplace: ecs.eid,
   birdCage: ecs.eid,
 },
 stateMachine: ({world, eid, schemaAttribute}) => {
   const raycaster = new THREE.Raycaster()
   const mouse = new THREE.Vector2()
   let lastInteractionTime = 0
   // Add state tracking for first wire click
   let hasClickedWireFirst = false


   // Define animation configurations
   const animationConfigs = {
     can: {target: 'flowerBuds', duration: 500},
     pottedPlant: {target: 'pottedPlants', duration: 500},
     vine: {target: 'vines', duration: 1000},
     pottedVine: {target: 'hangingPlants', duration: 500},
     lamp: {target: 'lamps', duration: 500},
     stringLights: {target: 'stringLights', duration: 1000},
     lightBulb: {target: 'hangingLights', duration: 500},
     firewood: {target: 'fireplace', duration: 500},
     wire: {target: 'birdCage', duration: 500},
   }

      

El esquema de este componente especifica qué objetos escalar en el entorno. animationConfigs configura la animación de escala para los campos del esquema. Para cada nivel asociado a un cambio de entorno, se asigna un campo de esquema y una duración de animación.

         
// Generic function to handle all animations
   const applyAnimation = (entityId, config) => {
     deleteAnimation(entityId)


     const targetGroup = schemaAttribute.get(eid)[config.target]


     // Check if this group has already been counted
     if (!scaledObjects.has(config.target)) {
       // Convert generator to array and check children scales
       const children = Array.from(world.getChildren(targetGroup))
       const hasScaledChildren = children.some((childEid) => {
         const scale = Scale.get(world, childEid)
         return scale.x !== 0 || scale.y !== 0 || scale.z !== 0
       })


       // If no children are scaled yet, increment score
       if (!hasScaledChildren) {
         scaledObjects.add(config.target)
         score++
         scoreDisplay.innerHTML = `
Found objects: ${score}/9
` } } // Animate child entities for (const childEid of world.getChildren(targetGroup)) { const scale = config.target === 'flowerBuds' ? Math.random() : 1 ScaleAnimation.set(world, childEid, { fromX: 0, fromY: 0, fromZ: 0, toX: scale, toY: scale, toZ: scale, duration: config.duration, loop: false, easeIn: true, easeOut: true, }) } }

Aplicar los resultados de la animación:

  1. Elimina el objeto sobre el que se ha hecho clic
  2. Se dirige al grupo correspondiente especificado en el esquema
  3. Animar la escala del grupo
  4. Añade a la puntuación
         
// Check for intersections
     for (const entityId of mergeableEntities) {
       const tapObject = world.three.entityToObject.get(entityId)
       if (!tapObject) {
         console.error('Tappable object not found for entity ID:', entityId)
       }


       const intersects = raycaster.intersectObject(tapObject, true)
       if (intersects.length > 0) {
         touchPoint.setFrom(intersects[0].point)
         console.log('Tapped on object with MergeableComponent:', tapObject.eid)


         const applyObj = MergeableComponent.get(world, entityId)
         const config = animationConfigs[applyObj?.level]


         if (config) {
           // Check if this is the first wire click
           if (applyObj?.level === 'wire' && !hasClickedWireFirst) {
             hasClickedWireFirst = true
             // Emit custom event for first wire click
             const newEvent = new CustomEvent('firstWireApplied', {
               detail: {
                 entityId,
                 target: config.target,
               },
             })
             window.dispatchEvent(newEvent)
           }
           applyAnimation(entityId, config)
           world.time.setTimeout(() => {
             checkCompletion()
           }, config.duration)
           break
         } else {
           deleteAnimation(entityId)
           showErrorText()
         }
       }
     }


      

Los clics y los golpecitos envían un raycast, y comprueban si interseca cualquier objeto con un MergeableComponent. También hay una lógica para detectar si es la primera vez que el usuario ha hecho clic en un objeto de alambre para decirle a la interfaz de usuario onboarding que continúe. Aparte de eso, se aplican las animaciones para eliminar y escalar en los objetos de destino.

 

INTERFAZ DE USUARIO

Cada elemento UI mostrado es controlado por el script UIController. Todo es HTML/CSS inyectado en la ventana a través de Javascript, y utiliza escuchadores de eventos para determinar qué parte de la interfaz de usuario debe mostrarse a continuación.

         
const showErrorText = () => {
 // Create error message element
 const errorMessage = document.createElement('div')
 errorMessage.textContent = 'Nothing happened...'
 errorMessage.style.cssText = `
       position: fixed;
       top: 50%;
       left: 50%;
       transform: translate(-50%, -50%);
       text-align: center;
       color: white;
       font-size: 24px;
       font-family: Arial, sans-serif;
       opacity: 0;
       transition: opacity 500ms ease;
       pointer-events: none;
       z-index: 1000;
     `
 document.body.appendChild(errorMessage)


 // Fade in
 requestAnimationFrame(() => {
   errorMessage.style.opacity = '1'
 })


 // Fade out and remove after delay
 setTimeout(() => {
   errorMessage.style.opacity = '0'
   setTimeout(() => {
     errorMessage.remove()
   }, 500)
 }, 2000)
}

      

Crea un mensaje de error diciendo que no se ha generado nada con una animación de desvanecimiento simple cuando el usuario hace clic en un objeto con un nivel no asignado a ningún cambio de entorno.

         
// Create score display
const createScoreDisplay = () => {
 const scoreDiv = document.createElement('div')
 scoreDiv.style.cssText = `
       position: fixed;
       bottom: 100px;
       left: 50%;
       transform: translateX(-50%);
       color: white;
       font-size: 20px;
       font-family: 'Helvetica Neue', Arial, sans-serif;
       text-align: center;
       pointer-events: none;
       z-index: 1000;
       text-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
       letter-spacing: 0.5px;
     `
 return scoreDiv
}

      

Crea la visualización de la puntuación que luego se exporta y se utiliza en ClickDetection.ts

         
  // Add score tracking
   let score = 0
   const scaledObjects = new Set()  // Track objects that have been scaled
   const scoreDisplay = createScoreDisplay()
   scoreDisplay.innerHTML = `
Found objects: ${score}/9
` document.body.appendChild(scoreDisplay)

UI Controller implementa la visualización de la puntuación construida por UIController

 

Los mensajes tutoriales se crean de forma similar, con CSS inyectado en la ventana.

         
 // Initial tutorial message
   showMessage('Click on the wire button to spawn a wire object')

      

Al entrar, aparece el primer mensaje que indica al usuario que haga clic en el objeto de alambre. 

         
button.addEventListener('click', () => {
       onClick()
       if (tutorialState === 'start' && label === 'Wire') {
         tutorialState = 'spawned'
         hasSpawnedObject = true
         showMessage('Tap on the spawned object to apply it to the scene')
       }
     })
     return button

      

A continuación, dentro del código que establece los botones, se añade un receptor de eventos para comprobar si el usuario ha hecho clic en el botón de alambre

         
   // Listen for first wire application
   window.addEventListener('firstWireApplied', () => {
     if (tutorialState === 'spawned' && !hasAppliedObject) {
       tutorialState = 'applied'
       hasAppliedObject = true
       showMessage('Try spawning in different objects to see if they will merge together and create new ones!', 5000)
     }
   })

      

FirstWireApplied es un evento que se emite desde el script ClickDetection que comprueba si el usuario ha aplicado el cable por primera vez. Una vez que el usuario lo ha hecho, muestra finalmente el último mensaje de onboarding.

 

Proceso

Bloqueos + Soluciones
Falta de un sistema de etiquetado

Desafío: 
A diferencia de Unity, Niantic Studio no tiene un sistema de etiquetado integrado para categorizar entidades o encontrarlas por etiqueta. Esto suponía una limitación, ya que la lógica del juego requería identificar los objetos por sus clasificaciones para permitir combinaciones de objetos específicas.

Solución:
Se creó un componente personalizado, MergeableComponet, para clasificar los objetos asignándoles una propiedad de nivel (o atributos similares) para definir su tipo o estado.

Para acceder a estas propiedades, se implementó un script independiente para leer los campos de este componente. Sin embargo, el acceso a los componentes personalizados a veces provocaba errores en tiempo de ejecución como No se pueden leer las propiedades de undefined (leyendo 'has').

Para solucionarlo:
Se han añadido comprobaciones condicionales (if (MergeableComponent.has(world, entity))) antes de acceder al componente.

Se ha utilizado el encadenamiento opcional (MergeableComponent?.get(world, entity)?.level) para acceder de forma segura a las propiedades sin provocar errores en tiempo de ejecución.

Instanciar una entidad con los componentes y propiedades adecuados.

Reto: 
Niantic Studio carece de un sistema de prefabricados como Unity, lo que significa que cada entidad debe instanciarse manualmente con sus componentes y propiedades. Esto dificultaba la clonación de objetos con componentes personalizados como MergeableComponet.

Solución:
Aunque el proyecto de ejemplo de Niantic Studio, Studio: World Effects, proporciona una matriz componentsForClone para clonar componentes, no funciona para componentes personalizados como Mergeable. Al intentar incluir componentes personalizados en esta matriz se producían errores.

La solución consistía en clonar manualmente el MergeableComponent en un bloque de código independiente:

         
  const cloneComponents = (sourceEid, targetEid, world) => {
 componentsForClone.forEach((component) => {
   if (component.has(world, sourceEid)) {
     const properties = component.get(world, sourceEid)
     component.set(world, targetEid, {...properties})
   }
 })


 if (MergeableComponent.has(world, sourceEid)) {
   const properties = MergeableComponent.get(world, sourceEid)
   console.log('Cloning component: MergeableComponent', properties)  // Debugging log
   MergeableComponent.set(world, targetEid, {...properties})
 }
}

      

Este enfoque garantizaba que se procesaran las entidades con el MergeableComponet y evitaba errores en tiempo de ejecución.

Detección de colisiones específicas basadas en las propiedades de los componentes

Reto: 
La lógica del juego requería detectar colisiones entre dos objetos y determinar si sus propiedades de nivel coincidían con criterios específicos (por ejemplo, mergeLevelA y mergeLevelB). 

Sin embargo:
Cuando ambos objetos estaban configurados como cuerpos rígidos dinámicos, la simulación física a veces provocaba que las colisiones se detectaran de forma inconsistente.


         
 if (MergeableComponent.get(world, entityB)?.level === mergeWith1) {
         handleCollision(entityB, thisLevel, mergeWith1)
       }

     const levelA = MergeableComponent.get(world, component.eid)?.level
     const levelB = MergeableComponent.get(world, entityB)?.level


     if (levelA === mergeLevelA && levelB === mergeLevelB) {
       // Spawn a new object if levels match
       if (MergeableComponent.get(world, entityB)?.level === mergeWith1) {
         spawnObject(component.schema.entityToSpawn1, spawnX, spawnY1, spawnZ)
         if (MergeHandler.has(world, component.schema.entityToSpawn1)) {
           const properties = MergeHandler.get(world, component.schema.entityToSpawn1)
           MergeHandler.set(world, component.newSpawnedEntity, {...properties})
         }
       }



   world.events.addListener(component.eid, ecs.physics.COLLISION_START_EVENT, ({data}) => {
     const {mergeWith1, mergeWith2, mergeWith3} = component.schema
     const {other: entityB} = data