# 見た目(visual.js)の仕様 見た目(`visual.js`)は、ルールの状態を3Dの絵にするファイルです。絵は[Three.js](https://threejs.org/)で描きます。 ルールの書き方は[ルール(game.js)の仕様](/docs/lang-spec)にあります。 見た目は勝ち負けに関わりません。ルールと違って、ふつうのJavaScriptで書けます。端末ごとに違う絵になってもかまいません。 --- ## 1. 全体の形 `visual.js` はモジュールで、次の関数を `export` します。 ```js 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. 見た目の変更の見本 | ```js 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` も呼ばれません。 - 席(`snapshot.myRole`)、表示名、見た目の選択(`snapshot.looks`)は、試合ごとに変わることがあります。`init` や最初の `update` で一度読むのではなく、毎回 `snapshot` を見ます(3.1) - 試合の前にも、vsboxが描画を準備するために `update` を呼びます(`myRole` は `null`)。CPUで少し進めた状態と、そのあいだの出来事(`effects`)も届きます。**最初の `update` を、試合の始まりと思わないでください** - 前の試合や準備のときに作った物(飛んでいる粒子など)は残ります。要らなければ自分で片付けます - 試合が変わったかは `snapshot.match`(試合ごとに1ずつ増える番号)で分かります。同じ席で続けて遊ぶこともあるので、席の変化では見分けられません。基本は、**いまの `snapshot` に合わせて絵を直す**書き方にします ### 1.2 エンティティの絵を、いまのエンティティに合わせる エンティティの `id` は、ラウンドが始まるたびに `"e1"` から振り直されます。試合が変わっても同じです。`id` で絵を覚えるときは、毎回いまのエンティティと突き合わせて、無くなったものを消し、`kind` が変わったものは作り直します。 ```js 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)で動きます。 - 画面の部品(DOM)には触れません。文字も3Dの中に描きます(5. 文字を出す) - 通信はできません(`fetch` なども使えません) - `import` は使えません。Three.jsは `ctx.THREE` を使います - 指の位置は読めません。指で動かすものは、ルールが `g.state` に置いた値を描きます `init`・`update`・`resize` でエラーが出ると、見た目はそこで止まります。試合中なら試合も止まり、結果の板に理由が出ます。見た目が12秒以上返事をしない(無限ループなど)ときも同じです。 ## 2. `ctx` `init` と `resize` に渡されるものです。 | 名前 | 中身 | |---|---| | `ctx.THREE` | Three.js | | `ctx.scene` | 描く場面 | | `ctx.camera` | カメラ(`PerspectiveCamera`) | | `ctx.renderer` | Three.jsの `WebGLRenderer` | | `ctx.toWorld(v)` | ルールの長さ(1単位 = 1000)を、場面の長さに直す(`v * 0.001`) | | `ctx.viewUnits` | `meta.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` で塗れる | - 長さは、ルールの1000が場面の1です(`ctx.toWorld`) - カメラの距離は、画面の縦横比で変わります。霧のように距離で効くものは、`ctx.cameraDistance` を基準にします - 画面の大きさや向きが変わると、vsboxはカメラを既定の位置に置き直してから `resize` を呼びます。カメラを動かすゲームは、`resize` でもう一度置きます ### 3.1 `player2` に反対側から見せる vsboxが決めた距離と角度はそのまま使い、位置の符号だけ変えます。ルールの側でも、指の向きを直します([ルール(game.js)の仕様](/docs/lang-spec) 6.5)。 ```js 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.names` | 2人の表示名 `{ player1, player2 }`(4.2) | | `snapshot.looks` | 2人の見た目の選択(4.3) | | `snapshot.countLeft` | ラウンド開始前に止まっている残りtick(`meta.countdown`) | | `snapshot.breakLeft` | ラウンドの区切りで止まっている残りtick | | `snapshot.view` | 伏せた札のうち、自分に見えている中身(名前 → 位置ごとの中身。見えていない位置は `null`) | | `snapshot.memo` | 手元の処理(`controls`)の覚え書き | | `snapshot.prev` | 1つ前に届いた状態(`state`・`entities`・`poses` など、`snapshot` と同じ形)(4.5) | | `snapshot.alpha` | `prev` から今の状態まで、どこまで来た時刻か。`0` 〜 `1`(4.5) | - `update` が呼ばれる回数は、ルールが進む回数と同じとは限りません。同じ `snapshot` が続けて来ることも、間が飛ぶこともあります。時間で動く演出は `dt` で進めます - 物理の体を描くときは、`poses` を使うと動きがなめらかです(`entities` の値は1000分の1きざみの整数) - **`state` には2人ぶんが入っています。**体力や持ち物のように本人のぶんだけ出すものは、`myRole` で選びます ### 4.1 出来事 `snapshot.effects` ルールの `g.effect(name, data)` が、一度だけ届きます。次のフレームでは空になります。爆発の粒子や得点の演出は、ここで作ります。 ```js for (const fx of snapshot.effects) { if (fx.name === "explode") { spawnSparks(fx.data.x, fx.data.z); } } ``` 音は見た目では鳴らしません。ルールの `meta.sounds` に出来事の名前と音を書くと、vsboxが鳴らします([ルール(game.js)の仕様](/docs/lang-spec) 4.8)。 ### 4.2 名前 `snapshot.names` 本人がマイページで決めた表示名です。決めていない人とログインしていない人は `null` です。試合の前の描画の準備とサムネイルでは、両方 `null` です。CPUは相手の名前(`meta.opponents` が無ければ `"CPU"`)です。 - `null` のときに何と出すか(「あなた」「相手」など)は、作者が決めます - 人が打った文字です。絵に描いて出す以外には使わないでください ### 4.3 見た目の選択 `snapshot.looks` `meta.looks`([ルール(game.js)の仕様](/docs/lang-spec) 4.7)で決めた欄ごとに、選ばれた選択肢の `id` が入っています。選んでいない人の欄も、標準で埋まっています。宣言していなければ `{}` です。 ```js snapshot.looks.dice // { player1: "red", player2: "white" } owner が "each" の欄 snapshot.looks.table // "felt" owner が "player1" か "player2" の欄 ``` ### 4.4 端末 `snapshot.device` 遊んでいる端末が、指で操作する端末(`"mobile"`)か、マウスとキーボードの端末(`"pc"`)かです。見分け方は、一覧に出すかどうか([ルール(game.js)の仕様](/docs/lang-spec) 4.5)と同じです。 スマホとパソコンの両方で遊べるゲーム(`meta.devices: "both"`)で、端末によって絵を変えたいときに使います。変えるかどうかは自由です。 ```js stick.visible = snapshot.device === "mobile"; // 例:画面のスティックは、指の端末でだけ描く ``` - 見た目は端末ごとに違ってよいので、2人で描くものが違っても試合はずれません - 見えなくしても、その場所をマウスで押せば今までどおり指として届きます(ルールは指の位置を読むので) - 読まなくてもかまいません。読まなければ、どの端末でも同じ絵になります ### 4.5 間を補う `snapshot.prev` `snapshot.alpha` ルールの `meta.interpolate` を `true` にすると([ルール(game.js)の仕様](/docs/lang-spec) 4.9)、絵がルールの進む回数より多く描かれます。そのままでは同じ姿が続けて描かれるだけなので、1つ前の状態(`prev`)と今の状態の間を、割合(`alpha`)で補って描きます。 ```js 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`)は、位置と一緒に向き(四元数)も混ぜます。一度に大きく跳んだものと、新しく出てきたものは、補わずに今の姿で置きます。 ```js 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); } } ``` - `alpha` は、`0` のとき `prev` の時点、`1` のとき今の状態の時点です。補って描くと、見える姿は最大でルールの1回ぶん(1/30秒)前になります - `prev` はいつも入っています。試合の最初と、試合の前の描画の準備、サムネイルでは、今の状態そのものです - `meta.interpolate` を宣言していないとき、描くのが間に合わずに1秒30回へ戻したとき、試合の前と決着のあとは、`alpha` はいつも `1` です - ラウンドが変わって初めの位置に戻るなど、一度に大きく跳んだものを補うと、間を滑って見えます。補うかどうかは、見た目のコードで決めます - 1つ前の状態に無かったもの(新しく出てきたもの)は、補えません。今の状態のまま描きます - エンティティの `id` は振り直されることがあります(1.2)。`prev` と `id` で突き合わせるときは、同じものかどうかも確かめます ## 5. 文字を出す 見た目からは画面の部品(DOM)に触れないので、文字は絵に描いて板に貼ります。 1. `OffscreenCanvas` に文字を描く 2. `THREE.CanvasTexture` にする 3. 板(`PlaneGeometry`)に貼る 4. 板をカメラの子にする(カメラが動いても、画面の同じ場所に留まる) > **注意** カメラは最初、場面に入っていません。子を足したら `ctx.scene.add(ctx.camera)` が要ります。入れないと、子は描かれません。 - 板の大きさは、カメラの画角と距離から計算します。決めた大きさにすると、縦横比の違う端末で画面の外に出ます - 文字の後ろに下地を敷きます。文字の色だけで読ませると、背景の色によっては読めなくなります - **文字を書き換えたときだけ描き直します。**毎フレーム描き直すと、そのたびに絵をGPUへ送り直すので、とても重くなります - 3・2・1(`countLeft`)やラウンドの区切り(`breakLeft`)も、見た目が描きます。vsboxは時間を止めるだけです ## 6. 見た目の変更の見本 `preview(v, dt)` `meta.looks` を宣言したゲームで、選んでいるものを動かして見せるための関数です。書かなければ、見た目の変更の画面には、選択肢と説明だけが出ます。 書くと、見た目の変更の画面を開いている間、`update` の代わりに毎フレーム呼ばれます。 | `v` | 中身 | |---|---| | `v.THREE` | Three.js | | `v.scene`、`v.camera` | 見本の場面とカメラ。試合とは別で、閉じると場面ごと捨てられる。明かりは既定と同じ | | `v.fresh` | 開いた直後の1回だけ `true`。ここで物を作る | | `v.slot` | いま開いている欄の `id` | | `v.looks` | `snapshot.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` | 見本の枠の短い辺 | - `x`・`y` は枠の左上からのpx、`ndc` は-1〜1の値で、Three.jsの `Raycaster.setFromCamera` にそのまま渡せます ```js 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 を触った */ } } ``` - 物は `v.scene` に新しく作ります。試合の場面の物を足すと、試合の場面から抜けてしまいます。形・材質・テクスチャは試合と使い回してかまいません ## 7. 持ち込んだファイル 画像・音・3Dモデルは、開発者タブの作品のページで置きます。同じ名前があれば置き換えます。 | 種類 | 拡張子 | 1つの上限 | |---|---|---| | 画像 | `.png` `.jpg` `.webp` | 2MB(縦×横4096×4096画素ぶんまで、1辺8192画素まで) | | 音 | `.mp3` `.ogg` `.wav` | 1MB(長さ5秒まで) | | 3Dモデル | `.glb` | 8MB | - 1つの作品に置けるのは、合計20MB・64個まで。画像の画素数は、全部あわせて縦×横32M画素ぶんまで - 音は全部あわせて、展開したあとの大きさで24MBまでです([ルール(game.js)の仕様](/docs/lang-spec) 4.8) - 大きさ・長さ・画素数は、置くときに確かめます。超えるものは断られます(遊ぶときに初めて分かる、ということはありません) - 素材は遊ぶ前にまとめて読み込みます。多いほど「準備中…」が長くなります(1MB を超えると、読み込んだ割合が出ます)。読み込めなかった素材があると、作品の箱に名前と理由が出ます - 名前は英小文字・数字で始まり、英小文字・数字・`.` `_` `-` だけ、40文字まで - 中身も確かめます。拡張子だけ変えたファイルは断られます ### 7.1 画像 `ctx.texture(name)` で、材質に貼れるテクスチャになります。 ```js const mat = new THREE.MeshStandardMaterial({ map: ctx.texture("tile.png") }); ``` ### 7.2 3Dモデル `.glb` だけを置けます。`ctx.model(name)` は読み込んだglTF(`.scene` と `.animations`)を返します。 ```js 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体が同じ動きになります。 置けるモデルには、次の決まりがあります。 - すべて1つのファイルに埋め込む(外部のファイルを指すモデルは断られる) - 使える拡張機能は `KHR_materials_unlit`・`KHR_texture_transform`・`KHR_materials_emissive_strength` だけ(Draco・KTX2・meshoptで圧縮したものは断られる) - ノード2000・メッシュ500・プリミティブ2000・埋め込み画像20枚まで - 頂点や索引などのデータの要素は、合計4,000,000まで - アニメーションのデータは8MBまで。キーの数1つを16バイトとして見積もります(どの種類のキーも同じ) - 埋め込み画像の大きさは、持ち込む画像と同じ(縦×横4096×4096画素ぶんまで、1辺8192画素まで) ファイルが8MB以下でも、これらを超えると断られます。 ## 8. 軽く描くために | すること | 理由 | |---|---| | 形と材質は `init` で作り、使い回す | 毎フレーム作ると、GPUの資源があふれる | | 絵を描いたテクスチャは、中身が変わったときだけ描き直す | 描き直すたびにGPUへ送り直す | | 同じものをたくさん出すときは `InstancedMesh` を使う | 描く回数が1回で済む | | 消えては出るもの(爆発など)は、隠して使い回す | 作り直すと、出るたびに準備がかかる | 開発者タブの「開発者として開く」で遊ぶと、画面の隅にfpsなどの数字が出ます。「転送」は1秒あたりの、絵(キャンバスや画像)のテクスチャの送り直しです。骨のある3Dモデルが動くたびに送る骨の位置は、数えません。ほぼ毎フレーム起きていると、試合の最初の20秒で ⚠ が付きます。中身が変わらないのに毎回描き直している絵があります。