Splatchemy!
Kombinieren Sie Alltagsgegenstände, um in diesem interaktiven WebXR-Spiel, das mit Niantic Studio entwickelt wurde, eine gemütliche, pflanzenreiche Umgebung zu schaffen.
Gestalten Sie es ganz nach Ihren Wünschen mit diesen Beispielprojekten.

Studio: Objekttyp beim Antippen erkennen
Dieses Beispielprojekt zeigt die Möglichkeit, ein Objekt zu markieren und durch einen Klick darauf zuzugreifen. So können verschiedene Objekte gruppiert und beschriftet werden.
Beispielprojekt ansehen
Studio: Merge Objects
Dieses Projekt zeigt die Kernfunktionalität der Zusammenführung, die es Spielern ermöglicht, verschiedene Objekte bei einer Kollision zu instanziieren.
Beispielprojekt ansehenBehind the Build: Splatchemy!
.jpg?width=56&height=56&name=pfp%20-%20Jessica%20Sheng%20(1).jpg)
Written by Jessica Sheng
March 7, 2025
Introduction
Diese Erfahrung ist ein Web3D-Spiel, das im November 2024 mit Niantic Studio Beta erstellt wurde.
Splatchemy! ist ein Spiel, das in einer Innenumgebung spielt, in der die Spieler Alltagsgegenstände kombinieren, um neue zu erschaffen und so einen einfachen Studentenwohnheimzimmer in einen magischen Raum zu verwandeln. Splatchemy! ermöglicht es den Spielern, mit Kombinationen verschiedener Objekte zu experimentieren, um Pflanzen zu züchten, Lichterketten zu erstellen und vieles mehr. Im Laufe des Spiels können die Spieler neue Objekte freischalten, die sie in der Umgebung platzieren und so den Raum zum Leben erwecken können.
Das Spiel dreht sich darum, kreative Kombinationen zu entdecken und diese in der Szene anzuwenden, um den Raum zum Leben zu erwecken. Insgesamt können 9 Objekte im Raum platziert werden. Dein Ziel ist es, die Kombinationen zu finden, mit denen du alle 9 Objekte erstellen und das schlichte Studentenwohnzimmer in einen gemütlichen, grünen Raum verwandeln kannst.
Verwende die Schaltflächen, um die grundlegenden Objekte zu erstellen.
Project Structure
3D-Szenen-
- -Basisentitäten: Umfasst die perspektivische AR-Kamera und Umgebungs-/Richtungslichter
- Interagierbare Entitäten: Objekte, mit denen Spieler interagieren oder die sie kombinieren können (z. B. Wasser, Tasse, Erde).
- Umgebungsentitäten: Objekte, die durch Zusammenführen in der Umgebung entstehen (z. B. Ranken, Topfpflanzen, Lichterketten).
- UI-Entitäten: Umfasst alle auf dem Bildschirm angezeigten Elemente der Benutzeroberfläche, die dem Spieler Informationen und Feedback liefern. Zu Beginn wird ein Onboarding-Ablauf angezeigt.
Assets
Enthält alle 3D-Modelle, Audiodateien und Bilder, die im gesamten Spiel verwendet werden.
Die Assets sind zur besseren Organisation in zwei Ordner unterteilt.
- Umgebung: Enthält alle Assets, die zur Gestaltung der Umgebung verwendet werden.
- Interactable: Enthält alle Assets, die über Schaltflächen oder durch Zusammenführen anderer Assets erzeugt werden.
Skripte
Dieses Spiel enthält nur wenige Skripte, die ausschließlich aus Komponenten bestehen.
- Komponenten: Enthält die Hauptlogik des Spiels. Dies umfasst die Benutzeroberfläche, die Spielerbewegungen, die Logik für das Erscheinen von Münzen auf dem gefrorenen Boden, das Verhalten der Pilze und die Follow-Logik sowie den Spielmanager, der Ereignisse während der Spielsitzung verarbeitet.
- MergeableComponent: Definiert Objekte, die mit anderen kombiniert werden können.
- MergeHandler: Verarbeitet die Logik zum Zusammenführen zweier Objekte und zum Erzeugen neuer Objekte.
- UIComponent: Verwaltet UI-Interaktionen zum Erzeugen von Primitiven und zum Anzeigen von Meldungen.
- Click Detection: Erkennt, wenn ein Spieler auf ein Objekt in der Szene klickt oder tippt. Diese Komponente bestimmt, was erstellt oder zerstört wird, wenn der Benutzer mit einem Objekt interagiert.
Implementation
Interagierbare Objekte
Dies sind die primitiven Objekte, mit denen Spieler interagieren oder die sie kombinieren können:
Wasser
Becher
Erde
Draht
Abgeleitete Objekte wie:
Gießkanne
Blumentopf
Topfpflanze
Korbpflanzen
Ranken
Lampe
Hängelampen
Ziegelstein
Brennholz
Lichterkette
Objekte werden mit MergeableComponent definiert, das ihren aktuellen Status (Level) für die Kombinationslogik verfolgt.
const MergeableComponent = ecs.registerComponent({
name: 'Mergeable',
schema: {
level: ecs.string,
// Add data that can be configured on the component.
},
Anschließend übernimmt MergeHandler die gesamte Logik, die dahinter steckt, welche Ebenen miteinander zusammengeführt werden können.
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
}
Dieser Teil befasst sich mit Kollisionen und der Überprüfung der Ebenen. Nur wenn die Ebene des aktuellen Objekts und die des kollidierten Objekts mit den angegebenen Ebenen übereinstimmen, kann ein neues Objekt zur Szene hinzugefügt werden.
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
}
Dieser Teil des Skripts übernimmt das Erzeugen von Entitäten, indem die Daten aus einer vorhandenen, ausgeblendeten Komponente geklont werden.
Beide Abschnitte des Skripts werden dann jedes Mal ausgeführt, wenn zwei Objekte mit MergeableComponents miteinander kollidieren.
Umgebungsobjekte
Diese werden in die Szene eingeblendet, wenn gültige Kombinationen gebildet werden:
Ranken
Topfpflanzen
Lichterketten
Kamin
Lampen
Hängelampen
Hängekörbe mit Pflanzen
Vogelkäfig
Blütenknospen
Umgebungsobjekte werden mit ClickDetection verarbeitet, das verfolgt, welches Level-Objekt angeklickt wurde, und das entsprechende Umgebungsobjekt skaliert, wenn das richtige Level-Objekt angeklickt wurde.
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},
}
Das Schema dieser Komponente legt fest, welche Objekte in die Umgebung skaliert werden sollen. animationConfigs konfiguriert die Skalierungsanimation für die Felder im Schema. Jede Stufe, die mit einer Umgebungsänderung verbunden ist, wird einem Schemafeld und einer Animationsdauer zugeordnet.
// 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, }) } }
Die Anwendung der Animation führt zu folgendem Ergebnis:
- Löschen des angeklickten Objekts
- Wendet die Animation auf das Objekt an, das angeklickt wurde
- Animiert die Skalierung der Gruppe
- Zur Punktzahl hinzufügen
// 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()
}
}
}
Klicks und Taps senden einen Raycast aus und prüfen, ob dieser ein Objekt mit einer MergeableComponent schneidet. Es gibt auch eine Logik, um zu erkennen, ob der Benutzer zum ersten Mal auf ein Drahtobjekt geklickt hat, damit die Onboarding-Benutzeroberfläche fortfahren kann. Ansonsten werden die Animationen zum Löschen und Skalieren in den Zielobjekten angewendet.
Benutzeroberfläche
Jedes angezeigte UI-Element wird vom UIController -Skript gesteuert. Alles wird über JavaScript als HTML/CSS in das Fenster eingefügt und mithilfe von Ereignis-Listenern wird bestimmt, welcher Teil der Benutzeroberfläche als Nächstes angezeigt werden soll.
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)
}
Erstellt eine Fehlermeldung, die besagt, dass nichts mit einer einfachen Überblendungsanimation erzeugt wurde, wenn der Benutzer auf ein Objekt klickt, dessen Ebene keiner Umgebungsänderung zugeordnet ist.
// 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
}
Erstellt eine Punktanzeige, die anschließend exportiert und in ClickDetection.ts verwendet wird.
// 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)
Der UI-Controller implementiert die von UIController erstellte Punktanzeige.
Tutorial-Meldungen werden auf ähnliche Weise erstellt, wobei CSS in das Fenster eingefügt wird.
// Initial tutorial message
showMessage('Click on the wire button to spawn a wire object')
Die erste Meldung, die den Benutzer auffordert, auf das Drahtobjekt zu klicken, wird bei der Eingabe angezeigt.
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
Anschließend wird innerhalb des Codes, der die Schaltflächen festlegt, ein Ereignis-Listener hinzugefügt, um zu überprüfen, ob der Benutzer auf die Schaltfläche "Draht" geklickt hat.
// 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 ist ein Ereignis, das vom ClickDetection-Skript übertragen wird, das überprüft, ob der Benutzer den Draht zum ersten Mal angewendet hat. Sobald der Benutzer dies getan hat, wird schließlich die letzte Onboarding-Meldung angezeigt.
Prozess
Hindernisse + Lösungen
Fehlen eines Tagging-Systems
Herausforderung:
Im Gegensatz zu Unity verfügt Niantic Studio nicht über ein integriertes Tagging-System, um Entitäten zu kategorisieren oder anhand von Tags zu finden. Dies war eine Einschränkung, da die Spielelogik die Identifizierung von Objekten anhand ihrer Klassifizierungen erforderte, um bestimmte Objektkombinationen zu ermöglichen.
Lösung:
Es wurde eine benutzerdefinierte Komponente, MergeableComponet, erstellt, um Objekte zu klassifizieren, indem ihnen eine Level-Eigenschaft (oder ähnliche Attribute) zugewiesen wurde, um ihren Typ oder Zustand zu definieren.
Um auf diese Eigenschaften zugreifen zu können, wurde ein separates Skript implementiert, das die Felder dieser Komponente liest. Der Zugriff auf benutzerdefinierte Komponenten führte jedoch manchmal zu Laufzeitfehlern wie "Cannot read properties of undefined (reading 'has')".
Um dieses Problem zu beheben, wurde Folgendes hinzugefügt:
Vor dem Zugriff auf die Komponente wurden bedingte Überprüfungen (if (MergeableComponent.has(world, entity))) hinzugefügt.
Es wurde optionale Verkettung verwendet (MergeableComponent?.get(world, entity)?.level), um sicher auf Eigenschaften zugreifen zu können, ohne Laufzeitfehler zu verursachen.
Instanziieren einer Entität mit den richtigen Komponenten und Eigenschaften.
Herausforderung:
Niantic Studio verfügt nicht über ein Prefab-System wie Unity, was bedeutet, dass jede Entität manuell mit ihren Komponenten und Eigenschaften instanziiert werden muss. Dies erschwerte das Klonen von Objekten mit benutzerdefinierten Komponenten wie MergeableComponet.
Lösung:
Das Niantic Studio-Beispielprojekt "Studio: World Effects" enthält zwar ein componentsForClone-Array zum Klonen von Komponenten, dieses funktioniert jedoch nicht für benutzerdefinierte Komponenten wie Mergeable. Der Versuch, benutzerdefinierte Komponenten in dieses Array aufzunehmen, führte zu Fehlern.
Die Problemumgehung bestand darin, die MergeableComponent manuell in einem separaten Codeblock zu klonen:
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})
}
}
Dieser Ansatz stellte sicher, dass Entitäten mit der MergeableComponet verarbeitet wurden, ohne dass Laufzeitfehler auftraten.
Erkennen spezifischer Kollisionen anhand von Komponenteneigenschaften
Herausforderung:
Die Spielelogik erforderte das Erkennen von Kollisionen zwischen zwei Objekten und das Feststellen, ob ihre Level-Eigenschaften bestimmten Kriterien entsprachen (z. B. mergeLevelA und mergeLevelB).
Allerdings:
Wenn beide Objekte als dynamische Rigidbodies festgelegt waren, führte die Physiksimulation manchmal dazu, dass Kollisionen inkonsistent erkannt wurden.
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