[Astro] #209 見つめると近づく VRM の広場ゲーム(注視判定・ハートのゲージ・Draco ステージ)

[Astro] #209 見つめると近づく VRM の広場ゲーム(注視判定・ハートのゲージ・Draco ステージ)

はじめに

スマホと Cardboard 型ゴーグル(PC でも動きます)で遊ぶ、PLAZA の実装記録です。

  • 広場の中心にカメラを置き、周りで VRM が歩いています
  • 顔を + で見つめると、近くまで歩いてきます。目を離すと、背を向けて離れます
  • 一番前まで来た VRM を見つめていると、頭上のハートが満ちます。全員が MAX になるとステージクリアです
  • ステージ(広場のモデル)は 5 つ。1 つ目は 2 体で、ステージごとに 2 体ずつ増えます。制限時間があります

部品は、先に作った DARUMA と共通です(../common/cardboard.js、../lib/three-vrm.js、../daruma/character.json)。

スクリーンショット

見つめると、近くまで歩いてくる

近くまで来た VRM と、頭上のハート

ハートが満ちて MAX になる

MAX になって金色になったハート MAX になって金色になったハート

1. 構成

public/tool/mobile/plaza/
  index.html     cardboard.css + plaza.css + plaza.js
  plaza.js       本体
  stages.json    FTG の Stages.json のコピー + 広場用の調整値
  levels.json    レベルごとのステージ・人数・ハートの速さ・制限時間
  • キャラは character.json のうち mobile !== false のものだけを使います(軽量な VRM 9 体)
  • 歩きは walk.vrma を 1 つだけ使い、全員で共有します(THREE.createVRMAnimationClip(walkAnim, vrm) を VRM ごとに作ります)
  • 住人は、読み込んだ分を pool に貯め、レベルの人数ぶんだけ folks に入れます。レベルが上がって人数が増えたら、足りない分だけ読み込みます

2. 顔を見つめる判定

レイキャストは使っていません。スキン付きの VRM に対して試したところ、1 回で約 110 ms かかったためです。カメラの前方向と、頭のボーンへの方向の角度で判定します。

headPos(f, tmp).sub(camera.position);                  // 頭のボーンのワールド位置
const d = tmp.length();
const lim = Math.max(degToRad(G.minDeg), Math.atan(G.halfWidth / d));   // 距離が遠いほど、角度は小さくなる
const r = fwd.angleTo(tmp) / lim;                      // 1 未満なら、顔に当たっている
  • halfWidth(0.22 m)は顔の半幅です。距離から角度の許容を決め、minDeg(3 度)を下回らないようにします
  • 当たっている住人が複数いるときは、r が最も小さい(真ん中に近い)1 人だけを選びます
  • 見つめ始めて holdSec(0.25 秒)で反応し、少し外れても graceSec(0.3 秒)は見ている扱いにします

近づいたあとのちらつき対策

一番前(nearDist = 1.3 m)まで来て止まったあと、後ろを向いたり前を向いたりを繰り返しました。顔が大きく見える距離では、判定が少し外れただけで「見ていない」となり、approach と turnAway を行き来していたためです。

approach 中の住人だけ、判定を広げ、猶予を延ばします。いったん入った状態から出にくくする、ヒステリシスです。

const lim = base * (f.state === 'approach' ? G.keepScale : 1);        // keepScale 1.8
f.grace = f.state === 'approach' ? G.keepGraceSec : G.graceSec;       // 0.9 秒

3. 住人の状態機械

状態内容次の状態
wander視界の中で、見つめられていない間、歩く・立ち止まるを繰り返す見つめられる → approach / 視界の外に lostDelay 秒 → enter
approach中心から nearDist まで歩いて来て、こちらを向いて止まる見つめるのをやめる → turnAway
turnAway中心と反対を向く向き終わる → retreat
retreat後ろ向きのまま、今より 1〜4 m 遠く(rMax まで)へ歩く着く → wander
enter今向いている方向の、視界の中の点へ歩く着く → wander

「見えているか」

体を包む球(高さ bodyCenterY、半径 bodyRadius)が、視錐台にかかっているかで判定します。ステレオ表示では、画面が左右に分かれるので、水平方向の広さに片目の縦横比 cb.aspectEye を使います。

const hHalf = Math.atan(Math.tan(vHalf) * cb.aspectEye);
return Math.atan2(Math.abs(p.x), d) - margin < hHalf && Math.atan2(Math.abs(p.y), d) - margin < vHalf;

歩きと向き

  • 歩く速さは speed(m/秒)で決め、アニメの timeScale = speed / strideSpeed にします。strideSpeed(1.2)は walk.vrma を 1 倍速で再生したときに進む距離です。足が滑りません
  • 向きと速さは、指数で追従させます(1 - exp(-dt / tau)、turnTau 0.15 秒、speedTau 0.2 秒)
  • 寄ってくる速さ(1.3 m/秒)は、自由に歩く速さ(0.5 m/秒)より速くしています。メリハリが付きます

住人どうしと中心

  • 0.7 m より近い住人どうしは、互いに押し離します
  • 中心に近すぎる住人は、rMin × 0.9 まで押し出します。この押し出しは wander と enter の間だけです。全状態に掛けると、近くにいた住人(approach / retreat)が、瞬間移動で外へ飛びました

4. ステージ(Draco 圧縮の GLB)

ステージは、別のゲームで使っているステージ GLB です。Draco 圧縮されているので、デコーダを指定します。

const draco = new THREE.DRACOLoader();
draco.setDecoderConfig({ type: 'js' });
draco.setDecoderPath('/tool/draco/');
loader.setDRACOLoader(draco);

stages.json は、元の Stages.json のコピーです。元を書き換えても影響を受けません。広場に合わせた調整値は、ステージの直下か、"plaza": {...} の中に書きます。

キー意味
usefalse で候補から外す
offset[x, y, z] 位置のずれ。json の position に足す
scaleMul大きさの倍率
rMin, rMaxそのステージでの、住人の距離の範囲

"plaza": [x, y, z] と書いても、offset として読みます。

位置と大きさ

stage.scale    = json.scale × scaleMul
stage.position = (json.position + offset) × scaleMul

位置にも scaleMul を掛けます。カメラ(原点)を軸に拡大・縮小されるので、大きさを変えても、中心がずれません。最初は位置を掛けておらず、大きくするたびに中心がずれました。

PC での調整

矢印キー(x, z)、, .(y)、[ ](大きさ)で動かし、P で現在の値を "plaza": {...} の形で表示します。それを stages.json に貼ります。

5. ハート

描画

canvas に Path2D でハートを描き、THREE.Sprite に貼ります。ハートの形は、24×24 の SVG パスです。

const path = new Path2D(HEART_PATH);
c.fillStyle = 'rgba(20,10,20,.45)'; c.fill(path);             // 空のハート
c.save(); c.clip(path);
c.fillStyle = maxed ? '#ffd24a' : '#ff5a7a';
c.fillRect(0, top, 24, 25 - top);                              // 下から満ちる(top を level で動かす)
c.restore();
c.stroke(path);                                                // 縁
  • 満ち具合は 24 段階にして、テクスチャをキャッシュします。毎フレーム描き直すのは、段が変わったときだけです
  • MAX は金色にして、少し大きくし、ゆっくり脈打たせます(サイズに sin を掛けます)

位置

最初は頭のボーンの上に固定の高さで置きましたが、モデルによっては頭に埋まりました。読み込み時に、モデルの一番高い所を測ります。

const top = new THREE.Box3().setFromObject(vrm.scene).max.y;     // 耳・髪・アクセサリーを含む

ハートの高さは、足元の y + top + heartGap + ハートの半分 です。x, z は頭のボーンから取るので、歩くと一緒に揺れます。heartGap(0.03 m)で、すき間を調整します。

消える前の点滅は、スプライトの不透明度を切り替えます。

h.material.opacity = f.blink ? (Math.sin(now * 0.016) > 0 ? 1 : 0.2) : 1;

6. ゲームの進み方

ハートの増減

function loveUpdate(f, loving, dt) {   // loving: 一番前まで来た住人を、見つめている
  const C = game.cfg;
  if (f.maxed) {
    if (loving) f.timer = C.loseSec;   // 見つめ直すと、保てる
    else f.timer -= dt;
    f.blink = f.timer <= C.blinkSec && !loving;
    if (f.timer <= 0) { f.maxed = false; f.love = 0; f.blink = false; }
    return;
  }
  if (loving) { f.love += dt / C.fillSec; if (f.love >= 1) { f.love = 1; f.maxed = true; f.timer = C.loseSec; } }
  else { f.love = Math.max(0, f.love - dt / C.loseSec); f.blink = f.love > 0 && f.love * C.loseSec <= C.blinkSec; }
}
  • loving は、approach 状態で、距離が nearDist + 0.25 以内、かつ見つめている間だけ真です
  • 満ちる速さは 1 / fillSec、減る速さは 1 / loseSec です
  • MAX も、見つめ直さないと loseSec 秒で空に戻ります。残りが blinkSec(10 秒)を切ると点滅します

levels.json

バランスは、すべて別の JSON に出しています。ステージの JSON はデータが多いので、分けました。

{
  "skipButton": true,
  "maxContinues": 3,
  "defaults": { "fillSec": 3, "loseSec": 40, "blinkSec": 10, "timeLimitSec": 120 },
  "levels": [
    { "stage": "MITAIKEN_HORIZON_LLSIFAS", "count": 2, "loseSec": 40, "timeLimitSec": 90 },
    { "stage": "HIKARI_LLSIFAS", "count": 4, "loseSec": 60, "timeLimitSec": 120 }
  ]
}
  • defaults に各レベルの値を上書きします(Object.assign({}, defaults, level))
  • stage は stages.json の id です。ステージ側に無い id は、読み飛ばします
  • levels.json が読めないときは、ステージの数だけ、2 体ずつ増やすレベルを作ります
  • maxContinues は、コンティニューの回数の上限です。書かなければ無制限です

フェーズ

loading → intro(3 秒)→ play → clear(clearSec 秒)→ 次の loading、play → over、最後のレベルのクリアは allclear(8 秒)→ 最初から です。

if (folks.length && folks.every((f) => f.maxed)) { /* clear か allclear */ }
else if (game.left <= 0) { /* over */ }

over は、自動では始めません。足元に CONTINUE(そのレベルをやり直す)を出し、見つめると再開します。maxContinues を超えると、RESTART(最初から)に変わります。

7. 表情

MAX になった瞬間に、目を閉じて微笑む表情にします。

em.setValue('happy', v); em.setValue('blink', v);   // 無い表情は、無視される
  • v は、目標(1 か 0)へ約 0.1 秒で追従させます
  • joySec(1 秒)だけ、その場にとどまります。この間は、見つめている扱いに固定します
  • そのあとは表情を戻し、turnAway → retreat → wander と進みます。joyCoolSec(3 秒)の間は、見つめても寄ってきません。これがないと、見つめたままのときに、すぐ引き返してきます

8. 世界に置く HUD

ステレオ表示は、画面を左右に分けるので、HTML / CSS の文字は片側にしか出ません。cb.createWorldHud でカメラに付けた板に、canvas で文字を描きます。

右下のステージ名や、上のハート数と残り時間は、画面の形に合わせて、毎フレーム位置を計算します。

const halfH = Math.tan(degToRad(camera.fov) / 2), halfW = halfH * cb.aspectEye;   // z = -1 での、見える範囲の半分
mesh.scale.setScalar(Math.min(1, (halfW * 2 - m * 2) / W));                        // 細長い画面では、縮める
mesh.position.set(halfW - m - (W * s) / 2, -halfH + m + (H * s) / 2, -1);          // 右下の隅

固定の位置にすると、細長い画面で見切れました。

9. 音(Web Audio)

最初のタップ(START)で AudioContext を作り、効果音と BGM を読み込みます。ファイルが無いときは、鳴らさないだけで、ゲームは動きます。

ハートが増えている間の音

ループ再生にすると、曲の最後まで待つので、間があいて、テンポが悪くなりました。一定間隔で、音の頭から鳴らし直し、前の音に重ねます。

if (t < fillNext) return;
fillNext = t + F.interval;                       // 0.43 秒ごと
g.gain.setValueAtTime(0, t);
g.gain.linearRampToValueAtTime(vol, t + 0.03);   // 頭と終わりを絞って、つなぎ目のプチ音を避ける
g.gain.setValueAtTime(vol, t + len - fade);
g.gain.linearRampToValueAtTime(0, t + len);
s.start(t, F.start, len);                        // 先頭から len 秒だけ

見つめるのをやめたときは、鳴っている分のゲインを絞って止めます。MAX になったときは、増加の音を止めて、MAX の音を 1 回鳴らします。

BGM と切り替え

  • BGM は、ゲインを 0.8 秒かけて上げながら始め、ループさせます
  • クリアとゲームオーバーでは、BGM を 0.3 秒で絞って止め、ジングルや効果音に切り替えます
  • 次のレベルの開始時(startLevel の最後)に、BGM を頭から再開します。鳴っているときは何もしません

10. つまずいた点

  • ステージが効かない: 調整値を、"plaza": {...} の中だけ読んでいました。ステージ直下のキーと、配列の "plaza": [..] も読むようにしました
  • 右下の文字が切れる: HUD の位置を固定値にしていました。画面の縦横比から計算します
  • BGM がステージ 2 で鳴らない: 再開の判定を update の中に置いていましたが、読み込み中の update は途中で戻るため、条件を一度も通りませんでした。startLevel の最後に移しました
  • ハートが頭に埋まる: 頭のボーンからの固定の高さでした。モデルの一番高い所を測ります
  • 近づいたあとの回転: 2 章の、ヒステリシスで直しました

11. 制約

  • ステージの GLB は、読み込んだものを全部メモリに残します。重い場合は、古いステージの解放が必要です
  • 10 体のレベルでは、9 体のモデルが 1 体重複します
  • 待機のモーションがありません。立ち止まったときは、歩きの途中の姿勢で止まります
  • ステージは CC Attribution です。作者名とリンクを CREDIT に出しています

関連

  • DARUMA(だるまさんが転んだ)