1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】ボス撃破で武器獲得型2Dアクション「BlastRunner」の技術解説 — テキストマップからステージを生成する設計

1
Last updated at Posted at 2026-10-19

feature_graphic_1024x500.png

googleplayストアで公開されました。
https://play.google.com/store/apps/details?id=com.sst.Blast

はじめに

「BlastRunner」は、ボスを倒して特殊武器を獲得する2D横スクロールアクションゲームです。暴走したAI「マザーコア」に立ち向かうロボット「ブラスト」を操り、全5ステージ+ボス戦を攻略します。

本記事では、Unity 6 LTS(6000.3.7f1)で開発しているこのゲームの主要な技術的仕組みを紹介します。

ゲーム概要

  • 全5ステージ: ステージ1〜4は任意順で選択、ステージ5は全面クリア後に解放
  • ボス撃破で特殊武器を獲得: スプレッドショット、バリアシールド、サンダーチェイン、ホーミングミサイル
  • HP28 / WE(武器エネルギー)制 / 残機3
  • ライバルキャラ「ノヴァ」: ステージ3・5で中ボスとして登場、ストーリーに絡む

1. テキストマップによるステージ定義

1.1 マップデータの構造

ステージデータは string[] によるテキストマップで定義しています。1文字が1タイルに対応します。

// StageData.cs
public class StageData
{
    public int stageNumber;
    public string stageName;
    public string[] map;
    public Color bgColor;
    public string bossName;

    public int Width => map != null && map.Length > 0 ? map[0].Length : 0;
    public int Height => map != null ? map.Length : 0;

    public char GetTile(int col, int row)
    {
        if (row < 0 || row >= Height || col < 0 || col >= Width) return '#';
        return map[row][col];
    }
}

実際のステージ定義はこんな感じです。

// StageDatabase.cs(ステージ1: ネオンシティ)
map = new string[]
{
    "########################################################",
    "#......................................................#",
    "#.........2..............2...................2.........#",
    "#....===.......===........===.........L..===...........#",
    "#...1.......1................1........L........1.......#",
    "#..###.....###.....###......###..###..L...*.>#########.#",
    "######################################L#################",
    // ... 以下続く
};

タイルの凡例:

文字 意味
# 地面(ソリッド)
= すり抜け床
L はしご
S トゲ
B 壊せるブロック
C/c コンベア(右/左)
P プレスマシン
D 消える足場
E 電撃地帯
W 風ゾーン
M 移動床
> ボスドア
* チェックポイント
@ プレイヤー開始位置
N ノヴァ(中ボス)
1〜5 敵タイプ

この方式の利点は、コード上でステージ全体を俯瞰できることです。エディタを開かずともマップの構造が把握でき、テキストエディタだけでレベルデザインが可能です。

1.2 タイル生成の仕組み

StageLoader がマップ文字列を走査し、1文字ごとに対応するGameObjectを生成します。

// StageLoader.cs
void ProcessTile(char tile, int wx, int wy, int col, int row)
{
    switch (tile)
    {
        case '#': CreateSolid(wx, wy); break;
        case '=': CreateOneWay(wx, wy); break;
        case 'L': CreateLadder(wx, wy); break;
        case 'S': CreateSpike(wx, wy); break;
        case 'B': CreateBreakable(wx, wy); break;
        case 'C': CreateConveyor(wx, wy, 1); break;
        case 'D': CreateDisappearing(wx, wy); break;
        case '1': case '2': case '3': case '4': case '5':
            CreateEnemy(tile, wx, wy); break;
        // ... 他のギミックも同様
    }
}

座標変換は worldY = Height - 1 - row です。テキストマップの上の行が画面上部に対応するよう、Y軸を反転しています。

1.3 CompositeCollider2D による最適化

地面タイルは個別にコライダーを持ちますが、最終的に CompositeCollider2D で統合しています。

// StageLoader.cs(ステージ生成後)
var rb = stageRoot.gameObject.AddComponent<Rigidbody2D>();
rb.bodyType = RigidbodyType2D.Static;
var composite = stageRoot.gameObject.AddComponent<CompositeCollider2D>();
composite.geometryType = CompositeCollider2D.GeometryType.Polygons;
composite.generationType = CompositeCollider2D.GenerationType.Synchronous;

各地面タイルの BoxCollider2D は usedByComposite = true を設定しています。これにより隣接するタイル境界でプレイヤーが引っかかる問題を防ぎつつ、物理計算のコライダー数も削減できます。


2. プレイヤーアクションシステム

2.1 状態管理

プレイヤーの状態は enum で管理しています。

public enum PlayerState { Idle, Run, Jump, Fall, WallSlide, Dash, Slide, Climb, Hit, Dead, Victory }

Update() で入力を受け取り、FixedUpdate() で物理を処理し、UpdateState() で状態を決定するという3段構成です。特殊アクション(ダッシュ、スライディング、はしご)は専用のハンドラが FixedUpdate 内で排他的に処理されます。

// PlayerController.cs — FixedUpdate内の処理順
if (hitStunTimer > 0) { hitStunTimer -= ...; return; }  // 被弾硬直
if (isDashing)  { HandleDash(); return; }                // ダッシュ
if (isSliding)  { HandleSlide(); return; }               // スライディング
if (isOnLadder) { HandleLadder(); return; }              // はしご

HandleMovement();    // 通常移動
HandleJump();        // ジャンプ(地上/壁)
HandleWallSlide();   // 壁ずり落ち
HandleVariableJump(); // 可変ジャンプ

2.2 コヨーテタイムとジャンプバッファ

2Dアクションの操作感で大事なのが、入力の「遊び」です。

public float coyoteTime = 0.1f;      // 足場を離れた後のジャンプ猶予
public float jumpBufferTime = 0.12f;  // 着地前のジャンプ先行入力

コヨーテタイム: 地面から足を離れた直後(0.1秒以内)はまだジャンプできます。崖際でのギリギリジャンプが気持ちよく決まるための仕組みです。

ジャンプバッファ: 着地の少し前にジャンプボタンを押した場合、着地した瞬間に自動でジャンプが発動します。リズミカルな連続ジャンプが可能になります。

void HandleJump()
{
    bool canJump = IsGrounded || coyoteTimer > 0;
    bool wantJump = jumpDown || jumpBufferTimer > 0;

    if (wantJump && canJump)
    {
        rb.linearVelocity = new Vector2(rb.linearVelocity.x, jumpForce);
        coyoteTimer = 0;
        jumpBufferTimer = 0;
    }
    // ... 壁ジャンプの処理
}

2.3 可変ジャンプ

ジャンプボタンを押す長さでジャンプの高さが変わります。

void HandleVariableJump()
{
    if (!jumpHeld && rb.linearVelocity.y > 0)
        rb.linearVelocity = new Vector2(rb.linearVelocity.x, rb.linearVelocity.y * variableJumpMultiplier);
}

ボタンを離した瞬間に上昇速度を半減(variableJumpMultiplier = 0.5f)させます。短押しで低いジャンプ、長押しで高いジャンプ。これだけのコードで直感的な高さ調整が実現できます。

2.4 壁ずり落ちと壁ジャンプ

壁に向かって移動しながら落下すると、壁ずり落ち状態に入ります。

void HandleWallSlide()
{
    isWallSliding = isTouchingWall && !IsGrounded && rb.linearVelocity.y < 0 && hInput * FacingDir > 0;
    if (isWallSliding)
        rb.linearVelocity = new Vector2(rb.linearVelocity.x, Mathf.Max(rb.linearVelocity.y, -wallSlideSpeed));
}

落下速度を wallSlideSpeed(2f)に制限することで、ゆっくりずり落ちる動きになります。この状態でジャンプすると、壁と反対方向に跳ぶ壁ジャンプが発動します。壁ジャンプ後は wallJumpLockTime(0.15秒)の間、横移動入力が無効になり、壁に張り付いたまま上に登れてしまうことを防いでいます。

2.5 スライディングとコライダー切替

void StartSlide()
{
    isSliding = true;
    slideTimer = slideDuration;
    col.size = slideColSize - Vector2.one * 0.08f;    // 高さ半分
    col.offset = slideColOffset;                       // 下寄り
    rb.linearVelocity = new Vector2(FacingDir * slideSpeed, rb.linearVelocity.y);
}

スライディング中はコライダーサイズを縮小して低い隙間をくぐれるようにしています。速度は時間経過で減衰し(slideSpeed * (slideTimer / slideDuration))、自然な減速感を出しています。


3. 武器システム

3.1 5種の武器とWE管理

public enum WeaponType { Blaster, SpreadShot, BarrierShield, ThunderChain, HomingMissile }

ブラスター(通常武器)以外はWE(Weapon Energy, 最大28)を消費します。WEが切れると自動的にブラスターに切り替わります。

3.2 チャージショット

ブラスターにはチャージ機能があります。射撃ボタンを1.2秒以上長押しするとチャージ完了。離すと強力な弾を発射します。

// WeaponSystem.cs
if (currentWeapon == WeaponType.Blaster)
{
    chargeTimer += Time.deltaTime;
    if (chargeTimer >= chargeTime && !isCharging)
    {
        isCharging = true;
        chargeEffect.enabled = true;  // チャージエフェクト表示
    }
}

チャージ中にも通常弾は撃てません(!isCharging の条件)。チャージショットのダメージは4、通常弾は1。見た目のサイズも変わります。

3.3 サンダーチェイン — 連鎖ダメージ

サンダーチェインは着弾時に近くの敵へ電撃が連鎖する武器です。

// ThunderChainBehavior.cs
void OnDestroy()
{
    if (maxChains <= 0) return;

    var enemies = FindObjectsByType<EnemyController>(FindObjectsSortMode.None);
    List<EnemyController> inRange = new List<EnemyController>();

    foreach (var e in enemies)
    {
        if (hitEnemies.Contains(e.GetInstanceID())) continue;
        if (Vector2.Distance(transform.position, e.transform.position) <= chainRange)
            inRange.Add(e);
    }

    inRange.Sort((a, b) =>
        Vector2.Distance(transform.position, a.transform.position)
        .CompareTo(Vector2.Distance(transform.position, b.transform.position)));

    int chains = Mathf.Min(maxChains, inRange.Count);
    for (int i = 0; i < chains; i++)
    {
        target.TakeDamage(chainDamage);
        DrawChainLine(transform.position, target.transform.position);  // 稲妻エフェクト
    }
}

弾が消滅する瞬間(OnDestroy)に範囲内の敵を探索し、距離順にソートして近い順に連鎖ダメージを与えます。hitEnemies の HashSet で同じ敵への二重ヒットを防止しています。

稲妻の描画は LineRenderer で6点の折れ線を引き、各中間点にランダムなオフセットを加えることで雷っぽいジグザグを表現しています。

3.4 ホーミングミサイル — 追尾アルゴリズム

// HomingBehavior.cs — FixedUpdate
Vector2 dir = ((Vector2)target.position - rb.position).normalized;
Vector2 currentDir = rb.linearVelocity.normalized;

float angle = Vector2.SignedAngle(currentDir, dir);
float maxTurn = turnSpeed * Time.fixedDeltaTime;
float turn = Mathf.Clamp(angle, -maxTurn, maxTurn);

float currentAngle = Mathf.Atan2(currentDir.y, currentDir.x) * Mathf.Rad2Deg;
float newAngle = (currentAngle + turn) * Mathf.Deg2Rad;
Vector2 newDir = new Vector2(Mathf.Cos(newAngle), Mathf.Sin(newAngle));
rb.linearVelocity = newDir * speed;

旋回速度制限付きの追尾です。ターゲットへの角度差を計算し、1フレームあたりの最大旋回角(turnSpeed * deltaTime)でクランプします。急カーブはできないけど徐々にターゲットに向かう、ミサイルらしい弧を描く動きになります。

3.5 バリアシールド — 持続型防御

バリアシールドは他の武器と違い、ボタンを押している間だけ展開される防御武器です。

// WeaponSystem.cs
if (barrierInstance != null)
{
    currentWE -= Mathf.CeilToInt(Time.deltaTime * 8);  // 毎秒8 WE消費
    if (currentWE <= 0 || !active)
    {
        Destroy(barrierInstance);
        barrierInstance = null;
    }
}

WEを毎秒8ポイント消費し続けるため、最大でも約3.5秒しか展開できません。ボス戦での被弾回避の切り札として使える設計です。


4. 敵AIシステム

4.1 5種類のAIタイプ

public enum EnemyAIType { Patrol, FlySine, Chase, Shooter, WallCrawler }

テキストマップの数字(1〜5)が敵タイプに対応し、EnemyController の1つのクラスで全タイプを処理します。

4.2 パトロール — 崖検知と壁検知

巡回型の敵は Raycast で足元と壁を検知して反転します。

// EnemyController.cs — FixedUpdatePatrol
// 崖検知: 進行方向の足元に地面がなければ反転
float checkX = transform.position.x + moveDir * 0.5f;
float checkY = col.bounds.min.y;
var edgeHit = Physics2D.Raycast(new Vector2(checkX, checkY), Vector2.down, 0.3f);
if (edgeHit.collider == null) moveDir = -moveDir;

// 壁検知: 進行方向に壁があれば反転
var wallHit = Physics2D.Raycast(..., new Vector2(moveDir, 0), 0.6f);
if (wallHit.collider != null && !wallHit.collider.isTrigger) moveDir = -moveDir;

加えて、patrolRange で初期位置からの移動範囲も制限しています。崖・壁・範囲の3重チェックで、自然な巡回行動を実現しています。

hasLanded フラグで初回着地を検知し、着地前にRaycastで判定してしまう問題を防いでいるのもポイントです。

4.3 飛行型 — 正弦波軌道

void UpdateFlySine()
{
    sinTimer += Time.deltaTime;
    float x = startPos.x + moveDir * moveSpeed * sinTimer;
    float y = startPos.y + Mathf.Sin(sinTimer * 3f) * 1.5f;
    transform.position = new Vector3(x, y, 0);
}

水平移動に Sin を足すだけで、上下に揺れながら飛ぶ敵の動きになります。重力は0に設定してあるので、Rigidbody2D の影響は受けません。

4.4 アイテムドロップ

void DropItem()
{
    float roll = Random.value;
    if (roll < 0.30f) itemSprite = "spr_hp_small";       // 30%: HP小回復
    else if (roll < 0.35f) itemSprite = "spr_hp_large";  // 5%: HP大回復
    else if (roll < 0.60f) itemSprite = "spr_we_capsule"; // 25%: WE回復
    else return;                                           // 40%: ドロップなし
}

確率設計はシンプルですが、HP大回復を5%と絞ることで希少感を出しています。ドロップアイテムは物理付きで跳ねて落ち、10秒で自動消滅します。


5. ボスシステム

5.1 BossBase — 抽象基底クラス

全ボスは BossBase を継承しています。

public abstract class BossBase : MonoBehaviour
{
    public int maxHP = 40;
    public int contactDamage = 4;
    public WeaponType reward = WeaponType.SpreadShot;

    protected abstract void UpdateBoss();

    protected virtual IEnumerator IntroSequence() { ... }
    protected virtual IEnumerator DeathSequence() { ... }
    protected void FireBullet(Vector2 pos, Vector2 velocity, int damage, float size) { ... }
}

共通機能(被弾フラッシュ、死亡爆発、弾発射)を基底クラスに集約し、各ボスは UpdateBoss() で行動パターンだけ実装すれば済む設計です。

5.2 マザーコア — 3形態変化

最終ボス「マザーコア」はHP80で、HPに応じて3形態に変化します。

// MotherCore.cs
enum Form { Shell, Arms, TrueCore }

formTransitionHP2 = maxHP * 0.6f;  // 48以下でArms形態へ
formTransitionHP3 = maxHP * 0.25f; // 20以下でTrueCore形態へ

各形態の攻撃パターン:

形態 攻撃 特徴
Shell 放射弾幕(8発) / レーザー掃射 序盤。弾速遅め、隙が大きい
Arms 放射弾幕(12発) / アームスラム / ミニオン召喚 弾数増加、召喚で場を荒らす
TrueCore 放射弾幕(16発) / 絶望ビーム / 弾幕地獄 高速・高密度、回避が困難

形態変化時には爆発演出を挟み、色とスケールが変わります。

IEnumerator FormTransition(Color newColor)
{
    isActive = false;  // 変化中は攻撃しない
    for (int i = 0; i < 6; i++)
    {
        SpawnExplosion(transform.position + (Vector3)Random.insideUnitCircle * 2f);
        yield return new WaitForSeconds(0.2f);
    }
    originalColor = newColor;
    sr.color = newColor;
    float scale = currentForm == Form.TrueCore ? 1.5f : 1.2f;
    transform.localScale = Vector3.one * scale;
    isActive = true;
}

5.3 弾幕地獄 — 回転放射弾

最終形態の「弾幕地獄」は、回転しながら放射状に弾を撃ちます。

IEnumerator BulletHell()
{
    float baseAngle = 0;
    for (int wave = 0; wave < 5; wave++)
    {
        for (int i = 0; i < 6; i++)
        {
            float angle = baseAngle + (360f / 6) * i;
            Vector2 dir = new Vector2(Mathf.Cos(angle * Mathf.Deg2Rad), Mathf.Sin(angle * Mathf.Deg2Rad));
            FireBullet(transform.position, dir * 4f, 2, 0.2f);
        }
        baseAngle += 15f;  // 各ウェーブで15度回転
        yield return new WaitForSeconds(0.25f);
    }
}

5ウェーブ × 6発 = 30発。baseAngle を毎ウェーブ15度ずらすことで、螺旋状に広がる弾幕パターンが生まれます。弾速は4fと遅めで、パターンを見切って隙間を縫う楽しさを重視しています。


6. ライバルキャラ「ノヴァ」の演出

6.1 ステージ別の行動変化

ノヴァはステージ3では通常の3パターン攻撃、ステージ5では4パターン(怒涛のコンボ攻撃が追加)で攻めてきます。

attackIndex = (attackIndex + 1) % (encounterStage == 5 ? 4 : 3);
switch (attackIndex)
{
    case 0: StartCoroutine(DashSlash()); break;   // 高速突進斬り
    case 1: StartCoroutine(SpreadFire()); break;  // 扇状弾幕
    case 2: StartCoroutine(JumpShot()); break;    // 跳び撃ち
    case 3: StartCoroutine(EnragedCombo()); break; // Stage5限定:怒涛の連撃
}

6.2 敗北演出の分岐

ノヴァの敗北演出はステージによって異なります。

  • ステージ3: テレポートビームで撤退。セリフを残して上方へ消えていく
  • ステージ5: 洗脳が解けて解放。爆発エフェクトの後、別のスプライトフレームに切り替わり穏やかに倒れる
IEnumerator Stage5Liberation()
{
    // 洗脳が解ける — フラッシュ
    sr.color = Color.white;
    yield return new WaitForSeconds(0.5f);

    // エネルギー放出
    for (int i = 0; i < 4; i++) { SpawnExplosion(); yield return ...; }

    // 解放後 — フレームを切り替え
    SetFrame(6);  // row 1 frame 0: 青いノヴァ
    sr.color = Color.white;

    var dlg = CreateDialogueText(GetDefeatLine(5)); // "…ありがとう…ブラスト…"
    yield return new WaitForSeconds(2.5f);
}

同じ中ボスクラスで、出現ステージに応じてHP、攻撃数、敗北演出、セリフがすべて変わる設計です。

6.3 多言語セリフ

ノヴァのセリフは6言語分を静的配列で持っています。

static readonly string[] defeatLine5 = {
    "Nova: ...Thank you... Blast...",       // EN
    "ノヴァ: …ありがとう…ブラスト…",         // JA
    "诺娃: ……谢谢你……布拉斯特……",           // ZH
    "노바: …고마워…블래스트…",               // KO
    "Nova: ...Gracias... Blast...",          // ES
    "Nova: ...Merci... Blast...",            // FR
};

7. カメラとパララックス

7.1 スムーズ追従とルームロック

// CameraController.cs — LateUpdate
pos.x = Mathf.Lerp(pos.x, tx, smoothSpeed * Time.deltaTime);
pos.y = Mathf.Lerp(pos.y, ty, smoothSpeed * Time.deltaTime);
pos.x = Mathf.Clamp(pos.x, minX, maxX);
pos.y = Mathf.Clamp(pos.y, minY, maxY);

基本は Lerp による追従ですが、ボス戦では LockToRoom でカメラを部屋にロックします。ロック時もプレイヤーの位置に追従しつつ、部屋の境界内に収まるようクランプしています。

ロック切替時には lockTransitionTimer で滑らかな遷移を行い、カメラが瞬間移動する不快さを防いでいます。

7.2 3層パララックス背景

// ParallaxBackground.cs
layers[i] = new ParallaxLayer
{
    factor = 0.1f + i * 0.15f  // far=0.1, mid=0.25, near=0.4
};

// LateUpdate
layers[i].offset += delta.x * layers[i].factor;
pos.x -= layers[i].offset;

カメラの移動量に factor を掛けた分だけ背景をずらすことで、奥行き感を表現しています。3層(遠景0.1、中景0.25、近景0.4)で、遠いほどゆっくり動きます。各レイヤーのスプライトは SpriteDrawMode.Tiled で横60ユニット分タイリングし、長いステージでも途切れないようにしています。


8. ステージギミック

8.1 消える足場

// DisappearingPlatform.cs
void OnCollisionEnter2D(Collision2D collision)
{
    if (collision.gameObject.CompareTag("Player") && !isDisappearing && !isGone)
    {
        isDisappearing = true;
        timer = disappearDelay;  // 1秒後に消える
    }
}

プレイヤーが乗ると点滅を始め、1秒後に消滅。3秒後に復活。PingPong で点滅させることで「もうすぐ消えるぞ」という視覚的警告を出しています。

8.2 コンベアベルト

// ConveyorBelt.cs
void OnCollisionStay2D(Collision2D col)
{
    if (col.gameObject.CompareTag("Player"))
    {
        var rb = col.gameObject.GetComponent<Rigidbody2D>();
        rb.linearVelocity = new Vector2(
            rb.linearVelocity.x + direction * speed * Time.fixedDeltaTime * 10f,
            rb.linearVelocity.y);
    }
}

OnCollisionStay2D で毎フレーム速度を加算する方式です。プレイヤーの移動入力と加算されるので、逆方向に歩けばゆっくり進める、順方向なら加速する、という自然な挙動になります。


まとめ

「BlastRunner」の技術的なポイントをまとめると:

システム 要点
ステージ生成 string[]テキストマップ → CompositeCollider2D統合
プレイヤー操作 コヨーテタイム+ジャンプバッファで操作感向上
可変ジャンプ ボタン離しで上昇速度半減、1行で実装
武器システム 5種+チャージ、WE制で選択のジレンマ
サンダーチェイン OnDestroyで範囲内敵に連鎖ダメージ
ホーミング 旋回速度制限でミサイルらしい弧
ボス 抽象基底クラス+形態変化、弾幕は角度回転
ノヴァ 1クラスでステージ別の行動・演出・セリフを分岐
パララックス 3層、factor差分で奥行き表現
ギミック テキスト1文字が1ギミック、switch文で生成

テキストマップ方式は、タイルベースの横スクロールステージと相性が良いです。ScriptableObjectやエディタ拡張を用意するまでもなく、コード上でマップの全体像が見えるのが最大の利点でした。

個人開発の参考になれば幸いです。

1
1
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?