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

Studio : Détecter le type d'objet en tapant
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
Studio : Fusionner les objets
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 projetBehind the Build: Splatchemy!
.jpg?width=56&height=56&name=pfp%20-%20Jessica%20Sheng%20(1).jpg)
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
- 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.
cène 3D
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 :
- Suppression de l'objet cliqué
- Cible le groupe correspondant spécifié dans le schéma
- Anime l'échelle du groupe
- 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