[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)。
スクリーンショット
見つめると、近くまで歩いてくる
ハートが満ちて 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)、turnTau0.15 秒、speedTau0.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": {...} の中に書きます。
| キー | 意味 |
|---|---|
use | false で候補から外す |
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(だるまさんが転んだ)