Skip to content

Art-7: アニメーション仕様

基本方針

手続き的アニメーション(Procedural Animation)

Rift Survivors のキャラクター・敵・ボスは スキンメッシュ不使用・ボーン不使用。 各パーツ(Object3D / Mesh)を階層に配置し、毎フレーム 回転・位置・スケール を 関数(sin / cos / 線形補間)で直接書き換えることでアニメーションさせる。

なぜ手続き的なのか

観点骨+スキンメッシュ手続き的(本作品の方式)
ロードサイズ.glb / .gltf が膨らむ(アニメクリップ込み)ジオメトリのみで済む(コード上で姿勢計算)
Vibe Jam 2026 要件 (< 2MB)厳しい有利
見た目滑らか(現代的)カクカク(PS1・FF7 ワールドマップ的)
実装工数DCC ツール連携が必要Three.js だけで完結
バリエーションクリップ追加が重い数値パラメータで自在に差分生成

PS1 時代の「カクカク感」は仕様

1997 年の PlayStation はキャラの手足が 1/10 秒ごとにスナップ するような低頻度更新で アニメーションしていた。本作品はこれを 意図的な表現 として採用する。 バグではなく、ノスタルジーとリズム感を生むデザイン要素

  • 一部パーツの更新レートを 10 fps 相当 に量子化(Frame-Step)
  • 胴体・頭は 60 fps で滑らかに、腕・脚・武器は 10 fps でスナップ、というハイブリッド運用も可

パーツ構成のおさらい

詳細は Art-3: キャラクターデザイン 参照。

typescript
class CharacterRig extends THREE.Object3D {
  head: THREE.Mesh;    // 頭
  body: THREE.Mesh;    // 胴体(回転の親にはしない)
  armL: THREE.Mesh;    // 左腕(肩ピボット)
  armR: THREE.Mesh;    // 右腕(肩ピボット)
  legL: THREE.Mesh;    // 左脚(股ピボット)
  legR: THREE.Mesh;    // 右脚(股ピボット)
  weapon: THREE.Object3D; // 武器(右手の子に配置 = 腕と連動)

  // 追加: アニメーション状態
  state: AnimState = 'idle';
  stateTime: number = 0;   // 現ステートの経過秒
  blendT: number = 0;      // 遷移中の補間係数 0→1
}

重要: 各パーツの geometry.translate()回転ピボットをパーツの端に寄せること。 これにより「肩を中心に腕が回る」「股を中心に脚が振れる」動きが成立する。

typescript
// 腕ジオメトリの生成例(肩が原点になるよう平行移動)
const armGeo = new THREE.BoxGeometry(0.2, 0.6, 0.2);
armGeo.translate(0, -0.3, 0); // ピボットが肩になる

プレイヤーアニメーション一覧

ステート概要

ステート周期 / 長さループ優先度備考
idle2.0 s1立ち止まり中の微振動
walk0.6 s2通常移動
dash0.4 s3ダッシュ中(ダッシュスキルとは別)
attack10.18 s5コンボ 1 段目
attack20.18 s5コンボ 2 段目
attack30.22 s5コンボ 3 段目
attack40.30 s6コンボ 4 段目フィニッシュ
skillCast0.40 s(タメ) + 0.25 s(解放)7スキル詠唱
hitReact0.20 s8被ダメージ
dodgeRoll0.40 s9回避(I-Frame 0.3s と同期)
death1.50 s10死亡

優先度: 大きい数値が勝つ(death > hitReact > dodgeRoll > 攻撃 > 移動 > idle)。


Idle(待機)

コンセプト: 息をするような上下の揺れ+頭と腕の小さな揺動。停止中であることをプレイヤーに伝える。

パラメータ
周期2.0 秒
上下幅(body)±0.03 units
腕の前後揺れ±0.04 rad
頭のわずかな首振り±0.02 rad(Y 軸、0.25x 周期)
typescript
updateIdle(dt: number) {
  this.stateTime += dt;
  const t = this.stateTime;
  const w = (Math.PI * 2) / 2.0; // 角速度: 1 周 2 秒

  this.body.position.y    = 0.0 + Math.sin(t * w) * 0.03;
  this.head.position.y    = 0.7 + Math.sin(t * w) * 0.03;
  this.armL.rotation.x    = Math.sin(t * w) * 0.04;
  this.armR.rotation.x    = -Math.sin(t * w) * 0.04;
  this.head.rotation.y    = Math.sin(t * w * 0.25) * 0.02;
  // 脚は動かさない
  this.legL.rotation.x = 0;
  this.legR.rotation.x = 0;
}

Walk(歩行)

コンセプト: 手足を sin カーブで逆位相に振る、標準的な手続き歩行。

パラメータ
周期0.6 秒(約 1.67 Hz)
腕の振り幅±0.5 rad
脚の振り幅±0.4 rad
体の上下バウンス±0.04 units(2x 周期)
typescript
updateWalk(dt: number) {
  this.stateTime += dt;
  const w = (Math.PI * 2) / 0.6;
  const s = Math.sin(this.stateTime * w);

  this.armL.rotation.x =  s * 0.5;
  this.armR.rotation.x = -s * 0.5;
  this.legL.rotation.x = -s * 0.4;
  this.legR.rotation.x =  s * 0.4;

  // 体のバウンス(脚の接地に同期: 2 倍周波数で上下)
  this.body.position.y = Math.abs(Math.sin(this.stateTime * w)) * 0.04;
  this.head.position.y = 0.7 + Math.abs(Math.sin(this.stateTime * w)) * 0.04;
}

Dash(ダッシュ移動)

コンセプト: Walk を速く、さらにキャラを進行方向に傾ける。

パラメータ
周期0.4 秒
腕の振り幅±0.7 rad
脚の振り幅±0.6 rad
前傾角度0.15 rad(本体 Object3D の rotation.x
typescript
updateDash(dt: number) {
  this.stateTime += dt;
  const w = (Math.PI * 2) / 0.4;
  const s = Math.sin(this.stateTime * w);

  this.armL.rotation.x =  s * 0.7;
  this.armR.rotation.x = -s * 0.7;
  this.legL.rotation.x = -s * 0.6;
  this.legR.rotation.x =  s * 0.6;

  // 前傾(親の Object3D を倒す)
  this.rotation.x = 0.15;
}

Attack Combo 1〜4(通常攻撃コンボ)

戦闘システム詳細: 戦闘システム の「通常攻撃」参照。

各段の総尺は短く、武器を持つ腕(利き腕 = armR) の回転で振りを表現する。 ヒットストップ(30〜80ms)は 外部タイムスケールで 0 倍 にするので、 アニメ側は単純に time を進めるだけでよい(global timescale をかければ自動で止まる)。

総尺振りかぶりインパクト戻し主パーツ方向
10.18 s0.05 s0.05 s0.08 sarmR右→左 横振り
20.18 s0.05 s0.05 s0.08 sarmR左→右 横振り(1 の逆)
30.22 s0.07 s0.05 s0.10 sarmR + body上→下 縦振り(体をひねる)
40.30 s0.10 s0.08 s0.12 sarmR + armL + body両手フィニッシュ(回転斬り)

フレーム換算表(60 fps)

総尺振りかぶりインパクト戻し
111 F3 F3 F5 F
211 F3 F3 F5 F
313 F4 F3 F6 F
418 F6 F5 F7 F
typescript
// 共通: フェーズ内 0→1 の正規化時刻
function phasePct(elapsed: number, start: number, len: number): number {
  return THREE.MathUtils.clamp((elapsed - start) / len, 0, 1);
}

updateAttack1(dt: number) {
  this.stateTime += dt;
  const t = this.stateTime;
  // 0.00-0.05 振りかぶり: armR を +1.8rad へ
  // 0.05-0.10 インパクト: armR を -1.2rad へ一気に
  // 0.10-0.18 戻し: 0 rad へ
  if (t < 0.05) {
    this.armR.rotation.z = THREE.MathUtils.lerp(0, 1.8, phasePct(t, 0, 0.05));
  } else if (t < 0.10) {
    this.armR.rotation.z = THREE.MathUtils.lerp(1.8, -1.2, phasePct(t, 0.05, 0.05));
  } else {
    this.armR.rotation.z = THREE.MathUtils.lerp(-1.2, 0, phasePct(t, 0.10, 0.08));
  }
  // コンボ終了判定
  if (t >= 0.18) this.transitionTo(this.combo.nextState);
}

Skill Cast(スキル詠唱)

コンセプト: 両腕を掲げて一瞬ためる → リリース。スキル1/2/R 共通のひな型。 アルティメット(R)のみ 0.50 s の無敵演出に合わせて全体を 1.25x に引き伸ばす。

フェーズ時間動き
構え0.00 - 0.10 s両腕を前下方へ下ろす (rotation.x = 0.3)
タメ0.10 - 0.40 s両腕を頭上へ (rotation.x = -2.4)、body わずかに後傾
ポーズ0.40 - 0.50 s静止(武器発光ピーク、カメラのズーム演出ポイント)
解放0.50 - 0.65 s両腕を前方へ振り下ろし、エフェクト放出
typescript
updateSkillCast(dt: number) {
  this.stateTime += dt;
  const t = this.stateTime;
  if (t < 0.10) {
    const p = t / 0.10;
    this.armL.rotation.x = THREE.MathUtils.lerp(0, 0.3, p);
    this.armR.rotation.x = THREE.MathUtils.lerp(0, 0.3, p);
  } else if (t < 0.40) {
    const p = (t - 0.10) / 0.30;
    this.armL.rotation.x = THREE.MathUtils.lerp(0.3, -2.4, p);
    this.armR.rotation.x = THREE.MathUtils.lerp(0.3, -2.4, p);
    this.rotation.x = THREE.MathUtils.lerp(0, -0.1, p); // 後傾
  } else if (t < 0.50) {
    // ポーズ: 何もしない(次フェーズまで保持)
  } else if (t < 0.65) {
    const p = (t - 0.50) / 0.15;
    this.armL.rotation.x = THREE.MathUtils.lerp(-2.4, 1.2, p);
    this.armR.rotation.x = THREE.MathUtils.lerp(-2.4, 1.2, p);
    this.rotation.x = THREE.MathUtils.lerp(-0.1, 0, p);
  } else {
    this.transitionTo('idle');
  }
}

Hit React(被弾)

コンセプト: 一瞬だけ体を後ろにのけぞらせ、赤フラッシュ をマテリアルカラーで表現。 I-Frame 0.5s は別仕様(戦闘システム)、アニメは 0.2s のみ。

パラメータ
総尺0.20 秒
のけぞり角rotation.x = -0.25 rad
赤フラッシュMeshmaterial.emissive#ff0033 に、強度 1.0 → 0 で線形減衰
減衰カーブ0.00 s でピーク、0.12 s でゼロ
typescript
updateHitReact(dt: number) {
  this.stateTime += dt;
  const t = this.stateTime;
  // のけぞり(のけぞり→戻し)
  if (t < 0.08) {
    this.rotation.x = THREE.MathUtils.lerp(0, -0.25, t / 0.08);
  } else {
    this.rotation.x = THREE.MathUtils.lerp(-0.25, 0, (t - 0.08) / 0.12);
  }
  // 赤フラッシュ
  const flash = Math.max(0, 1 - t / 0.12);
  this.forEachMesh(m => m.material.emissive.setHex(0xff0033).multiplyScalar(flash));
  if (t >= 0.20) this.transitionTo('idle');
}

Dodge Roll(回避)

コンセプト: 進行方向に一回転。I-Frame と完全に同期させる(戦闘システム)。

パラメータ
総尺0.40 秒(ダッシュ本体 0.2 + 後隙 0.1 + 立て直し 0.1)
回転本体 Object3D の rotation.x を 0 → −2π へ線形
I-Frame0.0 - 0.3 s(無敵)
移動距離5.0 units(0.2 s で移動、後半は惰性)
残像エフェクト毎 0.03 s に body/head のスナップショットを opacity 0.4 → 0 でフェード
typescript
updateDodgeRoll(dt: number) {
  this.stateTime += dt;
  const t = this.stateTime;
  const p = Math.min(t / 0.40, 1);
  this.rotation.x = -Math.PI * 2 * p;       // 1 回転
  // 残像トレイル(0.03 s 刻み)
  if (Math.floor(t / 0.03) !== Math.floor((t - dt) / 0.03)) {
    this.spawnAfterImage();
  }
  if (t >= 0.40) {
    this.rotation.x = 0;
    this.transitionTo('idle');
  }
}

Death(死亡)

コンセプト: 前のめりに倒れ、地面に接地後フェードアウト。

パラメータ
総尺1.50 秒
倒れ込み0.00 - 0.50 s(rotation.x 0 → π/2)
接地後ブレ0.50 - 0.70 s(頭だけ ±0.05 rad で 2 回バウンド)
フェードアウト0.70 - 1.50 s(material.opacity 1 → 0)

敵アニメーション

敵の基本ポリゴン構成は head / body / legs(まとめて1メッシュ可) / 武器か突起 程度で軽量化する。 スウォームは胴体だけの 1 パーツ構成でも良い。

スウォーム型(グリッチビット)

パラメータ
移動浮遊スウェイ: position.y = baseY + sin(t * 8) * 0.05
回転常時 rotation.y += 4.0 * dt(高速スピン)
攻撃接触ダメージのみ。独立アニメなし
死亡0.15 s でスケール 1→0、グリッチ風破片 4 個飛散

意図: 大量に画面を占拠するノイズ感を演出。

typescript
updateSwarmIdle(dt: number) {
  this.body.position.y = this.baseY + Math.sin(this.time * 8) * 0.05;
  this.body.rotation.y += 4.0 * dt;
}

メレー型(データドローン)

ステート内容
idle1.2 s ループ軽いホバー(y ±0.08)
chasewalk 0.5 s ループ脚を左右に振る
attack0.35 s片腕を上 → 振り下ろし(0.1 s タメ + 0.10 s 打撃 + 0.15 s 戻し)
stagger0.25 s後ろにのけぞり+赤フラッシュ
death0.50 s前のめり倒れ + フェード

レンジ型(ネオンスナイパー)

ステート内容
idle / chase0.5 s ループ歩行モーション共通
aim0.3 s武器を構える(armR.rotation.x = −π/2 へ 0.3 s で遷移)、頭がプレイヤーを追尾
fire0.15 s武器が反動で 0.08 s 後傾 → 0.07 s で戻る
kite(後退)歩行の逆再生time を負の速度で進めるだけで後退歩行になる

チャージ型(リフトチャージャー)

ステート内容
telegraph(予兆)1.5 sその場で足踏み(脚 rotation.x を ±0.3 rad, 5 Hz)、体を前傾 0.2 rad、赤いオーラ膨張
charge(突進)0.8 s前傾 0.35 rad 固定、残像は出さず フレームステップ 10 fps に量子化 して PS1 感を強調
recoil(衝突後)0.4 s後ずさり、ヨロヨロ(rotation.z ±0.15 rad で揺動)

ボスアニメーション(マルチフェーズ)

ボスは HP 閾値でフェーズが切り替わり、モーションセットが変わる。

フェーズ構成の例

フェーズHPモーションセットテンポ
P1100〜70%標準近接 3 種 + 遠隔 1 種通常
P270〜35%範囲攻撃追加、移動速度 1.15x速い
P335〜0%全攻撃の隙短縮、怒り状態さらに速い

フェーズ遷移アニメ(Phase Transition)

フェーズが切り替わる瞬間の 0.8 秒の専用モーション。 プレイヤーにフェーズ変化を明確に伝え、戦闘テンポを区切る。

時間内容
0.00 - 0.20 s両腕を真横に広げ、rotation.z = ±π/2。体を軽く沈める
0.20 - 0.50 s全身赤フラッシュ(emissive)、周囲に衝撃波リング放出
0.50 - 0.80 s構え直し、新フェーズ idle へ遷移

被弾判定の仕様(gameplay/enemies.md:235 準拠)

この演出中は ボス側に 0.5 秒の硬直 + DEF=0 の被弾弱点状態 が発生する(docs/gameplay/enemies.md:235, 267, 286 参照)。 プレイヤーは無敵にはならず、他の敵・環境ダメージは通常通り被弾する。演出 0.00〜0.50 s 区間(硬直)がプレイヤーの反撃ウィンドウとなる。

ボス攻撃パターン例(近接)

攻撃予兆発生戻し総尺備考
横薙ぎ0.4 s0.15 s0.4 s0.95 s左から右
叩きつけ0.6 s0.10 s0.5 s1.20 s着弾点に AoE
突進0.8 s0.5 s0.6 s1.90 s直線、壁で停止
咆哮(P2+)0.3 s0.6 s0.3 s1.20 s全方位ノックバック

予兆の重要性

予兆フェーズはボスの emissive を攻撃方向に沿って段階的に強める(0 → 1)。 プレイヤーはこの色変化を見て回避する。手続き的演出なのでテクスチャ追加ゼロ。


環境アニメーション

環境のアニメはパーティクルと Instanced シェーダで処理する。 1 オブジェクトごとに update() を回さず、共有 uniform uTime で全体を一括駆動する。

ネオン看板の明滅(Flicker)

typescript
// フラグメントシェーダで明滅。ジッター強めに
float flicker(float t, float seed) {
  float base = 0.85;
  float n = fract(sin(dot(vec2(seed, floor(t * 12.0)), vec2(12.9898, 78.233))) * 43758.5453);
  return base + step(0.9, n) * 0.3 - step(0.97, n) * 0.5; // 稀に消える
}
パラメータ
平均明度0.85
点滅周波数12 Hz(カクつかせる)
ドロップアウト確率3%(一瞬真っ暗)

データストリーム流(Data Stream Flow)

床・壁に走るシアン色のライン。

パラメータ
流速2.0 units/秒
シェーダuv.x + uTime * 2.0 を sin に通してグロー
方向ダンジョンの廊下方向(InstancedMesh の instance 属性で方向指定)

ポータル回転

パラメータ
回転軸Y
角速度1.2 rad/秒(リング内側)、−0.8 rad/秒(リング外側、逆回転)
スケール脈動1 + sin(t * 2) * 0.05
呼び込み演出プレイヤー接近時に 0.3 s で 1.3x スケール拡大、半透明波紋放出

背景パーティクルドリフト

パラメータ
粒子数デスクトップ 400 / モバイル 150
速度0.3 units/秒(上方向)
寿命4〜8 秒(個体ごとにランダム)
更新GPU 側で position.y += uTime * 0.3 相当を計算(Points + カスタムシェーダ)

実装アプローチ(Three.js)

階層とピボット

PlayerCharacter (Object3D) ← ここが世界座標で動く
├─ head (Mesh)
├─ body (Mesh)
├─ armL (Mesh, ピボット=肩)
├─ armR (Mesh, ピボット=肩)
│  └─ weapon (Object3D) ← 腕の子。腕が回ると武器も一緒に回る
├─ legL (Mesh, ピボット=股)
└─ legR (Mesh, ピボット=股)

更新ループの雛形

typescript
class CharacterRig extends THREE.Object3D {
  update(dt: number) {
    this.stateTime += dt;

    // 1. ベースステートを更新
    switch (this.state) {
      case 'idle':       this.updateIdle(dt); break;
      case 'walk':       this.updateWalk(dt); break;
      case 'dash':       this.updateDash(dt); break;
      case 'attack1':    this.updateAttack1(dt); break;
      // ...
    }

    // 2. ブレンド中なら前ステートと補間
    if (this.blending) this.applyBlend(dt);

    // 3. PS1 風フレームステップ(オプション)
    if (this.ps1Mode) this.snapToLowFps(10);
  }

  transitionTo(next: AnimState, blendTime = 0.08) {
    this.prevSnapshot = this.snapshotPose();
    this.state = next;
    this.stateTime = 0;
    this.blending = true;
    this.blendT = 0;
    this.blendDuration = blendTime;
  }
}

ブレンド(遷移補間)

ほとんどの遷移は 0.08 秒の線形補間 で十分。攻撃→被弾のような急な割り込みは 即時切り替え(blendTime = 0) で PS1 的なスパンッとした切れ味を出す。

typescript
applyBlend(dt: number) {
  this.blendT += dt / this.blendDuration;
  const k = Math.min(this.blendT, 1);

  // 現ステートが計算した「目標ポーズ」と prevSnapshot を k で補間
  for (const part of this.parts) {
    part.rotation.x = THREE.MathUtils.lerp(this.prevSnapshot[part.name].rx, part.rotation.x, k);
    // ... 他軸・位置も同様
  }

  if (k >= 1) this.blending = false;
}
遷移元 → 遷移先ブレンド時間備考
idle ↔ walk0.08 sスムーズ
walk → dash0.05 s俊敏感
* → attack10.00 sスパンッと切り替え(PS1 感)
attackN → attackN+10.00 sコンボは即接続
* → hitReact0.00 s割り込み
* → dodgeRoll0.00 s割り込み
* → death0.10 s少し余韻

PS1 風フレームステップ(Frame-Step)

typescript
// 一部パーツだけ 10 fps に量子化して「カクカク感」を出す
snapToLowFps(fps: number) {
  const step = 1 / fps; // 0.1 s
  const snapped = Math.floor(this.stateTime / step) * step;
  // snapped を使って改めてポーズ計算(各パーツの手足のみ)
  const s = Math.sin(snapped * ((Math.PI * 2) / 0.6));
  this.armL.rotation.x = s * 0.5;
  this.armR.rotation.x = -s * 0.5;
  this.legL.rotation.x = -s * 0.4;
  this.legR.rotation.x = s * 0.4;
  // 胴体・頭は 60 fps のまま(ハイブリッド)
}

対象: armL / armR / legL / legR / weapon 除外: body / head(スムーズに動いた方が酔わない)


モバイル最適化

画面外敵の更新ステップダウン

カメラ外は手抜きして良い

トップダウン斜めカメラで 画面外 にいる敵は、位置追従(AI)だけ動かし、 アニメーション(手足の sin 計算)はサボる。

条件更新頻度
画面内 + カメラから 15 units 以内毎フレーム(60 fps)
画面内 + カメラから 15 units 超2 フレームに 1 回(30 fps)
画面外(frustum culling)6 フレームに 1 回(10 fps)、姿勢は固定可
typescript
// シーンマネージャーの更新ループ内
enemies.forEach(e => {
  e.ai.update(dt);                 // AI は必ず動かす
  if (!frustum.containsPoint(e.position)) {
    e.animationTick = (e.animationTick + 1) % 6;
    if (e.animationTick !== 0) return; // スキップ
    e.rig.update(dt * 6);             // まとめて 6 倍 dt で進める
  } else {
    e.rig.update(dt);
  }
});

その他のモバイル配慮

項目デスクトップモバイル
PS1 フレームステップオフ(好みで)デフォルト ON(軽量化にも寄与)
ブレンド有効ON のままで OK(sin 計算の方が重い)
残像エフェクト生成頻度0.03 s0.06 s
パーティクル数400150
敵リグの手足パーツ数フルスウォームは 1 メッシュに統合

アニメーション優先度と割り込みルール

キャンセルルール:

  • 回避 (dodgeRoll) は 攻撃中でも発動可能(プレイヤー救済)
  • 被弾 (hitReact) は 攻撃中・スキル中は発生しない(I-Frame とは別のスーパーアーマー扱い)
    • 例外: ボス攻撃はスーパーアーマーを貫通
  • 死亡 (death) は 全てに優先

作業スコープのまとめ

項目v1v2 以降
プレイヤー基本 9 モーション
コンボ 1〜4
敵 4 タイプ基本モーション
ボス基本パターン + フェーズ遷移
環境手続きアニメ
PS1 フレームステップ✅(オプション)
モバイル最適化
残像エフェクト✅(簡易)高度な残像(メッシュ補間)
リアクション細分化(ダウン、受け身など)

参照