---
title: アクションのフレームシーケンスを戦闘で正確にタイミングさせる
url: https://doc.liz6.com/ja/game-development/battle-animation-event-clock
locale: ja
area: game-development
tags:
- ゲーム開発
date: 2026-08-27
modified: 2026-10-13
description: アクションアトラスがゲーム内に入った後、攻撃、被撃、エフェクト、ダメージ数字、最終状態がどのように同じ接触クロックを共有し、リソースの遅延、低動的嗜好、デコードメモリを処理するか。
---

# アクションのフレームシーケンスを戦闘で正確にタイミングさせる

> アニメーションの再生が滑らかだからといって、戦闘の演出が成立するわけではない。攻撃に力強さがあるかどうかを真に決定するのは、すべての可視結果が同じ因果の瞬間に発生することである。

[キャラクター原画からアニメーションフレームシーケンスへ](./character-art-to-animation-frames.md)では素材の生産問題を解決した：待機、攻撃、被撃、転倒の4種類のアトラスをどのように取得するか。リソースがデータベースに入ると、より隠れた別の問題が現れる：攻撃者が接触フレームに到達しても、対象がまだ被撃していない。数値が先に飛び出し、エフェクトが半拍遅れる。画像の読み込みが少し遅れると、キャラクターが第0フレームからやり直す。

これらの現象は通常、CSSアニメーションの記述ミスではなく、システム内に複数の時計が同時に存在するためである：アトラスは独自のFPSで再生され、移動はCSSの時間で再生され、エフェクトはタイマーで表示され、戦闘レポートは状態更新時に即座にレンダリングされる。それぞれを個別に見れば合理的だが、合わせると共通の「今」がない。

本記事では、イベント駆動型の戦闘演出手法を記録する。これはすべてのアクションリソースが同じフレーム数や持続時間を持つことを要求するものではなく、それらが小さな時間契約に共通して従うことを要求するものである。

<svg id="diagram-220ab4380ab2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1028.078125 356" stroke="none" stroke-width="1px" style="max-width:100%;height:auto;color-scheme:inherit;fill:var(--site-default-fg-color, #1f2328)" width="1028.078125" height="356" role="img" aria-labelledby="diagram-220ab4380ab2-title" data-diagram-theme="native" data-text-fit="true">
<title id="diagram-220ab4380ab2-title">アクションのフレームシーケンスを戦闘で正確にタイミングさせる</title>
<g>
<marker id="diagram-220ab4380ab2_flowchart-v2-pointEnd" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<path d="M 0 0 L 10 5 L 0 10 z" stroke-dasharray="1px, 0px">
</path>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-pointStart" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<path d="M 0 5 L 10 10 L 10 0 z" stroke-dasharray="1px, 0px">
</path>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-pointEnd-margin" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<path d="M 0 0 L 11.5 7 L 0 14 z" stroke-width="0px" stroke-dasharray="1px, 0px">
</path>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-pointStart-margin" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<polygon points="0,7 11.5,14 11.5,0" stroke-width="0px" stroke-dasharray="1px, 0px">
</polygon>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-circleEnd" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<circle cx="5" cy="5" r="5" stroke-dasharray="1px, 0px">
</circle>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-circleStart" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<circle cx="5" cy="5" r="5" stroke-dasharray="1px, 0px">
</circle>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-circleEnd-margin" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<circle cx="5" cy="5" r="5" stroke-width="0px" stroke-dasharray="1px, 0px">
</circle>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-circleStart-margin" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<circle cx="5" cy="5" r="5" stroke-width="0px" stroke-dasharray="1px, 0px">
</circle>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-crossEnd" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<path d="M 1,1 l 9,9 M 10,1 l -9,9" stroke-width="2px" stroke-dasharray="1px, 0px">
</path>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-crossStart" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<path d="M 1,1 l 9,9 M 10,1 l -9,9" stroke-width="2px" stroke-dasharray="1px, 0px">
</path>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-crossEnd-margin" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<path d="M 1,1 L 14,14 M 1,14 L 14,1" stroke-width="2.5px">
</path>
</marker>
<marker id="diagram-220ab4380ab2_flowchart-v2-crossStart-margin" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto" style="fill:var(--site-default-fg-color--light, #57606a);stroke:var(--site-default-fg-color--light, #57606a)">
<path d="M 1,1 L 14,14 M 1,14 L 14,1" stroke-width="2.5px" stroke-dasharray="1px, 0px">
</path>
</marker>
<g>
<g>
</g>
<g>
<path d="M196.016,178L200.182,178C204.349,178,212.682,178,220.349,178C228.016,178,235.016,178,238.516,178L242.016,178" id="diagram-220ab4380ab2-L_A_B_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
<path d="M386.031,178L390.198,178C394.365,178,402.698,178,410.365,178C418.031,178,425.031,178,428.531,178L432.031,178" id="diagram-220ab4380ab2-L_B_C_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
<path d="M521.279,154.5L534.574,134C547.868,113.5,574.458,72.5,596.586,52C618.714,31.5,636.38,31.5,645.214,31.5L654.047,31.5" id="diagram-220ab4380ab2-L_C_D_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
<path d="M551.604,154.5L559.845,150.25C568.085,146,584.566,137.5,601.64,133.25C618.714,129,636.38,129,645.214,129L654.047,129" id="diagram-220ab4380ab2-L_C_E_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
<path d="M551.604,201.5L559.845,205.75C568.085,210,584.566,218.5,600.306,222.75C616.047,227,631.047,227,638.547,227L646.047,227" id="diagram-220ab4380ab2-L_C_F_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
<path d="M521.279,201.5L534.574,222C547.868,242.5,574.458,283.5,591.252,304C608.047,324.5,615.047,324.5,618.547,324.5L622.047,324.5" id="diagram-220ab4380ab2-L_C_G_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
<path d="M782.063,31.5L791.563,31.5C801.063,31.5,820.063,31.5,843.593,51.455C867.124,71.409,895.185,111.319,909.216,131.273L923.246,151.228" id="diagram-220ab4380ab2-L_D_H_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
<path d="M782.063,129L791.563,129C801.063,129,820.063,129,837.895,132.964C855.727,136.927,872.392,144.854,880.724,148.818L889.056,152.782" id="diagram-220ab4380ab2-L_E_H_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
<path d="M790.063,227L798.229,227C806.396,227,822.729,227,839.228,223.036C855.727,219.073,872.392,211.146,880.724,207.182L889.056,203.218" id="diagram-220ab4380ab2-L_F_H_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
<path d="M814.063,324.5L818.229,324.5C822.396,324.5,830.729,324.5,848.926,304.545C867.124,284.591,895.185,244.681,909.216,224.727L923.246,204.772" id="diagram-220ab4380ab2-L_G_H_0" marker-end="url(#diagram-220ab4380ab2_flowchart-v2-pointEnd)" fill="none" stroke-dasharray="0px" style="stroke:var(--site-default-fg-color--light, #57606a)">
</path>
</g>
<g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
<g>
<g transform="translate(0, 0)">
</g>
</g>
<g>
<rect>
</rect>
</g>
</g>
<g>
<g id="diagram-220ab4380ab2-flowchart-A-0" transform="translate(102.0078125, 178)">
<rect x="-94.0078125" y="-23.5" width="188.015625" height="47" style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g transform="translate(0, -8.5)">
<rect style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g>
<rect style="fill:var(--reading-surface, #edf2f5)">
</rect>
<text y="-10.1" font-family="sans-serif" font-size="16px" text-anchor="middle">
<tspan x="0" y="-0.1em" dy="1.1em">成立した戦闘イベント</tspan>
</text>
</g>
</g>
</g>
<g id="diagram-220ab4380ab2-flowchart-B-1" transform="translate(316.0234375, 178)">
<rect x="-70.0078125" y="-23.5" width="140.015625" height="47" style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g transform="translate(0, -8.5)">
<rect style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g>
<rect style="fill:var(--reading-surface, #edf2f5)">
</rect>
<text y="-10.1" font-family="sans-serif" font-size="16px" text-anchor="middle">
<tspan x="0" y="-0.1em" dy="1.1em">演出ディレクターフレーム</tspan>
</text>
</g>
</g>
</g>
<g id="diagram-220ab4380ab2-flowchart-C-3" transform="translate(506.0390625, 178)">
<rect x="-70.0078125" y="-23.5" width="140.015625" height="47" style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g transform="translate(0, -8.5)">
<rect style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g>
<rect style="fill:var(--reading-surface, #edf2f5)">
</rect>
<text y="-10.1" font-family="sans-serif" font-size="16px" text-anchor="middle">
<tspan x="0" y="-0.1em" dy="1.1em">統一アクション拍</tspan>
</text>
</g>
</g>
</g>
<g id="diagram-220ab4380ab2-flowchart-D-5" transform="translate(720.0546875, 31.5)">
<rect x="-62.0078125" y="-23.5" width="124.015625" height="47" style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g transform="translate(0, -8.5)">
<rect style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g>
<rect style="fill:var(--reading-surface, #edf2f5)">
</rect>
<text y="-10.1" font-family="sans-serif" font-size="16px" text-anchor="middle">
<tspan x="0" y="-0.1em" dy="1.1em">攻撃アトラス</tspan>
</text>
</g>
</g>
</g>
<g id="diagram-220ab4380ab2-flowchart-E-7" transform="translate(720.0546875, 129)">
<rect x="-62.0078125" y="-24" width="124.015625" height="48" style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g transform="translate(0, -9)">
<rect style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g>
<rect style="fill:var(--reading-surface, #edf2f5)">
</rect>
<text y="-10.1" font-family="sans-serif" font-size="16px" text-anchor="middle">
<tspan x="0" y="-0.1em" dy="1.1em">対象の反応</tspan>
</text>
</g>
</g>
</g>
<g id="diagram-220ab4380ab2-flowchart-F-9" transform="translate(720.0546875, 227)">
<rect x="-70.0078125" y="-24" width="140.015625" height="48" style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g transform="translate(0, -9)">
<rect style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g>
<rect style="fill:var(--reading-surface, #edf2f5)">
</rect>
<text y="-10.1" font-family="sans-serif" font-size="16px" text-anchor="middle">
<tspan x="0" y="-0.1em" dy="1.1em">移動とカメラ</tspan>
</text>
</g>
</g>
</g>
<g id="diagram-220ab4380ab2-flowchart-G-11" transform="translate(720.0546875, 324.5)">
<rect x="-94.0078125" y="-23.5" width="188.015625" height="47" style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g transform="translate(0, -8.5)">
<rect style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g>
<rect style="fill:var(--reading-surface, #edf2f5)">
</rect>
<text y="-10.1" font-family="sans-serif" font-size="16px" text-anchor="middle">
<tspan x="0" y="-0.1em" dy="1.1em">エフェクト、ダメージ数字、戦闘レポート</tspan>
</text>
</g>
</g>
</g>
<g id="diagram-220ab4380ab2-flowchart-H-13" transform="translate(942.0703125, 178)">
<rect x="-78.0078125" y="-23.5" width="156.015625" height="47" style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g transform="translate(0, -8.5)">
<rect style="fill:var(--reading-surface, #edf2f5);stroke:var(--site-default-fg-color--light, #8194a2)">
</rect>
<g>
<rect style="fill:var(--reading-surface, #edf2f5)">
</rect>
<text y="-10.1" font-family="sans-serif" font-size="16px" text-anchor="middle">
<tspan x="0" y="-0.1em" dy="1.1em">安定した最終状態</tspan>
</text>
</g>
</g>
</g>
</g>
</g>
</g>
<defs>
<filter id="diagram-220ab4380ab2-drop-shadow" height="130%" width="130%">
<feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000">
</feDropShadow>
</filter>
</defs>
<defs>
<filter id="diagram-220ab4380ab2-drop-shadow-small" height="150%" width="150%">
<feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000">
</feDropShadow>
</filter>
</defs>
<linearGradient id="diagram-220ab4380ab2-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#8194a2" stop-opacity="1">
</stop>
<stop offset="100%" stop-color="hsl(202.5, 0%, 84.5098039216%)" stop-opacity="1">
</stop>
</linearGradient>
</svg>

## 戦闘の結果と戦闘の演出をまず分離する

戦闘のコアは事実を計算し、その後に表現層がこれらの事実を解釈すべきである。最小限のイベントシーケンスは以下のようになる：

```text
Action  { at, actor, source }
Damage  { at, target, amount, critical }
Guard   { at, target, amount }
Recover { at, target, amount }
Defeat  { at, actor }
```

同じ `at` 下のイベントは1拍を構成する。表現層はこの拍から以下を導出する：

- 現在の行動者は誰か；
- どのキャラクターが対象で、誰が主要対象か；
- 結果がダメージ、ガード、回復、免疫、位置変化のいずれに属するか；
- どの種類のアクション、ヒットエフェクト、カメラプリセットを使用するか；
- どの文字と数値が接触ポイントに表示されるか；
- この拍の終了後に待機状態に戻るか、転倒の最終フレームで停止するか、他の安定状態に入るか。

ここで最も重要な不変量は：**表現層はすでに成立した結果のみを消費し、アニメーションの終了後にダメージを逆算で提出せず、キャラクター名、画像パス、CSSクラスからゲームプレイの結論を推測しない。**

これにより2つの直接的な利点がある。第一に、アニメーションをスキップしたり、低動的設定に切り替えたり、ページがフォーカスを失ったりしても、戦闘結果は変わらない。第二に、同じ種類のイベントは異なる戦闘シーンで同じ演出マッピングを再利用でき、背景、配置、カメラのみを置き換えればよい。

## 1拍で2つの重要な時間のみを定義する

複雑な映画のタイムラインを事前に設計する必要はない。多くの短縮された戦闘アクションには、2つのグローバルな値だけで十分である：

```text
T_beat     1拍全体の壁時計の持続時間
T_contact  この拍の統一された接触時刻、0 < T_contact < T_beat
```

例えば、1拍が約 `900 ms`、接触点が約 `420 ms` である。これは理解を容易にするための丸められた例であり、汎用パラメータではない。これは以下のように分割できる：

| 段階 | 可視内容 | 事前に現れてはならない内容 |
|---|---|---|
| 蓄勢 | カメラの轻微なズーム、攻撃の準備、対象への焦点 | ダメージ数値、ヒットマーク、結果の戦闘レポート |
| 提出 | 攻撃者が戦闘軸に沿って対象に接近 | 接触後の被撃姿勢 |
| 接触 | 攻撃フレーム、被撃フレーム、エフェクト、ダメージ数字が同時に成立 | なし |
| 着地 | 収勢、転倒、カメラの復帰 | 待機状態への早期復帰 |

統一された持続時間は、すべての素材を `900 ms` に作ることを要求するものではない。真に統一されるべきは意味的な接触点である。

## アクションのリストはセマンティックフレームを保存し、持続時間を推測しない

各非ループアトラスは、フレーム数とFPSに加えて、いくつかのセマンティックキューを宣言すべきである：

```json
{
  "playback": {
    "frames": 24,
    "fps": 30,
    "loop": false,
    "end": "return-idle"
  },
  "cues": {
    "anticipationEnd": 7,
    "contact": 13,
    "recoverStart": 18
  }
}
```

`contact` は最も重要な項目である。これはアクションを見た人が注釈を付けるべきであり、総フレーム数の中央値を一律に取るべきではない。斬撃、突進、投射、被撃、転倒の有効な接触位置は異なる。ビデオのフレーム抽出後、幾何学的な中央値には信頼できるセマンティクスがない。

リストには以下の検証も含まれるべきである：

- キューはすべて有効なフレーム範囲内にある；
- `anticipationEnd <= contact <= recoverStart`；
- ループ待機には一度限りの接触キューを宣言しない；
- `end` は待機復帰、停留、非表示のいずれかを明確にする；
- アトラスの実際のサイズがセル、行、列と完全に一致する。

リスト検証がない場合、破損したまたは古い形式のアトラスは「偶発的なリズムの問題」として表現され、デバッグコストはロード時に直接拒否されるコストよりもはるかに高くなる。

## リタイムを使用して異なる素材を同じ接触点に圧縮する

アクションソースの接触フレームを `f_contact`、ソースのFPSを `fps` とすると、ソースアニメーションが接触フレームに到達するために必要なソース時間は以下のようになる：

```text
S_contact = f_contact × 1000 / fps
```

ランタイムでアトラスを以下の関係で進行させる場合：

```text
source_elapsed = wall_elapsed × playback_rate
```

攻撃アクションが1拍の起点から即座に再生される場合、その倍率は以下のように取れる：

```text
attack_rate = S_contact / T_contact
```

したがって、壁時計が `T_contact` に達したとき、ソースアニメーションはちょうど `S_contact` に到達する。接触の遅い長い攻撃は適切に加速され、接触の早い短い攻撃は遅くなる。素材はそれぞれのフレーム数とリズムを維持したまま、ランタイムで共通の意味的な点にのみ整列する。

被撃と転倒には、接触フレームが `T_contact` に落ち、アクションの末尾が `T_beat` に落ちるという2つの条件を同時に満たす必要がある。ソースの総持続時間を `S_end` とする：

```text
reaction_rate = (S_end - S_contact) / (T_beat - T_contact)
start_delay   = T_contact - S_contact / reaction_rate
```

これらの2つの式は、「接触後のソース時間」を「接触後の残りの拍の長さ」にマッピングし、反応アクションをいつ開始すべきかを逆算する。対象は攻撃が実際に当たる前に少量の予備の被撃フレームを再生できるが、ヒット姿勢に早く入りすぎることはない。転倒も1拍の終了時に最終フレームに到達する。

実際の実装では、倍率に適切な上下限を設定する必要がある。ある素材が整列するために非常に誇張された加速または減速を必要とする場合、問題はアクションウィンドウまたはキュー注釈にある可能性が高い。引き伸ばしを続けると、悪い素材をランタイムの問題として偽装するだけになる。上限を超えた場合は、アセットレビューに戻るか、静的姿勢にダウングレードする。

## 1拍にはディレクターが必要であり、複数のコンポーネントがそれぞれ目標を推測してはならない

攻撃者、対象、カメラ、エフェクト、戦闘レポートはすべて同じ「ディレクターフレーム」から読み取るべきである。ディレクターフレームはイベントシーケンスの純粋な派生結果であり、第二の戦闘状態を保存しない：

```text
DirectorFrame {
  beat_key,
  actor,
  targets,
  primary_target,
  source,
  motion_kind,
  impact_tone,
  critical,
  source_point,
  contact_points,
  camera_focus
}
```

これは、局所的な修正では消除しにくいいくつかの問題を解決する：

- 複数対象攻撃において、カメラ、エフェクト、ダメージ数字が「主要対象」について一貫した理解を持っていない；
- ブロックや免疫には正のダメージ数値がないが、「ヒットしていない」と誤判定される；
- 治療や移動アクションがダメージ姿勢を再利用している；
- 戦闘レポートが最新の状態を取得したが、キャラクターアニメーションは前の拍をまだ再生している。

新しい拍が到来すると、表現層は最新の明らかにされた拍を基準とし、古い拍の演出をキャンセルするべきであり、期限切れのアニメーションをキューに入れて再生補完してはならない。戦闘状態が権威であり、アニメーションはその瞬間の投影である。バックグラウンドタブの復帰後、特に数秒間の再生補完で最新の状態をブロックしてはならない。

## 最終状態は目の前の因果アクションを抹消してはならない

これはイベント演出で簡単に漏れがちな境界ケースである：あるキャラクターが自分のアクションで反撃をトリガーし、同じ拍内で転倒した。状態スナップショットではその最終HPはすでに0だが、この拍の因果順序は依然として「まず行動し、その後応答を受けた」である。

コンポーネントが最終HPのみを基準に姿勢を選択する場合、キャラクターは直接転倒し、自分の攻撃が空中に消える。正しい優先順位は以下の通りである：

```text
現在の拍の行動姿勢 > 現在の拍の対象反応 > 拍後の安定状態
```

したがって、行動者はまず本拍の攻撃を完全に表現し、拍の終了後に最終状態に基づいて転倒に遷移する。同様の原則が、同拍の治療、復活、変身、退場にも適用される。最終DOMは即座に正しいデータを持つことができるが、可視姿勢は最小限の因果順序を保持しなければならない。

## リソースの遅延時は現在のフレームに追従し、最初から再生補完しない

プリロードはレイテンシーを低減できるが、画像が常にアニメーションより先に到達することを保証するものではない。ネットワークキャッシュ、画像デコード、Reactのコミット順序、デバイス性能により、アトラスが数十〜数百ミリ秒遅れる可能性がある。

アトラスのロード完了時に `startedAt` を記録すると、第0フレームから再生され、カメラ、移動、エフェクトはすでに半分進行している。より信頼性の高い方法は、すべての一度限りのアトラスが同じ実行中の拍クロックを読み取るようにすることである：

```text
elapsed = shared_beat_clock.current_time - start_delay
frame   = frame_at(elapsed, playback_rate)
```

コンポーネントのマウント後の最初の描画で現在のフレームを計算する。リソースが接触後に到着した場合、接触後の対応する姿勢を直接表示する。拍全体が終了している場合は、直接完了して最終状態に落ちる。ここでは「初期オフセットを一度だけ補償」するだけでなく、後続のフレームでも共通の時間源を読み取り続けるべきである。そうしないと、ブラウザのスケジューリングの揺らぎにより、複数のトラックが徐々に分離してしまう。

ループ待機は例外である。一度限りの拍の起点がないため、独立したループクロックを使用でき、安定したキャラクターIDに基づいて非常に小さな位相と倍率の差異を与え、チーム全体がコピー＆ペーストのように同期呼吸しないようにする。

## 全姿勢をマウントするのではなく、次の拍をプリロードする

大規模アトラスのメモリコストは、圧縮ファイルサイズによって隠されることが多い。ブラウザは描画前に画像をピクセルにデコードする必要がある。大まかな見積もりは以下の通りである：

```text
decoded_bytes = sheet_width × sheet_height × 4
```

例えば、`1024×1024` のセルで構成される `6×4` のアトラスは、デコード後に約 `96 MiB` を占める。ディスク上では1〜2 MiBしかないかもしれないが、1人のキャラクターが待機、攻撃、被撃、転倒の4つのアトラスを同時にマウントする場合、単一のアクターが数百 MiB を占用する可能性がある。

より安全なリソース戦略は以下の通りである：

1. アクターは現在の姿勢のアトラスのみをマウントする；
2. ステージは未公開の次の拍を参照し、次の行動者の攻撃、主要対象の反応、対応するエフェクトのみをウォームアップする；
3. 同じリストのリクエストは同じロードPromiseを共有する；
4. キャッシュは参照カウント、最終使用順序、**デコードバイト数**を記録する；
5. 参照カウントが0のリソースのみを淘汰し、アイドルリソースにLRU予算を設定する；
6. プリローダーがリソースをアクターに引き渡す際、淘汰を現在のコミット後のマイクロタスクに延期し、ウォームアップ直後にクリアされないようにする。

予算の対象はデコードメモリでなければならず、WebPファイルサイズであってはならない。後者はダウンロードの評価に適しているが、ブラウザのピークメモリを予測することはできない。

## 低動的嗜好は安定した演出であり、持続時間をゼロに変更するものではない

`prefers-reduced-motion` の下で、単にすべてのアニメーションの持続時間をゼロにすると、半透明のレイヤー、誤った最初のフレーム、ヒットフィードバックのない空のフィールドが残ることが多い。低動的模式には明確な静的マッピングが必要である：

- 立位と一度限りの非転倒アクションは、安定した最初のフレームまたは同等の静的姿勢を表示する；
- 転倒は最後のフレームを表示し、維持する；
- キャラクターは編成アンカーポイントで停止し、カメラのズームイン/アウトやダッシュを実行しない；
- ヒットエフェクトは静止画像を使用する；
- 結果のテキストと最終数値は依然として完全に出現する；
- アニメーション完了コールバックは即座に表現状態を決済し、インターフェースが永遠に待機しないようにする。

リソースの欠落、リスト解析の失敗、画像デコードの失敗も、同じ静的ダウングレードパスに従うべきである。アニメーションは強化レイヤーであり、戦闘の可読性における単一障害点となってはならない。

## 検証ではセマンティックな時間点をチェックする

完全な映像のみを見ると、全体の感覚に基づいて数十ミリ秒のズレを見逃しやすい。より効果的な回帰テスト方法は、各テストで同じ決定論的な戦闘拍に入り、共通の時間軸を一時停止し、いくつかのセマンティックな時間点をキャプチャすることである：

| チェックポイント | 主要なアサーション |
|---|---|
| 初期 | キャラクター、配置、最終データが読み取り可能 |
| 蓄勢 | 結果テキストとヒットエフェクトがまだ露出していない |
| 接近 | 攻撃と反応アトラスが contact キューを越えていない |
| 接触 | 攻撃、反応、エフェクト、ダメージ数字、二次戦闘レポートが同時に可視 |
| 着地 | キャラクターが待機状態に飛び跳ねていない |
| 拍尾 | カメラが復帰し、転倒が最終フレームに達し、最終状態が安定 |

また、以下を個別にカバーする必要がある：

- 近接移動が対象に確かに接近しているが、戦闘軸を貫通していない；
- 複数対象イベントの対象セットとエフェクト位置が一致している；
- 行動者が同拍で転倒する場合、まず自分のアクションを表現する；
- アトラスの意図的なロード遅延後、最初のフレームが現在の拍に直接追いつく；
- 低動的模式で実行中の戦闘アニメーションがない；
- アトラスの失敗時に、静的キャラクター、数値、結果が依然として理解可能である；
- 連続する複数の拍の後、アイドルデコードメモリが予算内に回復する。

このようなテストの価値はスクリーンショットだけではない。それは「ヒットが十分に実感的でない」という感覚を再現可能な時間契約に変換する：接触前に結果を漏らしてはならず、接触時に共同で成立し、拍尾に正しい最終状態に入らなければならない。

## 個別に処理する必要があるサウンドクロック

サウンドは「イベントを受信したら再生すればよい」と誤解されやすい。戦闘イベントが1拍の開始時にすでに公開され、刀音やヒット音が即座に再生される場合、約 `T_contact` の画像よりもまだ一瞬早い。

サウンドを真に同期させるには、同じ拍の起点を読み取り、接触点に基づいてスケジュールする必要がある。ページの復帰、オーディオコンテキストのロック解除待ち、または接触点を既に逃した場合、スキップするか有限の追播を行うかを定義する必要がある。本記事の視覚的なタイムラインはブラウザのオーディオスケジューリングを自動的に解決しないため、「イベントソースが一貫している」ことを「サウンドが同期している」としてはならない。

## 最小実装リスト

- [ ] 戦闘コアはまず型付きイベントを生成し、表現層はゲームプレイ結果を逆算で書かない；
- [ ] 1拍につき `T_beat` と `T_contact` は1つずつ；
- [ ] 非ループアトラスは anticipation、contact、recover キューを明示的に注釈付ける；
- [ ] 攻撃、被撃、転倒はキューによるリタイムを行い、ソースのFPSを統一強制しない；
- [ ] カメラ、移動、アトラス、エフェクト、テキストは同じ拍クロックを読み取る；
- [ ] 遅延リソースは現在の拍に追従し、第0フレームから再生補完しない；
- [ ] 現在の因果アクションが拍後の最終姿勢より優先される；
- [ ] 現在のアトラスのみをマウントし、デコードサイズに基づいて参照カウントとLRUを行う；
- [ ] 次の拍は最も使用される可能性のある攻撃、主要反応、エフェクトのみをウォームアップする；
- [ ] 低動的嗜好とリソース失敗の両方に完全な静的表現がある；
- [ ] 回帰テストは蓄勢、接近、接触、着地、最終状態それぞれでアサーションを行う；
- [ ] サウンドを接続する場合、サウンドも同じ接触クロックに従わなければならない。

## 結び：アクションリソースが真にイベントシステムに入った

一組のアクションフレームは、ランタイムで「誰が行動し、いつ接触し、結果が誰に作用し、拍後に何が残り続けるか」を知ったときに、初めて画像リソースから戦闘演出へと変化する。

最も保持すべきなのは、特定の `900 ms` の定数ではなく、4つの不変量である：イベントが唯一の事実源であり、接触点が共通の意味的な錨であり、遅延リソースは現在のクロックに従い、最終状態が目の前の因果を抹消しない。この4点が安定していれば、アクションフレーム数、モデルソース、エフェクトスタイル、カメラは徐々に置き換えることができるが、互いにドリフトする複数の時計を再発することはない。

*Keywords: ゲーム開発, 戦闘アニメーション, フレームシーケンス, スプライトシート, イベントタイムライン, contact cue, アニメーション同期, reduced motion, アトラスプリロード, デコードメモリ*
