Splatchemy !

Combinez des objets du quotidien pour créer un environnement confortable et rempli de plantes dans ce jeu WebXR interactif conçu avec Niantic Studio.


Personnalisez-le grâce à ces exemples de projets

thumbnail: detect object sample project

Studio : Détecter le type d'objet en tapant checkmark bullet

Cet exemple de projet illustre la possibilité d'étiqueter un objet et d'y accéder d'un simple clic. Cela permet de regrouper et d'étiqueter différents objets.

Voir un exemple de projet
splatchemysampletwo

Studio : Fusionner les objets checkmark bullet

Ce projet présente la fonctionnalité principale de fusion, qui permet aux joueurs d'instancier différents objets lors d'une collision.

Voir un exemple de projet

Behind the Build: Splatchemy!

Written by Jessica Sheng

March 7, 2025


Introduction

Cette expérience est un jeu Web3D créé à l'aide de Niantic Studio Beta, en novembre 2024.

Splatchemy ! est un jeu qui se déroule dans un environnement intérieur où les joueurs combinent des objets du quotidien pour en créer de nouveaux et transformer une chambre d'étudiant ordinaire en un espace magique. Splatchemy ! permet aux joueurs d'expérimenter différentes combinaisons d'objets pour faire pousser des plantes, créer des guirlandes lumineuses et bien plus encore. Au fur et à mesure que les joueurs progressent, ils peuvent débloquer de nouveaux objets à placer dans l'environnement et donner vie à la pièce.

Le jeu consiste à découvrir des combinaisons créatives et à les appliquer à la scène pour donner vie à la pièce. Il y a au total 9 objets qui peuvent être appliqués à la pièce. Votre objectif est de trouver les combinaisons permettant de créer les 9 objets et de transformer complètement la chambre d'étudiant austère en un espace vert confortable.

Utilisez les boutons pour faire apparaître les objets de base.

Project Structure

    S
    cène 3D
  • Entités de base : comprend la caméra AR en perspective et les lumières ambiantes/directionnelles
  • Entités interactives : objets avec lesquels les joueurs peuvent interagir ou qu'ils peuvent combiner (par exemple, eau, tasse, terre).
  • Entités environnementales : objets qui apparaissent dans l'environnement à la suite d'une fusion (par exemple, vignes, plantes en pot, guirlandes lumineuses).
  • Entités UI : comprend tous les éléments de l'interface utilisateur affichés à l'écran, fournissant des informations et des commentaires au joueur. Un flux d'intégration s'affiche au démarrage.

Ressources

Comprend tous les modèles 3D, fichiers audio et images utilisés dans le jeu.
Les ressources sont réparties dans deux dossiers pour plus d'organisation. 

  • Environnement : contient toutes les ressources utilisées pour décorer l'environnement.
  • Interactable : contient tous les éléments qui apparaissent à l'aide de boutons ou en fusionnant d'autres éléments.
Scripts

Ce jeu ne contient que quelques scripts, composés uniquement de composants.

  • Composants : contient la logique principale du jeu. Cela comprend les écrans de l'interface utilisateur, les mouvements des joueurs, la logique de génération des pièces sur le sol gelé, le comportement des champignons et la logique de suivi, ainsi que le gestionnaire de jeu, qui gère les événements pendant la session de jeu.
  • MergeableComponent : définit les objets qui peuvent être combinés avec d'autres.
  • MergeHandler : gère la logique de fusion de deux objets et de génération de nouveaux objets.
  • UIComponent : gère les interactions de l'interface utilisateur pour la génération de primitives et l'affichage de messages.
  • Click Detection : détecte quand un joueur clique ou tape sur un objet dans la scène. Ce composant détermine ce qui doit être généré ou détruit lorsque l'utilisateur interagit avec un objet.

Implementation

Objets interactifs
Il s'agit des objets primitifs avec lesquels les joueurs peuvent interagir ou qu'ils peuvent combiner :
Eau
Tasse
Terre
Fil
Objets dérivés tels que :
Arrosoir
Pot de fleur
Plante en pot
Plantes en panier
Vignes
Lampe
Lampes suspendues
Brique
Bois de chauffage
Guirlande lumineuse

Les objets sont définis à l'aide du MergeableComponent, qui suit leur état actuel (niveau) pour la logique de combinaison. 

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

      

Ensuite, MergeHandler gère toute la logique qui détermine quels niveaux peuvent être fusionnés.

 

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
     }


      

Cette partie gère les collisions et la vérification des niveaux. Si le niveau de l'objet actuel et celui de l'objet avec lequel il est entré en collision correspondent aux niveaux spécifiés, un nouvel objet peut alors être ajouté à la scène.

         
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
   }

      

Cette partie du script gère la génération d'entités en clonant les données d'un composant existant qui est masqué.

Ces deux sections du script sont ensuite exécutées chaque fois que deux objets avec MergeableComponents entrent en collision.

 

Objets environnementaux

Ils apparaissent dans la scène lorsque des combinaisons valides sont effectuées :
Vignes
Plantes en pot
Guirlandes lumineuses
Cheminée
Lampes
Suspensions lumineuses
Plantes en suspension
Cage à oiseaux
Boutons de fleurs

Les objets d'environnement sont gérés à l'aide de ClickDetection, qui détecte l'objet de niveau sur lequel l'utilisateur a cliqué et redimensionne l'objet d'environnement correspondant si l'objet de niveau correct a été sélectionné.

         
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},
   }

      

Le schéma de ce composant spécifie les objets à mettre à l'échelle dans l'environnement. animationConfigs configure l'animation de mise à l'échelle pour les champs du schéma. Pour chaque niveau associé à un changement d'environnement, il est mappé à un champ de schéma et à une durée d'animation.

         
// 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, }) } }

L'application de l'animation donne le résultat suivant :

  1. Suppression de l'objet cliqué
  2. Cible le groupe correspondant spécifié dans le schéma
  3. Anime l'échelle du groupe
  4. Ajoute au score
         
// 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()
         }
       }
     }


      

Les clics et les tapotements envoient un raycast et vérifient s'il intersecte un objet avec un MergeableComponent. Il existe également une logique permettant de détecter si c'est la première fois que l'utilisateur clique sur un objet filaire afin d'indiquer à l'interface utilisateur d'intégration de continuer. En dehors de cela, les animations à supprimer et à redimensionner dans les objets cibles sont appliquées.

 

interface utilisateur

Chaque élément de l'interface utilisateur affiché est contrôlé par le script UIController. Tout est injecté en HTML/CSS dans la fenêtre via Javascript, et utilise des écouteurs d'événements pour déterminer quelle partie de l'interface utilisateur doit être affichée ensuite.

         
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)
}

      

Crée un message d'erreur indiquant que rien ne s'est généré avec une simple animation de fondu lorsque l'utilisateur clique sur un objet dont le niveau n'est associé à aucun changement d'environnement.

         
// 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
}

      

Crée un affichage du score qui est ensuite exporté et utilisé dans 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)

Le contrôleur UI implémente l'affichage du score construit par UIController

 

Les messages du didacticiel sont créés de manière similaire, avec du CSS injecté dans la fenêtre.

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

      

Le premier message qui invite l'utilisateur à cliquer sur l'objet fil s'affiche lors de l'entrée. 

         
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

      

Ensuite, dans le code qui définit les boutons, un écouteur d'événement est ajouté pour vérifier si l'utilisateur a cliqué sur le bouton filaire.

         
   // 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 est un événement diffusé à partir du script ClickDetection qui vérifie si l'utilisateur a appliqué le fil pour la première fois. Une fois que l'utilisateur a terminé, le dernier message d'intégration s'affiche enfin.

 

Processus

Obstacles + Solutions
Absence d'un système de balisage

Défi : 
Contrairement à Unity, Niantic Studio ne dispose pas d'un système de balisage intégré permettant de classer les entités ou de les rechercher par balise. Il s'agissait d'une limitation, car la logique du jeu nécessitait d'identifier les objets en fonction de leur classification afin de permettre des combinaisons spécifiques.

Solution :
Un composant personnalisé, MergeableComponet, a été créé pour classer les objets en leur attribuant une propriété de niveau (ou des attributs similaires) afin de définir leur type ou leur état.

Pour accéder à ces propriétés, un script distinct a été implémenté afin de lire les champs de ce composant. Cependant, l'accès à des composants personnalisés provoquait parfois des erreurs d'exécution telles que « Impossible de lire les propriétés de undefined (lecture de « has ») ».

Pour résoudre ce problème :
Ajout de vérifications conditionnelles (if (MergeableComponent.has(world, entity))) avant d'accéder au composant.

Utilisation d'un chaînage optionnel (MergeableComponent?.get(world, entity)?.level) pour accéder en toute sécurité aux propriétés sans provoquer d'erreurs d'exécution.

Instanciation d'une entité avec les composants et propriétés appropriés.

Défi : 
Niantic Studio ne dispose pas d'un système de préfabrication comme Unity, ce qui signifie que chaque entité doit être instanciée manuellement avec ses composants et ses propriétés. Cela rendait difficile le clonage d'objets avec des composants personnalisés tels que MergeableComponet.

Solution :
Bien que le projet exemple Niantic Studio, Studio: World Effects, fournisse un tableau componentsForClone pour cloner des composants, cela ne fonctionne pas pour les composants personnalisés tels que Mergeable. La tentative d'inclusion de composants personnalisés dans ce tableau a généré des erreurs.

La solution consistait à cloner manuellement le MergeableComponent dans un bloc de code distinct :

         
  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})
 }
}

      

Cette approche garantissait que les entités dotées du composant MergeableComponet étaient traitées tout en évitant les erreurs d'exécution.

Détection de collisions spécifiques en fonction des propriétés des composants

Défi : 
La logique du jeu nécessitait de détecter les collisions entre deux objets et de déterminer si leurs propriétés de niveau correspondaient à des critères spécifiques (par exemple, mergeLevelA et mergeLevelB). 

Cependant :
Lorsque les deux objets étaient définis comme des corps rigides dynamiques, la simulation physique entraînait parfois une détection incohérente des collisions.


         
 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