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,      // 設置したタワーの数

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

  selectedTower: null,      // 選択中のタワー
  lastEnemyType: null,      // 直前に出した敵のタイプ
  sameTypeCount: 0,         // 同じ敵タイプの連続カウント
 selectedType: null,      // 選択中ユニット
 isInTowerRange:false, //射程圏

  unitMax: 200,             // 部隊上限
  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マス直線)
  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
};



const stageHints = {
  2: "兵士は自動で施設を攻撃するぞ",
  3: "予算不足だと兵士は出せないぞ",
  4: "出撃する兵種は左メニューで選べるぞ",
  5: "兵士は射程外のどこからでも出撃可能!",
  6: "施設を破壊すると予算が増えるよ",
  7: "戦いの中で兵種と施設の相性を学ぼう",
  8: "細い円は施設の攻撃範囲かも",
  9: "タワーディフェンスもよろしくね!",
  10: "リトルノア復活しないかなぁ…",
  11: "範囲攻撃に気をつけて!",
  20: "速射砲がめちゃくちゃ強いぞ!",
  21: "タワーディフェンスと攻守逆転",
  22: "コードはタワーディフェンスの流用",
  23: "今のところ演出遊びでゲーム性無視",
  24: "飽きたら制作とまるかも",
  25: "バタくさいデザインより四角のほうが好き",
};

const enemyTypes = {
  normal: {
    label:"歩兵",
    color: "red",
    hp: 10,
    hpUp: 1.5,
    damage: 3,
    attackInterval: 80, // ← 近接の攻撃間隔
    range: defRange,
    speed: 1,
    speedUp: 0.04,
    speedMax: 3.2,
    reward: 20,
    size: 20,
    baseGroupSize: 5,
    groupGrowthRate: 0.4,
    baseSpawnInterval: 40,
  },
  archer: {
    label:"弓兵",
    color: "limegreen",
    hp: 6,
    hpUp: 0.3,
    damage: 5, // 射程攻撃力
    attackInterval: 90, // ← 近接の攻撃間隔
    range: defRange*1.5,
    speed: 0.8,
    speedUp: 0.03,
    speedMax: 2,
    reward: 30,
    size: 16,
    baseGroupSize: 4,
    groupGrowthRate: 0.3,
    baseSpawnInterval: 30,
  },
  fast: {
    label:"騎兵",
    color: "orange",
    hp: 8,
    hpUp: 0.2,
    damage: 1,
    attackInterval: 40, // 攻撃間隔
    range: defRange,
    speed: 6,
    speedUp: 0.1,
    speedMax: 4.8,
    reward: 40,
    size: 16,
    baseGroupSize: 4,
    groupGrowthRate: 0.2,
    baseSpawnInterval: 20,
  },
  tank: {
    label:"重装",
    color: "#2F4F4F",
    hp: 40,
    hpUp: 2,
    damage: 19,
    attackInterval: 240, // 攻撃間隔
    range: defRange,
    speed: 0.6,
    speedUp: 0.02,
    speedMax: 2,
    defense: 2,
    reward: 100,
    size: 32,
    baseGroupSize: 3,
    groupGrowthRate: 0.2,
    baseSpawnInterval: 60,
  },
  abnormal: {
    label:"工兵",
    color: "#aa0000",
    hp: 40,
    hpUp: 1.2,
    damage: 12,
    attackInterval: 120, // 攻撃間隔
    range: defRange/2,
    speed: 1,
    speedUp: 0.15,
    speedMax: 2.8,
    reward: 50,
    size: 18,
    baseGroupSize: 3,
    groupGrowthRate: 0.2,
    baseSpawnInterval: 40,
  },
  ant: {
    label: "偵察",
    color: "#191919",
    hp: 10,
    hpUp: 0.1,
    damage: 2,
    attackInterval: 3, // 攻撃間隔
    range: defRange/2,
    speed: 4,
    speedUp: 0.03,
    speedMax: 2,
    reward: 20,
    size: 10,
    baseGroupSize: 12,
    groupGrowthRate: 1,
    baseSpawnInterval: 12,
  },
  boss: {
    isBoss:true,
    label:"大型種",
    color: "darkgray",
    hp: 1000,
    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: 5,
    costFormula: (lv) => lv^2 * 200,
    lvTable:[
defRange,
defRange2*1,
tileSize * 2.5 +2,
tileSize * 3.5 +2,
tileSize * 4.5 +2,
0
    ],
    fireRate:20,
    area: defRange/2,
    shape: "arc",
    color: "black",//"green",
  },
  damage: {
    label: "■火力+",
    cost: 100,
    unlockAt: 6,
    maxLevel: 20,
    costFormula: (lv) => lv^2 * 200 ,
    lvTable:[0,4,8,10,12,4,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0],
    range: defRange*2+2,
    area: defRange/2,
    shape: "rect",
    color: "black",//"blue",
  },
  rapid: {
    label: "▲速射+",
    cost: 100,
    unlockAt: 9,
    maxLevel: 9,
    costFormula: (lv) => lv^2 * 200,
    lvTable:[0,20,10,10,5,5,5,5,5,2,2,0],
    shape: "tri",
    color: "black",//"cyan",
  },
  area: {
    label: "◆範囲+",
    cost: 100,
    unlockAt: 15,
    maxLevel: 5,
    costFormula: (lv) => lv^2 * 200,
    lvTable:[
defRange/2,
defRange,
defRange2,
tileSize * 1.5 +2,
tileSize * 2 +2,
tileSize * 3 +2,
0
    ],
    range: defRange*2+2,
    shape: "dia",
    color: "black",//"darkorange",
  },
  slow: {
    label: "低速",
    cost: 100,
    unlockAt: 20,
    maxLevel: 5,
    costFormula: (lv) =>  lv * 300,
    color: "blue",
    isEffect:true,
  },
  poison: {
    label: "猛毒",
    cost: 200,
    unlockAt: 25,
    maxLevel: 3,
    costFormula: (lv) =>  lv * 400,
    color: "green",
    isEffect:true,
  },
  burn: {
    label: "火炎",
    cost: 300,
    unlockAt: 30,
    maxLevel: 5,
    costFormula: (lv) =>  lv * 500,
    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);
}



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


/*
################################################################
################################################################
*/
// 敵クラス(流用しているだけで実際は味方ユニット)
class Enemy {
  constructor(type = "normal") {
    const t = enemyTypes[type];

    this.x = 0;
    this.y = 0;
    this.type = type;
    this.lv = 2;

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

    this.damage = t.damage || 1;
    this.attackCooldown = 0;
    this.attackInterval = t.attackInterval || 30; // ← 初期攻撃間隔

    this.size = t.size;


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

    this.timer = 0;


    this.hp += Math.floor((stageNumber-1) * t.hpUp);
    this.maxHp = this.hp;
    this.hpRecovery = 0;
    this.speed += stageNumber * 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 = 2;
    }

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

  }

  findTarget() {
    // タワー or 施設に向かわせるロジック
    this.target = findNearestTower(this.x, this.y);
  }

  moveTarget() {
    if (!this.target) return;

    const aimX = this.target.x;
    const aimY = this.target.y;

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

    if (dist <= this.range) {
      if (this.attackCooldown <= 0) {
        this.attackTarget(this.target);
        this.attackCooldown = this.attackInterval;
      } else {
        this.attackCooldown--;
      }
      return;
    }

    const angle = this.getSafeMoveAngle(aimX, aimY);
    this.x += Math.cos(angle) * this.speed;
    this.y += Math.sin(angle) * this.speed;
  }

  getSafeMoveAngle(targetX, targetY) {
    const dx = targetX - this.x;
    const dy = targetY - this.y;
    const baseAngle = Math.atan2(dy, dx);
    const angleOffset = 0.3;
    const maxTries = 5;

    for (let i = 0; i <= maxTries; i++) {
      const tryAngles = [baseAngle - angleOffset * i, baseAngle + angleOffset * i];
      for (let angle of tryAngles) {
        const nx = this.x + Math.cos(angle) * this.speed;
        const ny = this.y + Math.sin(angle) * this.speed;

        let blocked = false;

        if (!blocked) {
          return angle; // 安全な角度を返す
        }
      }
    }

    return baseAngle; // 全部ダメなら元の方向で突撃(壁にぶつかる)
  }

  avoidOverlap(enemies) {
    for (let other of enemies) {
      if (other === this || other.dead) continue;

      const dx = this.x - other.x;
      const dy = this.y - other.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      const minDist = (this.size + other.size) / 2 + 4;

      if (dist < minDist && dist > 0.01) {
        const pushX = dx / dist * 0.3; // ← 少しだけ押し返す
        const pushY = dy / dist * 0.3;
        this.x += pushX;
        this.y += pushY;
      }
    }
  }



  attackIfInRange() {
    // 攻撃範囲内ならダメージを与える
  }

  shot(target) {
    const bullet = new Bullet({ tower: this, target });
    //bullet.color = "blue";  // 固定色でテスト
    //bullet.size = 6;        // 大きめでテスト
    bullets.push(bullet);
    //console.log("🔫 Bullet fired", bullet);
  }

  attackTarget(target) {
    this.shot(target); // ← 弾で攻撃
    if (target.hp <= 0) {
      target.dead = true;
      console.log("敵施設が破壊された!");
    }

    if (this.type !== "archer") {
    //  this.dead = true; // 近接系は1発攻撃で消えなくていい
    //あとで自爆型に流用?
    }
  }

  update() {
    this.timer++;
    if(this.type === 'abnormal'){
      if (this.statusTimers["poison"] % 30 === 0) {
        this.hp += this.hpRecovery;
        this.hp = clamp(this.hp, 1, this.maxHp);
      }
    }
    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.target || this.target.dead) {
      this.findTarget();
    }

    if (this.target) {
      this.moveTarget(); // ← ここで壁回避も含めて全部行動
    }
    this.avoidOverlap(enemies); // ← 最後に他の敵との密集回避

  }


  takeDamage(damage) {
    this.hp -= damage;
    if (this.hp <= 0) {
      this.dead = true;
      areaEffects.push(new AreaEffects({
        x: this.x,
        y: this.y,
        color: "blue",
        type: "explosion",
        life: 30
      }));
      console.log("味方が破壊されました!");
    }
  }

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

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


  goal() {
    // 保険として target 攻撃を一回試みてから消える
    //継続戦闘させるので一時無効化
/*
    if (this.target && !this.target.dead) {
      this.attackTarget(this.target);
    } else {
      this.dead = 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 lineIntersects(x1, y1, x2, y2, x3, y3, x4, y4) {
  function ccw(ax, ay, bx, by, cx, cy) {
    return (cy - ay) * (bx - ax) > (by - ay) * (cx - ax);
  }
  return (
    ccw(x1, y1, x3, y3, x4, y4) !== ccw(x2, y2, x3, y3, x4, y4) &&
    ccw(x1, y1, x2, y2, x3, y3) !== ccw(x1, y1, x2, y2, x4, y4)
  );
}

function findNearestTower(x, y) {
  const candidates = towers.filter(t => !t.dead);

  // ★ ランダムで選ぶ確率(10〜30%)で実行
  if (Math.random() < 0.3) {
    return candidates[Math.floor(Math.random() * candidates.length)];
  }

  let minDist = Infinity;
  let nearest = null;

  for (let tower of candidates) {
    const dx = tower.x - x;
    const dy = tower.y - y;
    const dist = dx * dx + dy * dy;

    if (dist < minDist) {
      minDist = dist;
      nearest = tower;
    }
  }

  return nearest;
}


function allTowersDestroyed() {
  return towers.length === 0 || towers.every(t => t.dead);
}


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 createUnit(type) {
  let data = enemyTypes[type];
  if(!data){
    console.warn("❗ createUnit: 無効な敵タイプ:", type);
    data = enemyTypes["normal"];
    console.log(data);
  }
  const groupSize = Math.floor(data.baseGroupSize + stageNumber * data.groupGrowthRate);
  return {
    type,
    groupSize,
    remaining: groupSize
  };
}





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

// タワークラスを改良して強化機能を追加
class Tower {
  constructor({ x, y, type = null, range } = {}) {
    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.area = null;  // 初期威力

    this.effects = []; // 初期は何もなし
    this.color = 'rgba(0,0,0,1)';
    this.blue = 255;
    this.green = 0;
    this.cost = towerCost;//初期コスト
    this.label = '大砲';
    this.hp = 20;
    this.maxHp = this.hp;
    this.dead = false;

    // ランダムで指定された type に合わせて初期化
    if (type && towerUpgradeTypes[type]) {
      this.shape = towerUpgradeTypes[type].shape;
      this.color = towerUpgradeTypes[type].color;
      this.upgrade(type); // ← 初期タイプとして1段階上げておくと良い
    }
  }



  takeDamage(damage) {
    if (this.dead) return;
    this.hp -= damage;
    if (this.hp <= 0) {
      this.dead = true;
      console.log('money+:' + this.cost)
      money += this.cost;
      areaEffects.push(new AreaEffects({
        x: this.x,
        y: this.y,
        type: "explosion",
        life: 30
      }));
    }
  }

  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.hp += this.lv * 20;
    this.maxHp = this.hp;

    this.damage += 1;

    this.label = info.label;
    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(lvBoost){this.fireRate -= lvBoost}
        break;
      case "damage":
        this.area = info.area;
        if(lvBoost){this.damage += lvBoost}
        break;

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

      case "area":
        this.range = info.range;
        this.fireRate += 20
        this.damage += 1;
        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() {

    if (this.dead) return; // 描画スキップ
    ctx.save();
    ctx.translate(this.x, this.y);
    const path = getShapePath(this.size, this.shape);
    ctx.fillStyle = this.color;
    ctx.fill(path);
    ctx.restore();

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


function nextStage() {
  stageNumber++;
  unitMax = stageNumber * 20;
  const message = stageHints[stageNumber];
  if(message){
    showMessage(`${message} `);
  }
  spawnEnemyTowers();

}

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 getRandomTowerType() {
  const types = Object.keys(towerUpgradeTypes).filter(t => !towerUpgradeTypes[t].isEffect);
  return types[Math.floor(Math.random() * types.length)];
}


function spawnEnemyTowers(count = 3) {
  towers = [];

  for (let i = 0; i < count; i++) {
    // ランダム座標を生成
    const gridX = getRandomInt(4, 15); // 横(タイル単位)
    const gridY = getRandomInt(1, 7);  // 縦(タイル単位)

    const x = gridX * tileSize + tileSize / 2;
    const y = gridY * tileSize + tileSize / 2;

    // 道の上やスコアエリアを避ける(任意)
    if (isInScoreBox(x, y) || isOccupied(x, y)) {
      i--; // 再試行
      continue;
    }

    const type = getRandomTowerType();
    const tower = new Tower({x:x, y:y, type:type});
    tower.hp += stageNumber * 5; // ステージごとに強化
    tower.maxHp = tower.hp;
    tower.dead = false;
    towers.push(tower);

    const lvUpCount = Math.floor(stageNumber / 3);
    for(let i=0;i<lvUpCount;i++){
      tower.upgrade(type);
    }
  }
}
function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}



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));
}



/*
################################################################
################################################################
*/
// 弾クラス
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 || 20;
    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 = 100;
    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.area) {
      this.areaFire();
    } else {
      this.fire();
    }
  }

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

    if (this.target.hp <= 0) {
      this.target.takeDamage(damage);
    }

    this.applyEffects(this.target);
  }

  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;
        }
      }
    }
    this.life = 10;
    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();
  }
}

function parseColorString(colorStr) {
  const ctxTest = document.createElement("canvas").getContext("2d");
  ctxTest.fillStyle = colorStr;
  document.body.appendChild(ctxTest.canvas); // 強制適用
  const computed = ctxTest.fillStyle; // ← ブラウザが解釈した色
  document.body.removeChild(ctxTest.canvas);

  // rgba(255, 100, 50, 1) → [255, 100, 50]
  const match = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (match) {
    return {
      r: parseInt(match[1]),
      g: parseInt(match[2]),
      b: parseInt(match[3])
    };
  }
  return { r: 200, g: 100, b: 0 }; // fallback
}


class AreaEffects {
  constructor(bulletOrData) {
    this.x = bulletOrData.x;
    this.y = bulletOrData.y;
    this.area = bulletOrData.area || 80;
    this.color = bulletOrData.color || "orange";
    this.life = bulletOrData.life || 20;
    this.maxLife = this.life;
    this.type = bulletOrData.type || "area"; // ← 通常 or 爆破
    this.dead = false;
    console.log(this);
    // 爆煙用パーティクル(type: "explosion" 用)
    if (this.type === "explosion") {
      const base = parseColorString(this.color);
      this.particles = [];

      for (let i = 0; i < 20; i++) {
        const r = clamp(base.r + Math.floor(Math.random() * 50 - 25), 0, 255);
        const g = clamp(base.g + Math.floor(Math.random() * 50 - 25), 0, 255);
        const b = clamp(base.b + Math.floor(Math.random() * 50 - 25), 0, 255);

        this.particles.push({
          x: this.x,
          y: this.y,
          radius: Math.random() * 10 + 5,
          dx: Math.random() * 6 - 3,
          dy: Math.random() * 6 - 3,
          life: Math.random() * 30 + 10,
          color: `rgba(${r},${g},${b},0.5)`
        });
      }
    }

  }

  update() {
    this.life--;
    if (this.type === "explosion") {
      for (let p of this.particles) {
        p.x += p.dx;
        p.y += p.dy;
        p.radius *= 0.95;
        p.life--;
      }
      this.particles = this.particles.filter(p => p.life > 0);
    }

    if (this.life <= 0 && (this.type !== "explosion" || this.particles.length === 0)) {
      this.dead = true;
    }
  }

  draw() {
    ctx.save();
    if (this.type === "explosion") {
      for (let p of this.particles) {
        ctx.beginPath();
        ctx.fillStyle = p.color;
        ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
        ctx.fill();
      }
    } else {
      const t = 1 - this.life / this.maxLife; // 時間の進行 [0 → 1]
      let alpha;
      if (t < 0.5) {
        // 前半:ゆっくりフェードイン
        alpha = 0.4 * (t / 0.5); // 0 → 0.4
      } else {
        // 後半:急激にフェードアウト
        alpha = 0.4 * (1 - (t - 0.5) / 0.2); // 0.4 → 0(速く)
        alpha = Math.max(0, alpha); // 下限補正
      }
      ctx.save();
      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();
    }

    ctx.restore();
  }
}




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

  // 耐性: タンクvs速射
  if (bullet.lv > 2 && bullet.type === 'rapid' && enemy.type === 'tank') {
    damage = 1;
    if(enemy.hp < 2){
      damage = 0;
    }
  }
  // 耐性: 騎兵vs速射
  if (bullet.lv > 2 && bullet.type === 'rapid' && enemy.type === 'fast') {
    damage = 2;
  }
  // 耐性: 騎兵vs速射
  if (bullet.lv > 2 && bullet.type === 'rapid' && enemy.type === 'fast') {
    damage = 0;
  }
  // 耐性: 歩兵vs範囲
  if (bullet.lv > 2 && bullet.type === 'area' && enemy.type === 'normal') {
    damage = 1;
  }
  // 耐性: 小型vs火力
  if (bullet.lv > 2 && bullet.type === 'damage' && enemy.type === 'ant') {
    damage = 1;
  }
  // 耐性: 騎兵vs射程
  if (bullet.lv > 2 && bullet.type === 'range' && enemy.type === 'fast') {
    damage = 1;
  }
  // 耐性: 工兵vs射程
  if (bullet.lv > 2 && bullet.type === 'rapid' && enemy.type === 'fast') {
    damage = 2;
  }


  // 特攻: 工兵vs速射
  if (bullet.lv > 3 && bullet.type === 'abnormal' && enemy.type === 'rapid') {
    damage += bullet.damage * bullet.lv;
  }
  // 特攻: 騎兵vs射程
  if (bullet.lv > 3 && bullet.type === 'fast' && enemy.type === 'range') {
    damage += bullet.damage * bullet.lv;
  }
  // 特攻: 工兵vs火力
  if (bullet.lv > 3 && bullet.type === 'archer' && enemy.type === 'tank') {
    damage += bullet.damage * bullet.lv;
  }
  // 特攻: タンク敵に高威力弾
  if (bullet.lv > 3 && bullet.type === 'damage' && enemy.type === 'tank') {
    damage += (bullet.damage * bullet.lv) / 2;
  }

  // 特攻: 速射
  if (bullet.lv > 2 && bullet.type === 'fast' && enemy.type === 'rapid') {
    damage += damage * enemy.lv;
  }
  // 特攻: 飛行敵に射程強化弾
  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 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 drawTypeMenu() {
  // 背景の黒枠
  ctx.fillStyle = "black";
  ctx.fillRect(0, 0, tileSize, canvas.height);

  const allTypes = Object.entries(enemyTypes)
    .filter(([key, val]) => !val.isBoss); // boss除外
  const types = [{ key: null, label: "ランダム", color: "gray" }, 
                 ...allTypes.map(([key, val]) => ({
                   key,
                   label: val.label,
                   color: val.color
                 }))];
 
  const itemHeight = tileSize;

  types.forEach((entry, i) => {
    const y = i * itemHeight;
    const isSelected = selectedType === entry.key;

    // ハイライト
    if (isSelected) {
      ctx.fillStyle = "white";
      ctx.fillRect(0, y, tileSize, itemHeight);
    }

    // 色付き丸 or グレー
    ctx.fillStyle = entry.color;
    ctx.beginPath();
    ctx.arc(tileSize / 2, y + itemHeight / 2, 14, 0, Math.PI * 2);
    ctx.fill();

    ctx.fillStyle = isSelected ? "black" : "white";
    ctx.font = "10px sans-serif";
    ctx.textAlign = "center";
    ctx.fillText(entry.label, tileSize / 2, y + itemHeight - 4);
  });
}



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


// ゲームオーバー時のオーバーレイを描画
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(`Stage: ${stageNumber}`, 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(`■ :  ${enemies.length} / ${unitMax}`,  scoreBox.x+scoreBox.width-80, 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 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 clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}

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


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

function getCanvasPos(e) {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;

  if (e.touches && e.touches.length > 0) {
    return {
      x: (e.touches[0].clientX - rect.left) * scaleX,
      y: (e.touches[0].clientY - rect.top) * scaleY
    };
  } else {
    return {
      x: (e.clientX - rect.left) * scaleX,
      y: (e.clientY - rect.top) * scaleY
    };
  }
}

function getRandomEnemyType() {
  const keys = Object.keys(enemyTypes);
  // bossとか除きたければここでフィルタ
  const filtered = keys.filter(key => !enemyTypes[key].isBoss); 
  const r = Math.floor(Math.random() * filtered.length);
  return filtered[r];
}

function spawnUnitAt(x, y) {
  if(enemies.length >= unitMax){return;}

  // タワーの射程内なら出撃できない
  const blockingTowers = getTowersInRange(x, y);
  if (blockingTowers.length > 0) {
    //showMessage("射程内では出撃できません!");
    return;
  }

  const type = selectedType || getRandomEnemyType();

  const enemy = new Enemy(type);
  if(enemy.reward > money){return;}
  money -= enemy.reward;

  const rX = (Math.random() - 0.5) * 1;
  const rY = (Math.random() - 0.5) * 1;

  enemy.x += x+rX;
  enemy.y += y+rY;

  enemies.push(enemy);
}


function getTowersInRange(x, y) {
  return towers.filter(tower => {
    const dx = tower.x - x;
    const dy = tower.y - y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    return dist < tower.range;
  });
}

function drawBlockedRangeCircle(tower) {
  ctx.beginPath();
  ctx.arc(tower.x, tower.y, tower.range, 0, Math.PI * 2);
  ctx.fillStyle = "rgba(255, 0, 0, 0.1)";
  ctx.fill();
  ctx.strokeStyle = "rgba(255, 0, 0, 0.5)";
  ctx.lineWidth = 1;
  ctx.stroke();

  // タワーのタイプ名とレベル表示
  ctx.fillStyle = "rgba(255, 0, 0, 0.8)";
  ctx.font = "12px 'Arial', sans-serif";
  ctx.textAlign = "center";
  ctx.fillText(`${tower.label} Lv.${tower.lv}`, tower.x, tower.y - tower.range - 6);
}



let isPointerDown;
// 座標追跡
function updatePointer(e) {
  pointerPos = getCanvasPos(e);
}
function startSpawning() {
  isPointerDown = true;
  spawnUnitAt(pointerPos.x, pointerPos.y);
  spawnInterval = setInterval(() => {
    if (isPointerDown) {
      spawnUnitAt(pointerPos.x, pointerPos.y);
    }
  }, 90);
}
function stopSpawning() {
  isPointerDown = false;
  clearInterval(spawnInterval);
}


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;

  // 左メニュー内クリック時
  if (mouseX < tileSize) {
    const index = Math.floor(mouseY / tileSize);

    const availableTypes = Object.keys(enemyTypes).filter(
      t => !enemyTypes[t].isBoss
    );
    if (index === 0) {
      selectedType = null; // ランダム
    } else if (index - 1 < availableTypes.length) {
      selectedType = availableTypes[index - 1];
    }

    return;
  }
  // それ以外はユニット召喚
  spawnUnitAt(mouseX, mouseY);
});


let mousePos = { x: 0, y: 0 };
// マウス位置を常に追跡
canvas.addEventListener("mousemove", (e) => {
  const rect = canvas.getBoundingClientRect();
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  mousePos.x = (e.clientX - rect.left) * scaleX;
  mousePos.y = (e.clientY - rect.top) * scaleY;
});

// PC用イベント
canvas.addEventListener("mousedown", (e) => {
  updatePointer(e);
  startSpawning();
});
canvas.addEventListener("mousemove", (e) => {
  if (isPointerDown) updatePointer(e);
});
canvas.addEventListener("mouseup", stopSpawning);
canvas.addEventListener("mouseleave", stopSpawning);

// タッチ用イベント
canvas.addEventListener("touchstart", (e) => {
  updatePointer(e);
  startSpawning();
});
canvas.addEventListener("touchmove", (e) => {
  if (isPointerDown) updatePointer(e);
});
canvas.addEventListener("touchend", stopSpawning);
canvas.addEventListener("touchcancel", stopSpawning);

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


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

  spawnEnemyTowers(1);
  spawnUnitAt(tileSize*2, tileSize*2);
  showMessage(`タップして兵士を出撃させよう!`);
}

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

  //射程圏警告
  const blockingTowers = getTowersInRange(mousePos.x, mousePos.y);
  for (const tower of blockingTowers) {
    drawBlockedRangeCircle(tower);
  }

  // タワー選択が解除された場合はメニューを閉じる
  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();

  towers = towers.filter(t => !t.dead);
  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 (!gameOver && allTowersDestroyed()) {
    console.log("ステージクリア!");
    nextStage();
  }

  drawRightFlash(); // 右端点滅

  
  drawTypeMenu();
  drawScore();
  if (gameOver) {
    drawGameOverOverlay(); // ゲームオーバー時のオーバーレイ
  }

  requestAnimationFrame(gameLoop);

}

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



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

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 stageInput = document.getElementById("stage");
stageInput.addEventListener("change", (e) => {
  stageNumber = 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-strategy/

ABOUT

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

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

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

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

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

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

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

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