view source

JavaScript

//(function () {



document.title = 'Stickman War.io 棒人間が戦うサバイバルゲーム';



const width = 1280, height = 720;
const mapSize = 3600;
const container = document.getElementById('demo');

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
container.appendChild(canvas);
canvas.width = width;
canvas.height = height;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const gridSize = 100;
const rect = canvas.getBoundingClientRect();//DOM挿入が先


let name = localStorage.getItem('name') || '';
const input = document.createElement('input');
input.type = 'text';
input.maxLength = 8;
input.placeholder = 'Your Name';
input.value = name;
const STORAGE_KEY = 'playerName';
container.appendChild(input);
input.addEventListener('input', function () {
  this.value = this.value.replace(/[^a-zA-Z0-9]/g, '');
  name = this.value;
  name = name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
  localStorage.setItem('name', name);
  if(man){
    man.name = name;
  }
});


const h = document.createElement('h1');
h.textContent = 'Stickman War : 棒人間たちの殺し合いに参加しよう!';
container.appendChild(h);

const p = document.createElement('p');
p.textContent = 'ASDW,←↓→↑ キー・画面端長押しで移動  space,左クリックで射撃 マウスカーソルで攻撃方向指定';
container.appendChild(p);



const link = document.createElement('link');
link.href = 'https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap';
link.rel = 'stylesheet';
document.head.appendChild(link);


let gameOver = false;

const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let isUserInteracted = false;


let man = null;
let enemies = [];
let players = [];
let bullets = [];
let blocks = [];
let droppedWeapons = [];
let bloods = [];
let isDebug = false;
let startTime = performance.now();
let timeStr = 0;
let endTime = null;// ← 死亡した瞬間の時刻
let killCount = 0;

const manStartX = mapSize / 2;
const manStartY = mapSize / 2;
let frameCount = 0; // フレーム数のカウント
let gameOverCount = 0; //

/*
  range: 4, * gridSize
*/
const weaponTypes = {
  handgun: {
    power: 2,
    range: 4,
    rate: 20,
    size: 4,
    speed:4,
    isGun: true,
  },
  rifle: {
    power: 4,
    range: 6,
    rate: 60,
    size: 8,
    speed:3,
    isGun: true,
  },
  shotgun: {
    power: 10,
    range: 3.5,
    rate: 80,
    size: 10,
    speed:2.5,
    isGun: true,
  },
  knife: {
    power: 3,
    range: 1,
    rate: 10,
    size: 20,
    speed:9,
    isGun: false,
  },
  sword: {
    power: 9,
    range: 1.2,
    rate: 15,
    size: 24,
    speed:7,
    isGun: false,
  },
  recovery: {
    hp: 5,
    isItem: true,
  },

};
const keysToWeapons = Object.keys(weaponTypes);

const names = [
"Apple","Orange","John","Mike","Jake","Liam","Noah","Ethan","Mason","Logan","Lucas","Aiden","Caleb","Owen","Connor","Elijah","Hunter","Leo","Nathan","Ryan","Jack","Miles","Henry","Dylan","Simon","Aaron","Tyler","Zane","Ezra","Eli","Felix","Riley","Oscar","Joel","Cody","Blake","Shawn","Toby","Wyatt","Isaac","Troy","Grant","Bryce","Reid","Haruki","Ren","Souta","Takao","Riku","Yuji","Kenta","Daiki","Kouji","Shun","Aoi","Reo","Toma","Yuta","Sho","Naoki","Ryota","Keita","Hinata","Taiga","Sato","Suzuki","Sakamoto","Honda","Kato","Ronron","Daodao","Rucha","Banbi","Majun","Hoimiso","Regna","Aaaa","Zzz",
];

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

function getCorrectedCoords(e) {
  // CSSで適用されているtransform(scaleなど)を考慮
  const rect = canvas.getBoundingClientRect();//DOM挿入が先
  const scaleX = canvas.width / rect.width;
  const scaleY = canvas.height / rect.height;
  let clientX, clientY;
  // マウスイベントまたはタッチイベントに対応
  if (e.touches) {
    clientX = e.touches[0].clientX;
    clientY = e.touches[0].clientY;
  } else {
    clientX = e.clientX;
    clientY = e.clientY;
  }
  // スケーリング補正された座標を返す
  return {
    x: (clientX - rect.left) * scaleX,
    y: (clientY - rect.top) * scaleY
  };
}

const keys = {};
window.addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
let mouseX = width / 2;
let mouseY = height / 2;

// 長押し判定用の変数
let touchStartTime = 0;
let isTouching = false;
let touchStartX = 0;
let touchStartY = 0;
// 長押しの閾値(ミリ秒)
const LONG_PRESS_THRESHOLD = 500; // 0.5秒以上のタッチを長押しと判定

canvas.addEventListener('mousemove', function (e) {
  const { x, y } = getCorrectedCoords(e);
  mouseX = x;
  mouseY = y;
});


window.addEventListener('keydown', e => {
  keys[e.key.toLowerCase()] = true;

  const index = parseInt(e.key) - 1;
  if (index >= 0 && index < keysToWeapons.length) {
  //  man.setWeapon(keysToWeapons[index]);
  }
  if (e.code === 'Space' || e.code === 'Enter') {
    if (gameOver) {
      resetGame();
      return;
    }
    man.attack();
  }
});
window.addEventListener('click', e => {
  isUserInteracted = true;
  if (gameOver) {
    resetGame();
    return;
  }
  const { x, y } = getCorrectedCoords(e);
  handleAttack(x,y);
});
window.addEventListener('touchstart', e => {
  isUserInteracted = true;
  if (gameOver) {
    resetGame();
    return;
  }
  const { x, y } = getCorrectedCoords(e);
  handleAttack(x,y);
});

// マウス対応//スマホ用//汎用移動系
const mouseKeys = {
  up: false,
  down: false,
  left: false,
  right: false
};
function handlePointerDown(x, y) {
  const edge = gridSize/2;
  mouseKeys.up = y < edge;
  mouseKeys.down = y > canvas.height - edge;
  mouseKeys.left = x < edge;
  mouseKeys.right = x > canvas.width - edge;
}
function handlePointerUp() {
  for (let key in mouseKeys) {
    mouseKeys[key] = false;
  }
}


canvas.addEventListener('mousedown', (e) => {
  const { x, y } = getCorrectedCoords(e);
  handlePointerDown(x, y);
});
document.addEventListener('mouseup', handlePointerUp);
canvas.addEventListener('touchstart', (e) => {
  const { x, y } = getCorrectedCoords(e);
  handlePointerDown(x, y);
});
document.addEventListener('touchend', handlePointerUp);







// touchstartイベントでタッチの開始を検出
canvas.addEventListener('touchstart', (e) => {
  const { x, y } = getCorrectedCoords(e); // タッチ位置を取得
  touchStartTime = performance.now(); // タッチ開始時間を記録
  touchStartX = x;
  touchStartY = y;
  isTouching = true; // タッチ中フラグを立てる
});

// touchmoveイベントでタッチ位置を移動
canvas.addEventListener('touchmove', (e) => {
  if (isTouching) {
    const { x, y } = getCorrectedCoords(e); // タッチ位置を取得
    handleTouchMove(x, y); // タッチ位置に向かって移動
  }
});

// touchendまたはtouchcancelイベントでタッチを終了
canvas.addEventListener('touchend', (e) => {
  const touchEndTime = performance.now(); // タッチ終了時間を取得
  const touchDuration = touchEndTime - touchStartTime; // タッチ時間を計算

  // 長押しを判定(タッチ時間が閾値を超えた場合)
  if (touchDuration >= LONG_PRESS_THRESHOLD) {
    // 長押しを検出した場合の処理
    const { x, y } = getCorrectedCoords(e); // タッチ位置を取得
    handleTouchMove(x, y); // 長押しした位置にキャラクターを移動
  }

  isTouching = false; // タッチ終了
});


// 攻撃を行う関数
function handleAttack(x, y) {
  const dx = x - man.x; // プレイヤーの位置からタップ位置へのX軸の差
  const dy = y - man.y; // プレイヤーの位置からタップ位置へのY軸の差
  man.angle = Math.atan2(dy, dx); // 角度を計算(攻撃方向)

  // 攻撃を実行
  man.attack();
}

// 長押し時の移動方向を判定する関数
function handleTouchMove(x, y) {
  const dx = x - man.x; // プレイヤーの位置とタッチ位置のX軸の差
  const dy = y - man.y; // プレイヤーの位置とタッチ位置のY軸の差
  const angle = Math.atan2(dy, dx); // タッチ方向の角度を計算

  // キャラクターをタッチ位置に向かって移動させる
  man.x += Math.cos(angle) * man.speed;
  man.y += Math.sin(angle) * man.speed;
}

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

class Man {
  constructor(x, y, isEnemy=false) {
    this.x = x;
    this.y = y;
    this.cx = cx;
    this.cy = cy;
    this.hp = 10;
    this.maxHP = 10; // 最大HP
    this.type = 'handgun'//'handgun';
    this.size = 20;
    this.color = '#000000';
    this.colorDef = '#000000';
    this.info = weaponTypes[this.type];
    this.weapon = new Weapon(this); // 初期武器
    this.speed = this.info.speed;
    this.facing = 'right'; // 'left' or 'right'
    this.isEnemy = isEnemy;
    this.name = name || 'You';
    this.name += getRandomTwoDigit();
    this.dead = false;
    this.lastShotFrame = 0;
    this.target = null;
    this.blinking = false; // 点滅フラグ
    this.blinkDuration = 0; // 点滅時間のカウント
    //console.log(this);
  }

  update() {
    if (this.isEnemy) {
      return;
    }
    let dx = 0, dy = 0;
    if (keys['w'] || keys['arrowup'] || mouseKeys['up']) dy -= this.speed;
    if (keys['s'] || keys['arrowdown'] || mouseKeys['down']) dy += this.speed;
    if (keys['a'] || keys['arrowleft'] || mouseKeys['left']) dx -= this.speed;
    if (keys['d'] || keys['arrowright'] || mouseKeys['right']) dx += this.speed;

    // 移動先の仮位置
    const newX = this.x + dx;
    const newY = this.y + dy;
    const radius = 20;
    let collided = false;

    for (const block of blocks) {
      if (block.collidesWith(newX, this.y, radius)) {
        dx = 0; // X方向ぶつかった
      }
      if (block.collidesWith(this.x, newY, radius)) {
        dy = 0; // Y方向ぶつかった
      }
    }

    this.x += dx;
    this.y += dy;
    // 近接武器の場合、武器を振る動作
    if (!this.weapon.isGun) {
      //this.weapon.swing(this.angle); // 振る動作を追加
    }
    // 武器の更新(振り動作を反映)
    this.weapon.update(); // ここでupdate()を呼び出す!

    tryPickUpNearbyItem(this)

    // 点滅が終了したかチェック
    if (this.blinking) {
      this.blinkDuration--;
      this.color = '#ff0000';
      if (this.blinkDuration <= 0) {
        this.blinking = false; // 点滅終了
        this.color = this.colorDef;
      }
    }
  }


// 攻撃時(ショットや近接攻撃)の処理
  attack() {

    // 弾発射処理
    const cx = width / 2;
    const cy = height / 2;
    const dx = this.mx - cx;
    const dy = this.my - cy;
    this.angle = Math.atan2(dy, dx);

    if(this.isEnemy){
      let devMax = Math.random() * 20 + 20;
      if(this.target.dist < gridSize*2){
        devMax = devMax /2;
      }
      if(this.target.dist > gridSize*4){
        devMax = devMax *2;
      }
      const randomDev = (Math.random() * devMax - devMax/2) * (Math.PI / 180); // -15度 ~ 15度 (ラジアン)
      this.tAngle += randomDev;
    }

    // 弾発射(銃)
    const now = performance.now();
    const currentFrame = Math.floor(performance.now() / 1000 * 60);  // 60fps でフレームを計算
    const rate = this.info.rate;  // 発射間隔(フレーム数)

    if (currentFrame - this.lastShotFrame < rate) {
      return; // 発射間隔が短すぎる場合は何もしない
    }
    // プレイヤーのマップ座標から弾を発射
    bullets.push(new Bullet(this));

    // 銃声の再生
    playSound(man.weapon.type, this);

    // 発射時刻を更新
    this.lastShotFrame = currentFrame;

    // 近接武器の場合、振る動作開始
    if (!this.weapon.isGun) {
      this.weapon.swing(this.angle); // 攻撃時にスイング開始
    }
  }

  // 敵がダメージを受ける処理
  takeDamage(weapon, power) {
    if(this.dead){return;}
    this.hp -= power;

    // 点滅開始
    this.blinking = true;
    this.blinkDuration = 10; // 点滅時間を設定(例えば10フレーム)

    playSound('damage');  // ダメージ音を再生
    if(this.hp <= 0){
      this.deadEvent(weapon);
    }
  }

  deadEvent(weapon){
    playSound('blood');  // ダメージ音を再生
    this.dead = true;

    // 💥 KILL判定(プレイヤーが倒した場合のみ)
    if (!this.isEnemy && this !== man) {
      // nothing
    } else if (this.isEnemy && weapon.user === man) {
      killCount++;
      getHint(killCount);
    }

    if(!this.isEnemy){
      gameOver = true;
      gameOverCount = 120;//プレイヤー死亡
      endTime = performance.now();// 🛑 タイマー停止
    }
    bloods.push(new Blood(this.x, this.y));
    setTimeout(()=>{
      createEnemies(1);
    }, 6000);
  }


  setWeapon(type) {
    const oldType = this.type;
    this.type = type;
    this.info = weaponTypes[this.type];

    //  同じ武器なら rate を強化(最小10まで)
    if (oldType === type) {
      this.info = Object.assign({}, this.info); // オリジナルを壊さないコピー

      this.info.rate = Math.max(10, this.info.rate - 10);
    }

    this.weapon = new Weapon(this);
    this.speed = this.info.speed;

    if(this.isEnemy){
      this.speed *= 0.8;
    }
    if(this.isEnemy && killCount < 3){
      this.weapon.power -= 1;
    }

  }

  drawName() {
    const screenX = this.x - man.x + width / 2; //
    const screenY = this.y - man.y + height / 2 - this.size - 10; //
    // 名前表示
    ctx.font = '16px "Share Tech Mono", monospace';
    ctx.fillStyle = this.color;
    ctx.textAlign = 'center';
    ctx.fillText(this.name || '', screenX, screenY - this.size - 10);
  }
  // HPゲージの描画
  drawHP() {
    const screenX = this.x - man.x + width / 2; //
    const screenY = this.y - man.y + height / 2 - this.size - 10; //
    const barWidth = 40; // HPゲージの幅
    const barHeight = 4; // HPゲージの高さ
    const hpPercentage = this.hp / this.maxHP; // HPの割合(0~1)

    // HPゲージの背景(黒)
    ctx.fillStyle = 'black';
    ctx.fillRect(screenX - barWidth / 2, screenY-20, barWidth, barHeight);

    // HPゲージの前景(緑)
    ctx.fillStyle = 'green';
    ctx.fillRect(screenX - barWidth / 2, screenY-20, barWidth * hpPercentage, barHeight);
  }

  drawPointer() {
    // マウス方向への腕(銃)
    if(!this.isEnemy){
      this.mx = mouseX - this.cx;
      this.my = mouseY - this.cy;
      this.tAngle = Math.atan2(this.my, this.mx);  // 角度
    }

    
    const target = getClosestEnemy(this, players);
    this.target = target;
    if(this.isEnemy){
      // ターゲットの位置との相対位置を計算
      if(!target){return}
      this.tx = target.x - this.x;  //相対位置
      this.ty = target.y - this.y;  //相対位置
    }

    if(this.isEnemy){
      this.tAngle = Math.atan2(this.ty, this.tx);  // 角度
    }

    const dist = 30; // 棒人間の中心からどれくらい外に置くか
    const size = 12; // 三角の大きさ

    // 三角の中心位置
    const px = this.cx + Math.cos(this.tAngle) * dist;
    const py = this.cy + Math.sin(this.tAngle) * dist;

    // 三角の3点を計算
    const p1x = px + Math.cos(this.tAngle) * size;
    const p1y = py + Math.sin(this.tAngle) * size;

    const p2x = px + Math.cos(this.tAngle + Math.PI * 2 / 3) * size * 0.7;
    const p2y = py + Math.sin(this.tAngle + Math.PI * 2 / 3) * size * 0.7;

    const p3x = px + Math.cos(this.tAngle - Math.PI * 2 / 3) * size * 0.7;
    const p3y = py + Math.sin(this.tAngle - Math.PI * 2 / 3) * size * 0.7;

    // 描画
    ctx.fillStyle = 'red';
    ctx.beginPath();
    ctx.moveTo(p1x, p1y);
    ctx.lineTo(p2x, p2y);
    ctx.lineTo(p3x, p3y);
    ctx.closePath();
    ctx.fill();
  }


  draw() {
    const offsetX = man.x - width / 2;
    const offsetY = man.y - height / 2;
    const screenX = this.x - offsetX; // map座標 → canvas座標に変換
    const screenY = this.y - offsetY;
    this.cx = screenX;
    this.cy = screenY;
    // 頭
    ctx.strokeStyle = this.color;
    if(this.isEnemy){
      ctx.strokeStyle = this.color;
    }
    if(this.dead){
      ctx.strokeStyle = 'rgba(0,0,0, 0.1)';
    }
    ctx.lineWidth = 8;
    ctx.beginPath();
    ctx.arc(screenX, screenY, this.size, 0, Math.PI * 2);
    ctx.stroke();
    this.drawName();
    this.drawHP();
    if(!this.isEnemy){
      //console.log(screenX);
    }
    // 武器描画(腕+武器も含む)
    this.weapon.draw(screenX, screenY);

    this.drawPointer();
  }

}


class Enemy extends Man {
  constructor(x, y) {
    super(x, y, true);  // 親クラス(Man)のコンストラクタを呼び出す、isEnemy = true
    //this.hp = 5;  // 敵のHP
    //this.speed = 2;  // 敵のスピード
    //this.size = 20;

    this.color = '#aa0000';
    this.colorDef = '#aa0000';
    this.name = names[Math.floor(Math.random() * names.length)];
    this.name += getRandomTwoDigit();
    this.direction = Math.random() * 2 * Math.PI; // ランダムな移動方向
    this.changeDirectionTimer = 0; // 方向を変えるタイマー
  }

  update() {
    this.changeDirectionTimer--;
    // ターゲットの位置との相対位置を計算
    const t = getTarget(this);
    if(!t){return;}
    this.target = t.target;
    let dx = t.dx;
    let dy = t.dy;
    const angle = t.angle;
    const dist = t.dist;

    console.log()
    const range = this.weapon.range*gridSize;

    // 武器を拾う処理
    tryPickUpNearbyItem(this);
    this.pickUpWeaponIfNearby();

    if(dist >= range * 3){
      this.randomMove();//見えない間はランダム
    }if(dist >= range * 9/10 && range > 2){
      // プレイヤーの方向に移動
      if(frameCount % 6 === 0){
        this.randomMove();
      } else {
        dx = Math.cos(angle) * this.speed;////
        dy = Math.sin(angle) * this.speed;
      }
      dx = Math.cos(angle) * this.speed;////
      dy = Math.sin(angle) * this.speed;
    } else if(dist < range / 3 * gridSize && range > 2) {
      //逃げる
      dx = -Math.cos(angle) * this.speed;
      dy = -Math.sin(angle) * this.speed;
    } else {
      this.randomMove();
    }

    this.x += dx;
    this.y += dy;

    // 敵がプレイヤーに接近した場合の攻撃(一定距離で攻撃など)
    if (dist < range * 0.9) {  // 例えばプレイヤーが400px以内に近づいた場合
      if(!man.dead){
        this.movePerpendicularToTarget(angle);
        this.attack();
      }
    }

    // 障害物との衝突を避ける
    this.avoidObstacles();

    if(isInBlocks(this)){
      this.dead = true;// 敵を消滅
      setTimeout(() => createEnemies(1), 500); // 少し遅らせて再ポップ
      return; // 以降の処理をスキップ
    }

    // 武器の更新(近接武器や銃の処理)
    this.weapon.update();
  }



  // 近くの落ちている武器を拾いに行く処理
  pickUpWeaponIfNearby() {
    for (const weapon of droppedWeapons) {
      const distToWeapon = getDist(this, weapon);
      
      if (distToWeapon < gridSize * 4) {  // もし武器が近くにあれば
        // 武器の方向に移動
        const angleToWeapon = Math.atan2(weapon.y - this.y, weapon.x - this.x);
        this.x += Math.cos(angleToWeapon) * this.speed;
        this.y += Math.sin(angleToWeapon) * this.speed;

        // 近づいたら武器を拾う
        if (distToWeapon < 20) {
          tryPickUpNearbyItem(this);
          break;  // 一つの武器を拾ったら終わり
        }
      }
    }
  }

  // 武器を拾う処理
  pickUpWeapon(weapon) {
    this.setWeapon(weapon.type);  // 武器を設定
  }

  // ランダムに移動する関数
  randomMove() {
    if (this.changeDirectionTimer > 0) {
      return;
    }
    // ランダム方向に移動
    this.x += Math.cos(this.direction) * this.speed;
    this.y += Math.sin(this.direction) * this.speed;

    // 一定時間(フレーム数)ごとに方向を変える
    if (this.changeDirectionTimer <= 0) {
      this.direction = Math.random() * 2 * Math.PI; // 新しいランダム方向
      this.changeDirectionTimer = Math.floor(Math.random() * 60) + 60; // ランダムな時間(1〜2秒)で方向を変える
    }
  }


  // 射程範囲内でターゲットに直角方向に移動する関数
  movePerpendicularToTarget(angleToTarget) {
    if (this.changeDirectionTimer > 0) {
      return;
    }
    const direction = Math.random() < 0.5 ? 1 : -1; // 0.5の確率で右(1)か左(-1)を選択
    // プレイヤーに向かう方向の直角(90度回転)
    const perpendicularAngle = angleToTarget + Math.PI / 2 * direction ;
    // 直角にずらす(右または左)
    this.x += Math.cos(perpendicularAngle) * this.speed;
    this.y += Math.sin(perpendicularAngle) * this.speed;
    if (this.changeDirectionTimer <= 0) {
      this.changeDirectionTimer = Math.floor(Math.random() * 60) + 60; // ランダムな時間(1〜2秒)で方向を変える
    }
  }


  // 障害物との衝突を避ける処理
  avoidObstacles() {
    const radius = 20;  // 敵の半径
    let dx = 0, dy = 0;

    // 衝突判定
    for (const block of blocks) {
      if (block.collidesWith(this.x, this.y, radius)) {
        // 衝突している場合、移動方向を調整(例:X方向またはY方向の移動を停止)
        if (block.collidesWith(this.x + this.speed, this.y, radius)) {
          dx = -this.speed; // 進行方向を反転
        } else if (block.collidesWith(this.x, this.y + this.speed, radius)) {
          dy = -this.speed; // 進行方向を反転
        }
      }
    }

    // 衝突を避けるための調整
    this.x += dx;
    this.y += dy;
  }
}

function getTarget(self){
  const target = getClosestEnemy(self, players);
  if(!target){return}
  const dx = target.x - self.x;  //相対位置
  const dy = target.y - self.y;  //相対位置
  const angle = Math.atan2(dy, dx);  // 角度
  const dist = Math.sqrt(dx * dx + dy * dy);
  return {
    target:target,
    dx:dx,
    dy:dy,
    angle:angle,
    dist:dist,
  }
}

//死角判定
function isInEnemySight(enemy, target, fov = Math.PI * 2 / 3) {
  // default: 120度
  const dx = target.x - enemy.x;
  const dy = target.y - enemy.y;
  const angleToTarget = Math.atan2(dy, dx);
  let angleDiff = Math.abs(enemy.tAngle - angleToTarget);

  // 角度の差を0〜π以内に正規化
  angleDiff = Math.min(angleDiff, Math.abs(2 * Math.PI - angleDiff));

  return angleDiff < fov / 2;
}
//死角処理
function shouldEnemyReact(enemy, player, maxDetectDist = gridSize * 6) {
  const dist = getDist(enemy, player);
  const canSee = isInEnemySight(enemy, player, enemy.fov || Math.PI * 2 / 3);

  // 視野内 or 極端に近い → 反応OK
  if (canSee || dist < gridSize * 1.2) return true;

  // 視野外 & 遠い → 反応しない
  return false;
}


function createEnemies(count = 3) {
  if (enemies.length > 10) { return; }
  for (let i = 0; i < count; i++) {
    let x, y, tooClose;
    let tries = 0;
    
    do {
      x = Math.floor(Math.random() * (mapSize - gridSize)) + gridSize; // mapSize内でランダムに位置を決める
      y = Math.floor(Math.random() * (mapSize - gridSize)) + gridSize;

      // プレイヤーの位置(man.x, man.y)からgridSize*6以上離れた位置に配置する
      tooClose = Math.sqrt(Math.pow(x - man.x, 2) + Math.pow(y - man.y, 2)) < gridSize * 6;

      // 他の敵との重複をチェック
      for (const enemy of enemies) {
        if (Math.sqrt(Math.pow(x - enemy.x, 2) + Math.pow(y - enemy.y, 2)) < gridSize) {
          tooClose = true;
          break;
        }
      }

      tries++;
    } while (tooClose && tries < 100); // 100回まで試す
    let enemy = new Enemy(x, y);
    enemies.push(enemy); // 敵を配置
    players.push(enemy);
  }
}

function getRandomTwoDigit() {
  return String(Math.floor(Math.random() * 100)).padStart(2, '0');
}
function L(self, enemy) {
  const dx = enemy.x - self.x; // x座標の差
  const dy = enemy.y - self.y; // y座標の差

  // 距離が0(自分自身の場合)は除外
  if (dx === 0 && dy === 0) {
    return null;  // 自分自身の場合は null を返す
  }

  return { dx, dy };  // dx, dy の差を返す
}



//視野角で重み付けあり
function getClosestEnemy(self, candidates) {
  let closest = null;
  let minScore = Infinity;

  for (const target of candidates) {
    if (target === self || target.dead) continue;

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

    // 👀 視野角を使って重みづけ
    const angleToTarget = Math.atan2(dy, dx);
    let angleDiff = Math.abs(self.tAngle - angleToTarget);
    angleDiff = Math.min(angleDiff, Math.abs(2 * Math.PI - angleDiff));

    const fov = self.fov || Math.PI * 2 / 3; // 120度
    const sightFactor = angleDiff < fov / 2 ? 1 : 1.5; // 視野外は1.5倍の重み

    const score = dist * sightFactor;

    if (score < minScore) {
      minScore = score;
      closest = target;
    }
  }

  return closest;
}

//単純な距離別判定
function getClosestEnemy2(self, enemies) {
  let closestEnemy = null;
  let minDistance = Infinity; // 最小距離を無限大に設定

  for (const enemy of enemies) {
    const result = L(self, enemy);  // プレイヤーと敵の相対位置を計算

    // 距離が 0 の場合(自分自身を除外)
    if (result === null) {
      continue;  // 自分自身なので、スキップ
    }

    const { dx, dy } = result;
    const distance = Math.sqrt(dx * dx + dy * dy); // ユークリッド距離

    if (distance < minDistance) {
      minDistance = distance;
      closestEnemy = enemy;
    }
  }

  return closestEnemy;
}


/*
################################################################
################################################################
*/
class Bullet {
  constructor(user) {
    this.x = user.x;
    this.y = user.y;
    this.user = user;
    this.info = user.info;
    this.angle = user.tAngle;
    this.weapon = user.weapon;
    this.range = user.info.range * gridSize;
    this.size = user.info.size || 8;
    this.speed = 30;
    this.life = 60; // フレーム数で消える(=1秒)
    this.distanceTraveled = 0;  // 移動距離
    this.dead = false;

    if (!this.weapon.isGun) {
      this.speed = 5;
    }
  }

  update() {
    this.life--;
    const dx = Math.cos(this.angle) * this.speed;
    const dy = Math.sin(this.angle) * this.speed;

    this.x += dx;
    this.y += dy;

    // 距離の計算
    this.distanceTraveled += Math.sqrt(dx * dx + dy * dy);

    if(this.user.info.isGun){
      // 衝突判定:弾がブロックと衝突したか
      for (const block of blocks) {
        if (block.collidesWith(this.x, this.y, this.size)) {
          this.dead = true;  // 衝突したら弾を消す
          return;  // 衝突したら、更新を終了
        }
      }
    }

    // プレイヤーや敵と衝突しているか判定
    for (const target of players) {
      if (target === this.user) continue;//自分自身を除外
      if (isCollision(this, target)) { // 衝突していたら
        target.takeDamage(this, this.info.power); // ダメージを与える
        this.dead = true; // 弾を消す
        return;
      }
    }

    this.bladeParry();

    // 射程を超えたか、ライフが尽きたら死んでいると判断
    this.dead = this.distanceTraveled > this.range || this.life <= 0;
  }

  bladeParry() {
    // 他の弾との衝突(近接武器のみ)
    if (!this.weapon.isGun) {
      for (const other of bullets) {
        if (other === this || other.dead) continue;

        const isEnemyBullet = other.user !== this.user;

        // 弾同士の距離が近ければ衝突(お互い中心距離で判定)
        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;

        if (isEnemyBullet && dist < minDist) {
          other.dead = true; // 相手の弾を消す
          // this.dead = true; // ←こっちも消すならONに
          playSound('parry', this.user); // パリィ音みたいなのもあり
        }
      }
    }
  }

  drawRifle() {
    const length = this.size*2;
    const thickness = this.size/2;
    ctx.save();
    ctx.translate(this.screenX, this.screenY); // 弾の位置に移動
    ctx.rotate(this.angle);          // 弾の角度に回転
    ctx.fillStyle = '#000';
    ctx.fillRect(-length / 2, -thickness / 2, length, thickness); // 中心から
    ctx.restore();
  }

  drawCircle() {
    ctx.fillStyle = 'black';
    ctx.beginPath();
    ctx.arc(this.screenX, this.screenY, this.size, 0, Math.PI * 2);
    ctx.fill();
  }

  draw() {
    const offsetX = man.x - width / 2;
    const offsetY = man.y - height / 2;
    this.screenX = this.x - offsetX;
    this.screenY = this.y - offsetY;

    if (this.weapon.type === 'rifle') {
      this.drawRifle();
    } else if (this.weapon.isGun) {
      this.drawCircle();
    }
  }

}

/*
################################################################
################################################################
*/
class Block {
  constructor(x, y, width = gridSize, height = gridSize) {
    this.x = x;
    this.y = y;
    this.width = width;
    this.height = height;
  }

  draw() {
    const offsetX = man.x - width / 2;
    const offsetY = man.y - height / 2;
    const screenX = this.x - offsetX;
    const screenY = this.y - offsetY;

    ctx.fillStyle = '#333';
    ctx.fillRect(screenX, screenY, this.width, this.height);
  }

  collidesWith(x, y, radius = 20) {
    return (
      x + radius > this.x &&
      x - radius < this.x + this.width &&
      y + radius > this.y &&
      y - radius < this.y + this.height
    );
  }
}

function isInBlocks(user) {
  // 壁と完全に重なってるかチェック(敵の中心が壁の中にある)
  for (const block of blocks) {
    if (
      user.x > block.x &&
      user.x < block.x + block.width &&
      user.y > block.y &&
      user.y < block.y + block.height
    ) {
      return true;
    }
  }
}
/*
################################################################
################################################################
*/
class Weapon {
  constructor(user) {
    this.user = user;
    this.type = user.type;
    this.info = user.info;
    this.angle = user.angle;
    this.swingSpeed = 0.2; // 振りのスピード(角度の変化速度)
    this.rate = this.info.rate;  // 発射間隔(フレーム数)
    this.range = this.info.range;  //
    this.isGun = this.info.isGun; 
    this.swinging = false; // 振っているかどうか
    this.swingMaxAngle = Math.PI / 3; // 最大60度
    this.targetAngle = 0; // 目標となる角度
    this.swingStartTime = 0; // 振り動作の開始時刻
    this.swingDuration = 0; // 振り動作の所要フレーム数

    this.cx = 0;
    this.cy = 0;
  }


  swing(targetAngle) {
    if (!this.swinging && !this.user.isEnemy) {
      
      const mx = mouseX - cx;
      const my = mouseY - cy;
      this.targetAngle = Math.atan2(my, mx);
      this.angle = this.targetAngle + this.swingMaxAngle;
      this.swinging = true; // スイング開始

      this.swingStartFrame = frameCount; // 振り開始フレームを記録
      this.swingDuration = this.rate; // 振りの所要フレーム数(rate
    }
  }

  update() {
    if (this.swinging) {
      if(!this.user.isEnemy){
        const mx = mouseX - cx;
        const my = mouseY - cy;
        this.targetAngle = Math.atan2(my, mx);
      }
      const elapsedFrames = frameCount - this.swingStartFrame;

      // 振り動作が始まったら
      if (elapsedFrames < this.swingDuration) {
        const angleDiff = this.targetAngle - this.angle;
        if (Math.abs(angleDiff) > this.swingMaxAngle) {
          this.angle = this.targetAngle + (angleDiff > 0 ? this.swingMaxAngle : +this.swingMaxAngle);
        } else {
          this.angle += Math.sign(angleDiff) * this.swingSpeed;
        }
      } else {
        // 指定したフレーム数後に元の角度に戻す
        this.angle = this.initialAngle;
        this.swinging = false; // 振り終わったので停止
      }
    }
  }

  draw(screenX=0, screenY=0) {
    this.cx = screenX;
    this.cy = screenY;

    if(!this.user.isEnemy){
      this.tx = mouseX - this.cx;
      this.ty = mouseY - this.cy;
      this.tAngle = Math.atan2(this.ty, this.tx);  // 角度
    } else {
      this.tAngle = Math.atan2(this.user.ty, this.user.tx);  // 角度
    }

    if(this.info.isEnemy){
    //  this.angle = Math.atan2(this.my, this.mx);
    }
    if(!this.swinging){
      this.angle = Math.atan2(this.my, this.mx);
    }

    this.drawArm();
    // 描画スタイル変更例(typeに応じて)
    if (this.isGun) {
      this.drawGun();
    } else {
      this.drawMelee();
    }
  }


  drawArm() {

    const gripDistance = 30;
    const gripX = this.cx + Math.cos(this.tAngle) * gripDistance;
    const gripY = this.cy + Math.sin(this.tAngle) * gripDistance;
    // 3. 肘位置(グリップ → 肘へ“固定角度&長さ”で逆向きに戻す)
    const lowerArmLength = 30;
    const elbowBendAngle = -Math.PI / 4; // 30度曲げ

    const elbowAngle = this.tAngle + elbowBendAngle;
    const shoulderAngle = elbowAngle + elbowBendAngle;

    const elbowX = gripX - Math.cos(elbowAngle) * lowerArmLength;
    const elbowY = gripY - Math.sin(elbowAngle) * lowerArmLength;

    // 💪 肘 → グリップ
    ctx.beginPath();
    ctx.lineWidth = 8;
    ctx.strokeStyle = this.user.color;
    ctx.moveTo(elbowX, elbowY);
    ctx.lineTo(gripX, gripY);
    ctx.stroke();
  }

  drawGun() {
    // 1. グリップ位置(顔から銃方向に突き出す)
    const gripDistance = 30;
    const gripX = this.cx + Math.cos(this.tAngle) * gripDistance;
    const gripY = this.cy + Math.sin(this.tAngle) * gripDistance;

    // 2. 銃口の位置(グリップからさらに銃の長さぶん前)
    let barrelLength = 30;
    let barrelWidth = 4;
    let color = '#333';

    switch (this.type) {
      case 'handgun':
        barrelLength = 20;
        barrelWidth = 3;
        color = '#333';
        break;
      case 'rifle':
        barrelLength = 50;
        barrelWidth = 5;
        color = '#0a0';
        break;
      case 'shotgun':
        barrelLength = 35;
        barrelWidth = 6;
        color = '#a00';
        break;
    }

    const muzzleX = gripX + Math.cos(this.tAngle) * barrelLength;
    const muzzleY = gripY + Math.sin(this.tAngle) * barrelLength;


    // 🔫 グリップ → 銃口(=顔からまっすぐマウス方向)
    ctx.beginPath();
    ctx.lineWidth = barrelWidth;
    ctx.strokeStyle = color;
    ctx.moveTo(gripX, gripY);
    ctx.lineTo(muzzleX, muzzleY);
    ctx.stroke();
  }

  drawMelee() {
    let angle = 0
    if(!this.user.isEnemy){
      angle = this.angle;
    } else {
      angle = this.tAngle;
    }

    const gripDistance = 30;
    const gripX = this.cx + Math.cos(angle) * gripDistance;
    const gripY = this.cy + Math.sin(angle) * gripDistance;

    const bladeLength = this.type === 'knife' ? 30 : 60;
    const bladeHeight = this.type === 'knife' ? 4 : 6; // 刃の幅
    const tipCut = 16; // ← 左側の斜め切り込みの距離

    const hiltLength = 2;
    const tsubaX = gripX + Math.cos(angle) * hiltLength;
    const tsubaY = gripY + Math.sin(angle) * hiltLength;

    const perp = angle + Math.PI / 2;

    // グリップ側(底辺)
    const back1x = tsubaX + Math.cos(perp) * bladeHeight;
    const back1y = tsubaY + Math.sin(perp) * bladeHeight;
    const back2x = tsubaX - Math.cos(perp) * bladeHeight;
    const back2y = tsubaY - Math.sin(perp) * bladeHeight;

    // 右側(そのまま直線)
    const tip2x = back2x + Math.cos(angle) * bladeLength;
    const tip2y = back2y + Math.sin(angle) * bladeLength;

    // 左側(斜めに短くカット)
    const tip1x = back1x + Math.cos(angle) * (bladeLength + tipCut);
    const tip1y = back1y + Math.sin(angle) * (bladeLength + tipCut);

    const color = this.type === 'knife' ? '#666' : '#aaa';

    // 🪓 刃全体:非対称五角形を塗る
    ctx.beginPath();
    ctx.fillStyle = color;
    ctx.moveTo(back1x, back1y);     // グリップ左
    ctx.lineTo(tip1x, tip1y);       // 左側斜め
    ctx.lineTo(tip2x, tip2y);       // 先端右
    ctx.lineTo(back2x, back2y);     // グリップ右
    ctx.closePath();
    ctx.fill();

    // 鍔(swordのみ)
    if (this.type === 'sword') {
      const tsubaLength = 12;
      const t1x = tsubaX + Math.cos(perp) * tsubaLength;
      const t1y = tsubaY + Math.sin(perp) * tsubaLength;
      const t2x = tsubaX - Math.cos(perp) * tsubaLength;
      const t2y = tsubaY - Math.sin(perp) * tsubaLength;

      ctx.beginPath();
      ctx.lineWidth = 4;
      ctx.strokeStyle = '#ccc';
      ctx.moveTo(t1x, t1y);
      ctx.lineTo(t2x, t2y);
      ctx.stroke();
    }
  }


}


/*
################################################################
################################################################
*/
class DroppedWeapon {
  constructor(x, y, type) {
    this.x = x;
    this.y = y;
    this.type = type;
    this.radius = 80; // 表示サイズ / 判定範囲
    this.image = new Image();
    this.image.src = `img/weapon-${type}.png`;
    this.size = 50; // 表示サイズ
  }

  draw(ctx, offsetX, offsetY) {
    const screenX = this.x - offsetX;
    const screenY = this.y - offsetY;

    const half = this.size / 2;

    if (this.image.complete) {
      ctx.drawImage(this.image, screenX - half, screenY - half, this.size, this.size);
    } else {
      // ロード中 or エラー → プレースホルダ描画
      ctx.beginPath();
      ctx.fillStyle = '#ff0';
      ctx.arc(screenX, screenY, this.radius, 0, Math.PI * 2);
      ctx.fill();

      ctx.fillStyle = '#000';
      ctx.font = 'bold 14px sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillText(this.type[0].toUpperCase(), screenX, screenY);
    }
  }

  isNear(px, py, range = 40) {
    const dx = this.x - px;
    const dy = this.y - py;
    return Math.sqrt(dx * dx + dy * dy) < range;
  }
}


function dropWeapons(count = 5) {
  const types = Object.keys(weaponTypes); // 武器の種類('handgun', 'rifle' など)
  let tries = 0;

  while (droppedWeapons.length < count && tries < count * 10) {
    const type = types[Math.floor(Math.random() * types.length)]; // ランダムで武器を選ぶ

    const x = Math.floor(Math.random() * (mapSize / gridSize)) * gridSize;
    const y = Math.floor(Math.random() * (mapSize / gridSize)) * gridSize;

    // ブロックに重なってたらスキップ
    let collides = false;
    for (const block of blocks) {
      if (block.collidesWith(x, y, 20)) {
        collides = true;
        break;
      }
    }

    if (!collides) {
      droppedWeapons.push(new DroppedWeapon(x, y, type));
    }

    tries++;
  }
}

function tryPickUpNearbyItem(user) {
  for (let i = droppedWeapons.length - 1; i >= 0; i--) {
    const item = droppedWeapons[i];
    if (item.isNear(user.x, user.y)) {
      const info = weaponTypes[item.type];

      if (info?.isItem) {
        // 💊 回復アイテム
        user.hp = Math.min(user.maxHP, user.hp + info.hp);
        playSound(item.type);
      } else {
        // 🔫 武器
        user.setWeapon(item.type);
        playSound('get');
      }

      droppedWeapons.splice(i, 1);
      dropWeapons(1);
      return true;
    }
  }
  return false;
}

/*
################################################################
################################################################
*/
class Blood {
  constructor(x,y, radius=30) {
    this.x = x;
    this.y = y;
    this.radius = radius;
    this.count = 8;
    //this.size = gridSize * 0.8;
    this.bloodStains = this.generateRandomBloodStains();
  }

 // 血痕をランダムに生成するメソッド
  generateRandomBloodStains() {
    const stains = [];
    for (let i = 0; i < this.count; i++) {
      const r = this.getRandomRadius();
      // 半径が小さいものほど、中心から離す
      // 半径に基づいてオフセット
      const offsetX = (Math.random() * 2 - 1) * (r * 1.5); 
      const offsetY = (Math.random() * 2 - 1) * (r * 1.5);
      const stain = {
        x: this.x + offsetX, // 中心位置からのランダムなXオフセット
        y: this.y + offsetY, // 中心位置からのランダムなYオフセット
        radius: r // ランダムな半径(最小5)
      };
      stains.push(stain);
    }
    return stains;
  }

  // 半径を決定するための重み付けランダム関数
  getRandomRadius() {
    const randomValue = Math.random(); // 0〜1のランダム値
    const weightedRandom = Math.pow(randomValue, 2); // 二乗して小さいものが多くなるようにする
    const r = weightedRandom * this.radius + 5; // 最小半径5を加えた値
    return r;
  }

  draw(ctx, offsetX, offsetY) {
    // ランダムに生成された血痕を描画
    this.bloodStains.forEach(stain => {
      const screenX = stain.x - offsetX;
      const screenY = stain.y - offsetY;

      ctx.beginPath();
      ctx.arc(screenX, screenY, stain.radius, 0, Math.PI * 2, false);
      ctx.fillStyle = '#990000'; // 血痕の色
      ctx.fill();
      ctx.closePath();
    });
  }
}
/*
################################################################
################################################################
*/
//
function playSound(type = 'handgun', self=null) {
  if(!isUserInteracted){return;}
  let volumeDef = 0.5;
  let volume = 1;
  if (self) {
    const dist = getDist(self, man); // プレイヤーとselfの距離を取得
    const max = gridSize * 9; // 最大距離(音量がゼロになる距離)

    // 音量の計算(距離が最大値に達すると音量は0になる)
    volume = 1 - (dist / max); // 距離が増えると音量が減少
    volume = Math.max(0, Math.min(1, volume)); // 音量は0〜1の範囲に制限
  }
  if(!volume){return}
  const key = 'se-' + type;
  const original = assets[key];
  const au = original.cloneNode();
  volume = volumeDef * volume;
  volume = Math.max(0, Math.min(1, volume));
  au.volume = volume;
  au.play();
}



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

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

function drawGrid(offsetX, offsetY) {
  ctx.strokeStyle = '#ddd';
  ctx.lineWidth = 1;
  for (let x = -offsetX % gridSize; x < width; x += gridSize) {
    ctx.beginPath();
    ctx.moveTo(x, 0);
    ctx.lineTo(x, height);
    ctx.stroke();
  }
  for (let y = -offsetY % gridSize; y < height; y += gridSize) {
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(width, y);
    ctx.stroke();
  }
}

function createBorder(offsetX, offsetY) {
  // 四辺を壁で囲う(1ブロック = 100 = gridSize)
  // 上・下・左・右の外周ブロックをまとめて追加
  const t = width;
  // 上
  blocks.push(new Block(-t, -t, mapSize + t * 2, t));

  // 下
  blocks.push(new Block(-t, mapSize, mapSize + t * 2, t));

  // 左
  blocks.push(new Block(-t, 0, t, mapSize));

  // 右
  blocks.push(new Block(mapSize, 0, t, mapSize));
}



  // 2つのオブジェクトの中心間の距離を計算
function getDist(a, b) {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  const distance = Math.sqrt(dx * dx + dy * dy);
  return distance;
}

function isCollision(a, b) {
  const distance = getDist(a,b)

  // 衝突判定:距離が2つのオブジェクトの半径(サイズの合計)より小さいか等しい場合
  return distance < (a.size + b.size);
}

function isPointInRect(p, rect) {
  return p.x >= rect.w && p.x <= rect.x + rect.w && 
          p.y >= rect.y && p.y <= rect.y + rect.h;
}

function createBlocks(count = 20) {
  const minSize = 360;
  const maxSize = 600;
  const minDistance = 120;

  let tries = 0;
  let maxTries = count * 10;

  while (blocks.length < count + 4 && tries < maxTries) {
    const width = Math.floor(Math.random() * ((maxSize - minSize) / gridSize + 1)) * gridSize + minSize;
    const height = Math.floor(Math.random() * ((maxSize - minSize) / gridSize + 1)) * gridSize + minSize;

    const x = Math.floor(Math.random() * ((mapSize - width) / gridSize)) * gridSize;
    const y = Math.floor(Math.random() * ((mapSize - height) / gridSize)) * gridSize;

    const candidate = new Block(x, y, width, height);

    let tooClose = false;

    for (const block of blocks) {
      const dx = Math.max(0, Math.max(block.x - (x + width), x - (block.x + block.width)));
      const dy = Math.max(0, Math.max(block.y - (y + height), y - (block.y + block.height)));
      const distance = Math.sqrt(dx * dx + dy * dy);

      if (distance < minDistance) {
        tooClose = true;
        break;
      }
    }

    if(isPointInRect(man,{x:x,y:y, w:width, h:height})){
      tooClose = true;
      break;
    }
    for (const enemy of enemies) {
      if(isPointInRect(enemy, {x:x,y:y, w:width, h:height})){
        tooClose = true;
        break;
      }
    }

    

    if (!tooClose) {
      blocks.push(candidate);
    }

    tries++;
  }
}





/*
################################################################
################################################################
*/
// ゲームオーバー時のオーバーレイを描画
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);
  gameOverCount--;
}

function drawBar() {
  ctx.save();
  ctx.fillStyle = 'rgba(255,255,255, 0.5)';
  ctx.fillRect(0, 0, width, gridSize/2);
  ctx.restore();
}

function drawSurvivalTimer() {
  const now = endTime || performance.now();
  const elapsed = (now - startTime) / 1000; // 秒
  const timeStr = elapsed.toFixed(2) + '';

  ctx.save();
  ctx.fillStyle = 'rgba(0,0,0, 0.5)';
  ctx.font = '24px "Share Tech Mono", monospace';
  ctx.textAlign = 'left';
  ctx.fillText(`${timeStr}`, 20, 40);
  ctx.restore();
}

function drawKillCount() {
  ctx.save();
  ctx.fillStyle = 'rgba(0,0,0, 0.5)';
  ctx.font = '24px "Share Tech Mono", monospace';
  ctx.textAlign = 'right';
  ctx.fillText(`KILL : ${killCount}`, width - 20, 40);
  ctx.restore();
}

function draw() {
  ctx.clearRect(0, 0, width, height);
  ctx.fillStyle = "white";
  ctx.fillRect(0, 0, width, height);
  const offsetX = man.x - width / 2;
  const offsetY = man.y - height / 2;
  drawGrid(offsetX, offsetY);

  // 障害物の描画
  for (const block of blocks) {
    block.draw(ctx, offsetX, offsetY);
  }
  for (const blood of bloods) {
    blood.draw(ctx, offsetX, offsetY);
  }

  if (gameOver) {
    drawGameOverOverlay();
  }

  drawBar();
  drawSurvivalTimer();
  drawKillCount();

  if (gameOver) {
    return;
  }
  for (const weapon of droppedWeapons) {
    weapon.draw(ctx, offsetX, offsetY);
  }


  man.update();
  for (let bullet of bullets) bullet.update();
  for (let enemy of enemies) enemy.update();
  //for (let block of blocks) block.update();


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

  for (let bullet of bullets) bullet.draw(offsetX,offsetY);
  for (let enemy of enemies) enemy.draw(offsetX,offsetY);
  for (let block of blocks) block.draw(offsetX,offsetY);

  man.draw();
}

function resetGame(){
  if(gameOverCount>0){return}
  gameOver = false;

  frameCount = 0;  // frameCountをリセット
  bullets = bullets.filter(b => !b.dead);
  enemies = enemies.filter(e => !e.dead);
  players = players.filter(e => !e.dead);
  players = players.filter(player => player !== man);
  enemies = [];
  players = [];
  blocks = [];
  droppedWeapons = [];
  bloods = [];
  startTime = performance.now();
  timeStr = 0;
  endTime = null;
  killCount = 0;
  man = null;
  man = new Man(manStartX, manStartY);

  createBorder();
  players.push(man);
  createEnemies(5);
  createBlocks(22);
  dropWeapons(11);
}


function loop() {
  frameCount++;
  draw()
  requestAnimationFrame(loop);
}

function init(){
  loop();
}


/*
################################################################
################################################################
*/
const assets = {};
const assetList = [];

for (const type in weaponTypes) {
  assetList.push(
    { name: `weapon-${type}`, src: `./img/weapon-${type}.png` },
    { name: `se-${type}`, src: `./se/se-${type}.mp3` }
  );
}
assetList.push(
  { name: 'se-get', src: './se/se-get.mp3' },
  { name: 'se-damage', src: './se/se-damage.mp3' },
  { name: 'se-blood', src: './se/se-blood.mp3' },
  { name: 'se-parry', src: './se/se-parry.mp3' },
  //{ name: 'enemy-slime', src: './img/enemy-slime.png' },
  //{ name: 'item-health', src: './img/item-health.png' }
  // ...
);
// thx!!
//https://icon-rainbow.com/
//https://on-jin.com/sound/sen.php
//https://soundeffect-lab.info/


function loadAssets(assetList, callback) {
  const assets = {};
  let loadedCount = 0;
  const total = assetList.length;

  for (const { name, src } of assetList) {
    let asset;

    if (src.match(/\.(mp3|wav|ogg)$/)) {
      asset = new Audio();
      asset.src = src;
      asset.load(); // 明示的に読み込み開始

      asset.oncanplaythrough = () => {
        assets[name] = asset;
        loadedCount++;
        if (loadedCount === total) callback(assets);
      };
      asset.onerror = () => {
        console.error(`Failed to load audio: ${src}`);
        loadedCount++;
        if (loadedCount === total) callback(assets);
      };

    } else {
      asset = new Image();
      asset.src = src;

      asset.onload = () => {
        assets[name] = asset;
        loadedCount++;
        if (loadedCount === total) callback(assets);
      };
      asset.onerror = () => {
        console.error(`Failed to load image: ${src}`);
        loadedCount++;
        if (loadedCount === total) callback(assets);
      };
    }
  }
}


loadAssets(assetList, (loaded) => {
  Object.assign(assets, loaded);
  resetGame();
  init();
});








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

//})();


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

function isMobile() {
  const ua = navigator.userAgent || navigator.vendor || window.opera;
  return /android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(ua);
}
document.addEventListener('dblclick', (e) => {
  if(isMobile()){
    canvas.requestFullscreen();
  }
});
document.oncontextmenu = function(){
  if(!isDebug){return false;}
}

CSS

body{
  overflow:hidden;
  background-color:#ffffff;
  font-family:monospace;
}

canvas{
  display: block;
  margin: auto;
  width: 100vw;
  aspect-ratio: 16 / 9;
}

HTML

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

view-source:https://hi0a.com/demo/-js/js-game-stick-fighting/

ABOUT

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

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

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

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

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

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

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

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