作者向けの文書 › visual ・ 元の文書(.md)

見た目(visual.js)の仕様

見た目(visual.js)は、ルールの状態を3Dの絵にするファイルです。絵はThree.jsで描きます。ルールの書き方はルール(game.js)の仕様にあります。

見た目は勝ち負けに関わりません。ルールと違って、ふつうのJavaScriptで書けます。端末ごとに違う絵になってもかまいません。


1. 全体の形

visual.js はモジュールで、次の関数を export します。

export function init(ctx) { … }            // 必須。最初に1回
export function update(snapshot, dt) { … } // 必須。絵を描くたび
export function resize(ctx) { … }          // 任意。画面の大きさが変わったとき
export function preview(v, dt) { … }       // 任意。見た目の変更の画面で、選んだものを見せる
関数呼ばれるとき
init(ctx)ゲームの箱を開いたとき1回。持ち込んだ画像とモデルは、呼ばれる前に読み終わっている
update(snapshot, dt)絵を描く前に毎回(1秒に30回ほどまで。meta.interpolate を宣言すると60回ほどまで。端末が重いと減る)。dt は前に描いてからの秒数(0.1まで)
resize(ctx)画面の大きさや向きが変わったとき。vsboxがカメラを既定の位置に置き直したあとに呼ばれる。新しいカメラの距離などが ctx に入っている
preview(v, dt)6. 見た目の変更の見本
let THREE, ctx, ball;

export function init(c) {
  ctx = c;
  THREE = c.THREE;
  ctx.scene.background = new THREE.Color(0x8ecae6);
  ball = new THREE.Mesh(new THREE.SphereGeometry(0.5, 24, 16),
                        new THREE.MeshStandardMaterial({ color: 0xff6633 }));
  ctx.scene.add(ball);
}

export function update(snapshot, dt) {
  const s = snapshot.state;
  ball.position.set(ctx.toWorld(s.ballX), 0.5, ctx.toWorld(s.ballZ));
}

1.1 試合をまたいで使われる

visual.js は、ゲームの箱を開いてから閉じるまで、同じものが使われます。同じページで続けて遊ぶと、次の試合でも読み込み直されず、init も呼ばれません。

1.2 エンティティの絵を、いまのエンティティに合わせる

エンティティの id は、ラウンドが始まるたびに "e1" から振り直されます。試合が変わっても同じです。id で絵を覚えるときは、毎回いまのエンティティと突き合わせて、無くなったものを消し、kind が変わったものは作り直します。

let THREE = null;
let ctx = null;
const shown = new Map();   // id → { kind, mesh }
let ball = null;
const paint = {};

export function init(c) {
  ctx = c;
  THREE = c.THREE;
  ball = new THREE.SphereGeometry(0.3, 16, 12);             // 形と材質は1回だけ作って使い回す(8)
  paint.coin = new THREE.MeshStandardMaterial({ color: 0xffcc33 });
  paint.other = new THREE.MeshStandardMaterial({ color: 0x88aacc });
}

export function update(snapshot, dt) {
  const alive = new Set();
  for (const e of snapshot.entities) {
    alive.add(e.id);
    let s = shown.get(e.id);
    if (s && s.kind !== e.kind) {          // 同じ id でも、別の物になっていたら作り直す
      ctx.scene.remove(s.mesh);
      s = null;
    }
    if (!s) {
      const mesh = new THREE.Mesh(ball, e.kind === "coin" ? paint.coin : paint.other);
      ctx.scene.add(mesh);
      s = { kind: e.kind, mesh: mesh };
      shown.set(e.id, s);
    }
    s.mesh.position.set(ctx.toWorld(e.x), 0.3, ctx.toWorld(e.z));
  }
  for (const [id, s] of shown) {
    if (!alive.has(id)) {                  // いまは無いもの(消えた・ラウンドが変わった)を片付ける
      ctx.scene.remove(s.mesh);
      shown.delete(id);
    }
  }
}

1.3 動く場所

見た目は、ゲームの本体とは別の場所(Worker)で動きます。

init・update・resize でエラーが出ると、見た目はそこで止まります。試合中なら試合も止まり、結果の板に理由が出ます。見た目が12秒以上返事をしない(無限ループなど)ときも同じです。

2. ctx

init と resize に渡されるものです。

名前中身
ctx.THREEThree.js
ctx.scene描く場面
ctx.cameraカメラ(PerspectiveCamera)
ctx.rendererThree.jsの WebGLRenderer
ctx.toWorld(v)ルールの長さ(1単位 = 1000)を、場面の長さに直す(v * 0.001)
ctx.viewUnitsmeta.view({ width, height })
ctx.cameraDistanceカメラから原点までの距離
ctx.dp画面の短い辺の長さ(dp)。ルールに届く input.dp と同じ数
ctx.image(name)持ち込んだ画像(ImageBitmap)。無ければ null
ctx.texture(name)持ち込んだ画像のテクスチャ。同じ名前なら同じものを返す。無ければ null
ctx.model(name)持ち込んだ3Dモデル(読み込んだglTF。.scene・.animations)。無ければ null
ctx.clone(obj)モデルの2体目以降を作る(7.2)

持ち込んだファイルは、あとから足したり消したりできます。無いときに null が返ることを前提に書きます。

3. 既定の場面

vsboxは、何も書かなくても絵が出るように、場面を用意してから init を呼びます。どれも作者が変えてかまいません。

もの既定
向きyが上。地面はx-zの平面
カメラ原点を斜め上(水平から62度)から見下ろす。meta.view の広さ(x方向に width、z方向に height、原点が中心)が画面に収まる距離
明かり空と地面の半球光、影を落とす太陽、反対側からの弱い補助光
影有効。影を落とす物には castShadow、受ける物には receiveShadow を付ける
背景透明で、後ろの meta.frameColor(書かなければ #161b22)が見える。ctx.scene.background で塗れる

3.1 player2 に反対側から見せる

vsboxが決めた距離と角度はそのまま使い、位置の符号だけ変えます。ルールの側でも、指の向きを直します(ルール(game.js)の仕様 6.5)。

let ctx = null;
let role = null;

// 席に合わせてカメラを置く。player2 は反対側(z が負)、それ以外は vsbox の既定の側(z が正)
function faceMySide() {
  const z = Math.abs(ctx.camera.position.z);
  ctx.camera.position.z = role === "player2" ? -z : z;
  ctx.camera.lookAt(0, 0, 0);
}

export function init(c) { ctx = c; }

export function resize(c) {
  ctx = c;
  faceMySide();      // vsbox がカメラを既定の位置に戻したあとなので、もう一度置く
}

export function update(snapshot, dt) {
  if (snapshot.myRole !== role) {   // 席は試合ごとに変わることがある(1.1)
    role = snapshot.myRole;
    faceMySide();
  }
}

4. snapshot

update に渡されるものです。中身は写しなので、書き換えてもルールには影響しません。

名前中身
snapshot.match試合の番号。試合を始めるたびに1ずつ増える。試合の前の描画の準備とサムネイルでは 0
snapshot.stateルールの visualState(g) が返したもの。無ければ g.state
snapshot.entitiesエンティティの配列。作者のフィールドと、物理の体の整数の値(x など)が入っている
snapshot.poses物理の体の姿勢 [{ id, kind, pose: { x, y, z, qx, qy, qz, qw } }]。場面の長さの小数と四元数
snapshot.effectsこのフレームまでに起きた出来事 [{ name, data }](4.1)
snapshot.device遊んでいる端末。"mobile"(指で操作する端末)か "pc"(マウスとキーボードの端末)(4.4)
snapshot.myRole画面を見ている人の席("player1" か "player2")。試合の前に描画を準備するときは null、サムネイルを撮るときは "player1"
snapshot.names2人の表示名 { player1, player2 }(4.2)
snapshot.looks2人の見た目の選択(4.3)
snapshot.countLeftラウンド開始前に止まっている残りtick(meta.countdown)
snapshot.breakLeftラウンドの区切りで止まっている残りtick
snapshot.view伏せた札のうち、自分に見えている中身(名前 → 位置ごとの中身。見えていない位置は null)
snapshot.memo手元の処理(controls)の覚え書き
snapshot.prev1つ前に届いた状態(state・entities・poses など、snapshot と同じ形)(4.5)
snapshot.alphaprev から今の状態まで、どこまで来た時刻か。0 〜 1(4.5)

4.1 出来事 snapshot.effects

ルールの g.effect(name, data) が、一度だけ届きます。次のフレームでは空になります。爆発の粒子や得点の演出は、ここで作ります。

for (const fx of snapshot.effects) {
  if (fx.name === "explode") { spawnSparks(fx.data.x, fx.data.z); }
}

音は見た目では鳴らしません。ルールの meta.sounds に出来事の名前と音を書くと、vsboxが鳴らします(ルール(game.js)の仕様 4.8)。

4.2 名前 snapshot.names

本人がマイページで決めた表示名です。決めていない人とログインしていない人は null です。試合の前の描画の準備とサムネイルでは、両方 null です。CPUは相手の名前(meta.opponents が無ければ "CPU")です。

4.3 見た目の選択 snapshot.looks

meta.looks(ルール(game.js)の仕様 4.7)で決めた欄ごとに、選ばれた選択肢の id が入っています。選んでいない人の欄も、標準で埋まっています。宣言していなければ {} です。

snapshot.looks.dice    // { player1: "red", player2: "white" }   owner が "each" の欄
snapshot.looks.table   // "felt"                                 owner が "player1" か "player2" の欄

4.4 端末 snapshot.device

遊んでいる端末が、指で操作する端末("mobile")か、マウスとキーボードの端末("pc")かです。見分け方は、一覧に出すかどうか(ルール(game.js)の仕様 4.5)と同じです。

スマホとパソコンの両方で遊べるゲーム(meta.devices: "both")で、端末によって絵を変えたいときに使います。変えるかどうかは自由です。

stick.visible = snapshot.device === "mobile";   // 例:画面のスティックは、指の端末でだけ描く

4.5 間を補う snapshot.prev snapshot.alpha

ルールの meta.interpolate を true にすると(ルール(game.js)の仕様 4.9)、絵がルールの進む回数より多く描かれます。そのままでは同じ姿が続けて描かれるだけなので、1つ前の状態(prev)と今の状態の間を、割合(alpha)で補って描きます。

const a = snapshot.alpha;
const before = snapshot.prev.state.ballX;
const now = snapshot.state.ballX;
ball.position.x = ctx.toWorld(before + (now - before) * a);

物理の体(snapshot.poses)は、位置と一緒に向き(四元数)も混ぜます。一度に大きく跳んだものと、新しく出てきたものは、補わずに今の姿で置きます。

const qa = new THREE.Quaternion();   // 使い回す
const qb = new THREE.Quaternion();

function placeBodies(snapshot) {
  const t = snapshot.alpha;
  const was = new Map(snapshot.prev.poses.map((p) => [p.id, p.pose]));
  for (const p of snapshot.poses) {
    const mesh = meshes.get(p.id);
    const b = p.pose;
    const a = was.get(p.id);
    // 1回(1/30秒)で動けるより遠くへ跳んだ:補わない
    if (!a || Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z) > 3) {
      mesh.position.set(b.x, b.y, b.z);
      mesh.quaternion.set(b.qx, b.qy, b.qz, b.qw);
      continue;
    }
    mesh.position.set(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t);
    // slerpQuaternions は、近いほうの回り方で混ぜる
    mesh.quaternion.slerpQuaternions(qa.set(a.qx, a.qy, a.qz, a.qw), qb.set(b.qx, b.qy, b.qz, b.qw), t);
  }
}

5. 文字を出す

見た目からは画面の部品(DOM)に触れないので、文字は絵に描いて板に貼ります。

  1. OffscreenCanvas に文字を描く
  2. THREE.CanvasTexture にする
  3. 板(PlaneGeometry)に貼る
  4. 板をカメラの子にする(カメラが動いても、画面の同じ場所に留まる)

注意 カメラは最初、場面に入っていません。子を足したら ctx.scene.add(ctx.camera) が要ります。入れないと、子は描かれません。

6. 見た目の変更の見本 preview(v, dt)

meta.looks を宣言したゲームで、選んでいるものを動かして見せるための関数です。書かなければ、見た目の変更の画面には、選択肢と説明だけが出ます。

書くと、見た目の変更の画面を開いている間、update の代わりに毎フレーム呼ばれます。

v中身
v.THREEThree.js
v.scene、v.camera見本の場面とカメラ。試合とは別で、閉じると場面ごと捨てられる。明かりは既定と同じ
v.fresh開いた直後の1回だけ true。ここで物を作る
v.slotいま開いている欄の id
v.lookssnapshot.looks と同じ形。両方の席が、いま選んでいるものになっている
v.pointer押している指の位置 { x, y, ndc: { x, y } } か null
v.tapsこのフレームに指を離した位置の配列(同じ形)
v.sound(name)meta.sounds の名前で音を鳴らす(1フレームに4回まで)
v.width、v.height見本の枠の広さ(px)
v.dp見本の枠の短い辺
const ray = new v.THREE.Raycaster();
for (const t of v.taps) {
  ray.setFromCamera(t.ndc, v.camera);
  const hit = ray.intersectObjects(v.scene.children, true)[0];
  if (hit) { /* hit.object を触った */ }
}

7. 持ち込んだファイル

画像・音・3Dモデルは、開発者タブの作品のページで置きます。同じ名前があれば置き換えます。

種類拡張子1つの上限
画像.png .jpg .webp2MB(縦×横4096×4096画素ぶんまで、1辺8192画素まで)
音.mp3 .ogg .wav1MB(長さ5秒まで)
3Dモデル.glb8MB

7.1 画像

ctx.texture(name) で、材質に貼れるテクスチャになります。

const mat = new THREE.MeshStandardMaterial({ map: ctx.texture("tile.png") });

7.2 3Dモデル

.glb だけを置けます。ctx.model(name) は読み込んだglTF(.scene と .animations)を返します。

const gltf = ctx.model("hero.glb");
if (gltf) {
  const a = ctx.clone(gltf.scene);   // 1体目
  const b = ctx.clone(gltf.scene);   // 2体目
  ctx.scene.add(a, b);
}

注意 2体目以降は、必ず ctx.clone で作ります。Three.jsの clone() は骨を共有するので、骨のあるモデルでは2体が同じ動きになります。

置けるモデルには、次の決まりがあります。

ファイルが8MB以下でも、これらを超えると断られます。

8. 軽く描くために

すること理由
形と材質は init で作り、使い回す毎フレーム作ると、GPUの資源があふれる
絵を描いたテクスチャは、中身が変わったときだけ描き直す描き直すたびにGPUへ送り直す
同じものをたくさん出すときは InstancedMesh を使う描く回数が1回で済む
消えては出るもの(爆発など)は、隠して使い回す作り直すと、出るたびに準備がかかる

開発者タブの「開発者として開く」で遊ぶと、画面の隅にfpsなどの数字が出ます。「転送」は1秒あたりの、絵(キャンバスや画像)のテクスチャの送り直しです。骨のある3Dモデルが動くたびに送る骨の位置は、数えません。ほぼ毎フレーム起きていると、試合の最初の20秒で ⚠ が付きます。中身が変わらないのに毎回描き直している絵があります。

作者向けの文書 ・ vsbox について ・ トップ