作者向けの文書 › lang-spec ・ 元の文書(.md)

ルール(game.js)の仕様

vsboxのゲームは、ルール(game.js)と見た目(visual.js)の2つのファイルでできています。この文書は、ルールの書き方をまとめたものです。見た目は見た目(visual.js)の仕様、物理は物理のAPIにあります。

ルールは、2人の端末でまったく同じ計算をして試合を進めます。そのため、ルールはJavaScriptの一部だけを使う決まった書き方で書きます。決まりに合わない書き方は、投稿したときにエラーになります。


1. 全体の形

const meta = { ... };               // このゲームの宣言(4. meta)

function setup(g) { ... }           // ラウンドの始まりに1回
function tick(g, input) { ... }     // 1秒に30回
function bot(g, owner) { ... }      // CPU の入力を返す
function visualState(g) { ... }     // 任意。見た目に渡す中身を返す
function controls(m, input) { ... } // 任意。手元の処理(9. 手元の処理)
関数必須呼ばれるとき
tick(g, input)○1tickごと(1秒に30回)
bot(g, owner)○CPUがいる試合で、1tickごとに tick の前
setup(g)ラウンドの始まり(試合の始まりを含む)
visualState(g)見た目に中身を渡すとき
controls(m, input)自分の端末で、入力を送る前

試合中にエラーが出ると、試合はそこで止まり、結果の板に理由と行番号が出ます。

ゲームが止まりました: 計算の結果が数になりません(undefined(そのプロパティは存在しません) * 600 → NaN) (7行目)

2. 言語

2.1 値

値例
整数0、-250、1500
文字列"player1"、'red'
真偽値true、false
nullnull
配列[1, 2, 3]
オブジェクト{ x: 0, hp: 100 }

小数はありません。長さや速さは、1単位を1000とした整数で書きます(1.5なら 1500)。

if などで偽として扱われるのは、false・0・""・null と、無いプロパティを読んだときの値です。それ以外は真です。

2.2 書けるもの

種類書き方
変数const、let
算術+ - * %、単項の -
代入=、+= -= *= %=、++ --(前にも後ろにも付けられる。数にだけ使える)
比較=== !== < > <= >=
論理&& || !
分岐if、else、else if、三項演算子 a ? b : c
繰り返しfor (const x of 配列)、for (const i of range(0, n))、break、continue
関数トップレベルの function 宣言、return
配列・オブジェクトリテラル、a[i]、o.k、o[k]、代入
文字列の連結"P" + n
コメント//、/* */

文の終わりは ; か改行です。1行に2つ以上の文を書くときは、; で区切ります。return のすぐ後で改行すると、値は返りません(JavaScriptと同じ)。

2.3 書けないもの

どれも、投稿したときにエラーになります。

書けないもの代わりに
/(割り算)div(a, b)
小数・指数(1e3)・16進数(0xff)整数
==、!====、!==
while、do、for (let i = 0; …)for (const i of range(0, n)) と break
再帰(関数が自分を呼ぶ・呼び合う)処理待ちの配列と、回数を決めたループ(8.1)
アロー関数 =>、関数式、関数の中の関数トップレベルの function 宣言
/=a = div(a, b)
テンプレート文字列 ` ${x} `"a" + x
switchif / else if
分割代入 const { x } = o、...1つずつ取り出す
1つの宣言で複数の変数 const a = 1, b = 21行に1つ
オブジェクトの省略記法 { x }{ x: x }
計算したキー { [key]: 1 }const o = {}; のあと o[key] = 1;
数のキー { 0: "a" }{ "0": "a" }(読むときは o[0] でよい)
ビット演算 & | ^ ~ << >>% と div
?.、??if か三項演算子で、null でないかを確かめる
typeof、instanceof、in、delete、void(なし)
varconst、let
class、new、thisオブジェクトと関数
try、catch、throw(なし)
async、await、yield(なし)
Math、Date、require、import、export、eval、Function、globalThis、window3. 組み込みの関数、g.random

2.4 配列・文字列・オブジェクトでできること

配列で使えるのは、次のものだけです。関数を受け取るもの(map・filter・sort など)と、splice・join などはありません。

書き方中身
a[i]、a.length要素と長さ
a.push(x, …)後ろに足す。新しい長さを返す
a.pop()後ろから1つ取り出して返す。空なら null
a.slice(始め, 終わり)一部の写し(元は変えない)。引数は省ける。負の数は後ろから数える
a.indexOf(x)=== で比べて最初の位置。無ければ -1
a.includes(x)=== で比べて、あれば true

配列やオブジェクトどうしの === は、同じものかどうかで比べます(中身が同じでも、別に作ったものは別)。

要素を条件で取り除くときは、残す要素で新しい配列を作ります。

const kept = [];
for (const b of g.state.bullets) {
  if (b.life > 0) { kept.push(b); }
}
g.state.bullets = kept;

文字列は、比較、+ での連結、s.length(長さ)ができます。s[0] のように1文字ずつ取り出すことはできません。

オブジェクトは、o.k・o[k] で読み書きできます。キーがあるかは has(o, k)、キーの一覧は keys(o) で調べます(3)。キーを消す方法はありません。

注意 無いプロパティを読むと「値が無い」が返りますが、それと比べる方法がありません(undefined という名前は使えません)。キーがあるかは has(o, k) で確かめるか、最初に null で用意しておいて === null で確かめます。

__proto__・constructor・prototype という名前は、読むことも書くこともできません。

2.5 整数の計算

2.6 状態は g.state に置く

試合の状態は g.state に置きます。エンティティ(7.2)の自分のフィールドも状態になります。

トップレベルの const と let は、読むことしかできません。変数への代入も、中の配列やオブジェクトの書き換えもエラーになります。

const SPEED = 300;              // ○ 定数として読む
const plan = { target: 0 };

function tick(g, input) {
  plan.target = 5;              // ✗ エラー
  g.state.target = 5;           // ○
}

関数の中で作った配列やオブジェクトは、自由に書き換えられます。

ヒント ルールは、通信の遅れを埋めるために、過去のtickに戻ってやり直すことがあります。戻るのは g.state とエンティティと物理だけなので、状態をほかに置くと、やり直しのたびにずれていきます。

2.7 命令数の上限

1回の呼び出し(tick・bot など)で実行できる文と関数呼び出しは、合わせて2,000,000回までです。超えると「命令数の上限を超えました」というエラーになります。

3. 組み込みの関数

g. を付けずに呼べる関数です。数を受け取る関数(div・abs・min・max・sqrt・normalize・range)に数以外を渡すと、エラーになります。

関数返すもの
div(a, b)割り算の商(0の方向へ切り捨て)。b が 0 ならエラー
abs(a)絶対値
min(a, b)、max(a, b)小さいほう、大きいほう
sqrt(a)平方根(切り捨て)。a が負ならエラー
normalize(dx, dy)同じ向きで長さ1000の { x, y }。(0, 0) なら { x: 0, y: 0 }
range(a, b)[a, a+1, …, b-1]
has(o, k)オブジェクト(か配列)が、キー k を持っていれば true
keys(o)オブジェクトのキーの配列(書いた順。整数の形のキーは小さい順に先に並ぶ)

4. meta

const meta = { … } は、このゲームについてvsboxに伝える宣言です。どの項目も省略できます。

const meta = {
  countdown: 90,
  view: { width: 16000, height: 24000 },
  frameColor: "#7fb069",
  sounds: { explode: "boom.mp3" },
};
項目中身書かないとき
countdown各ラウンドの始まりに世界を止めておくtick数。0〜1800(60秒)0
view画面に収める世界の広さ { width, height }。それぞれ500〜1000000012000×12000
frameColor背景の色(試合画面と一覧のカード)。#rgb・#rrggbb・rgb(r,g,b)・色名#161b22
orientation試合画面の向き。"portrait"(縦)か "landscape"(横)(4.4)"portrait"
devices遊べる端末。"mobile"(スマホ・タブレット)・"pc"(パソコン)・"both"(両方)(4.5)"mobile"
interpolate間を補って描くか。true か false(4.9)false
gravity重力 [x, y, z]。物理の体を作るなら必須無し(物理のAPI)
layers衝突レイヤーの名前の配列すべてが当たる(物理のAPI)
opponentsCPUの一覧(4.6)CPUは1体
looks見た目の変更の欄(4.7)見た目の変更は無し
sounds出来事に鳴らす音(4.8)音は鳴らない

4.1 countdown

ラウンドが始まる前に、世界を止めておく長さです。この間は tick も物理も止まります。止まっている間に「3・2・1」を出すのは見た目の仕事です。

4.2 view

画面に収める世界の広さです(1単位 = 1000)。vsboxはこの範囲が画面に収まる位置にカメラを置きます。見た目でカメラを動かすゲームでは、最初の位置とサムネイルにだけ効きます。

4.3 frameColor

背景の色です。次の2か所に使います。

空や地面に近い色にしておくと、読み込みの途中やサムネイルの前でも、ゲームの雰囲気が伝わります。

4.4 orientation

試合画面を縦長と横長のどちらで作るかです。書かなければ縦です。

orientation: "landscape",

4.5 devices

どの端末で遊べるゲームかです。書かなければ "mobile" です。

devices: "both",
値意味
"mobile"指で操作する端末(スマホ・タブレット)
"pc"マウスとキーボードで操作する端末(パソコン)
"both"両方

4.6 opponents

CPU対戦で選べる相手の一覧です。最大12体です。

opponents: [
  { name: "Easy", description: "ゆっくり動く", speed: 70, rating: 900 },
  { name: "Hard", description: "先を読む",     speed: 100 },
]
キー必須中身
name○名前(60文字まで)
description○説明(60文字まで)
ratingCPU戦レートの目盛り。0〜4000の整数
そのほか自由。bot の中で読んで、強さの差を作る

4.7 looks

勝ち負けに関わらない見た目(サイコロの色、キャラの服、背景など)を、遊ぶ人が選べるようにします。選んだものは見た目にだけ届き、ルールには届きません。

looks: [
  { id: "dice", name: "サイコロ", owner: "each", options: [
    { id: "white", name: "白", description: "定番の白" },   // 最初が標準
    { id: "red",   name: "赤" },
  ] },
  { id: "table", name: "卓", owner: "player1", options: [ … ] },
]
キー中身
id英小文字・数字・_・- の1〜24文字。頭は英小文字か数字
name画面に出す名前(20文字まで)
owner誰の選択を使うか(下)
options選択肢の配列(1〜24個)。最初が標準

選択肢は { id, name, description } です。description は任意で、80文字までです。欄は最大8つです。同じ id を2回使うことはできません。

owner使う選択
"each"2人それぞれの選択(自分のサイコロ、自分のキャラ)
"player1"player1 の選択を2人とも使う(マップ、背景、2人で使う札)
"player2"player2 の選択を2人とも使う

4.8 sounds

g.effect(名前, data)(7.4)を出したときに鳴らす音を決めます。最大20個です。音は3通りで書けます。

書き方例中身
棚の音"@card-place-1"vsboxが置いている録音の音。一覧は音の棚
数の並び"[,,925,.04,.3,.6,1,.3,,6.27,-184,.09,.17]"数から音を作る(ZzFX)。電子音になりやすい
ファイル名"boom.mp3"作品に持ち込んだ音のファイル
sounds: {
  place: "@card-place-1",                              // 棚の音
  shoot: "[,,925,.04,.3,.6,1,.3,,6.27,-184,.09,.17]",  // 数の並び
  explode: "boom.mp3",                                 // ファイル
  hit: { self: "[,,537,.02,.02,.22,1,1.59,-6.98,4.97]", other: "hurt.wav" }, // 持ち主が自分なら self、相手なら other
}

{ self, other } の形にすると、出来事の持ち主(data.owner)が画面を見ている本人かどうかで、鳴らす音を変えられます。片方だけでもかまいません。持ち主の無い出来事では鳴りません。

数の並び(ZzFX)

ZzFX の並びを、そのまま " " で囲んで書きます(game.js には小数が無いため、文字にします)。

4.9 interpolate

ルールは1秒に30回進みます。true にすると、絵をそれより多く描き、その間の姿を見た目のコードが補えるようになります。書かなければ、絵はルールが進むのと同じ1秒30回までです。

interpolate: true,

5. 関数

5.1 setup(g)

ラウンドの始まりに1回呼ばれます。試合の始まりも、1ラウンド目の始まりです。

function setup(g) {
  if (g.round === 1) { g.state.wins = { player1: 0, player2: 0 }; }   // 最初だけ
  g.state.timeLeft = 1800;                                            // 毎ラウンド
}

5.2 tick(g, input)

1tickごとに呼ばれ、試合を1歩進めます。input には2人の入力が入っています(6. 入力)。

5.3 bot(g, owner)

CPUの席(owner は "player1" か "player2")の入力を返します。返したものが、そのtickの input[owner] になります。

function bot(g, owner) {
  const me = meta.opponents[g.cpu[owner]];      // いまの相手
  return { cpu: { move: 1, fire: g.state.ready } };
}

5.4 visualState(g)

見た目に渡す中身を返します。書かなければ g.state がそのまま渡ります。

見た目には毎tick中身の写しが渡るので、g.state に大きなデータ(CPUの読みの表など)があると重くなります。そのときは、見た目が使う欄だけを返します。

5.5 controls(m, input)

  1. 手元の処理を見てください。

6. 入力

tick(g, input) の input は、席の名前ごとの入力です。

input.player1 = {
  w: 1000, h: 2164,   // 画面の広さ
  dp: 393,            // 画面の短い辺の長さ(dp)
  touches: [ { id, x, y, sx, sy, ray, pressed, released }, … ],
  keys: [ { code, pressed, released }, … ],
}

6.1 画面と指

名前中身
w、h画面の広さ。短いほうの辺が1000。縦持ちなら w は1000、h は2000前後
dp画面の短い辺の実際の長さ(dp。160dpがおよそ1インチ)
touchesいま触れている指(最大20)
keysいま押しているキー(最大10。6.6)
指のキー中身
id指の番号。押してから離すまで同じ。押すたびに新しい番号
x、y指の位置。画面の左上から、w・h と同じ物差し
sx、syその指を押し始めた位置
pressed触れた瞬間のtickだけ true
released離した瞬間のtickだけ true。速いタップでは pressed と同時に立つ
ray指の先の世界の光線 { ox, oy, oz, dx, dy, dz }。始点はカメラのすぐ前の点で、物理と同じ物差し(1000 = 見た目の場面の長さ1)。向きは長さ1000

6.2 世界の何かを指す

ray を g.rayHit(物理のAPI)で世界に当てると、盤のマスや駒が分かります。光線は入力として相手にも同じ値が届くので、当てた結果も2人で同じになります。

for (const t of input[owner].touches) {
  if (!t.pressed) { continue; }
  const r = t.ray;
  const hit = g.rayHit(r.ox, r.oy, r.oz, r.dx, r.dy, r.dz, 200000);
  if (hit !== null) { g.state.picked = { x: hit.point.x, z: hit.point.z }; }
}

6.3 スティックとボタン

vsboxは操作の部品を用意しません。画面全体が入力の面で、どこをスティックやボタンにするかは作者が決め、絵も見た目で描きます。スティックは次の形で書けます。

  1. 押した瞬間に丸の中なら、その指の id を覚える(覚えないと、2本目の指に移ってしまう)
  2. それからはその指だけを追い、中心からのずれを半径で頭打ちにする
  3. 中心の近くは、倒していない扱いにする
  4. 離したら忘れる

つまみの位置を見た目に描かせるには、g.state に置きます。見た目は指を直接読めません。

6.4 CPUの入力

CPUの入力は、bot が返したものです。指を作る必要はありません。CPU専用のキーで意図を返し、tick で見分けます。

function intentOf(g, input, owner) {
  const inp = input[owner];
  if (inp.cpu) { return inp.cpu; }       // CPU
  return readPad(g, inp, owner);         // 人(6.3 のスティック)
}

6.5 カメラを反対側に置いたとき

見た目で player2 のカメラを反対側に回すと(見た目(visual.js)の仕様 3.1)、その人の画面は180度回ります。x・y は画面の座標なので、世界の向きに直すのはルールの仕事です。ray は実際のカメラから作るので、直す必要はありません。

if (owner === "player2") { return { dx: -stick.dx, dy: -stick.dy }; }

6.6 キーボード

パソコンで押しているキーです。指と同じく、押した瞬間と離した瞬間に印が立ちます。

キーのキー中身
codeキーの場所の名前。"KeyW"、"ArrowUp"、"Space"、"Enter"、"Digit1" など
pressed押した瞬間のtickだけ true
released離した瞬間のtickだけ true。速く押して離すと pressed と同時に立つ
let dx = 0;
for (const k of input[owner].keys) {
  if (k.code === "Space" && k.pressed) { jump = true; }
  if (k.released) { continue; }
  if (k.code === "KeyA" || k.code === "ArrowLeft") { dx = dx - 1; }
  if (k.code === "KeyD" || k.code === "ArrowRight") { dx = dx + 1; }
}

7. g のAPI

7.1 状態と時間

名前中身
g.state作者が自由に使う状態。入れ子にしてよい
g.tickいまのtick(試合の始まりが0)
g.roundいまのラウンド(1から)
g.cpu席ごとのCPUの番号 { player1, player2 }。人の席は -1。meta.opponents が無ければCPUは 0

7.2 エンティティ

エンティティは、物を入れておく入れ物です。使わなくてもかまいません(盤のゲームは g.state だけで書けます)。

API中身
g.spawn({ … })体を持たないエンティティを作って返す
g.destroy(e)エンティティを消す(体があれば体も)
g.entitiesすべてのエンティティの配列(作った順)
g.entitiesOfKind(kind)kind が同じものだけの配列
const coin = g.spawn({ kind: "coin", owner: null, x: 0, z: 3000, value: 10 });
coin.value = 20;   // 自分のフィールドは書き換えられる

7.3 ラウンドと決着

API中身
g.endRound()ラウンドを区切る
g.winMatch(owner)owner の勝ちで試合を終える
g.drawMatch()引き分けで試合を終える
function scoreRound(g, winner) {
  if (winner !== null) {
    g.state.wins[winner] = g.state.wins[winner] + 1;
    if (g.state.wins[winner] >= 2) { g.winMatch(winner); return; }
  }
  g.endRound();
}

7.4 見た目への出来事 g.effect(name, data)

爆発や得点のような「その瞬間に起きたこと」を、見た目に伝えます。data には好きな値を入れられます。

g.effect("hit", { owner: "player1", x: e.x, z: e.z });

7.5 乱数

乱数は2種類あります。使い分けの基準は、相手より先に値を知ると、選び方が変わるかどうかです。

g.random(n)g.deal(名前, 個数, n)
値が出るとき呼んだその場少しあと(届くまで0.1〜0.3秒ほど)
先に読めるか読める(改造した端末なら、これから出る値を計算できる)読めない
向いているもの盤面の配置、演出の揺らぎサイコロ、カードの山、くじ
API中身
g.random(n)0〜n-1の整数を返す。n を省くと、0〜4294967295の整数を返す
g.deal(名前, 個数, n)0〜n-1の整数を、個数ぶん頼む
g.dealt(名前)頼んだ値。届くまでは null、届くと整数の配列

g.random(n) は、0〜n-1の整数を返します。n を省くと、0〜4294967295の整数を返します。

g.deal(名前, 個数, n) で頼み、届いたら g.dealt(名前) で受け取ります。

if (rollPressed) { g.deal("dice", 5, 6); g.state.rolling = 1; }   // 0〜5 を5個
const v = g.dealt("dice");        // 届くまで null。届くと [3, 0, 5, 5, 2] のような配列
if (g.state.rolling === 1 && v !== null) { g.state.dice = v; g.state.rolling = 0; }

7.6 伏せた札

手札のように、本人にしか見えてはいけないものを扱います。札の中身はサーバーが混ぜて、見せる相手の端末にだけ送ります。相手の端末には中身が届かないので、改造しても読めません。

g.deck("cards", 52);                      // 0〜51 が1つずつ入った、誰にも見えない山
g.show("cards", [0, 1, 2], "player1");    // 位置0〜2の札を player1 にだけ見せる(配る)
g.show("cards", [5], "all");              // 位置5の札を全員に明かす(出す)
const s = g.shown("cards");               // 全員に明かした札の中身。ほかの位置は null
if (s[5] !== null) { /* 明かした札で判定する */ }
API中身
g.deck(名前, 枚数)0〜枚数-1が1つずつ入った山を作る。同じ名前で呼ぶと、新しく混ぜ直した山になる
g.show(名前, 位置の配列, 誰に)その位置の札を見せる。誰に = "player1"・"player2"・"all"。中身は少しあとに届く
g.shown(名前)全員に明かした札の中身の配列(ほかの位置は null)。山が無ければ null
g.peek(名前)bot の中でだけ使える。そのCPUの席に見せた札の中身の配列

7.7 整数の幾何

API中身
g.sin(angle)、g.cos(angle)角度のsin・cosを1000倍した整数。角度は1周 = 3600(0.1度きざみ)
g.rotate(x, y, angle)点 (x, y) を原点のまわりに回した { x, y }

回るものは、前の位置から少しずつ回すのではなく、毎回 g.rotate で元の位置から角度ぶん回します。少しずつ回すと、丸めの誤差がたまって縮んでいきます。

7.8 物理

体を作る・動かす・調べるAPI(g.dynamicBody・g.applyImpulse・g.rayHit など)は、物理のAPIにあります。物理を使わないゲームは、1つも使わなくてかまいません。

8. 書き方の例

8.1 連鎖の書き方

爆弾の連鎖のように広がっていく処理は、再帰の代わりに「処理待ちの配列」と回数を決めたループで書きます。

function explodeAll(g, first) {
  let queue = [first];
  for (const step of range(0, 100)) {            // 回数の上限を決める
    if (queue.length === 0) { break; }
    const next = [];
    for (const b of queue) {
      for (const other of g.entitiesOfKind("bomb")) {
        if (other.lit === 0 && near(b, other)) { other.lit = 1; next.push(other); }
      }
    }
    queue = next;
  }
}

9. 手元の処理(controls)

手札を強さの順に並べて見せるゲームでは、触った場所から札の中身が分かってしまいます(「右端を触った」は、いちばん強い札)。触った指は相手の端末にも届くので、そういうゲームは controls(m, input) を書きます。

controls は自分の端末でだけ動き、触った指を「行動」(整数の配列)に直します。相手に届くのは、返した配列だけです。ルールには input[席].pick として届きます。

controls の input は、自分1人ぶんの指の入力({ w, h, dp, touches }。6.1)です。tick の input のように、席ごとには分かれていません。

function controls(m, input) {
  const hand = m.view("cards");         // 自分に見えている札の中身
  // 並べ替え・触った札の判定・選んでいる札の印(m.memo)・出せるかの確認
  if (pressedPlay && legal) { return chosenPositions; }   // 出す札の位置
  return [];                            // 何もしないtick
}

function tick(g, input) {
  const p = input[g.state.turn];
  if (p && p.pick && p.pick.length > 0) { g.show("cards", p.pick, "all"); }   // 明かしてから判定する
}
m中身
m.role自分の席
m.stateルールの状態(写し。読むだけ)
m.view(名前)自分に見えている山の中身(位置ごと。見えていない位置は null)
m.memo自分だけの覚え書き。相手には届かない。見た目には届く
作者向けの文書 ・ vsbox について ・ トップ