サンプルコードを写してキャラを動かせるようになった頃、ふと自分の PlayerController.cs を見ると——移動もHPもアイテムもUI更新も全部入りで、気づけば数百行。どこかを直すと別の場所が壊れ、「クラスをどう分ければいいのか」が分からない。 変数と関数を覚えた初心者が、次に必ずぶつかる壁です。
この記事では、その壁を越えるためのC#の道具—— クラスとインスタンス、値型と参照型と null、プロパティ、継承とコンポジション、interface ——を順番にそろえます。最後は IDamageable というinterfaceを使って、プレイヤー・敵・木箱を「同じ剣の一振り」で処理できるところまで組み立てます。
この記事でわかること
- クラス= データと振る舞いのまとまり、インスタンス=その実体
- 値型と参照型の違いと、
null/ NullReferenceException の正体- Unity特有の「破棄済みオブジェクトは
== null」という特別ルールpublicフィールドではなく[SerializeField] privateとプロパティ を使う理由- 継承とコンポジション の使い分け(Unityは「部品を組む」エンジン)
- interface で「ダメージを受けられるもの」を共通化する実例
動作確認環境: Unity 2022.3 LTS / Unity 6
「全部PlayerControllerに書く」と何が壊れるか
まず、壁の正体をはっきりさせましょう。次のようなスクリプト、心当たりはないでしょうか。
public class PlayerController : MonoBehaviour
{
// 移動もHPもコインもUIも音も、ぜんぶここに……
public float moveSpeed = 5f;
public int health = 100;
public int coinCount = 0;
public Text healthText;
public AudioClip damageSound;
void Update()
{
// 移動処理 30行
// ダメージ処理 40行
// コイン処理 20行
// UI更新 30行 ……
}
}
動いてはいます。でも、この書き方は規模が少し増えただけで壊れ始めます。
- 直す場所が探せない: ジャンプのバグを直したいのに、HPやUIのコードをか き分けて探すことになる
- 1カ所の変更が全体に波及する: HPの変数名を変えたら、移動処理の中に紛れていた無敵判定まで巻き添えでエラーになる
- 使い回せない: 「敵にもHPが欲しい」と思っても、この中からHP部分だけを取り出せない

解決の方向はシンプルで、 「役割ごとに小さなクラスへ分ける」 です。移動は PlayerMover、HPは Health、コインは CoinCollector。実は GameObjectとコンポーネント で見た「部品を組み合わせる」というUnityの設計思想そのものです。この記事では、その「分ける」ためのC#の文法を一つずつ見ていきます。
クラスは「データと振る舞い」のまとまり
クラス は、関連する変数(データ)と関数(振る舞い)をひとまとめにした「設計図」です。そして設計図から作られた実体を インスタンス と呼びます。

MonoBehaviourを継承しない、純粋なC#のクラスを作ってみます。
// Weapon.cs — MonoBehaviourを継承しない「普通のクラス」
public class Weapon
{
// データ(この武器が覚えていること)
public string weaponName;
public int damage;
// コンストラクタ:newされた瞬間に呼ばれる初期化関数
public Weapon(string name, int damageValue)
{
weaponName = name;
damage = damageValue;
}
// 振る舞い(この武器ができること)
public string GetLabel()
{
return weaponName + "(攻撃力" + damage + ")";
}
}
使う側は new でインスタンスを作ります。
Weapon sword = new Weapon("鉄の剣", 10);
Weapon axe = new Weapon("戦斧", 25);
Debug.Log(sword.GetLabel()); // 鉄の剣(攻撃力10)
Debug.Log(axe.GetLabel()); // 戦斧(攻撃力25)
大事なのは、sword と axe が 同じ設計図から生まれた別々の実体 で、それぞれ独立したデータを持っていることです。「敵が10体いれば、EnemyHealth のインスタンスが10個あり、HPもそれぞれ別」——この感覚が、クラスを使う土台になります。
注意:
MonoBehaviourを継承したクラスだけはnewできません。GameObjectに載って初めて動く部品だからで、生成はAddComponent()かエディタでのアタッチで行います。逆に言うと、GameObjectに載せる必要のない純粋なデータやルール(所持金の計算、ダメージ計算式など)は、上のWeaponのような普通のクラスに切り出せます。詳しくは MonoBehaviourの記事 を参照してください。
値型と参照型、そしてnull
クラスが使えるようになると、すぐに不思議な現象に出会います。「コピーしたはずなのに、片方を変えたらもう片方も変わった」——これは 値型と参照型 の違いです。
- 値型(
int、float、bool、Vector3など): 代入すると 中身がコピー される。コピー先を変えても元は変わらない - 参照型(クラス全般。
stringや配列、GameObjectも): 代入すると 同じ実体への「参照(住所)」がコピー される。どちらの変数から変えても、同じ実体が変わる

// 値型:コピーなので、bを変えてもaはそのまま
Vector3 a = new Vector3(0, 0, 0);
Vector3 b = a;
b.x = 100f;
Debug.Log(a.x); // 0 のまま
// 参照型:swordAとswordBは「同じ武器 」を指している
Weapon swordA = new Weapon("鉄の剣", 10);
Weapon swordB = swordA;
swordB.damage = 999;
Debug.Log(swordA.damage); // 999 に変わっている!
これはバグではなく、参照型の正しい動きです。敵のリストをAIとUIの両方へ渡しても、全員が「同じ敵たち」を見られるのは参照型のおかげです。
nullは「何も指していない」
参照型の変数は、 どの実体も指していない状態 を取れます。それが null です。null の変数のメンバーへアクセスすると、Unity初心者が最初に出会うエラーの王様 NullReferenceException が発生します。
Weapon currentWeapon = null; // まだ武器を持っていない
// currentWeaponは何も指していないので、ここで
// NullReferenceException: Object reference not set to an instance of an object
Debug.Log(currentWeapon.damage);
対策の基本は「使う前に確認する」です。
if (currentWeapon != null)
{
Debug.Log(currentWeapon.damage);
}
このエラーの追い詰め方(どの変数がnullだったのかをエラーメッセージと行番号から特定する手順)は、デバッグ手法の記事 で詳しく扱っています。
Unity特有の「破棄済みオブジェクト」判定
ここにUnity特有の重要ルールがあります。Destroy() で破棄したGameObjectやComponentへの参照は、C#の変数としてはまだ何かを指しているのに、 Unityが == null を true と答えるように特別に作ってある のです。
[SerializeField] private EnemyHealth enemy;
void DefeatEnemy()
{
Destroy(enemy.gameObject);
}
void Update()
{
// 破棄された次のフレーム以降、enemy == null は true になる(Unityの特別ルール)
if (enemy != null)
{
Debug.Log(enemy.name);
}
}
このおかげで「破棄済みかどうか」を普通のnullチェックで確認できます。ただし落とし穴が1つ。C#の便利記法である ?. や ?? は この特別ルールを通りません。破棄済みのオブジェクトを「まだある」と誤判定してエラーになるため、 Unityのオブジェクトにはあえてベタな if (obj != null) を使う のが正解です。
外に見せる窓口を絞る:SerializeField privateとプロパティ
クラスを分けたら、次は「外からどこまで触らせるか」を決めます。全部 public にすると、どのクラスからでも書き換えられてしまい、「HPがいつの間にかマイナスになっている。誰が書いた?」を全クラスから探すはめになります。
方針は2つだけ覚えれば十分です。
1. Inspectorで調整したいだけなら [SerializeField] private
public class Health : MonoBehaviour
{
// Inspectorには表示されるが、他のクラスからは書き換えられない
[SerializeField] private int maxHealth = 100;
}
2. 外へ「読ませたい」値は、読み取り専用のプロパティで公開する
public class Health : MonoBehaviour
{
[SerializeField] private int maxHealth = 100;
// 外からは読めるが、書き換えはこのクラス内からしかできない
public int Current { get; private set; }
void Awake()
{
Current = maxHealth;
}
public void TakeDamage(int amount)
{
// 書き換えの入り口をこの関数に絞ったので、
// 「0未満にしない」というルールを必ず通せる
Current = Mathf.Max(0, Current - amount);
}
}
プロパティ は「読み書きの窓口になれる変数のようなもの」です。get; private set; と書くと、読むのは誰でも・書くのは自分のクラスだけ、になります。こうしてHPの変更を TakeDamage() という1つの入り口に絞れば、「HPがマイナスになった」バグは原理的に起きなくなります。変更の通知まで欲しくなったら、イベントとデリゲートの記事 が次の一歩です。
継承とコンポジション
継承 は、既存のクラスを土台に「差分だけ」を書く仕組みです。実はもう毎日使っています——public class Player : MonoBehaviour の : がそれで、Start() や Update() が呼ばれるのはMonoBehaviourを継承しているからです。
自分で使う典型例は、敵のバリエーション作りです。
// 敵に共通するものを土台にまとめる
public class EnemyBase : MonoBehaviour
{
[SerializeField] protected float moveSpeed = 2f;
// virtual:子クラスが上書き(override)できる関数
protected virtual void Move()
{
transform.Translate(Vector3.left * moveSpeed * Time.deltaTime);
}
void Update()
{
Move();
}
}
// スライム:共通の動きをそのまま使う
public class Slime : EnemyBase
{
}
// コウモリ:動き方だけ上書きする
public class Bat : EnemyBase
{
protected override void Move()
{
float wave = Mathf.Sin(Time.time * 5f);
transform.Translate(new Vector3(-moveSpeed, wave, 0) * Time.deltaTime);
}
}
便利ですが、継承には「効きすぎる」危険があります。EnemyBase → FlyingEnemy → FireFlyingEnemy → …と階層を深くすると、土台の1行の変更が全子孫へ波及して、巨大クラスと同じ「どこを直せば良いか分からない」状態に逆戻りします。
そこで思い出してほしいのが、Unityがもともと採用している コンポジション(部品の組み合わせ) です。「空を飛ぶ敵」は「飛ぶクラスを継承した敵」ではなく、「敵オブジェクトに Flying コンポーネントを足したもの」として作れます。

使い分けの目安はこうです。
| 判断基準 | 向いている道具 |
|---|---|
| 「スライム は 敵 である」と自然に言える(is-a) | 継承(浅く。1〜2段まで) |
| 「敵が飛ぶ機能 を持つ」と言う方が 自然(has-a) | コンポジション(コンポーネントを足す) |
| 種類の違うものに「同じ約束」だけさせたい | interface(次の節) |
迷ったらコンポジションに倒してください。GameObjectとコンポーネントの記事 で見た「部品を組み替えて3つのオブジェクトを作る」感覚が、そのまま設計の正解です。
interfaceは「できること」の約束
最後の道具が interface です。interfaceは実装を持たない「約束のリスト」で、「このクラスは、この関数を必ず持っています」と宣言します。
// 「ダメージを受けられるもの」という約束
public interface IDamageable
{
void TakeDamage(int amount);
}
なぜこれが強力なのか。プレイヤー・敵・壊れる木箱を考えてみてください。3つは「敵の仲間」でも「プレイヤーの仲間」でもなく、共通の親クラスを作ろうとすると不自然です。でも 「ダメージを受けられる」という1点だけは共通 しています。

interfaceなら、系統の違うクラスたちへ横断的に同じ約束をさせられます。
// クラス継承は1つだけだが、interfaceはカンマで並べていくつでも実装できる
public class EnemyHealth : MonoBehaviour, IDamageable
{
public void TakeDamage(int amount)
{
// 約束した関数の中身は、クラスごとに自由に書く
}
}
攻撃する側は、相手が何者かを知る必要がなくなります。「IDamageable を持っているなら殴れる」——それだけです。次の実践で、この配線を実際に組んでみましょう。
実践:1本の剣でプレイヤーも敵も木箱も殴れるようにする
アクションRPGの剣攻撃、シューティングの弾、パズルゲームのブロック破壊——「1つの攻撃が、種類の違ういろいろなものに当たる」場面はどのジャンルにもあります。ここでは剣を題材に、IDamageable で組み立てます。
完成すると、こうなります。剣を一振りすると、範囲内にいた 敵はHPが減って倒れ、木箱は壊れてアイテムを落とし、当たってしまった 味方はHPが減る。剣のスクリプトは、この3種類のクラス名を1つも知りません。

まず、約束を定義します。
// IDamageable.cs — 「ダメージを受けられるもの」の約束
public interface IDamageable
{
void TakeDamage(int amount);
}
攻撃側は、約束だけを見ます。剣の当たり判定(IsTriggerをオンにしたCollider)に付けてください。
// SwordAttack.cs — 剣の当たり判定に付ける
using UnityEngine;
public class SwordAttack : MonoBehaviour
{
[SerializeField] private int damage = 10;
private void OnTriggerEnter(Collider other)
{
// 相手が「ダメージを受けられるもの」なら、種類を問わず殴れる
if (other.TryGetComponent<IDamageable>(out IDamageable target))
{
target.TakeDamage(damage);
}
}
}
受ける側は、約束の中身をそれぞれの事情で書きます。
// EnemyHealth.cs — 敵:HPが尽きたら倒れる
using UnityEngine;
public class EnemyHealth : MonoBehaviour, IDamageable
{
[SerializeField] private int maxHealth = 30;
private int currentHealth;
void Awake()
{
currentHealth = maxHealth;
}
public void TakeDamage(int amount)
{
currentHealth -= amount;
Debug.Log(name + " のHP: " + currentHealth);
if (currentHealth <= 0)
{
Destroy(gameObject);
}
}
}
// BreakableCrate.cs — 木箱:一撃で壊れてアイテムを落とす
using UnityEngine;
public class BreakableCrate : MonoBehaviour, IDamageable
{
[SerializeField] private GameObject itemPrefab;
public void TakeDamage(int amount)
{
// 木箱はHPを持たない。ダメージ量も見ずに壊れる——それも「自由」のうち
if (itemPrefab != null)
{
Instantiate(itemPrefab, transform.position, Quaternion.identity);
}
Destroy(gameObject);
}
}
// PlayerHealth.cs — 味方:HPが減り、0でゲームオーバー通知
using UnityEngine;
public class PlayerHealth : MonoBehaviour, IDamageable
{
[SerializeField] private int maxHealth = 100;
public int Current { get; private set; }
void Awake()
{
Current = maxHealth;
}
public void TakeDamage(int amount)
{
Current = Mathf.Max(0, Current - amount);
Debug.Log("プレイヤーのHP: " + Current);
if (Current == 0)
{
Debug.Log("ゲームオーバー");
}
}
}
Playを押して、剣を敵・木箱・味方の順に当ててみてください。敵は3発で消え、木箱は一撃でアイテムに変わり、味方はHPのログが減っていく——同じ SwordAttack から、3通りの反応が返ってくるはずです。そして SwordAttack.cs をもう一度見てください。if (敵なら) if (木箱なら) という分岐が 1つもない ことが、この設計の成果です。
明日「壊せる街灯」を追加したくなっても、やることは街灯に IDamageable を実装したクラスを付けるだけ。 SwordAttack.cs には1文字も触りません。もし剣を当てても何も起きなかったら、見る場所は2つです。剣のColliderの Is Trigger がオンになっているか。そして TryGetComponent は Colliderと同じGameObject しか見ないので、Colliderが子オブジェクトに付いているなら GetComponentInParent で親のコンポーネントを探す 形に変えてみてください。
ポイントは2つです。 攻撃側はinterfaceしか見ない(だから増築してもコードが壊れない)。 反応の中身は受ける側それぞれの責任(だから敵と木箱で違う反応を自由に書ける)。この「約束で切り離す」感覚は、Stateパターン や イベント駆動の設計 へそのままつながっていきます。
おまけ:先に知っておくと良いこと
この記事の道具を使い始めると、いずれ次の言葉に出会います。今は名前と一言だけ知っておけば十分です。
abstract(抽象クラス): 「土台としてだけ使い、単体ではインスタンス化させない」クラス。EnemyBaseを直接シーンに置かれたくなくなったら調べてみてくださいstatic(静的メンバー): インスタンスではなく クラスそのもの に属する変数・関数。Mathf.Max()をnew Mathf()なしで呼べるのはこれです。強力ですが多用すると密結合の温床になるため、Singletonパターンの記事 で作法ごと学ぶのがおすすめです- ジェネリック(
<T>): 型をあとから差し込める仕組み。実はList<int>やGetComponent<Rigidbody>()でもう使っています。「使う」は今日からでき、「自作する」はずっと先で大丈夫です
まとめ
- クラス はデータと振る舞いのまとまり、 インスタンス はその実体。役割ごとに小さく分けるのが巨大クラス対策の第一歩
- クラスは 参照型。代入は「同じ実体の共有」で、どこも指していない状態が
null - Unityでは破棄済みオブジェクトが
== nullでtrueになる。?.や??はこの特別ルールを通らない ので使わない - Inspectorに出したいだけなら
[SerializeField] private、外へ読ませたいならget; private set;のプロパティ - 継承は is-a の関係に浅く使い、迷ったら コンポーネントの組み合わせ に倒す
- interface は「できること」の約束。系統の違うクラスたちを、同じ攻撃・同じ処理から扱えるようになる
次にあなたのプロジェクトを開いたら、いちばん行数の多いスクリプトを眺めてみてください——その中に「別のクラスに切り出せるまとまり」は、いくつ見えますか?