見た目(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 も呼ばれません。
- 席(
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 が変わったものは作り直します。
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)の仕様 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.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) が、一度だけ届きます。次のフレームでは空になります。爆発の粒子や得点の演出は、ここで作ります。
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")です。
nullのときに何と出すか(「あなた」「相手」など)は、作者が決めます- 人が打った文字です。絵に描いて出す以外には使わないでください
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"; // 例:画面のスティックは、指の端末でだけ描く
- 見た目は端末ごとに違ってよいので、2人で描くものが違っても試合はずれません
- 見えなくしても、その場所をマウスで押せば今までどおり指として届きます(ルールは指の位置を読むので)
- 読まなくてもかまいません。読まなければ、どの端末でも同じ絵になります
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);
}
}
alphaは、0のときprevの時点、1のとき今の状態の時点です。補って描くと、見える姿は最大でルールの1回ぶん(1/30秒)前になりますprevはいつも入っています。試合の最初と、試合の前の描画の準備、サムネイルでは、今の状態そのものですmeta.interpolateを宣言していないとき、描くのが間に合わずに1秒30回へ戻したとき、試合の前と決着のあとは、alphaはいつも1です- ラウンドが変わって初めの位置に戻るなど、一度に大きく跳んだものを補うと、間を滑って見えます。補うかどうかは、見た目のコードで決めます
- 1つ前の状態に無かったもの(新しく出てきたもの)は、補えません。今の状態のまま描きます
- エンティティの
idは振り直されることがあります(1.2)。prevとidで突き合わせるときは、同じものかどうかも確かめます
5. 文字を出す
見た目からは画面の部品(DOM)に触れないので、文字は絵に描いて板に貼ります。
OffscreenCanvasに文字を描くTHREE.CanvasTextureにする- 板(
PlaneGeometry)に貼る - 板をカメラの子にする(カメラが動いても、画面の同じ場所に留まる)
注意 カメラは最初、場面に入っていません。子を足したら
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にそのまま渡せます
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)の仕様 4.8)
- 大きさ・長さ・画素数は、置くときに確かめます。超えるものは断られます(遊ぶときに初めて分かる、ということはありません)
- 素材は遊ぶ前にまとめて読み込みます。多いほど「準備中…」が長くなります(1MB を超えると、読み込んだ割合が出ます)。読み込めなかった素材があると、作品の箱に名前と理由が出ます
- 名前は英小文字・数字で始まり、英小文字・数字・
._-だけ、40文字まで - 中身も確かめます。拡張子だけ変えたファイルは断られます
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体が同じ動きになります。
置けるモデルには、次の決まりがあります。
- すべて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秒で ⚠ が付きます。中身が変わらないのに毎回描き直している絵があります。