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

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


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要素、背景、イベントリスナーを動的に管理し、ゲーム全体を通じて滑らかな移行とユーザーインタラクションを保証します。
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要素を管理し、必要に応じて
などの他のコンポーネントにイベントをディスパッチします。

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