Aim Training は、ブラウザで遊べる無料のエイム練習ゲームです。練習メニュー(ドリル)を選び、ハンドガンやライフル、スナイパーで的を撃つと、記録に応じてランクが付きます。
実装は Claude Code が書き、私は仕様の判断と試遊を担当しました。最初のコミットが 2026 年 9 月 2 日、ゲームサイトへの提出が 10 月 1 日なので、ちょうど 1 か月です。この記事では、その 1 か月で決めたこと、作ったしくみ、そしてつまずいたところを順に書きます。
感度の換算表から始まった
いちばん古い資料は、8 月 31 日に書いた感度の換算仕様です。よく遊ばれている FPS 8 本ぶんの感度、視野角、弾道を、タイトル名を伏せた記号(CS セット、V セット、のように)で扱う内容でした。この時点で、感度は cm/360(照準を 360 度回すのに、マウスを何 cm 動かすか)だけを正とする、と決めています。
翌 9 月 1 日に、仕様書を 00 から 15 までと、設計の判断を残す ADR(Architecture Decision Record)を 6 本書きました。README の冒頭には「番号付きの文書が唯一の正で、コードはそれに従う」と書いてあります。既存のエイム練習ソフトは PC 専用で有料、抽象的な球を撃つものが多いので、それに対して次の 3 つを打ち出しました。
- 人気のジャンルの手触りを、アーキタイプ(反動や拡散の傾向をまとめた設定の組)で再現する
- マウス、ゲームパッド、タッチに初日から対応し、ブラウザで無料で遊べる
- 的はホログラムにして、出血の表現を入れない
仕様書には、作り始める前に確かめるべき技術的な検証項目(V1〜V8)も並べていました。たとえば V7 は「1000Hz のマウスの移動量が、ブラウザのイベントで欠けないか」です。ただ、技術検証のマイルストーン(M0)はやらないと決めたので、V1〜V8 はいまも全部「未」のままです。この記事に出てくる入力のしくみは、取りこぼさないように作ったものであって、取りこぼさないことを測ったものではありません。
マイルストーンの進み方は次のとおりです。
| マイルストーン | 内容 | 実際 |
|---|---|---|
| M1 | 1 つの部屋、3 種の銃、的、当たり判定 | 9 月 2 日 |
| M2 | ドリル 9 本、スコア、自己ベスト | 9 月 2 日 |
| M3 | タッチ、ゲームパッド、TPS のドリル 3 本 | 9 月 2 日 |
| M4 | 演出、UI、音、クレジット | 9 月 3 日 |
| M5 | ゲームサイトの公開要件をそろえて提出 | 10 月 1 日 |
M1 のコミットは、仕様書と実装を合わせて 96 ファイル、1 万 8 千行ありました。M4 までが 2 日で終わり、残りの 4 週間は、手触りの調整やドリルの追加に使っています。
途中で外したものもあります。9 月 5 日には TPS(三人称視点)のモードをやめました。照準の練習への寄与が小さいと判断したからで、ドリル 3 本と自キャラのモデル、684KB のコードをまとめて消しています。
描画は WebGL2、依存は three.js だけ
描画には three.js の WebGLRenderer(WebGL2)を使っています。ADR の 1 本目で、WebGPU 用の WebGPURenderer を見送り、実行時の依存も three 1 つに絞りました。
WebGPURenderer を見送った理由は、three.js のマニュアルが当時 WebGPURenderer を実験的と位置づけ、ShaderMaterial と EffectComposer に対応しないと書いていたことです。このゲームは、ホログラムの的を手書きの GLSL(ShaderMaterial)で描き、高画質の設定では EffectComposer でブルームをかけています。どちらも使えないと困るので、WebGL2 にしました。
ADR には「WebGPU は iOS 26 以降が必要で、対象の端末に届かない」とも書きました。ただ、WebGPURenderer は、WebGPU が使えない環境では WebGL2 に切り替えて動く作りです(Rollshade はその作りに頼っていて、WebGL2 の経路で黒い画面を踏みました)。後から見ると、iOS の版は WebGPURenderer を避ける理由としては弱く、効いていたのは上の 2 つの機能のほうです。
物理エンジンも入れていません。候補にした Rapier は WebAssembly で 2MB 前後あり、当初の提出先候補の容量上限(8MB)を圧迫するためです。移動と衝突は、球やカプセル、箱を相手にする程度なので、自前で書いています。
Pointer Lock で、加速のかからない入力を受け取る
FPS では、マウスを動かしてもカーソルが画面の端で止まってはいけません。Pointer Lock は、カーソルを隠して画面に固定し、マウスの移動量だけを受け取れるようにする API です。
Pointer Lock には unadjustedMovement という指定があります。OS のマウスの加速(速く動かすと余分に進む機能)を通さない、生の移動量を受け取るための指定です。仕様書には「Chrome、Edge、Firefox のデスクトップで使える。Safari と、Chromium でも Linux では使えない」と調べてあり、使えないときは普通の Pointer Lock に切り替えます。
/** 03 §3-1 の手順。unadjustedMovement を試し、駄目なら通常のロックへ落とす。 */
async requestPointerLock(): Promise<void> {
const canvas = this.canvas;
if (!canvas) return;
try {
const result = canvas.requestPointerLock({ unadjustedMovement: true }) as
| Promise<void>
| undefined;
if (result && typeof result.then === 'function') {
await result;
this.lock.locked = true;
this.lock.rawInput = true;
this.lock.unavailable = false;
return;
}
// 戻り値が undefined の古い API は pointerlockchange を待つ
await this.waitForLockChange(500);
this.lock.rawInput = false;
return;
} catch {
try {
const result = canvas.requestPointerLock() as Promise<void> | undefined;
// ...
古いブラウザの requestPointerLock() は Promise を返さないので、ロックできたかどうかを pointerlockchange イベントで待っています。生の入力が使えたかどうか(rawInput)は、プレイの記録と一緒に残しています。
1 フレームに何回動いても、全部数える
マウスによっては、1 秒に 1000 回も移動量を送ってきます。ところが、ブラウザの pointermove イベントは、画面の更新に合わせて間引かれることがあります。間引かれた分は、getCoalescedEvents() で取り出せます。
// コアレス分も展開して順序を保つ(03 §2-1)
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [e];
const list = events.length > 0 ? events : [e];
for (const ev of list) {
const dx = ev.movementX ?? 0;
const dy = ev.movementY ?? 0;
if (dx === 0 && dy === 0) continue;
this.push({ kind: 'look', tS: ev.timeStamp / 1000, dxCounts: dx, dyCounts: dy });
}
画面の更新を待たずに届く pointerrawupdate イベントもあります。ただ、環境によってはイベントの名前が存在するのに 1 回も届かないので、最初から決め打ちにはしていません。pointerrawupdate が 1 回でも届いたら、それ以降は pointermove の移動量を無視します。両方を数えると、同じ動きを二重に足してしまうからです。
感度は「1 回転に何 cm か」で持つ
FPS の感度は、ゲームごとに単位がばらばらです。cm/360 なら、ゲームが変わっても手の動かし方を同じにできます。内部では、これを 1 カウントあたりの角度に直して使います。
export function degPerCount(s: SensitivitySettings): number {
return 360 / ((s.cm360 / 2.54) * s.dpi);
}
マウスの DPI は「1 インチ動かしたときのカウント数」なので、cm をインチに直して DPI を掛けると、1 回転ぶんのカウント数になります。360 度をそれで割れば、1 カウントあたりの角度です。初期値は 35cm/360、800DPI にしています。
最初は cm/360 を 1cm 刻みでしか設定できませんでした。20cm/360 くらいの感度だと 1cm は 5% にあたるので、ほかのゲームの感度を持ち込むと、最大で 2.5% ずれます。ほかのゲームと同じ感度で練習する、という一番の使い方ができないので、0.1cm 刻みにして直接入力の欄も足しました。
マウスの移動量は、この角度を掛けて、そのまま視点の向きに足します。
/** マウスの計数を角度に変換して積分する(03 §4-1)。平滑化と加速は行わない。 */
applyLookCounts(
dxCounts: number,
dyCounts: number,
degPerCount: number,
sensScale: number,
invertY: boolean,
): void {
const k = degPerCount * sensScale * this.slowdownFactor;
this.heldYawDeg -= dxCounts * k;
this.heldPitchDeg = clamp(this.heldPitchDeg - dyCounts * k * (invertY ? -1 : 1), -89, 89);
}
sensScale は、後で書くスコープを覗いたときの倍率です。slowdownFactor は、ゲームパッドとタッチのためのエイムアシスト(的の上で照準を遅くする)の係数です。アシストはマウスには一切かけない、と ADR で決めているので、マウスでは常に 1 です。上下の向きは ±89 度で止めています。真上や真下を向くと、左右の回転の向きが決まらなくなるからです。
スコープを覗いたときの感度
スコープを覗くと、視野が狭くなって画面が拡大されます。このとき感度が覗く前と同じだと、照準が速く動きすぎて扱えません。そこで、拡大の度合いに合わせて感度を下げています。
export function adsSensScale(settings: UserSettings, hipZoom: number, adsZoom: number): number {
const vfov = vfovDegFrom169(settings.fovHdeg169);
if (settings.ads.zoomMode === 'none') return settings.ads.adsExtraMultiplier;
const hipV = zoomVfovDeg(vfov, hipZoom);
const adsV = zoomVfovDeg(vfov, adsZoom);
let base: number;
if (settings.ads.zoomMode === 'focalLength') {
base = Math.tan((adsV * Math.PI) / 360) / Math.tan((hipV * Math.PI) / 360);
既定の focalLength は、覗く前と後の視野角の tan の比を倍率にする方式です。画面の中央付近では、覗く前と同じ手の動きで、画面上の同じ距離だけ照準が動きます。ほかのゲームに合わせられるよう、倍率を変えない方式と、画面上の決まった距離で合わせる方式も選べます。
動きは 60Hz、視点は描画のたびに
ゲームの中の時間は、1/60 秒ずつ進めています。的の動きや移動を、描画の速さに関係なく同じにするためです。
if (this.state === 'drill' && !world.cleared) {
this.reportBlocked(world, frame);
this.accumulatorS += elapsed;
let steps = 0;
while (this.accumulatorS >= SIM_DT && steps < MAX_STEPS_PER_FRAME) {
world.step(SIM_DT);
this.accumulatorS -= SIM_DT;
steps += 1;
}
if (this.accumulatorS >= SIM_DT) {
this.droppedS += this.accumulatorS;
this.accumulatorS = 0;
}
world.resolvePendingShots();
経過した時間を溜めておき、1/60 秒ぶん溜まるたびに 1 歩進めます。1 回の描画で進めるのは 4 歩までで、処理が追いつかずに捨てた時間は droppedS に数えます。捨てた時間が合計 0.5 秒を超えたプレイは、正しく測れていないので記録の対象から外します。
最初の案は、すべてを 120Hz の固定ステップで動かすものでした。これだと 60Hz の画面で毎フレーム 2 歩、30fps しか出ない Chromebook では 4 歩になり、キャラクターの骨の計算が重すぎます。そこで ADR の 3 本目で、移動と的は 60Hz、視点の向きだけは描画のたびに反映する、と分けました。
視点まで 60Hz の歩みに縛ると、高いリフレッシュレートの画面で、手の動きに対して視点が遅れて見えます。移動や的のように描画時に補間すれば見た目はなめらかになりますが、そのぶん最大で 1 歩(16.7ms)遅れます。視点は遅れをいちばん感じやすいところなので、届いた入力をその場で足しています。
for (const ev of frame.events) {
const tS = toSimTime(ev.tS);
if (ev.kind === 'look') {
this.view.applyLookCounts(
ev.dxCounts,
ev.dyCounts,
degPerCount(this.settings.sensitivity),
this.currentSensScale(),
this.settings.sensitivity.invertY,
);
} else {
this.handleButton(ev.button, ev.pressed, tS);
}
}
マウスの移動とクリックは、届いた順に 1 つずつ処理しています。同じフレームの中で「動かしてから撃った」場合、押した瞬間の 1 発目は、クリックより前の動きだけを足した向きで撃たれます。フレームの最後の向きで判定すると、撃ったあとに動かした分まで照準に含まれてしまいます。
ただし、ボタンを押しっぱなしにした連射の 2 発目以降は、そのフレームの入力を全部足したあとの向きで撃っています。発射の時刻がフレームの途中に落ちても、使う向きは最新のフレームのもので、ずれは 1 フレーム未満です。
当たり判定は、撃った瞬間の的の位置で行う
動く的を撃ったとき、判定に使う的の位置もずれやすいところです。撃った瞬間は、1/60 秒の区切りとは限りません。そこで、的の位置を一定時間ぶん記録しておき、撃った時刻の位置を前後の記録から補間して求め、それに対して判定しています。
/** tShot へ補間した標的姿勢に対するレイキャスト(05 §7、§10)。 */
private raycastTargets(
// ...
): RaycastHit | null {
let best: RaycastHit | null = null;
for (const target of this.targets.targets) {
if (!target.hittable) continue;
const count = this.history.sample(tS, target.id, this.sampleBuffer);
for (let i = 0; i < count; i++) {
const hb = this.sampleBuffer[i]!;
const t = rayHitbox(origin, dir, hb, inflateM);
if (t === null || t > maxRangeM) continue;
// ...
判定には three.js の Raycaster を使わず、球やカプセルと直線の交わりを計算する関数を自分で書いています。的は球やカプセルのような単純な形の組み合わせなので、ポリゴンを 1 枚ずつ調べなくても、交わる位置を式で直接求められます。
弾の出る位置にも同じ考え方が要りました。横に移動しながら撃つと、最初は弾の出る位置が画面に描いた位置から最大 7.6cm ずれていました。画面は 2 つの歩みの間を補間して描いているのに、弾は歩みの位置から出していたからです。直前に描いたフレームと同じ補間位置から撃つように直しています。
つまずいたところ
ここからは、試遊しながら見つけた不具合と、その直し方です。動作の確認には、ヘッドレス Chrome を Chrome DevTools Protocol で操作する使い捨てのスクリプトを使いました。開発ビルドだけに出している内部の窓口から、ボットに撃たせて数字を取ります。ちなみにヘッドレス Chrome でも音はスピーカーから鳴るので、一度テスト中に音を鳴らして驚かされました。それ以来 --mute-audio を必ず付けています。
右クリックを押したまま左クリックすると、撃てない
9 月 26 日に、スコープを覗く操作の既定を F キーから右クリックに変えました。一般的な FPS に合わせるためです。変えた直後に試すと、右クリックで覗いたまま左クリックしても、弾が出ませんでした。
原因は、ブラウザが「別のボタンを押している最中に押されたボタン」を pointerdown として通知しないことでした。Pointer Events の仕様では、2 つめのボタンの押下は pointermove などのイベントの buttons(押されているボタンを表すビットの組)の変化としてしか伝わりません1。それまでのコードは、pointerdown の e.button しか見ていませんでした。
- const code = `Mouse${e.button}`;
e.preventDefault();
- this.keysDown.add(code);
- this.handleAction(this.actionFor(code), true, e.timeStamp / 1000);
+ this.syncMouseButtons(e);
+ }
+
+ private syncMouseButtons(e: PointerEvent): void {
+ if (e.pointerType === 'touch') return;
+ for (let bit = 0; bit < MOUSE_BIT_TO_BUTTON.length; bit++) {
+ const code = `Mouse${MOUSE_BIT_TO_BUTTON[bit]}`;
+ const pressed = (e.buttons & (1 << bit)) !== 0;
+ if (pressed === this.keysDown.has(code)) continue;
+ if (pressed) this.keysDown.add(code);
+ else this.keysDown.delete(code);
+ this.handleAction(this.actionFor(code), pressed, e.timeStamp / 1000);
+ }
イベントが届くたびに buttons を前回と比べ、変わったボタンを押した、離したとして扱います。pointerrawupdate と pointermove のどちらでも同じ処理を呼んでいます。
ここでもう一つ罠があります。buttons のビットは左、右、中の順ですが、button の番号は左が 0、中が 1、右が 2 です。並びが違うので、ビットの位置からボタン番号へ変換する表を挟んでいます。
const MOUSE_BIT_TO_BUTTON = [0, 2, 1, 3, 4];
一度ロックに失敗すると、戻れない
提出前の総点検で、Pointer Lock の取得に一度失敗すると、そのまま「使えない」状態から戻らないことがわかりました。Pointer Lock は利用者のクリックの中からしか要求できず、await を挟んでから要求すると、操作の有効期限が切れて断られることがあります。練習中にロックが外れたときは、画面をもう一度クリックしたその場で掛け直す経路を足しました。
連射しても、弾が散らない
9 月 6 日に、一般的な FPS との違いを洗い出してもらい、その結果を ADR の 8 本目にまとめました。見つかった中でいちばん大きかったのは、ライフルを連射しても弾がまったく散らないことです。マガジンの 30 発すべてが、1 発目と同じ精度で飛んでいました。
拡散は、1 発撃つごとに perShotDeg だけ広がり、毎秒 recoveryDps ずつ戻るしくみです。ところが、1 発ぶんの広がりより、次の弾までに戻る量(recoveryDps × 発射間隔)のほうが大きく、撃つ時点の広がりが常に 0 に戻っていました。ライフルの値を perShotDeg 0.12→0.21、recoveryDps 6→1.5 に変え、構えたときの拡散は 1 発目 0.168 度から 20 発目 1.485 度まで広がるようになりました。ADR には「perShotDeg > recoveryDps × 発射間隔」を、守るべき条件として書き足しています。
真ん中を狙ったのに 50 点
10 月 1 日、試遊していて納得のいかないことがありました。構えた状態で的の真ん中にぴったり合わせているのに、運が悪いと 50 点になるのです。エイムの練習なのに、結局ランダムで点が変わってしまいます。
一般的にどうなのかを調べてもらうと、Aim Lab や KovaaK's のようなエイム練習ソフトや、CoD や Apex の系統のゲームでは、構えた射撃はまっすぐ飛びます。止まって構えた 1 発目もずれる作りのタイトルもありますが、競技の場では運の要素として批判されていました。そこで ADR の 10 本目に追記し、構えきって、地面に立ち、止まっているときは拡散を 0 にしました。
/** 拡散の半角(05 §5)。 */
spreadDegFor(ctx: FireContext): number {
const s = this.params.spread;
if (
this.adsFraction >= SCOPE_READY_FRACTION &&
ctx.grounded &&
ctx.speedRatio <= ctx.accurateSpeedRatio &&
ctx.inaccuracyDeg === 0
) {
return 0;
}
SCOPE_READY_FRACTION は 0.95 で、構える動作がほぼ終わった状態です。スナイパーに限らず、すべての銃に効きます。inaccuracyDeg には、着地した直後のような一時的な不正確さが入るので、それが残っている間は散ります。走りながらや腰だめで撃ったときも、これまでどおり散ります。
何個撃っても 0 点
スコアにも、遊んで初めてわかる不具合がありました。
角で待ち伏せるドリルでは、何をしても 0 点でした。反応時間を、敵が画面に見える瞬間ではなく、敵が出現した時刻から測っていたのが原因です。倒した敵への追い撃ちも、撃つのが早すぎた(フライング)としてマイナス 100 点にしていました。見えた瞬間から測り、見えている敵がいないときの発射だけをフライングにしています。
ポップアップする的を撃つドリルでも、何個撃っても 0 点でした。0 点になる時間が 650ms で、視点を振る時間を含めると人間は 500〜700ms かかるので、ほとんど間に合っていませんでした。1000ms に延ばし、フライングも、的が出ていない間 1 回につき 1 回までしか数えないようにしました。
ランクの基準を、ボットに決めさせる
的を撃つドリルのスコアは、倒した的の点数に、命中率の平方根を掛けて決めています。
export function computeScore(category: ScoringCategory, input: ScoreInput): number {
switch (category) {
case 'clicking':
case 'switching':
return Math.round(input.killPoints * Math.sqrt(Math.max(0, input.accuracy)));
case 'tracking':
return input.maxDamage <= 0
? 0
: Math.round((1000 * input.damage) / input.maxDamage);
命中率をそのまま掛けると、外すのを怖がって慎重に撃つほうが得になります。命中率を無視すると、とにかく連射するのが得になります。平方根を掛けると、命中率が下がったときの減り方がゆるやかになり、そのどちらにも偏りません。
問題はランクの基準でした。当初は、公開後に協力者に遊んでもらって基準を実測する計画でしたが、9 月 5 日にその計画をやめ、ドリルごとに手で仮の値を決めていました。9 月 27 日に「100 発 100 中なら、最上位まで行けるようになっているか」と聞くと、そうなっていませんでした。完璧に撃っても最上位に届かないドリルと、完璧の 3 割の出来で最上位になれるドリルが混ざっていたのです。
そこで、人間らしい速さで、狙いだけは完璧なボットに全ドリルを遊ばせ、その点数から基準を決めることにしました。照準を合わせるまでの時間は、ポインティングの速さを表す Fitts の法則で見積もっています。
_to.copy(target.position);
if (target.kind === 'humanoid') _to.y += bodyAim ? heightM * 0.62 : heightM * 0.93;
_to.sub(_eye);
const distance = Math.max(0.5, _to.length());
const angle = _to.normalize().angleTo(_view);
const width = 2 * Math.atan(radius / distance);
return REACTION_S + FITTS_S_PER_BIT * Math.log2(1 + angle / Math.max(width, 1e-4));
反応に 0.2 秒、それに「振り向く角度」と「的の見かけの大きさ」の比で決まる時間を足します。遠くて小さい的、大きく振り向く必要のある的ほど、時間がかかる計算です。このボットの点数の 9 割を、最上位のランクの基準にしました。
それでも、遊んでみると、人型の的が動き回るドリルは全部難しすぎました。ボットは動く的に遅れなく照準を合わせ続けられますが、人間はそうはいきません。人型が動くドリル 9 本と、もう 1 本の計 10 本には rankScale: 0.5 を付け、基準をさらに半分にしています。
const base = unscaled * (drill.rankScale ?? 1);
なので、最上位の基準は、多くのドリルでボットの 9 割、この 10 本ではボットの 45% です。コースを走り抜けるタイムアタックだけは、ボットではなく、経路の長さから見積もった時間で基準を決めています。
前のランの的が、巨大になる
10 月 1 日には、あるドリルを 1 回遊んだあとにリトライすると、的が超巨大になる不具合が出ました。しばらく遊ぶうちに徐々に小さくなり、残り 10 秒ほどで普通の大きさに戻ります。
的の表示は、作り直さずに使い回しています。その表示に、撃たれたときにふくらむ演出の開始時刻が残っていました。新しいランはゲーム内の時刻が 0 から始まるので、「いまの時刻 − ふくらみ始めた時刻」がマイナスになり、ふくらみの式が大きな値を返していたのです。
+function pulseOf(view: TargetView, timeS: number): number {
+ const age = timeS - view.pulseAtS;
+ if (age < 0) return 0;
+ return Math.max(0, 1 - age / (PULSE_S * view.pulseGain));
+}
// ...
+ view.pulseAtS = Number.NEGATIVE_INFINITY;
+ view.pulseGain = 1;
// ...
- const pulse = Math.max(0, 1 - (timeS - view.pulseAtS) / (PULSE_S * view.pulseGain));
+ const pulse = pulseOf(view, timeS);
使い回すときに時刻を初期化し、未来の時刻は無視するようにしました。画面上のヒットマーカーにも同じ形の問題があったので、ランの開始時に片付けています。ゲーム内の時刻がランごとに 0 に戻る作りでは、ランをまたいで残るものに時刻を持たせたら、開始時に必ず初期化する必要があります。
低い fps で、銃が震える
同じ日、ストアに出す紹介動画を 1 秒 30 コマで撮っていると、画面の銃がブルブル震えていました。撃っていないときも震えています。
銃は、視点を振ったときに少し遅れてついてくるように、ばねで揺らしています。このばねを、1 フレームぶんの時間 dt でまとめて 1 歩だけ積分していました。
- this.swayYawVel += (-yawDeltaDeg * 0.25 - this.swayYaw) * omega * omega * dt - this.swayYawVel * damping * dt;
// ...
+ const steps = Math.max(1, Math.ceil(dt / SPRING_STEP_S));
+ const h = dt / steps;
+ const swayYawTarget = -yawDeltaDeg * 0.25;
+ const swayPitchTarget = -pitchDeltaDeg * 0.25;
+ for (let i = 0; i < steps; i++) {
+ this.swayYawVel += (swayYawTarget - this.swayYaw) * omega * omega * h - this.swayYawVel * damping * h;
// ...
+ }
ばねの周期は 0.12 秒(omega は約 52)、減衰の係数 damping は 2 * 0.8 * omega で約 84 です。計算してみると、1 秒 30 コマでは damping * dt が約 2.8 になり、減衰の項だけで速度に「1 − 2.8 = −1.8」が掛かります。速度が毎フレーム向きを変えながら大きくなるので、ばねが落ち着かずに震えます。直す前は 66 コマのうち 65 回、揺れの向きが反転していました。
SPRING_STEP_S は 1/240 秒で、1 フレームの時間をそれ以下の小さな歩みに分けて積分するようにしました。直したあとの反転は 1 回です。1 秒 60 コマなら damping * dt は約 1.4 で、向きは変わっても揺れは小さくなっていくので、30 コマで撮るまで気づきませんでした。
銃と人型のモデルを作り直す
銃のモデルは 2 回作り直しています。最初は配布されている無料の銃のモデルを、Blender をコマンドラインから動かすスクリプトで変換していました。このとき、Blender の glTF 書き出しが Z 軸上向きから Y 軸上向きへの変換をもう一度かけるのを見落とし、銃が上を向いてしまったことがあります。
9 月 28 日には、中のモデルがしょぼく見えたので、世界でよく使われているハンドガン、ライフル、スナイパーライフルを調べてもらい、Blender のスクリプトで一から組み立ててもらいました。横から見た形だけを合わせて角張っている、引き金の囲いがガタガタ、マガジンを抜くとくっついていない部品が出てくる、と何度か指摘して直してもらっています。Blender のブーリアン(形の足し引き)は、入力が閉じた立体でないと、エラーも出さずに何もしないことも、このとき知りました。3 丁を合わせて、圧縮後 181KB です。
人型の的は、最初 Mixamo のキャラクターとアニメーションを使っていました。Mixamo のアニメーションは位置の単位が cm なので、0.01 倍しないとキャラクターが地面の下に沈みます。9 月 30 日に、自作の人型モデル(65KB)に差し替え、歩きなどの動きはコードで作るようにしました。
MP3 の先頭の無音
効果音の MP3 は、デコードすると先頭に 5〜35ms の無音が付きます。MP3 のエンコーダーは先頭に遅延を入れ、その長さを書いておく情報(ギャップレス再生の情報)を、使ったエンコーダーが書き出さないためです。銃声が遅れると撃った感触が鈍るので、読み込み時に無音の長さを測り、そこから再生しています。
function leadingSilenceS(buffer: AudioBuffer): number {
const data = buffer.getChannelData(0);
const threshold = 0.001;
const limit = Math.min(data.length, buffer.sampleRate);
for (let i = 0; i < limit; i++) {
if (Math.abs(data[i]!) >= threshold) return i / buffer.sampleRate;
}
return 0;
}
ほかのサービスでは、逆に「遅れを補正しない」と決めた場面もあります。音まわりの判断はブラウザの音の記事にまとめました。
ゲームサイトの SDK
最初の提出先は、ブラウザゲームのサイトの CrazyGames です。専用の SDK を読み込み、広告やログイン、セーブをその SDK に任せます。
スマホから LAN の IP アドレスで開いて試すと、起動が止まりました。SDK が使えない環境だと判断された状態では、ユーザー情報の機能にアクセスしただけで GeneralError という例外が投げられていたのです。機能ごとに try で囲み、SDK の初期化にも 8 秒のタイムアウトを付けました。
ほかのサイトに無断で載せられるのを防ぐ「サイトロック」でも、つまずきました。SDK の資料にある判定の例は、ホスト名に crazygames というラベルが後ろから 3 つ以内にあれば通す、というものです。これだと crazygames.evil.com も通ってしまいます。最初は国別のドメインも通すように自前で絞った判定を書いていましたが、国別のドメインが非推奨になっていたので、最終的には crazygames.com とそのサブドメインだけを通しています。
export function isAllowedHost(hostname: string, origin: string = self.origin): boolean {
const host = hostname === '' ? hostOfOrigin(origin) : hostname;
if (host === null) return false;
if (isDevelopmentHost(host)) return true;
return host === 'crazygames.com' || host.endsWith('.crazygames.com');
}
提出前の確認では、広告がまったく出ませんでした。ゲーム側の不具合を疑いましたが、コンソールには adsDisabledBasicLaunch と出ていて、公開の最初の段階(Basic Launch)では広告がすべて無効になる仕様でした。
審査に落ちて、itch.io へ
10 月 1 日に CrazyGames に提出し、翌日、不採用の連絡が来ました。理由は「ゲームの全体的な品質が、まだプラットフォームの期待に届いていない」という定型文だけです。ビルドの不具合や著作権、SDK の組み込みについての指摘はありませんでした。
選択肢は、作り直して再挑戦するか、審査の無い別のサイトに出すか、の 2 つでした。審査の無い itch.io に先に出して反応を見て、作り直すかどうかはそのあとで決めることにしました。コードは分けず、ビルドの切り替え(広告なし、サイトロックなし、セーブはブラウザ内)で itch.io 向けを作っています。いま公開しているのは、この itch.io 版です。
手触りの調整はまだ続けるつもりです。V1〜V8 の検証も、遊んでくれる人の環境が増えてきたら、改めて測りたいと思っています。
- Pointer Events の仕様では、すでにボタンが押されている状態で別のボタンが押されることを「chorded button」と呼び、
pointerdownではなくpointermoveで通知すると決めています。 ↩