寿司 ビーツ ドロップ

頭を動かして、リズムに合わせて落ちてくる寿司を食べよう! ポイントを獲得し、コンボを組み立て、特別な寿司を捕まえて報酬を手に入れよう!楽しいで夢中になれる体験!

sushibeatsdropcover

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

headtouchinput

ヘッド・タッチ入力 checkmark bullet

このプロジェクトは、頭の傾きコントロールとタッチスクリーンコントロールを利用してイベントをトリガーするゲームの入力システムを実証する。

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

オブジェクトのクローンと検出 checkmark bullet

既存のシーンオブジェクトを複数回クローンし、落下させる。 コライダーとイベントを使って、寿司が皿に触れたことを検知する。

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

Behind the Build: Sushi Beat Drop

Written by Saul Pena Gamero & Mackenzie Li

May 8, 2025


Summary

過去2ヶ月間、当社のチームは、伝統的なリズムゲームプレイと革新的な拡張現実(AR)要素を融合させたリズムゲームを開発してきました。 クラシックなゲームメカニクスとAR技術の融合は、当社ゲームの特徴となる唯一無二で没入感のある体験を生み出しています。 私たちの主な目標は、楽しく没入感のあるゲームを設計し、プレイヤーがリズムの挑戦を全く新しい形で楽しむことを促すことでした。 私たちのインスピレーションは、Niantic Studioのサンプルプロジェクトから得たもので、ARにおけるヘッドムーブメントのメカニクスを展示したものでした。 私たちは、このコンセプトがどれほどインタラクティブでエンターテインメント性が高いかに魅了され、これをゲームの中心的なメカニクスとして採用することを決定しました。 

このメカニクスの最適なゲームプレイスタイルを決定するため、私たちはアイデアを出し合い、Taiko no TatsujinやOsuのようなリズムゲームからインスピレーションを得ました。  

このメカニクスの最適なゲームプレイスタイルを決定するため、私たちはアイデアを出し合い、Taiko no TatsujinやOsuのようなリズムゲームからインスピレーションを得ました。 ジャンル愛好家として、私たちは単にタイミングとリズムだけでなく、頭部の動きを通じた物理的なインタラクションを組み込んだゲームを描いてきました。

Gameplay

概要 
当社のリズムゲームは、シンプルながら魅力的なコンセプトを軸にしています:画面の上部から音楽のリズムに合わせて寿司が落ちてきます。 プレイヤーの目標は、下の皿に落ちる瞬間に寿司をキャッチすることです。 画面の左右の 
側に2枚のプレートが配置されており、音楽のリズムに合わせて寿司のピースが各プレートに向かって落ちてきます。 寿司が皿と一致した瞬間、プレイヤーは「キャッチ」する動作を行い、正確なタイミングでポイントを獲得します。 主な操作方法は、ヘッドティルト(頭部の傾け)を使用したハンズフリーで没入感のある操作方法です。 寿司が左の皿に向かって落ちている場合、プレイヤーは左に頭を傾けてキャッチします。右の皿の場合も同様で、右に頭を傾けます。 右の皿の場合も同様で、右に頭を傾けます。 このコントロール方式は、拡張現実(AR)のメカニクスを活用して、物理的な操作感と楽しさを追加しています。 この代替案は、ゲームが楽しくプレイしやすく、多様な好みやプレイスタイルに対応できるように設計されています。 

リズムベースのゲームプレイと直感的でカスタマイズ可能なコントロールを組み合わせることで、幅広い層にアピールする楽しいインタラクティブな体験を提供します。

 

Project Structure

3Dシーン

当社のゲームシーンには、スクリプト、3Dアセット、顔追跡システム、カメラ、照明など、複数の重要なコンポーネントが含まれています。 これらのうち、カメラや照明などは比較的直感的に理解できるものもありますが、本セクションでは3Dアセットとフェイストラッカーに焦点を当てて説明します。 この機能により、ゲームは頭の傾きの方向とタイミングを判定し、ゲームプレイメカニクスとの正確なインタラクションを可能にします。 
3Dアセットには、2枚のプレートと寿司のピースが含まれており、いずれもリズムゲーム体験に不可欠な要素です。  
3Dアセットには、2枚のプレートと寿司のピースが含まれており、いずれもリズムゲーム体験に不可欠な要素です。 プレートは静的オブジェクトであり、それぞれに物理コライダーと静的リジッドボディコンポーネントが装備されています。 各プレートには、寿司が乗った際にスコアリング処理を扱うスクリプト「ScoreArea」が添付されています。 さらに、各寿司のピースには、その動作とゲームプレイへの統合を制御する「Sushi スクリプト」が関連付けられています。 

スクリプトの具体的な機能については後述しますが、これらのコンポーネントが総合的に、当リズムゲームのインタラクティブで没入感のある体験を構築しています。 

アセット
当ゲーム内のすべてのアセットは、手描きまたはカスタム作成されたもので、プロジェクトに注がれた芸術的な努力と細部へのこだわりが反映されています。 3DモデルはBlenderを使用して作成され、ゲームに独自の個性と独自性を加えています。 
アセットには、カバーページ、プレイヤーをガイドする説明用GIF、ガチャシステムのアニメーションなど、多様なビジュアルとインタラクティブな要素が含まれています。  

スクリプトの具体的な機能については後述しますが、これらのコンポーネントが総合的に、当リズムゲームのインタラクティブで没入感のある体験を構築しています。 状態マシンは、各状態ごとにUI要素、背景、イベントリスナーを動的に管理し、ゲーム全体を通じて滑らかな移行とユーザーインタラクションを保証します。

  • 次の2つの画像は、定義されている状態マシンから異なる状態の例を示しています。
  • 注目すべきポイントとして、他のイベントに移行する予定のイベント、および各状態がページから独自のHTML UIを作成し削除する方法があります。 
  •          
    ecs.registerComponent({
      name: 'gameManager',
      stateMachine: ({ world, eid }) => {
        let startButton = null
        let levelButtons = []
        let endButton = null
    
        // Function to clean up the start button
        const removeStartButton = () => {}
    
        // Function to clean up level selection buttons
        const removeLevelButtons = () => {}
    
        // Function to clean up the end button
        const removeEndButton = () => {}
    
        ecs.defineState('startGame')
          .initial()
          .onEvent('interact', 'levelSelection', { target: world.events.globalId })
          .onEnter(() => {
            if (activeGameManagerEid !== null && activeGameManagerEid !== eid) {
              return
            }
    
            activeGameManagerEid = eid
    
            // Create the background and image
            createBackground(world)
          })
      }
    })
    
          
             
    ecs.defineState('levelSelection')
      .onEvent('levelSelected2', 'inGame', { target: world.events.globalId })
      .onEvent('showTutorial', 'tutorial', { target: world.events.globalId })
      .onEnter(() => {
        const levels = [
          { label: 'Slow', event: 'gameStartedSlow' },
          { label: 'Mid', event: 'gameStartedMid' },
          { label: 'Fast', event: 'gameStartedFast' },
        ]
      })
    
          
     
    顔追跡: 
    • 顔追跡コンポーネントは、頭部の動きと画面のタッチに基づいてスコアとコンボの更新を管理し、イベントをトリガーし、UIを動的に更新します。 ヘッドの回転は、ティックごとにヘッドのZ軸の位置を追跡することで処理しています。 回転に応じて、異なるチェックを実施します。 また、チェックを再度行う前に、頭が中立位置に戻ることを期待しています。 これにより、ユーザーがゲーム中ずっと頭を横に向けたままにすることを防ぎます。
             
    tick: (world, component) => {
      const { touchTimerLeft, touchTimerRight } = component.data
      const rotation = ecs.quaternion.get(world, component.eid)
    
      if (component.data.touchTriggeredLeft && world.time.elapsed > touchTimerLeft) {
        component.data.touchTriggeredLeft = false
      }
    
      if (component.data.touchTriggeredRight && world.time.elapsed > touchTimerRight) {
        component.data.touchTriggeredRight = false
      }
    
      if (rotation) {
        const z = rotation.z
    
        // Handle right-side logic
        if (z > 0.20) {
          component.data.hitLeft = false
    
          if (!component.data.hitRight) {
            component.data.hitRight = true
            component.data.canHitRight = true
          }
    
          if (component.data.hitRight && component.data.canHitRight) {
            handleRightSide(world, component)
          }
        } else if (z < -0.20) {
          // Handle left-side logic
          component.data.hitRight = false
    
          if (!component.data.hitLeft) {
            component.data.hitLeft = true
            component.data.canHitLeft = true
          }
    
          if (component.data.hitLeft && component.data.canHitLeft) {
            handleLeftSide(world, component)
          }
        } else {
          // Reset state when head returns to neutral
          resetHeadState(component)
        }
      }
    }
    
          
     
    • また、プレイヤーが指で画面に触れているかどうかを監視しています。 ユーザーが画面の左側または右側をタップしたかどうかによって、グローバルイベントをトリガーしてイベントを発火します。 ユーザーが指を離す必要があるようにタイマーを実装します。 これは、ユーザーが中立的な状態に戻るために必要です。
             
    // Dispatch global events on touch
    world.events.addListener(world.events.globalId, ecs.input.SCREEN_TOUCH_START, (event) => {
      const touchX = event.data?.position?.x
      if (touchX < 0.5) {
        world.events.dispatch(eid, 'touchLeft')
      } else {
        world.events.dispatch(world.events.globalId, 'touchRight')
      }
    })
    
    // Listen for global touch events within this component
    world.events.addListener(eid, 'touchLeft', () => {
      const data = dataAttribute.cursor(eid)
      data.touchTriggeredLeft = true
      data.touchTimerLeft = world.time.elapsed + 1000
    })
    
    world.events.addListener(world.events.globalId, 'touchRight', () => {
      const data = dataAttribute.cursor(eid)
      data.touchTriggeredRight = true
      data.touchTimerRight = world.time.elapsed + 1000
    })
    
          
     
    ObjectSpawner:

    • ObjectSpawner コンポーネントは、事前に定義されたタイムスタンプで寿司オブジェクトの生成を処理し、異なる速度のオーディオトラックと同期します。 ランダムにスポーン位置と寿司の種類を決定し、異なるゲームモード用のイベントリスナーを設定し、オーディオ再生を管理して、グローバルな
      イベントによって駆動されるダイナミックなゲームプレイ体験を提供します。 以下のコードを使用して、それらを複製し、その後下に配置します。
    • 新しいエンティティ(ターゲットエンティティ)を作成し、ソースからすべてのコンポーネントをコピーします。
             
    function spawnSushi(world, objectToSpawn, objectToSpawnSuper, spawnY, spawnZ, timeStamps) {
      if (currentTimestampIndex >= timeStamps.length)
        return
    
      const sushiType = randomizeSushi()
      const newEid = world.createEntity()
      const spawnX = randomizeSpawnLocation()
    
      const clonedSuccessfully = sushiType === "regular"
        ? cloneComponents(objectToSpawn, newEid, world)
        : cloneComponents(objectToSpawnSuper, newEid, world)
    
      if (!clonedSuccessfully) {
        world.deleteEntity(newEid)
        return
      }
    }
    
    // Clone components from the source to the target entity
    const cloneComponents = (sourceEid, targetEid, world) => {
      const componentsToClone = [
        Position, Quaternion, Scale, Shadow, BoxGeometry, Material,
        ecs.PositionAnimation, ecs.RotateAnimation, ecs.GltfModel,
        ecs.Collider, ecs.Audio, Sushi
      ]
    
      let clonedAnyComponent = false
    
      componentsToClone.forEach((component) => {
        if (component && component.has(world, sourceEid)) {
          const properties = component.get(world, sourceEid)
          component.set(world, targetEid, { ...properties })
          clonedAnyComponent = true
        }
      })
    
      return clonedAnyComponent
    }
    
          
     
    • 私たちは、寿司をいつ生成するかを判断するためにタイムスタンプシステムを使用しています。 各曲には、次のように使用される独自の
      タイムスタンプ配列が用意されています:
             
    currentTimestampIndex++
    
    if (currentTimestampIndex < timeStamps.length) {
      const delay = (timeStamps[currentTimestampIndex] - timeStamps[currentTimestampIndex - 1]) * 1000
      setTimeout(() => spawnSushi(world, objectToSpawn, objectToSpawnSuper, spawnY, spawnZ, timeStamps), delay)
    }
    
          
     
    • 以下のタイムスタンプの例です:
             
    // Slow - One
    const timeStampsSlow = [
      0.22, 1.31, 2.5, 3.8, 5.08, 6.37, 7.66, 8.95, 10.23, 11.53, 12.76, 14.1, 15.39,
      16.68, 17.97, 19.26, 20.55, 21.85, 23.14, 24.43, 25.72, 27.01, 28.3, 29.59,
      30.88, 32.16, 33.45, 34.74, 36.03, 37.32, 38.61, 39.9, 41.19, 42.49, 43.79,
      45.07, 46.36, 47.65, 48.94, 50.23, 51.52, 52.79, 54.1, 55.39, 56.68, 57.97,
      59.26, 60.55, 61.85, 63.14, 64.43, 65.72, 67.01, 68.3, 69.59, 70.88, 72.17,
      73.66, 75.01, 76.18, 77.59, 78.7, 79.93, 81.19, 82.49, 83.78, 85.07, 86.36,
      87.65, 88.94, 90.23, 91.52, 92.81, 94.1, 95.39, 96.68, 97.97, 99.26, 100.55,
      101.83, 103.14, 104.43, 105.72, 107.01, 108.3, 109.59, 110.88, 112.17, 115.52
    ]
    
          
     
    スコアエリア:
    ● スコアエリア コンポーネントは、寿司エンティティとの衝突を検出し、寿司の種類に応じてスコアを更新し、エリア内の寿司の状態を管理します。 寿司のエンティティを収集または削除した際に破壊し、スコア領域のビジュアルフィードバックを動的に更新してその状態を反映します。
             
    const handleCollisionStart = (e) => {
      if (Sushi.has(world, e.data.other)) {
        const areadata = component.schemaAttribute.get(eid)
        areadata.hasSushi = true
        areadata.sushiEntity = e.data.other
    
        ecs.Material.set(world, eid, { r: 225, g: 225, b: 0 })
        const rightScoreAreaData = Sushi.get(world, areadata.sushiEntity)
    
        if (rightScoreAreaData.type === "regular") {
          areadata.score = Math.floor(Math.random() * 3) + 1
        } else if (rightScoreAreaData.type === "super") {
          areadata.score = 10
        } else {
          areadata.score = -1
        }
      }
    }
    
          
     
    寿司:
    ● Sushi コンポーネントは、移動速度、タイプ、状態(移動中または静止中)などの属性を持つ寿司エンティティを定義します。 そのシステムは、寿司の配置更新を管理し、画面外に移動した寿司を自動的に削除し、アクティブな寿司に対してタイマーによる破壊メカニズムを実装することで、効率的なゲームプレイのダイナミクスを実現します。
             
    if (ecs.Position.has(world, eid)) {
      const currentPosition = ecs.Position.get(world, eid)
    
      if (currentPosition) {
        currentPosition.y -= sushiData.speed
    
        if (currentPosition.y < -5.0) {
          // console.log(`Sushi (${eid}) went off-screen, deleting entity.`)
          world.deleteEntity(eid)
        } else {
          ecs.Position.set(world, eid, currentPosition)
        }
      }
    }
    
          
     
    UIRewardController:
    ● UIRewardController コンポーネントは、報酬表示システムを管理し、S、SS、SSS などの報酬を対応する GIF とポイントと共にアニメーション表示し、
    で表示します。
    は、UIを動的に更新し、イベントベースの報酬追加を処理し、
    の報酬シーケンスをトリガーし、魅力的なポストゲーム体験を確保します。 これには、
    でランダムな報酬(S、SS、またはSSS)を含むメガボーナスガチャが含まれます。 報酬データを追跡し、グローバルイベントを監視し、
    を通じて視覚的およびイベント駆動型の報酬でプレイヤーに魅力的なフィードバックを提供します。

    Implementation

    ゲーム状態(ステートマシン):
    ゲームは、それぞれが
    のゲームプレイ体験の特定の部分を制御する独立したゲーム状態によって管理されています。 各状態は独自のUI要素を管理し、必要に応じて
    などの他のコンポーネントにイベントをディスパッチします。

    Screenshot 2025-05-09 at 1.37.36 PM

    ❖ ゲーム開始
    遷移先: レベル選択
    説明: プレイヤーが冒険を始める最初の画面。

    Your cool escaped html goes here.