view source

JavaScript

document.title = 'タワーディフェンスゲーム';

const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const tileSize = 80;


function resizeCanvasToVmin() {
  canvas.width = tileSize * 16;
  canvas.height = tileSize * 9;
}

window.addEventListener("resize", resizeCanvasToVmin);
resizeCanvasToVmin();


//1280x720
// 初期状態をまとめた定数(再代入しないため const)
const initialGameState = {
  // 🔄 状態変化する変数(元: let)
  enemies: [],              // 出現中の敵リスト
  towers: [],               // 設置されたタワー
  bullets: [],              // 飛んでる弾
  areaEffects: [],          // 爆発・範囲エフェクト

  spawnTimer: 0,            // 敵出現タイマー
  playerHP: 10,             // プレイヤー体力
  money: 1000,              // 所持金
  towerPlacedCount: 0,      // 設置したタワーの数

  waveNumber: 1,            // 現在のウェーブ番号
  enemiesSpawned: 0,        // 既に出現済みの敵数

  selectedTower: null,      // 選択中のタワー
  inWave: true,             // ウェーブ中かどうか
  waveCooldown: 0,          // 次のウェーブまでの残り時間
  lastEnemyType: null,      // 直前に出した敵のタイプ
  sameTypeCount: 0,         // 同じ敵タイプの連続カウント

  unitList: [],             // 今ウェーブで出す部隊構成
  unitIndex: 0,             // unitList の現在インデックス
  currentUnit: null,        // 今出してる部隊の情報

  mouseGridPos: null,       // マウス座標(設置用プレビュー)

  recentMessage: "",        // 表示中メッセージ
  messageTimer: 0,          // メッセージ残り表示時間

  isDebug: false,           // デバッグモードON/OFF
  isDebugUnlock: false,     // 強制アンロック状態

  gameOver: false,          // ゲームオーバーフラグ
  flashTimer: 0,            // 画面右端点滅タイマー

  // (元: const)
  towerCost: 100,           // タワー1基のコスト
  defRange: tileSize / 1.414 + 2, // 基本射程(1マスナナメ)
  defRange2: tileSize * 1.5 + 2,  // 長距離射程(2マス直線)
  waveCooldownMax: 180,     // ウェーブ間の待機時間(3秒)
  flashDuration: 20,        // ダメージ時の点滅時間
  messageDuration: 1200,    // メッセージの表示時間(20秒)
};

Object.assign(window, structuredClone(initialGameState));


const menu = document.createElement("div");
menu.id = 'towerMenu';

// スコア表示の位置とサイズを定義
const scoreBox = {
  x: 0,
  y: canvas.height - tileSize-2,
  width: canvas.width,
  height: tileSize+4
};

//800,540
const paths = [

  [
    { x: 0, y: 2 },
    { x: 2, y: 2 },
    { x: 2, y: 4 },
    { x: 4, y: 4 },
    { x: 4, y: 6 },
    { x: 6, y: 6 },
    { x: 6, y: 1 },
    { x: 8, y: 1 },
    { x: 8, y: 5 },
    { x: 10, y: 5 },
    { x: 10, y: 2 },
    { x: 12, y: 2 },
    { x: 12, y: 3 },
    { x: 13, y: 3 },
    { x: 13, y: 4 },
    { x: 14, y: 4 },
    { x: 14, y: 5 },
    { x: 15, y: 5 },
    { x: 15, y: 6 },
    { x: 16, y: 6 },
  ],
  [
    { x: 0, y: 2 },
    { x: 6, y: 2 },
    { x: 6, y: 4 },
    { x: 1, y: 4 },
    { x: 1, y: 6 },
    { x: 8, y: 6 },
    { x: 8, y: 2 },
    { x: 10, y: 2 },
    { x: 10, y: 5 },
    { x: 14, y: 5 },
    { x: 14, y: 4 },
    { x: 15, y: 4 },
    { x: 15, y: 5 },
    { x: 16, y: 5 },
  ],

  [
    { x: 0, y: 3 },
    { x: 3, y: 0 },
    { x: 6, y: 3 },
    { x: 3, y: 6 },
    { x: 1, y: 4 },
    { x: 3, y: 2 },
    { x: 8, y: 7 },

    { x: 10, y: 5 },
    { x: 12, y: 3 },
    { x: 10, y: 1 },
    { x: 8, y: 3 },
    { x: 12, y: 7 },
    { x: 14, y: 5 },
    { x: 16, y: 7 },
  ],

];



const waveHints = {
  2: "大砲をもっと増やそう",
  3: "大砲を強化して火力をあげよう",
  4: "道を無視して進む飛行種もいる",
  5: "奇行種の動きは不思議だ",
  6: "小型種がぞろぞろぞろぞろ…",
  7: "混成部隊に対処せよ",
  8: "各敵の特性と対処法を覚えよう",
  9: "大型種に備えよ・・・",
  10: "大型種進行中!!!!",
  11: "初心にもどる",
  12: "速い敵には速射+",
  13: "硬い敵には火力+",
  14: "飛行型には射程+が有利!",
  15: "タワー配置を見直そう",
  16: "次から大攻勢がくるぞ!",
  17: "どんどんタワーを置こう",
  18: "Lvをあげると特定の敵に特攻ダメージ",
  19: "炸裂弾で火傷の敵は倍ダメージ!",
  20: "全力で迎撃せよ!",
  21: "敵とウェーブの法則性に気づいた?",
  22: "はやすぎる!!",
  23: "もっと施設レベルをあげて",
  24: "飛行種が結構守りを抜けてくる",
  25: "奇行種の自動回復には毒が有効?",
  26: "どの系統の敵が苦手?",
  27: "飛行型には射程+が有効!",
  28: "速い敵には速射+が有効!",
  29: "毒だけでは死なない!トドメは砲弾!",
  32: "お金をどれだけ貯めれるかとかね?",
  33: "違う道のステージも試してみてね",
  34: "デバッグよろしく",
  35: "ゲームバランスだいじょうぶ?",
  40: "ナナメの道のステージが一番むずかしい?",
  41: "普通が一番こわい",
  42: "逃さない!",
  43: "火力は正義",
  44: "ハードモードがほしい?",
  45: "勝手に縛りプレイしてくれ!",
  46: "右半分のマップは救済措置",
  46: "防衛費を節約して10万G貯金が裏ゴール!",
  50: "10万Gたまった?",
};


const enemyTypes = {
  normal: {
    label:"兵士",
    color: "red",
    hp: 2,
    hpUp: 1.5,
    speed: 1,
    speedUp: 0.04,
    speedMax: 3.2,
    reward: 20,
    size: 20,
    baseGroupSize: 5,
    groupGrowthRate: 0.4,
    baseSpawnInterval: 40,
  },
  fast: {
    label:"騎手",
    color: "orange",
    hp: 4,
    hpUp: 0.2,
    speed: 2,
    speedUp: 0.08,
    speedMax: 4.8,
    reward: 10,
    size: 16,
    baseGroupSize: 4,
    groupGrowthRate: 0.2,
    baseSpawnInterval: 20,
  },
  tank: {
    label:"戦車",
    color: "#2F4F4F",
    hp: 4,
    hpUp: 2,
    speed: 0.6,
    speedUp: 0.02,
    speedMax: 2,
    defense: 1,
    reward: 30,
    size: 32,
    baseGroupSize: 3,
    groupGrowthRate: 0.2,
    baseSpawnInterval: 60,
  },
  fly: {
    label:"飛行",
    color: "rgba(255,0,0,0.3)",
    hp: 2,
    hpUp: 2/3,
    speed: 0.8,
    speedUp: 0.025,
    speedMax: 2.4,
    reward: 15,
    size: 18,
    baseGroupSize: 4,
    groupGrowthRate: 0.3,
    baseSpawnInterval: 35,
  },
  abnormal: {
    label:"奇行種",
    color: "#aa0000",
    hp: 4,
    hpUp: 1.2,
    speed: 1,
    speedUp: 0.15,
    speedMax: 2.8,
    reward: 16,
    size: 18,
    baseGroupSize: 3,
    groupGrowthRate: 0.2,
    baseSpawnInterval: 40,
  },
  ant: {
    label: "小型種",
    color: "#191919",
    hp: 1,
    hpUp: 0.1,
    speed: 1.8,
    speedUp: 0.03,
    speedMax: 2,
    reward: 2,
    size: 10,
    baseGroupSize: 12,
    groupGrowthRate: 1,
    baseSpawnInterval: 10,
  },
  boss: {
    isBoss:true,
    label:"大型種",
    color: "darkgray",
    hp: 100,
    hpUp: 10,
    speed: 0.4,
    speedUp: 0.01,
    speedMax: 1.6,
    reward: 1000,
    size: 50,
    baseGroupSize: 1,
    groupGrowthRate: 0,
    baseSpawnInterval: 120,
  },
};


const towerUpgradeTypes = {
  range: {
    label: "●射程+",
    cost: 100,
    unlockAt: 1,
    maxLevel: 9,
    costFormula: (lv) => (lv-1) * 100 +100,
    lvTable:[
defRange,
defRange2,
tileSize * 2.5 +2,
tileSize * 3.5 +2,
tileSize * 4.5 +2,
tileSize * 4.5 +2,
tileSize * 4.5 +2,
tileSize * 4.5 +2,
tileSize * 4.5 +2,
0
    ],
    shape: "arc",
    color: "black",//"green",
  },
  damage: {
    label: "■火力+",
    cost: 100,
    unlockAt: 6,
    maxLevel: 20,
    costFormula: (lv) => 100 * lv ** 2,
    lvTable:[0,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0],
    shape: "rect",
    color: "black",//"blue",
  },
  rapid: {
    label: "▲速射+",
    cost: 200,
    unlockAt: 9,
    maxLevel: 9,
    costFormula: (lv) => 100 * lv ** 2,
    lvTable:[0,30,10,5,5,5,2,2,2,2,1,0],
    shape: "tri",
    color: "black",//"cyan",
  },
  area: {
    label: "◆範囲+",
    cost: 200,
    unlockAt: 15,
    maxLevel: 5,
    costFormula: (lv) => 800 * lv ** 2,
    lvTable:[
defRange/2,
defRange,
defRange2,
tileSize * 2.5 +2,
tileSize * 3.5 +2,
tileSize * 4.5 +2,
0
    ],
    shape: "dia",
    color: "black",//"darkorange",
  },
  poison: {
    label: "猛毒",
    cost: 300,
    unlockAt: 20,
    maxLevel: 2,
    costFormula: (lv) =>  3000,
    color: "green",
    isEffect:true,
  },
  slow: {
    label: "低速",
    cost: 200,
    unlockAt: 25,
    maxLevel: 2,
    costFormula: (lv) =>  4000,
    color: "blue",
    isEffect:true,
  },
  burn: {
    label: "火炎",
    cost: 300,
    unlockAt: 30,
    maxLevel: 2,
    costFormula: (lv) =>  5000,
    color: "red",
    isEffect:true,
  }
};

const effectTypes = {
  poison: {
    label: "毒",
    onHit(bullet, target) {
      target.applyEffect("poison");
    }
  },
  burn: {
    label: "火傷",
    onHit(bullet, target) {
      target.applyEffect("burn");
    }
  },
  slow: {
    label: "低速",
    onHit(bullet, target) {
      target.speed *= 0.8;
      target.speed = clamp(target.speed, 0.5, 7);
    }
  }
};


function createButton(type) {
  const info = towerUpgradeTypes[type];
  info.type = type;

  const btn = createButtonElement(info);

  btn.onclick = () => {
    const tower = selectedTower;
    handleUpgradeClick(tower, info, type);
  };

  btn._update = () => {
    updateUpgradeButton(btn, info, type);
  };

  menu.appendChild(btn);
  info._button = btn;
  return btn;
}

function createButtonElement(info) {
  const btn = document.createElement("button");
  btn.classList.add("buy", info.type);
  btn.style.display = "none";

  const typeSpan = document.createElement("span");
  typeSpan.classList.add("type");
  typeSpan.textContent = `${info.label} `;
  btn.appendChild(typeSpan);

  const costSpan = document.createElement("span");
  costSpan.classList.add("cost");
  btn.appendChild(costSpan);

  btn._costSpan = costSpan;
  btn._typeSpan = typeSpan;

  return btn;
}

function handleUpgradeClick(tower, info, type) {
  if (!tower) return;
  const cost = info.costFormula(tower.lv);
  if (money < cost) return;

  if (info.isEffect) {
    tower.upgradeEffect(type);
  } else {
    tower.upgrade(type);
  }

  closeTowerUpgradeMenu();
  selectedTower = null;
}
function updateUpgradeButton(btn, info, type) {
  const tower = selectedTower;
  if (!tower) return;

  const lv = tower.lv;
  const cost = info.costFormula(lv);
  const isEffect = info.isEffect;
  const reachedMax = lv >= info.maxLevel;
  const isUnlocked = towerPlacedCount >= info.unlockAt;

  btn.style.display = "none";
  btn.disabled = false;

  if (isDebugUnlock) {
    btn.style.display = "block";
    btn.disabled = false;
  }
  if (!isUnlocked && !isDebugUnlock) return;

  const costSpan = btn._costSpan;

  if (isEffect) {
    if (lv < 2) return;
    if (tower.effects.length > 0 && !tower.effects.includes(type)) return;

    if (tower.effects.includes(type)) {
      costSpan.textContent = `+`;
      btn.disabled = true;
    } else {
      costSpan.textContent = `Lv.${lv} (${cost}G)`;
    }
    btn.style.display = "block";
    return;
  }

  const towerType = tower.type;
  const isSameType = towerType === type;
  const isUntyped = !towerType;

  if (isUntyped || isSameType) {
    if (reachedMax) {
      costSpan.textContent = ` [Lv.${lv} / MAX]`;
      btn.disabled = true;
    } else {
      costSpan.textContent = `Lv.${lv} (${cost}G)`;
      btn.disabled = money < cost;
      btn.style.color = money < cost ? "#aa0000" : "black";
    }
    btn.style.display = "block";
  }

}






function initTowerMenu() {
  document.body.appendChild(menu);
  menu.style.display = "none";

  for (let type in towerUpgradeTypes) {
    createButton(type);
  }

  // 売却ボタン(特別扱い)
  const sellBtn = document.createElement("button");
  sellBtn.classList.add("sell");
  sellBtn.textContent = "売却";
  sellBtn.onclick = () => {
    if (selectedTower) {
      selectedTower.sell();
      closeTowerUpgradeMenu();
      selectedTower = null;
    }
  };
  menu.appendChild(sellBtn);
}



/*
################################################################
################################################################
*/
function generateFixedMazePath({
  startX = 0,
  endX = 16,
  fixedLen = 2,
  y = 4,
  minY = 1,
  maxY = 7,
  minTotalLength = 32, // 👈 最低通過数をここで設定
  maxAttempts = 9
} = {}) {
  let attempt = 0;
  let finalPath = [];

  while (attempt++ < maxAttempts) {
    const path = [];
    const visited = new Set();

    const add = (p) => {
      path.push(p);
      visited.add(`${p.x},${p.y}`);
    };

    // ① 開始直線
    for (let i = 0; i < fixedLen; i++) {
      add({ x: startX + i, y });
    }

    const middleStartX = startX + fixedLen;
    const middleEndX = endX - fixedLen;

    // ② 迷路ゾーン
    let current = { x: middleStartX, y };
    add(current);

    const directions = [
      { dx: 1, dy: 0 },
      { dx: -1, dy: 0 },
      { dx: 0, dy: 1 },
      { dx: 0, dy: -1 },
    ];

    let steps = 0;
    const maxSteps = 300;

    while (current.x < middleEndX && steps++ < maxSteps) {
      const shuffled = directions.sort(() => Math.random() - 0.5);
      let moved = false;

      for (let dir of shuffled) {
        const next = {
          x: clamp(current.x + dir.dx, startX, endX),
          y: clamp(current.y + dir.dy, minY, maxY)
        };
        const key = `${next.x},${next.y}`;
        const tooFarRight = next.x > middleEndX;

        if (!visited.has(key) && !tooFarRight) {
          add(next);
          current = next;
          moved = true;
          break;
        }
      }

      if (!moved) {
        path.pop();
        current = path[path.length - 1];
        if (!current) break;
      }
    }

    // 最後の直線
    const lastX = path[path.length - 1].x;
    const lastY = path[path.length - 1].y;
    if (lastY !== y) add({ x: lastX, y }); // y=4に戻る

    for (let i = lastX + 1; i <= endX; i++) {
      add({ x: i, y });
    }

    // ✅ 最小長さチェック
    if (path.length >= minTotalLength) {
      finalPath = path;
      break;
    }
  }

  return finalPath;
}



function getScaledPath(path) {
  return path.map(p => ({
    x: p.x * tileSize,
    y: p.y * tileSize
  }));
}

paths.push(generateFixedMazePath());
const pathR = Math.floor(Math.random() * paths.length);
let rawPath = paths[pathR];
//rawPath = generateFixedMazePath();
let path = getScaledPath(rawPath);

/*
################################################################
################################################################
*/
// 敵クラス
class Enemy {
  constructor(type = "normal") {
    const t = enemyTypes[type];
    this.x = path[0].x;
    this.y = path[0].y;
    this.type = type;

    this.color = t.color;
    this.effectColor = null;
    this.hp = t.hp;
    this.speed = t.speed;
    this.defense = t.defense || 0; // 敵の防御力
    this.defenseMax = this.defense;

    this.size = t.size;

    this.pathIndex = 1;

    this.reward = t.reward;
    this.reward +=  Math.floor(waveNumber/2);

    this.timer = 0;


    this.hp += Math.floor((waveNumber-1) * t.hpUp);
    this.maxHp = this.hp;
    this.hpRecovery = 0;
    this.speed += waveNumber * t.speedUp;
    this.speed = clamp(this.speed, 0.4, t.speedMax);

    this.effects = []; // 状態異常管理
    this.statusTimers = {}; // 状態名: 残り時間(例: burn: 300)

    if (this.type === 'fly') {
      let r = Math.floor(Math.random() * (canvas.height / tileSize -2))
      this.x = 0;
      this.y = tileSize * (r + 1); //
    }
    if(this.type === 'abnormal'){
      this.hpRecovery = 1;
    }

    //敵の特性強化
    this.upper = Math.floor(waveNumber/10)+1;
    this.defense = this.defense * this.upper;
    this.hpRecovery = this.hpRecovery * this.upper;
  }

  update() {
    this.timer++;

    if (hasEffect(this, "poison")) {
      this.statusTimers["poison"]--;
      if (this.statusTimers["poison"] % 60 === 0) {
        this.hp -= 1;
        this.hp = clamp(this.hp, 1, this.maxHp);
      }
      if (this.statusTimers["poison"] <= 0 || this.hp < 2) {
        this.effects = this.effects.filter(e => e !== "poison");
        delete this.statusTimers["poison"];
      }
    }

    if (hasEffect(this, "burn")) {
      this.statusTimers["burn"]--;
      if (this.statusTimers["burn"] <= 0 || this.hp < 2) {
        this.effects = this.effects.filter(e => e !== "burn");
        delete this.statusTimers["burn"];
      }
    }

    if (this.hp <= 0) {
      this.dead = true;
      return;
    }

    if (this.type === 'abnormal') {

      if(!this.isPoisoned){
        if (this.timer % 90 === 0) {
          this.hp += this.hpRecovery;
          this.hp = clamp(this.hp, 0, this.maxHp);
        }
      }
      if(Math.random() > 0.2){
        this.speed += Math.random()*0.3;
      } else {
        this.speed = 0.2+Math.random()*0.2;
      }
    }
    if (this.type === 'fly') {
      this.x += this.speed;
      if (this.x > canvas.width) {
        this.goal();
        return;
      }
    } else {
      // 通常ルートの移動
      if (this.pathIndex >= path.length) {
        this.goal();
        return;
      }
      const target = path[this.pathIndex];
      const dx = target.x - this.x;
      const dy = target.y - this.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      if (dist < this.speed) {
        this.x = target.x;
        this.y = target.y;
        this.pathIndex++;
      } else {
        this.x += (dx / dist) * this.speed;
        this.y += (dy / dist) * this.speed;
      }
    }
  }

  applyEffect(name, duration = 300) {
    if (!hasEffect(this, name)) {
      this.effects.push(name);
    }

    this.effectColor = towerUpgradeTypes[name].color;
    this.statusTimers[name] = duration;
  }


  goal() {
      if(this.x < canvas.width){
        console.warn(" goal()", this.type, this.x, this.y, "index:", this.pathIndex, "/", path.length);
      }

      this.dead = true;
      playerHP--;
      console.log(`敵が突破!残りHP: ${playerHP}`);
      money += 100;//救済
      flashTimer = flashDuration; // 点滅開始
      if (playerHP <= 0) {
        console.log(`ゲームオーバー`);
        gameOver = true; // 
      }
  }

  draw() {
    ctx.fillStyle = this.color;
    ctx.fillRect(this.x - this.size / 2, this.y - this.size / 2, this.size, this.size);

    if (hasEffect(this, "burn")) {
      ctx.strokeStyle = this.effectColor;
      ctx.lineWidth = 4;
      ctx.strokeRect(this.x - this.size / 2, this.y - this.size / 2, this.size, this.size);
    }
    if (hasEffect(this, "poison")) {
      ctx.strokeStyle = this.effectColor;
      ctx.lineWidth = 2;
      ctx.strokeRect(this.x - this.size / 2, this.y - this.size / 2, this.size, this.size);
    }
    drawHpGauge(this);
    if (isDebug) {
      drawNumber(this, this.hp);
    }

  }
}

function drawNumber(target, n) {
  ctx.fillStyle = "white";
  ctx.font = "12px 'Roboto Mono', monospace";
  ctx.textAlign = "center";
  ctx.fillText(n, target.x, target.y + 4); // 中心に表示(+4でちょい下げ)
}

function drawHpGauge(target) {
  // HPゲージ(上部)
  const barWidth = target.size;
  const barHeight = 4;
  const hpRatio = target.hp / target.maxHp;
  ctx.fillStyle = "black";
  ctx.fillRect(target.x - barWidth / 2, target.y - target.size / 2 - 6, barWidth, barHeight);
  ctx.fillStyle = "green";
  ctx.fillRect(target.x - barWidth / 2, target.y - target.size / 2 - 6, barWidth * hpRatio, barHeight);
}


function getSpawnInterval(type) {
  const t = enemyTypes[type]
  return t.baseSpawnInterval - t.speed*4; // 今はこれでOK
}
function getUnitsPerWave(waveNumber) {
  return 2 + Math.floor(waveNumber / 5);  // 5waveごとに+1部隊
}

// ✅ ユニット作成用の共通関数
function createUnit(type) {
  let data = enemyTypes[type];
  if(!data){
    console.warn("❗ createUnit: 無効な敵タイプ:", type);
    data = enemyTypes["normal"];
    console.log(data);
  }
  const groupSize = Math.floor(data.baseGroupSize + waveNumber * data.groupGrowthRate);
  return {
    type,
    groupSize,
    remaining: groupSize
  };
}

function setupWave() {
  unitList = [];

  const allTypes = Object.keys(enemyTypes);
  const types = allTypes.filter(t => !enemyTypes[t].isBoss);
  const bossTypes = allTypes.filter(t => enemyTypes[t].isBoss);

  const withinSequence = waveNumber % 10; // 0〜9 の値になる

  console.log(types);
  console.log(withinSequence);
  // wave 10, 20, 30... → boss専用
  if (withinSequence === 0) {
    const type = bossTypes[0]; // "boss"
    unitList.push(createUnit(type));
  
  // 10wave毎に wave 1〜6:順番に1種類ずつ
  } else if (withinSequence <= 6 && withinSequence - 1 < types.length) {
    const type = types[withinSequence - 1];
    unitList.push(createUnit(type));
  
  // wave 7以降:ランダムな部隊構成
  } else {
    const units = getUnitsPerWave(waveNumber);
    for (let i = 0; i < units; i++) {
      const type = types[Math.floor(Math.random() * types.length)];
      unitList.push(createUnit(type));
    }
  }

  unitIndex = 0;
  currentUnit = unitList[0];

  // ✅ 部隊構成ログ
  console.log(`--- Wave ${waveNumber} 部隊構成 ---`);
  for (let unit of unitList) {
    const enemy = enemyTypes[unit.type];
    console.log(enemy);
    console.log(`- ${enemy.label} x ${unit.groupSize}`);
  }
  if (waveHints[waveNumber]) {
    showMessage(waveHints[waveNumber]);
  }
}




// 敵の生成

function popEnemy() {
  if (inWave) {
    spawnTimer++;
    //console.log("⏲ spawnTimer:", spawnTimer);
    if (currentUnit && spawnTimer > getSpawnInterval(currentUnit.type)) {
      //console.log("⏱️ Pop check:", currentUnit);
      if (currentUnit.remaining > 0) {
        //console.log("Spawning:", currentUnit.type);
        enemies.push(new Enemy(currentUnit.type));
        currentUnit.remaining--;
        spawnTimer = 0;
      } else {
        unitIndex++;
        if (unitIndex < unitList.length) {
          currentUnit = unitList[unitIndex];
        } else if (enemies.length === 0) {
          // 全部隊出して全部倒した
          inWave = false;
          waveCooldown = waveCooldownMax;
        }
      }
    }
  } else {
    waveCooldown--;

    if (waveCooldown <= 0) {
      if(!gameOver){
        waveNumber++;
        getHint(waveNumber);
      }
      setupWave();
      spawnTimer = 0;
      inWave = true;
    }
  }
}


/*
################################################################
################################################################
*/

// タワークラスを改良して強化機能を追加
class Tower {
  constructor(x, y) {
    this.lv = 1;
    this.x = x;
    this.y = y;
    this.type = null; // 初期は汎用型(未選択)
    this.size = tileSize/4;
    this.fireRate = 60;
    this.cooldown = 0;
    this.range = defRange; // 初期射程
    this.damage = 2;  // 初期威力

    this.effects = []; // 初期は何もなし
    this.color = 'rgba(0,0,0,1)';
    this.blue = 255;
    this.green = 0;
    this.cost = towerCost;//初期コスト

  }



  upgrade(type) {
    const info = towerUpgradeTypes[type];
    console.log(type);
    if (!info) return;
    

    const cost = info.costFormula(this.lv);
    if (!buy(cost)) return;

    if (!this.type) this.type = type;

    // 同一タイプ以外のアップグレードは禁止
    if (this.type !== type) return;

    this.cost += cost;
    this.lv++;

    this.damage += 1;

    if (info.shape){this.shape = info.shape;}
    console.log(this);

    if(this.lv === 2){
      this.size += tileSize/12;
    } else if(this.lv < 4){
      this.size += tileSize/16;
    } else {
      this.size += tileSize/32;
    }
    this.size = clamp(this.size, tileSize/16, tileSize-tileSize/8)

    const lvBoost = info.lvTable?.[this.lv - 1];
    // 個別処理(カスタムな変化)
    switch (type) {
      case "rapid":
        if(this.lv === 2){
          this.damage -= 1
        }
        if(lvBoost){this.fireRate -= lvBoost}
        break;
      case "damage":
        if(this.lv === 2){
          this.fireRate += 20
        }
        this.fireRate += 5
        if(lvBoost){this.damage += lvBoost}
        break;

      case "range":
        this.fireRate -= 5
        if(lvBoost){this.range = lvBoost}
        break;

      case "area":
        this.fireRate += 20
        if(lvBoost){this.area = lvBoost}
        break;
    }
  }

  upgradeEffect(effectName) {
    const info = towerUpgradeTypes[effectName];
    if (!info || !info.isEffect) return;

    const alreadyHas = this.effects.includes(effectName);
    if (alreadyHas) return;

    const cost = info.costFormula(this.lv);
    if (!buy(cost)) return;

    this.effects.push(effectName);
    this.cost += cost;
    if (info.color){this.color = info.color;}
    console.log(` ${effectName} を付与しました`);
  }

  sell() {
    money += this.cost * 0.8;
    const index = towers.indexOf(this);
    if (index > -1) {
      towers.splice(index, 1);
    }
    selectedTower = null;
  }

  shot(BulletClass, target) {
    bullets.push(new BulletClass({ tower: this, target }));
  }

  update() {
    if (this.cooldown > 0) this.cooldown--;

    for (let enemy of enemies) {
      let dx = enemy.x - this.x;
      let dy = enemy.y - this.y;
      let dist = Math.sqrt(dx * dx + dy * dy);

      if (dist < this.range && this.cooldown <= 0) {
        this.shot(Bullet, enemy);
        this.cooldown = this.fireRate;
        break;
      }
    }
  }

  drawRange() {
    ctx.strokeStyle = "rgba(0,0,0, 0.1)";
    ctx.lineWidth = 0.5;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.range, 0, Math.PI * 2);
    ctx.stroke();
  }

  draw() {

    ctx.save();
    ctx.translate(this.x, this.y);
    const path = getShapePath(this.size, this.shape);
    ctx.fillStyle = this.color;
    ctx.fill(path);
    ctx.restore();

    if (isDebug) {
      drawNumber(this, this.damage);
    }
  }
}


function getShapePath(size, shape="rect") {
  const path = new Path2D();

  switch (shape) {
    case "tri": {
      const s = size;
      const h = (Math.sqrt(3) / 2) * s;
      path.moveTo(0, -2/3 * h);
      path.lineTo(s / 2, 1/3 * h);
      path.lineTo(-s / 2, 1/3 * h);
      path.closePath();
      break;
    }

    case "arc": {
      path.arc(0, 0, size / 2, 0, Math.PI * 2);
      break;
    }

    case "dia": {
      const s = size / 2;
      path.moveTo(0, -s);
      path.lineTo(s, 0);
      path.lineTo(0, s);
      path.lineTo(-s, 0);
      path.closePath();
      break;
    }

    default: {
      const s = size / 2;
      path.rect(-s, -s, size, size);
    }
  }

  return path;
}


function hasEffect(entity, effectName) {
  return entity.effects?.includes(effectName);
}
//OR検索
function hasAnyEffect(entity, effectNames = []) {
  return effectNames.some(e => entity.effects?.includes(e));
}
//AND検索
function hasAllEffects(entity, effectNames = []) {
  return effectNames.every(e => entity.effects?.includes(e));
}

function setTower(mouseX, mouseY){

  const { x, y } = getGridPosition(mouseX, mouseY);

  if (isOnPath(x, y)) {
    console.log("設置不可:道の上");
    return;
  }

  if (isInScoreBox(x, y)) {
    console.log("設置不可:スコアボックス");
    return;
  }
  if (isOccupied(x, y)) {
    console.log("設置不可:タワー重複");
    return;
  }

  if (money < towerCost) {
    console.log("お金が足りません!");
    return;
  }

  towers.push(new Tower(x, y));
  money -= towerCost;
  towerPlacedCount++;

  for (let type in towerUpgradeTypes) {
    const info = towerUpgradeTypes[type];
    if (
      towerPlacedCount === info.unlockAt && // ピッタリ解放タイミング
      info._button
    ) {
      showMessage(` ${info.label} が解放された!`);
    }
  }

}

/*
################################################################
################################################################
*/
// 弾クラス
class Bullet {
  constructor({ tower, target}) {
    this.x = tower.x;
    this.y = tower.y;
    this.target = target;
    this.tower = tower;

    this.lv = tower.lv;
    this.type = tower.type;
    this.damage = tower.damage;
    this.range = tower.range;
    this.area = tower.area;
    this.effects = tower.effects?.slice() || [];
    this.color = tower.color || "black";

    this.size = this.lv / 4 + 2;
    this.speed = hasEffect(this, "slow") ? 15 : 5.5;
    this.life = this.range * 1.5;
    this.dead = false;
  }

  update() {
    this.life--;
    if (this.life < 0) {
      this.dead = true;
      return;
    }

    const dx = this.target.x - this.x;
    const dy = this.target.y - this.y;
    const dist = Math.sqrt(dx * dx + dy * dy);

    let targetDist = this.target.speed*4 || 5;
    targetDist = clamp(targetDist, 2, 20);
    if (dist < targetDist) {
      this.onHit();
      this.dead = true;
    } else {
      this.x += (dx / dist) * this.speed;
      this.y += (dy / dist) * this.speed;
    }
  }

  onHit() {
    if (this.type === "area") {
      this.areaFire();
    } else {
      this.fire();
    }
  }

  fire(){
    let damage = calculateDamage(this, this.target);
    damage = damage - this.target.defense;
    damage = clamp(damage, 0, 999);
    this.target.hp -= damage;
    this.applyEffects(this.target);

    if (this.target.hp <= 0) {
      this.target.dead = true;
      money += this.target.reward;
    }
  }

  areaFire(){
    for (let enemy of enemies) {
      const dx = enemy.x - this.x;
      const dy = enemy.y - this.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      if (dist < this.area/2) {
        const factor = Math.max(0.2, 1 - dist / this.area/2);
        let damage = calculateDamage(this, this.target);
        damage = Math.floor(damage * factor);
        enemy.hp -= damage;
        this.applyEffects(enemy);

        if (enemy.hp <= 0) {
          enemy.dead = true;
          money += enemy.reward;
        }
      }
    }
    areaEffects.push(new AreaEffects(this));
  }

  applyEffects(target) {
    for (const effectName of this.effects) {
      const effect = effectTypes[effectName];
      if (effect?.onHit) {
        effect.onHit(this, target);
      }
    }
  }

  draw() {
    ctx.fillStyle = this.color;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
    ctx.fill();
  }
}



class AreaEffects {
  constructor(bullet) {
    this.x = bullet.x;
    this.y = bullet.y;
    this.area = bullet.area;
    this.color = bullet.color;
    this.life = 20;
    this.dead = false;
  }

  update() {
    this.life--;
    if (this.life <= 0) this.dead = true;
  }

  draw() {
    ctx.save();
    const alpha = Math.sin((this.life / 20) * Math.PI) * 0.3;
    ctx.globalAlpha = alpha;
    ctx.fillStyle = this.color;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.area / 2, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }
}




function calculateDamage(bullet, enemy) {
  let damage = bullet.damage;

  // 特攻: タンク敵に高威力弾
  if (bullet.lv > 3 && bullet.type === 'damage' && enemy.type === 'tank') {
    damage += (bullet.damage) / 2;
  }
  // 特攻: 飛行敵に射程強化弾
  if (bullet.lv > 3 && bullet.type === 'range' && enemy.type === 'fly') {
    damage += bullet.damage * bullet.lv;
  }

  // 特攻: 高速敵にスロー弾
  if (hasEffect(bullet, "slow") && enemy.type === 'fast') {
    damage += bullet.damage * bullet.lv; // 特攻ボーナス
    enemy.speed *= 0.4;
  }

  // スロー効果(一般)
  if (hasEffect(bullet, "slow")) {
    enemy.speed *= 0.8;
    enemy.speed = clamp(enemy.speed, 0.5, 7);
  }
  // 火傷 (ダメージ上昇)
  if (bullet.isBurned) {
    damage = damage * 2;
  }

  damage = Math.floor(damage);
  return damage;
}

/*
################################################################
################################################################
*/

function buy(cost) {
  if(money<cost)return false;
  money -= cost;
  return true;
}


function getMenuPosition(tower) {
  menu.style.display = "block"; // メニューが非表示だとサイズ取得できないため表示
  const menuRect = menu.getBoundingClientRect();
  const menuWidth = menuRect.width || 120;
  const menuHeight = menuRect.height || 320;
  const padding = 10;

  const bodyWidth = window.innerWidth;
  const bodyHeight = window.innerHeight;

  const canvasRect = canvas.getBoundingClientRect();
  const scaleX = canvasRect.width / canvas.width;
  const scaleY = canvasRect.height / canvas.height;

  // canvas上のクリック位置をブラウザ上の座標に変換
  const screenX = canvasRect.left + tower.x * scaleX;
  const screenY = canvasRect.top + tower.y * scaleY;

  // 初期は右横に出す
  let x = screenX + padding;
  let y = screenY;

  // 右にはみ出す場合は左側に出す
  if (x + menuWidth > bodyWidth) {
    x = screenX - menuWidth - padding;
  }

  // 下にはみ出す場合は上にずらす
  if (y + menuHeight > bodyHeight) {
    y = bodyHeight - menuHeight - padding;
  }

  return { x, y };
}



// タワー強化メニューを表示する関数
function showTowerUpgradeMenu(tower) {
  // メニューの外枠作成
  menu.style.display = "block";
  const pos = getMenuPosition(tower);
  menu.style.left = `${pos.x}px`;
  menu.style.top = `${pos.y}px`;

  for (let type in towerUpgradeTypes) {
    const btn = towerUpgradeTypes[type]._button;
    if (btn && btn._update) btn._update();
  }
}

// タワー強化メニューを閉じる関数
function closeTowerUpgradeMenu() {
  menu.style.display = "none";
}





function isOccupied(x, y) {
  for (let tower of towers) {
    if (
      Math.abs(tower.x - x) < tileSize / 2 &&
      Math.abs(tower.y - y) < tileSize / 2
    ) {
      return true; // すでに誰かいる
    }
  }
  return false;
}


function isOnPath(x, y) {
  const threshold = 20; // 道の太さの許容範囲(=道の太さ)

  for (let i = 1; i < path.length; i++) {
    const p1 = path[i - 1];
    const p2 = path[i];

    const dist = pointToSegmentDistance(x, y, p1.x, p1.y, p2.x, p2.y);
    if (dist < threshold) {
      return true; // 道の上
    }
  }
  return false;
}

function isInScoreBox(x, y) {
  return x > scoreBox.x && x < scoreBox.x + scoreBox.width && y > scoreBox.y && y < scoreBox.y + scoreBox.height || y > scoreBox.y;
}

// 点と線分の距離計算(ちょっと数学)
function pointToSegmentDistance(px, py, x1, y1, x2, y2) {
  const A = px - x1;
  const B = py - y1;
  const C = x2 - x1;
  const D = y2 - y1;

  const dot = A * C + B * D;
  const len_sq = C * C + D * D;
  let param = -1;
  if (len_sq !== 0) param = dot / len_sq;

  let xx, yy;
  if (param < 0) {
    xx = x1;
    yy = y1;
  } else if (param > 1) {
    xx = x2;
    yy = y2;
  } else {
    xx = x1 + param * C;
    yy = y1 + param * D;
  }

  const dx = px - xx;
  const dy = py - yy;
  return Math.sqrt(dx * dx + dy * dy);
}






/*
################################################################
################################################################
*/


// ゲームオーバー時のオーバーレイを描画
function drawGameOverOverlay() {
  ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; // 半透明の黒
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "white";
  ctx.font = "64px 'Roboto Mono', monospace";
  ctx.textAlign = "center";
  ctx.fillText("GAME OVER", canvas.width/2, canvas.height / 2, canvas.width);
}

// 右端を赤く点滅させる
function drawRightFlash() {
  if (flashTimer > 0) {
    ctx.fillStyle = "red";
    ctx.globalAlpha = 0.5;
    ctx.fillRect(canvas.width - tileSize/4, 0, tileSize/4, canvas.height);
    ctx.globalAlpha = 1.0;
    flashTimer--;
  }
}

function drawScore() {
  ctx.fillStyle = "black";
  ctx.fillRect(scoreBox.x, scoreBox.y, scoreBox.width, scoreBox.height+2);
  ctx.fillStyle = "white";
  ctx.textAlign = "center";
  ctx.font = "20px sans-serif";
  ctx.fillText(`Wave: ${waveNumber}`, scoreBox.x+60, scoreBox.y+25);

  ctx.font = "20px sans-serif";
  ctx.fillStyle = "white";


  ctx.textAlign = "right";
  ctx.fillText(`G : ${money}`,  scoreBox.x+scoreBox.width-240, scoreBox.y+25);

  ctx.fillText(`■ : ${towerPlacedCount}`,  scoreBox.x+scoreBox.width-160+60, scoreBox.y+25);
  ctx.fillText(`❤: ${playerHP}`,  scoreBox.x+scoreBox.width-80+60, scoreBox.y+25);

  // 🔔 メッセージ表示
  if (messageTimer > 0) {
    ctx.fillStyle = "lightyellow";
    ctx.font = "16px sans-serif";
    ctx.textAlign = "left";
    ctx.fillText(recentMessage, scoreBox.x +160, scoreBox.y + 25);
    messageTimer--;
  }
}
function showMessage(text) {
  recentMessage = text;
  messageTimer = messageDuration;
}






/*
################################################################
################################################################
*/


function getGridPosition(x, y) {
  const gridX = Math.floor(x / tileSize) * tileSize + tileSize / 2;
  const gridY = Math.floor(y / tileSize) * tileSize + tileSize / 2;
  return { x: gridX, y: gridY };
}
function drawPlacementPreview(x, y) {
  const valid = !isOnPath(x, y) && !isInScoreBox(x, y);
  ctx.fillStyle = valid ? "rgba(0, 0, 255, 0.3)" : "rgba(255, 0, 0, 0.3)";
  if(isOccupied(x,y)){
    ctx.fillStyle = "rgba(0, 0, 255, 0.5)"
  }
  let c = tileSize / 2;
  ctx.fillRect(x-c, y-c, tileSize, tileSize );
}


function drawGrid() {

  ctx.strokeStyle = "rgba(0,0,0,0.05)";
  ctx.lineWidth = 1;
  for (let x = 0; x < canvas.width; x += tileSize) {
    ctx.beginPath();
    ctx.moveTo(x, 0);
    ctx.lineTo(x, canvas.height);
    ctx.stroke();
  }
  for (let y = 0; y < canvas.height; y += tileSize) {
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(canvas.width, y);
    ctx.stroke();
  }
}

function drawPath() {
  ctx.strokeStyle = "gray";
  ctx.lineWidth = 4;
  ctx.beginPath();
  ctx.moveTo(path[0].x, path[0].y);
  for (let point of path) {
    ctx.lineTo(point.x, point.y);
  }
  ctx.stroke();
}


//数値の上限・下限
function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}



/*
################################################################
################################################################
*/


canvas.addEventListener("mousemove", (e) => {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  const mouseX = (e.clientX - rect.left) * scaleX;
  const mouseY = (e.clientY - rect.top) * scaleY;
  const { x, y } = getGridPosition(mouseX, mouseY);
  mouseGridPos = { x, y };
});



// タワーのクリック時に選択
canvas.addEventListener("click", (e) => {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  const mouseX = (e.clientX - rect.left) * scaleX;
  const mouseY = (e.clientY - rect.top) * scaleY;

  const { x, y } = getGridPosition(mouseX, mouseY);
  //console.log([x,y]);

  if(gameOver){
    resetGame()
    return;
  }
  // タワーがクリックされた場合
  for (let tower of towers) {
    if (Math.abs(tower.x - x) < tileSize / 2 && Math.abs(tower.y - y) < tileSize / 2) {
      selectedTower = tower;
      showTowerUpgradeMenu(tower); // メニュー表示
      return;
    }
  }

  // タワーがクリックされていない場合、メニューを非表示
  if (selectedTower) {
    closeTowerUpgradeMenu();
    selectedTower = null;
  }

  // タワー設置の処理(既存のコードと同じ)
  setTower(mouseX, mouseY);
});



/*
################################################################
################################################################
*/


function resetGame(){
  //状態変数の初期化
  Object.assign(window, structuredClone(initialGameState));
  setupWave();
}

// ゲームループ内で強化メニューを閉じる
function gameLoop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "#eeeeee";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  drawGrid();
  drawPath();

  // タワー選択が解除された場合はメニューを閉じる
  if (!selectedTower) {
    closeTowerUpgradeMenu();
  }

  popEnemy();

  for (let tower of towers) {
    tower.update();
    tower.draw();
    tower.drawRange();
  }

  for (let bullet of bullets) bullet.update();
  for (let enemy of enemies) enemy.update();
  for (let effect of areaEffects) effect.update();

  bullets = bullets.filter(b => !b.dead);
  enemies = enemies.filter(e => !e.dead);
  areaEffects = areaEffects.filter(e => !e.dead);

  for (let bullet of bullets) bullet.draw();
  for (let enemy of enemies) enemy.draw();
  for (let effect of areaEffects) effect.draw();



  if (mouseGridPos) drawPlacementPreview(mouseGridPos.x, mouseGridPos.y);

  drawRightFlash(); // 右端点滅
  drawScore();
  if (gameOver) {
    drawGameOverOverlay(); // ゲームオーバー時のオーバーレイ
  }

  requestAnimationFrame(gameLoop);
}

resetGame();
initTowerMenu();
gameLoop();

// 初期タワー設置
setTower(tileSize*0,tileSize*0)
setTower(tileSize*0,tileSize*7)
setTower(tileSize*15,tileSize*7)
setTower(tileSize*15,tileSize*0)
setTower(tileSize*5,tileSize*5)
showMessage("大砲を設置して防衛しよう")




/*
################################################################
################################################################
*/

function isMobile() {
  const ua = navigator.userAgent || navigator.vendor || window.opera;
  return /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(ua);
}
const form = document.getElementById("form");
form.style.display = "none";
const debugInput = document.getElementById("debug");
debugInput.addEventListener("change", (e) => {
  if(!isMobile()){
    isDebug = e.target.checked;
    form.style.display = isDebug ? "block" : "none";
  }
});
const unlockInput = document.getElementById("unlock");
unlockInput.addEventListener("change", (e) => {
  isDebugUnlock = e.target.checked;
  form.style.display = isDebug ? "block" : "none";
  if(isDebugUnlock){money=99999;}
});
const hpInput = document.getElementById("hp");
hpInput.addEventListener("change", (e) => {
  playerHP = Number(e.target.value)
});
const moneyInput = document.getElementById("money");
moneyInput.addEventListener("change", (e) => {
  money = Number(e.target.value)
});
const waveInput = document.getElementById("wave");
waveInput.addEventListener("change", (e) => {
  waveNumber = Number(e.target.value)
});

document.oncontextmenu = function(){
  if(!isDebug){return false;}
}
document.addEventListener("click", (e) => {
  if(isMobile()){
    document.documentElement.requestFullscreen();
  }
});
setTimeout(() => {
  window.scrollTo(0, 1);
}, 100);

CSS

HTML

ページのソースを表示 : Ctrl+U , DevTools : F12

view-source:https://hi0a.com/demo/-js/js-game-tower-defense/

ABOUT

hi0a.com 「ひまアプリ」は無料で遊べるミニゲームや便利ツールを公開しています。

プログラミング言語の動作デモやWEBデザイン、ソースコード、フロントエンド等の開発者のための技術を公開しています。

必要な機能の関数をコピペ利用したり勉強に活用できます。

プログラムの動作サンプル結果は画面上部に出力表示されています。

環境:最新のブラウザ GoogleChrome / Windows / Android / iPhone 等の端末で動作確認しています。

画像素材や音素材は半分自作でフリー素材配布サイトも利用しています。LINK参照。

動く便利なものが好きなだけで技術自体に興味はないのでコードは汚いです。

途中放置や実験状態、仕様変更、API廃止等で動かないページもあります。