スプラケミー!

Niantic Studioで開発されたインタラクティブなWebXRゲームで、日常のアイテムを組み合わせて、温かく植物に囲まれた空間を創造しましょう。


これらのサンプルプロジェクトを参考に、自分好みにカスタマイズしてください。

thumbnail: detect object sample project

スタジオタップでオブジェクトタイプを検出 checkmark bullet

このサンプル・プロジェクトでは、オブジェクトにタグを付けてクリックでアクセスする機能を紹介しています。 これにより、異なるオブジェクトをグループ化し、ラベルを付けることができる。

サンプルプロジェクトを見る
splatchemysampletwo

スタジオマージ・オブジェクト checkmark bullet

このプロジェクトでは、プレイヤーが衝突時に異なるオブジェクトをインスタンス化できる、コアのマージ機能を紹介する。

サンプルプロジェクトを見る

Behind the Build: Splatchemy!

Written by Jessica Sheng

March 7, 2025


Introduction

この体験は、2024年11月にNiantic Studio Betaを使用して作成されたWeb3Dゲームです。 プレイヤーが日常のアイテムを組み合わせて新しいものを作り出し、平凡な学生寮の部屋を魔法のような空間に変える室内を舞台にしたゲームです。 スプラチェミー! プレイヤーは、異なるオブジェクトの組み合わせを実験して、植物を育てたり、フェアリーライトを作成したり、さらに多くのことを行うことができます。 プレイヤーが進行するにつれ、環境内に配置できる新しいオブジェクトをアンロックし、部屋に命を吹き込むことができます。

このゲームは、創造的な組み合わせを発見し、それらをシーンに適用して部屋に命を吹き込むことが中心となっています。

このゲームは、創造的な組み合わせを発見し、それらをシーンに適用して部屋に命を吹き込むことが中心となっています。 部屋に適用できるオブジェクトは合計で9つあります。

Project Structure

3Dシーン
  • ベースエンティティ: 視点ARカメラと環境/方向光を含む。
  • 環境エンティティ: 合成の結果として環境に生成されるオブジェクト(例: ツタ、鉢植えの植物、フェアリーライト)。 これには、UI画面、プレイヤーの移動、凍結した床でのコインの生成ロジック、キノコの動作と追従ロジック、およびゲームセッション中のイベントを処理するゲームマネージャーが含まれます。
  • MergeableComponent: 他のオブジェクトと結合可能なオブジェクトを定義します。
  • MergeHandler: 2つのオブジェクトの結合ロジックと新しいオブジェクトの生成を処理します。
  • UIComponent: プリミティブの生成とメッセージの表示に関するUIインタラクションを管理します。
  • クリック検出: シーン内のオブジェクトにプレイヤーがクリックまたはタップした際に検出します。

アセット

ゲーム内で使用されるすべての3Dモデル、オーディオファイル、画像が含まれます。

Implementation

インタラクタブルオブジェクト
プレイヤーが操作したり組み合わせたりできる基本的なオブジェクトです:

カップ

ワイヤー
派生オブジェクトの例:
水やり缶
花鉢
鉢植えの植物
バスケット植物
ツタ
ランプ
吊り下げ照明
レンガ

フェアリーライト

オブジェクトはMergeableComponentを使用して定義され、組み合わせロジックのための現在の状態(レベル)を追跡します。

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

      

次に、MergeHandler が、どのレベルが結合可能かを判断するすべてのロジックを処理します。

 

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
     }


      

この部分は衝突処理とレベルチェックを担当します。 現在のオブジェクトと衝突したオブジェクトのレベルが指定されたレベルと一致する場合にのみ、シーンに新しいオブジェクトを追加できます。

         
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
   }

      

このスクリプトのこの部分は、既存の非表示コンポーネントからデータをクローンしてエンティティの生成を処理します。

 

環境オブジェクト

これらのオブジェクトは、有効な組み合わせが作成された際にシーンに生成されます:
つる植物
鉢植え植物
フェアリーライト
暖炉
ランプ
吊り下げライト
吊り下げバスケット植物
鳥かご
花芽

環境オブジェクトはClickDetectionを使用して処理されます

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

      

このコンポーネントのスキームは、環境にスケールするオブジェクトを指定します。 animationConfigs は、スキーマ内のフィールドのスケールアニメーションを設定します。 環境の変更と関連付けられた各レベルは、スキーマのフィールドとアニメーションの持続時間にマッピングされます。

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

アニメーションを適用すると、次のように表示されます:

  1. クリックされたオブジェクトを削除します
  2. スキーマで指定された対応するグループをターゲットにします
  3. グループのスケールをアニメーション化
  4. スコアに追加します
         
// 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()
         }
       }
     }


      

クリックとタップはレイキャストを送信し、そのレイキャストがMergeableComponentを持つオブジェクトと交差するかどうかを確認します。 ユーザーがワイヤーオブジェクトをクリックしたことが初めてかどうかを検出するロジックも存在し、これによりオンボーディングUIに処理を継続するよう指示します。 それ以外には、ターゲットオブジェクトに対して削除とスケーリングのアニメーションが適用されます。

 

ユーザーインターフェース

表示されている各UI要素は、UIControllerスクリプトによって制御されています。 すべてはJavaScriptを通じてウィンドウにHTML/CSSが埋め込まれており、イベントリスナーを使用して次に表示すべきUIのどの部分かを判断しています。

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

      

ユーザーが、環境変更にマッピングされていないレベルを持つオブジェクトをクリックした際に、何も生成されず、シンプルなフェードアニメーションが表示されるエラーメッセージを作成します。

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

      

スコア表示を作成し、その後エクスポートして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は、UIControllerによって構築されたスコア表示を実装します

 

チュートリアルメッセージは同様の方法で作成され、CSSがウィンドウに注入されます。

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

      

ユーザーにワイヤーオブジェクトをクリックするように指示する最初のメッセージは、入力時に表示されます。

         
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

      

次に、ボタンを設定するコード内で、ユーザーがワイヤーボタンをクリックしたかどうかを確認するためのイベントリスナーが追加されます。

         
   // 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 は、ユーザーがワイヤーを初めて適用したかどうかを検出する ClickDetection スクリプトから送信されるイベントです。 ユーザーが操作を完了すると、最後にオンボーディングメッセージが表示されます。

 

プロセス

障害物 + 解決策
タグ付けシステムの欠如

課題: 
Unityとは異なり、Niantic Studioにはエンティティを分類したり、タグで検索したりするための組み込みのタグ付けシステムがありません。 これは、ゲームロジックがオブジェクトを分類によって識別する必要があったため、特定のオブジェクトの組み合わせを可能にするための制限でした。

これらのプロパティにアクセスするため、このコンポーネントのフィールドを読み取るための別スクリプトを実装しました。.level) を使用して、実行時エラーを引き起こさずにプロパティに安全にアクセスできます。

適切なコンポーネントとプロパティを備えたエンティティのインスタンス化。

課題: 
Niantic StudioにはUnityのようなプリファブシステムがないため、すべてのエンティティはコンポーネントとプロパティをマニュアルで設定してインスタンス化する必要があります。

この問題を解決するため:
コンポーネントにアクセスする前に条件分岐を追加しました(if (MergeableComponent.has(world, entity))))。 )。

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

      

このアプローチにより、MergeableComponent を持ちながら実行時エラーを回避しつつ、該当するエンティティが処理されるようになりました。

         
 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