流氷でサーフィングする「IceSurfSafari」


画像

・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ICE SURF SAFARI - 南極サマーライド</title>
<style>
  html,body{margin:0;padding:0;background:#04070d;overflow:hidden;font-family:'Segoe UI',sans-serif;user-select:none;}
  #stage{position:relative;width:900px;height:600px;margin:20px auto;box-shadow:0 0 40px rgba(80,200,255,0.25);border-radius:10px;overflow:hidden;}
  canvas{display:block;background:#bfe9ff;}
  .hud{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;color:#0c2f4a;text-shadow:1px 1px 2px rgba(255,255,255,0.6);}
  #topBar{position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between;align-items:flex-start;}
  #hpBox{display:flex;gap:4px;background:rgba(255,255,255,0.45);padding:6px 8px;border-radius:10px;}
  .heart{width:22px;height:20px;background:#ff5c7a;clip-path:polygon(50% 15%,61% 5%,75% 5%,88% 15%,88% 35%,50% 75%,12% 35%,12% 15%,25% 5%,39% 5%);}
  .heart.empty{background:#cfd8e0;}
  #scoreBox{font-size:18px;font-weight:bold;text-align:right;background:rgba(255,255,255,0.45);padding:6px 12px;border-radius:10px;color:#0c2f4a;}
  #distWrap{position:absolute;top:10px;left:50%;transform:translateX(-50%);width:340px;}
  #distBarBg{width:100%;height:14px;background:rgba(255,255,255,0.5);border:1px solid #2ea3ff;border-radius:7px;overflow:hidden;}
  #distBarFill{height:100%;width:0%;background:linear-gradient(90deg,#7fd8ff,#ffd6e8);transition:width .15s;}
  #distLabel{text-align:center;font-size:12px;margin-top:2px;letter-spacing:1px;font-weight:bold;}
  #iceGaugeWrap{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);width:230px;display:none;text-align:center;}
  #iceGaugeBg{width:100%;height:10px;background:rgba(255,255,255,0.5);border:1px solid #4fd0ff;border-radius:5px;overflow:hidden;}
  #iceGaugeFill{height:100%;width:100%;background:linear-gradient(90deg,#7fd8ff,#eaf7ff);}
  #iceLabel{font-size:11px;color:#1f7fae;margin-top:2px;font-weight:bold;}
  #lockHint{position:absolute;left:50%;transform:translateX(-50%);top:130px;font-size:13px;font-weight:bold;color:#ff9800;background:rgba(255,255,255,0.75);padding:4px 12px;border-radius:8px;display:none;}
  #dangerHint{position:absolute;left:50%;transform:translateX(-50%);top:170px;font-size:15px;font-weight:bold;color:#fff;background:rgba(220,20,50,0.8);padding:6px 16px;border-radius:8px;display:none;animation:dangerFlash 0.5s infinite;}
  @keyframes dangerFlash{ 0%,100%{opacity:1;} 50%{opacity:0.4;} }
  #instructions{position:absolute;bottom:12px;left:12px;font-size:12px;line-height:1.6;background:rgba(255,255,255,0.55);padding:8px 12px;border-radius:10px;color:#0c2f4a;max-width:250px;}
  .key{color:#d1466b;font-weight:bold;}
  #overlay{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(4,10,20,0.88);display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;pointer-events:auto;z-index:20;}
  #overlay h1{font-size:42px;margin:0 0 6px;color:#7fd8ff;text-shadow:0 0 18px rgba(127,216,255,0.8);letter-spacing:1px;}
  #overlay h2{font-size:26px;margin:0 0 12px;letter-spacing:1px;color:#ffd6e8;}
  #overlay p.sub{opacity:0.85;margin:0 0 22px;font-size:14px;}
  #overlay .rules{max-width:560px;font-size:13px;line-height:1.85;background:rgba(255,255,255,0.07);border:1px solid rgba(127,216,255,0.35);border-radius:10px;padding:16px 22px;margin-bottom:22px;}
  #overlay button{padding:14px 34px;font-size:18px;font-weight:bold;border:none;border-radius:10px;cursor:pointer;background:#7fd8ff;color:#04121c;}
  #overlay button:hover{background:#c6efff;}
</style>
</head>
<body>
<div id="stage">
  <canvas id="c" width="900" height="600"></canvas>
  <div class="hud">
    <div id="topBar">
      <div id="hpBox"></div>
      <div id="scoreBox">SCORE<br><span id="scoreVal">0</span></div>
    </div>
    <div id="distWrap">
      <div id="distBarBg"><div id="distBarFill"></div></div>
      <div id="distLabel">進行度 <span id="distPct">0</span>%</div>
    </div>
    <div id="lockHint">🎯 X で遠くの氷へジャンプ</div>
    <div id="dangerHint">⚠️ 海に流されている!氷にロックオンしてXで乗れ!</div>
    <div id="iceGaugeWrap">
      <div id="iceGaugeBg"><div id="iceGaugeFill"></div></div>
      <div id="iceLabel">ICE</div>
    </div>
    <div id="instructions">
      矢印: サーフィン操作(慣性あり) <span class="key">Z</span>: 氷弾で敵を凍らせる<br>
      <span class="key">X</span>: 離れた氷へジャンプ移動<br>
      近くの流氷に触れると、そのままそっちに乗り換わる。<br>
      敵(凍った敵も含む)にぶつかると倒せてスコアになるが、乗っている氷も少し欠ける。<br>
      氷がなくなると海に流される!
    </div>
  </div>
  <div id="overlay">
    <h1>ICE SURF SAFARI</h1>
    <h2 id="overlayTitle">南極の夏、氷でサーフィン</h2>
    <p class="sub" id="overlaySub">凍らせて、乗り移って、突き進め</p>
    <div class="rules">
      <b>ルール</b><br>
      ・矢印キーで今乗っている氷を操縦する。加速・減速に慣性があり、サーフボードのような操作感<br>
      ・すぐ近くの流氷に触れると、自動的にそっちの氷に乗り換わる(乗っていた氷はそのまま漂流を続ける)<br>
      ・少し離れた氷には🎯ロックオン表示が出て、<span class="key">X</span>キーで瞬時にジャンプできる<br>
      ・<span class="key">Z</span>キーでパステルカラーの氷弾を発射。敵に当てるほど凍っていき、完全凍結すると氷そのものになる<br>
      ・敵(通常の敵も、凍って氷になった敵も)に体当たりすると倒してスコア獲得!ただし乗っている氷も少しずつ欠けていく<br>
      ・氷がなくなったり海に落ちると、海に流されてしまう。近くの氷にロックオンしてすぐに戻ろう<br>
      ・進行度が100%に到達するとステージクリア!
    </div>
    <button id="startBtn">START</button>
  </div>
</div>
<script>
(function(){
"use strict";
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;

/* ---------- Audio (synthesized, no external files) ---------- */
let actx = null;
function ensureAudio(){ if(!actx){ try{ actx = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){} } if(actx && actx.state==='suspended') actx.resume(); }
function beep(freq, dur, type, gainStart, glideTo){
  if(!actx) return;
  try{
    const osc = actx.createOscillator();
    const gain = actx.createGain();
    osc.connect(gain); gain.connect(actx.destination);
    const now = actx.currentTime;
    osc.type = type||'sine';
    osc.frequency.setValueAtTime(freq, now);
    if(glideTo) osc.frequency.linearRampToValueAtTime(glideTo, now+dur);
    gain.gain.setValueAtTime(gainStart||0.25, now);
    gain.gain.exponentialRampToValueAtTime(0.001, now+dur);
    osc.start(now); osc.stop(now+dur);
  }catch(e){}
}
const SE = {
  shoot: ()=>beep(750,0.07,'sine',0.14,1050),
  freezeHit: ()=>beep(1000,0.12,'triangle',0.2,1500),
  fullFreeze: ()=>beep(1400,0.22,'triangle',0.25,2100),
  hop: ()=>beep(320,0.1,'square',0.2,560),
  board: ()=>beep(500,0.08,'sine',0.15,700),
  kill: ()=>beep(200,0.16,'sawtooth',0.28,50),
  crack: ()=>beep(680,0.08,'square',0.14,320),
  damage: ()=>beep(160,0.2,'sawtooth',0.35,60),
  thaw: ()=>beep(420,0.14,'sine',0.14,220),
  clear: ()=>beep(660,0.3,'triangle',0.3,1320),
  gameover: ()=>beep(220,0.5,'sawtooth',0.3,60),
};

/* ---------- Utility ---------- */
function rand(a,b){ return a+Math.random()*(b-a); }
function pick(arr){ return arr[Math.floor(rand(0,arr.length))]; }
function dist(x1,y1,x2,y2){ return Math.hypot(x1-x2,y1-y2); }
function clamp(v,a,b){ return Math.max(a,Math.min(b,v)); }

/* ---------- Palettes ---------- */
const PASTELS = [
  {name:'mint',       base:'#b5ead7', dark:'#7fcdb0'},
  {name:'strawberry', base:'#ffc2d6', dark:'#ff8fae'},
  {name:'lemon',      base:'#fff6b7', dark:'#ffe066'},
  {name:'blueberry',  base:'#b8d8ff', dark:'#7fb0ff'},
  {name:'grape',      base:'#dcc6ff', dark:'#b98aff'},
  {name:'vanilla',    base:'#fff3e0', dark:'#ffdca8'},
];
const ICE_COLOR = { base:'#eaf7ff', dark:'#a8d8ee' };

/* ---------- Game State ---------- */
const GOAL_DISTANCE = 9200;
const THAW_DELAY = 3.0;       // seconds without a hit before partial freeze decays
const WATER_GRACE = 0.6;      // seconds you can flail in open water before being swept away
const LOCK_RANGE = 230;       // how far away you can jump with X
const ICE_MIN_R = 14;         // below this radius, ice shatters completely
const RAM_WEAR = 3;           // ice radius lost when you ram down an enemy (slow wear, by design)
const KILL_SCORE = 100;
const RIDE_MAX_SPEED = 420;
const RIDE_ACCEL = 2000;
const RIDE_FRICTION = 420;
const BOARD_COOLDOWN = 0.4; // prevents rapid re-switching between overlapping floes
let state = 'title'; // title | playing | gameover | clear
let distance = 0;
let score = 0;
let shakeTime = 0, shakeMag = 0;
let animClock = 0;
let waterSplashTimer = 0;
let wakeTimer = 0;

const player = {
  x: W/2, y: H-70, r: 15,
  hp: 5, maxHp: 5,
  facingX: 0, facingY: -1,
  invincible: 0,
  shootCd: 0,
  ride: null,        // the ice object (from `ice[]`) currently being ridden, or null while swimming
  animPhase: 0,
  waterTimer: 0,
  boardCooldown: 0,
};

let enemies = [];   // active (not-yet-fully-frozen) monsters
let ice = [];       // every rideable ice piece: plain floes and fully-frozen former monsters
let iceShots = [];
let particles = [];
let scorePopups = [];
let bgProps = [];
let speedLines = [];
let groundScroll = 0;

let enemySpawnTimer = 0;
let iceSpawnTimer = 0;
let bgPropTimer = 0;
let lockOnTarget = null;

function difficultyFactor(){ return clamp(distance/GOAL_DISTANCE, 0, 1); }
function scrollSpeed(){ return 160 + difficultyFactor()*100; }
function enemySpawnInterval(){ return clamp(1.9 - difficultyFactor()*1.1, 0.55, 1.9); }
function iceSpawnInterval(){ return clamp(0.55 - difficultyFactor()*0.22, 0.22, 0.55); }

const FULL_LEFT = 20, FULL_RIGHT = W-20, FULL_TOP = 40, FULL_BOTTOM = H-20;

/* ---------- Input ---------- */
const keys = { ArrowUp:false, ArrowDown:false, ArrowLeft:false, ArrowRight:false };
let zHeld=false, xHeld=false;

window.addEventListener('keydown', (e)=>{
  if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','KeyZ','KeyX','Space'].includes(e.code)) e.preventDefault();
  if(e.code in keys) keys[e.code]=true;
  if(e.code==='KeyZ' && !zHeld){ zHeld=true; doShoot(); }
  if(e.code==='KeyX' && !xHeld){ xHeld=true; doHop(); }
});
window.addEventListener('keyup', (e)=>{
  if(e.code in keys) keys[e.code]=false;
  if(e.code==='KeyZ') zHeld=false;
  if(e.code==='KeyX') xHeld=false;
});

/* ---------- Spawning ---------- */
function spawnEnemy(){
  const pal = pick(PASTELS);
  enemies.push({
    x: rand(60, W-60), y: -30, r: 22,
    pal,
    vyBase: scrollSpeed()*0.5 + rand(0,15),
    freezeLevel: 0,   // 0..2 while active; becomes ice at 3 (converted & removed from this array)
    noHitTimer: 0,
    hitFlash: 0,
    wobblePhase: rand(0, Math.PI*2),
  });
}
function spawnFloe(){
  const r = rand(30,52);
  ice.push({
    kind:'floe', x: rand(45, W-45), y: -40, r, maxR: r,
    vy: scrollSpeed() + rand(-10,20), vx:0,
    rot: rand(0,Math.PI*2), rotSpeed: rand(-1.0,1.0),
  });
}
function convertEnemyToIce(e){
  const r = 24;
  const obj = {
    kind:'monster', x: e.x, y: e.y, r, maxR: r,
    vy: e.vyBase*0.12, vx:0,
    rot:0, rotSpeed:0, pal: e.pal,
  };
  ice.push(obj);
  const idx = enemies.indexOf(e);
  if(idx>=0) enemies.splice(idx,1);
  return obj;
}
function spawnBgProp(){
  const types=['iceberg','penguin','umbrella','palm'];
  bgProps.push({
    type: pick(types), x: rand(30,W-30), y:-50,
    vy: scrollSpeed()*0.55, scale: rand(0.8,1.3), flip: Math.random()<0.5?-1:1
  });
}
function spawnParticles(x,y,color,count,power){
  for(let i=0;i<count;i++){
    const ang = rand(0,Math.PI*2);
    const spd = rand(power*0.3, power);
    particles.push({
      x, y, vx: Math.cos(ang)*spd, vy: Math.sin(ang)*spd - power*0.3,
      life: 1, size: rand(3,7), color, rot: rand(0,Math.PI*2), rotSpeed: rand(-6,6)
    });
  }
}
function spawnScorePopup(x,y,amount){
  scorePopups.push({ x, y, text:'+'+amount, life:1 });
}
function triggerShake(mag, time){ shakeMag = mag; shakeTime = time; }

/* ---------- Actions ---------- */
function doShoot(){
  if(state!=='playing') return;
  if(player.shootCd>0) return;
  ensureAudio();
  player.shootCd = 0.3;
  let fx=player.facingX, fy=player.facingY;
  if(fx===0 && fy===0){ fx=0; fy=-1; }
  const col = pick(PASTELS);
  iceShots.push({
    x: player.x + fx*player.r, y: player.y + fy*player.r,
    vx: fx*640, vy: fy*640, life: 1.1, col
  });
  SE.shoot();
}

function findLockOnTarget(){
  let best=null, bestD=999999;
  for(const obj of ice){
    if(obj===player.ride) continue;
    const d = dist(player.x,player.y,obj.x,obj.y);
    if(d < LOCK_RANGE && d < bestD){ best=obj; bestD=d; }
  }
  return best;
}

function switchRide(target){
  const oldVx = player.ride ? player.ride.vx : 0;
  const oldVy = player.ride ? player.ride.vy : 0;
  if(player.ride) spawnParticles(player.ride.x, player.ride.y, ICE_COLOR.base, 5, 80);
  player.ride = target;
  target.vx = oldVx; target.vy = oldVy; // carry surfing momentum onto the new ice
  player.x = target.x; player.y = target.y;
  player.waterTimer = 0;
  player.boardCooldown = BOARD_COOLDOWN;
}

function doHop(){
  if(state!=='playing') return;
  const target = lockOnTarget || findLockOnTarget();
  if(!target) return;
  ensureAudio();
  switchRide(target);
  spawnParticles(target.x, target.y, target.pal ? target.pal.base : ICE_COLOR.base, 8, 100);
  SE.hop();
  triggerShake(3,0.12);
}

/* ---------- Reset ---------- */
function resetGame(){
  distance=0; score=0; animClock=0;
  player.x=W/2; player.y=H-90; player.hp=player.maxHp;
  player.invincible=0; player.shootCd=0; player.boardCooldown=0;
  player.facingX=0; player.facingY=-1; player.waterTimer=0;
  enemies=[]; iceShots=[]; particles=[]; scorePopups=[]; bgProps=[];
  const starter = { kind:'floe', x:player.x, y:player.y+20, r:75, maxR:75, vy: scrollSpeed()*0.3, vx:0, rot:0, rotSpeed:0 };
  ice=[starter];
  player.ride = starter;
  enemySpawnTimer=0.8; iceSpawnTimer=0.35; bgPropTimer=0.4;
  groundScroll=0; waterSplashTimer=0; wakeTimer=0;
  speedLines = [];
  for(let i=0;i<10;i++) speedLines.push({ x: rand(0,W), y: rand(0,H), len: rand(20,50) });
}

/* ---------- Update: movement (surf-style inertia) ---------- */
function updatePlayerMovement(dt){
  let dx=0, dy=0;
  if(keys.ArrowUp) dy-=1;
  if(keys.ArrowDown) dy+=1;
  if(keys.ArrowLeft) dx-=1;
  if(keys.ArrowRight) dx+=1;
  const mag = Math.hypot(dx,dy);
  const moving = mag>0;
  if(moving){ dx/=mag; dy/=mag; }

  if(player.ride){
    const r = player.ride;
    if(moving){
      r.vx += dx*RIDE_ACCEL*dt;
      r.vy += dy*RIDE_ACCEL*dt;
    } else {
      const speed = Math.hypot(r.vx, r.vy);
      if(speed>0){
        const dec = Math.min(speed, RIDE_FRICTION*dt);
        r.vx -= (r.vx/speed)*dec;
        r.vy -= (r.vy/speed)*dec;
      }
    }
    const spNow = Math.hypot(r.vx, r.vy);
    if(spNow > RIDE_MAX_SPEED){ r.vx = r.vx/spNow*RIDE_MAX_SPEED; r.vy = r.vy/spNow*RIDE_MAX_SPEED; }

    r.x = clamp(r.x + r.vx*dt, r.r, W-r.r);
    r.y = clamp(r.y + r.vy*dt, r.r*0.4, H-r.r*0.4);
    player.x = r.x; player.y = r.y;
    playerSupported = true;
    player.waterTimer = 0;

    // bank the ice slightly into turns for a surfboard-carving feel
    r.rot = clamp(r.vx/RIDE_MAX_SPEED, -1, 1) * 0.22;

    if(spNow > 20){
      player.facingX = r.vx/spNow; player.facingY = r.vy/spNow;
      wakeTimer -= dt;
      if(wakeTimer<=0){
        spawnParticles(player.x - (r.vx/spNow)*r.r*0.6, player.y - (r.vy/spNow)*r.r*0.6, 'rgba(255,255,255,0.8)', 1, 25);
        wakeTimer = 0.05;
      }
    }
  } else {
    playerSupported = false;
    if(moving){ player.facingX = dx; player.facingY = dy; }
    const spd = 110;
    player.waterTimer += dt;
    player.y += 55*dt;
    player.x += Math.sin(animClock*3)*16*dt;
    player.x = clamp(player.x + dx*spd*dt, FULL_LEFT, FULL_RIGHT);
    player.y = clamp(player.y + dy*spd*dt, FULL_TOP, FULL_BOTTOM);
  }
  if(moving) player.animPhase += dt*9;
  else player.animPhase += dt*2.2;
}

/* ---------- Update: enemies (active monsters, freezeLevel 0-2) ---------- */
function updateEnemies(dt){
  for(let i=enemies.length-1;i>=0;i--){
    const e = enemies[i];
    if(e.hitFlash>0) e.hitFlash -= dt;

    const animSpeed = 2.3 * (1 - e.freezeLevel/3.4);
    e.wobblePhase += dt*animSpeed;
    const moveSpeedFactor = 1 - e.freezeLevel/3;
    if(moveSpeedFactor>0){
      const dx = player.x - e.x;
      const homeSpeed = 32*moveSpeedFactor;
      if(Math.abs(dx) > 3) e.x += Math.sign(dx)*Math.min(homeSpeed*dt, Math.abs(dx));
      e.x += Math.sin(e.wobblePhase)*7*dt*moveSpeedFactor;
      e.y += e.vyBase*moveSpeedFactor*dt + e.vyBase*0.15*dt;
    } else {
      e.y += e.vyBase*0.15*dt;
    }
    e.x = clamp(e.x, 28, W-28);

    e.noHitTimer += dt;
    if(e.freezeLevel>0 && e.noHitTimer > THAW_DELAY){
      e.freezeLevel -= 1;
      e.noHitTimer = 0;
      SE.thaw();
    }

    // ramming an enemy while surfing destroys it and chips your ice
    if(player.ride && dist(player.x,player.y,e.x,e.y) < player.ride.r+e.r-4){
      killEnemyForScore(e.x, e.y, e.pal, KILL_SCORE);
      enemies.splice(i,1);
      continue;
    }

    // contact damages the player only if not riding any ice
    if(!playerSupported && player.invincible<=0){
      if(dist(player.x,player.y,e.x,e.y) < player.r+e.r-4){
        player.hp -= 1;
        player.invincible = 1.1;
        triggerShake(6,0.25);
        SE.damage();
        const ang = Math.atan2(player.y-e.y, player.x-e.x);
        player.x = clamp(player.x + Math.cos(ang)*30, FULL_LEFT, FULL_RIGHT);
        player.y = clamp(player.y + Math.sin(ang)*30, FULL_TOP, FULL_BOTTOM);
      }
    }

    if(e.y > H+60) enemies.splice(i,1);
  }
}

function killEnemyForScore(x, y, pal, amount){
  spawnParticles(x, y, pal.base, 14, 200);
  spawnScorePopup(x, y-20, amount);
  score += amount;
  triggerShake(5,0.15);
  SE.kill();
  if(player.ride){
    player.ride.r -= RAM_WEAR;
    spawnParticles(player.ride.x, player.ride.y, ICE_COLOR.base, 4, 90);
    SE.crack();
  }
}

/* ---------- Update: ice pieces (floes + frozen monsters) ---------- */
function updateIce(dt){
  for(const obj of ice){
    if(obj !== player.ride){
      obj.y += obj.vy*dt;
      obj.rot += obj.rotSpeed*dt;
    }
  }

  if(player.ride){
    const r = player.ride;
    for(let i=ice.length-1;i>=0;i--){
      const obj = ice[i];
      if(obj===r) continue;
      const d = dist(r.x,r.y,obj.x,obj.y);
      if(d < r.r+obj.r){
        if(obj.kind==='floe'){
          if(player.boardCooldown>0) continue; // avoid rapid re-switching between overlapping floes
          switchRide(obj);
          SE.board();
          break; // ride reference changed; re-evaluate collisions next frame
        } else if(obj.kind==='monster'){
          killEnemyForScore(obj.x, obj.y, obj.pal, KILL_SCORE);
          ice.splice(i,1);
        }
      }
    }
  }

  // remove destroyed / off-screen ice
  for(let i=ice.length-1;i>=0;i--){
    const obj = ice[i];
    if(obj.r <= ICE_MIN_R){
      spawnParticles(obj.x, obj.y, obj.pal?obj.pal.base:ICE_COLOR.base, 12, 160);
      if(obj===player.ride){ player.ride=null; player.invincible=0.4; }
      ice.splice(i,1);
      continue;
    }
    if(obj.y > H+80){
      if(obj===player.ride) player.ride=null;
      ice.splice(i,1);
    }
  }
}

/* ---------- Update: ice shots ---------- */
function updateIceShots(dt){
  for(let i=iceShots.length-1;i>=0;i--){
    const s = iceShots[i];
    s.x += s.vx*dt; s.y += s.vy*dt; s.life -= dt;
    let hit=false;
    for(const e of enemies){
      if(e.freezeLevel<3 && dist(s.x,s.y,e.x,e.y) < e.r){
        e.freezeLevel += 1;
        e.noHitTimer = 0;
        e.hitFlash = 0.15;
        spawnParticles(e.x,e.y,s.col.base,10,130);
        if(e.freezeLevel>=3){
          SE.fullFreeze();
          convertEnemyToIce(e);
        } else {
          SE.freezeHit();
        }
        hit=true;
        break;
      }
    }
    if(hit || s.life<=0 || s.x<0 || s.x>W || s.y<0 || s.y>H){
      iceShots.splice(i,1);
    }
  }
}

function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p = particles[i];
    p.vy += 300*dt;
    p.x += p.vx*dt; p.y += p.vy*dt;
    p.rot += p.rotSpeed*dt;
    p.life -= dt*1.3;
    if(p.life<=0) particles.splice(i,1);
  }
}

function updateScorePopups(dt){
  for(let i=scorePopups.length-1;i>=0;i--){
    const p = scorePopups[i];
    p.y -= 42*dt;
    p.life -= dt*0.9;
    if(p.life<=0) scorePopups.splice(i,1);
  }
}

function updateBgProps(dt){
  for(let i=bgProps.length-1;i>=0;i--){
    const p = bgProps[i];
    p.y += p.vy*dt;
    if(p.y > H+60) bgProps.splice(i,1);
  }
}

function updateSpeedLines(dt){
  for(const l of speedLines){
    l.y += scrollSpeed()*1.6*dt;
    if(l.y > H+50){ l.y=-50; l.x=rand(0,W); }
  }
}

/* ---------- HUD ---------- */
function updateHUD(){
  const hpBox = document.getElementById('hpBox');
  hpBox.innerHTML='';
  for(let i=0;i<player.maxHp;i++){
    const d=document.createElement('div');
    d.className = 'heart' + (i<player.hp?'':' empty');
    hpBox.appendChild(d);
  }
  document.getElementById('scoreVal').textContent = score;
  const pct = clamp(Math.floor(distance/GOAL_DISTANCE*100),0,100);
  document.getElementById('distBarFill').style.width = pct+'%';
  document.getElementById('distPct').textContent = pct;

  const ig = document.getElementById('iceGaugeWrap');
  if(player.ride){
    ig.style.display='block';
    const pctI = clamp(player.ride.r/player.ride.maxR,0,1)*100;
    document.getElementById('iceGaugeFill').style.width = pctI+'%';
  } else {
    ig.style.display='none';
  }

  lockOnTarget = findLockOnTarget();
  document.getElementById('lockHint').style.display = lockOnTarget ? 'block' : 'none';
  document.getElementById('dangerHint').style.display = (!player.ride) ? 'block' : 'none';
}

/* ---------- Main update ---------- */
let playerSupported = true;
function update(dt){
  animClock += dt;
  if(player.shootCd>0) player.shootCd -= dt;
  if(player.invincible>0) player.invincible -= dt;
  if(player.boardCooldown>0) player.boardCooldown -= dt;
  if(shakeTime>0) shakeTime -= dt;

  distance += scrollSpeed()*dt;
  groundScroll += scrollSpeed()*dt;

  updatePlayerMovement(dt);

  if(!player.ride && player.waterTimer > WATER_GRACE){
    state='gameover';
    SE.gameover();
    showOverlay('SWEPT AWAY...','氷を逃して海に流されてしまった… もう一度挑戦しよう', 'RETRY');
    updateHUD();
    return;
  }
  if(!player.ride){
    waterSplashTimer -= dt;
    if(waterSplashTimer<=0){ spawnParticles(player.x,player.y,'#eaf7ff',3,55); waterSplashTimer=0.1; }
  }

  enemySpawnTimer -= dt;
  if(enemySpawnTimer<=0){ spawnEnemy(); enemySpawnTimer = enemySpawnInterval(); }
  iceSpawnTimer -= dt;
  if(iceSpawnTimer<=0){ spawnFloe(); iceSpawnTimer = iceSpawnInterval(); }
  if(ice.length < 6) spawnFloe(); // keep plenty of ice flowing at all times
  bgPropTimer -= dt;
  if(bgPropTimer<=0){ spawnBgProp(); bgPropTimer = rand(1.1,2.0); }

  updateEnemies(dt);
  updateIce(dt);
  updateIceShots(dt);
  updateParticles(dt);
  updateScorePopups(dt);
  updateBgProps(dt);
  updateSpeedLines(dt);
  updateHUD();

  if(player.hp<=0){
    state='gameover';
    SE.gameover();
    showOverlay('GAME OVER','怪物に阻まれた… もう一度挑戦しよう', 'RETRY');
  } else if(distance>=GOAL_DISTANCE){
    state='clear';
    SE.clear();
    showOverlay('STAGE CLEAR!','見事、南極の海を駆け抜けた! スコア: '+score, 'PLAY AGAIN');
  }
}

/* ---------- Draw: background ---------- */
function drawSky(){
  const grad = ctx.createLinearGradient(0,0,0,150);
  grad.addColorStop(0,'#ffe3b0');
  grad.addColorStop(1,'#bfe9ff');
  ctx.fillStyle = grad;
  ctx.fillRect(0,0,W,150);
  ctx.fillStyle = '#ffd35c';
  ctx.beginPath(); ctx.arc(W-80, 55, 34, 0, Math.PI*2); ctx.fill();
  ctx.fillStyle = 'rgba(255,255,255,0.85)';
  const cloudX = (animClock*8)%(W+200)-100;
  drawCloud(cloudX, 45);
  drawCloud(cloudX+320, 85);
  drawCloud(cloudX+620, 55);
}
function drawCloud(x,y){
  ctx.beginPath();
  ctx.arc(x,y,18,0,Math.PI*2);
  ctx.arc(x+20,y-8,16,0,Math.PI*2);
  ctx.arc(x+38,y,18,0,Math.PI*2);
  ctx.fill();
}
function drawOceanField(){
  ctx.fillStyle = '#d8f2ff';
  ctx.fillRect(0,150,W,H-150);
  ctx.strokeStyle = 'rgba(120,190,230,0.5)';
  ctx.lineWidth = 2;
  const spacing = 46;
  const offset = groundScroll % spacing;
  for(let y=150-spacing; y<H+spacing; y+=spacing){
    const yy = y + offset;
    ctx.beginPath();
    for(let x=0;x<=W;x+=20){
      const wob = Math.sin((x*0.05)+(yy*0.03))*3;
      if(x===0) ctx.moveTo(x, yy+wob); else ctx.lineTo(x, yy+wob);
    }
    ctx.stroke();
  }
  ctx.strokeStyle = 'rgba(255,255,255,0.4)';
  ctx.lineWidth = 2;
  for(const l of speedLines){
    ctx.beginPath();
    ctx.moveTo(l.x, l.y);
    ctx.lineTo(l.x, l.y+l.len);
    ctx.stroke();
  }
}
function drawBgProp(p){
  ctx.save();
  ctx.globalAlpha = 0.9;
  ctx.translate(p.x, p.y);
  ctx.scale(p.flip*p.scale, p.scale);
  if(p.type==='iceberg'){
    ctx.fillStyle = '#eaf7ff';
    ctx.beginPath();
    ctx.moveTo(-30,10); ctx.lineTo(-14,-22); ctx.lineTo(4,-10); ctx.lineTo(20,-26); ctx.lineTo(32,8); ctx.closePath();
    ctx.fill();
    ctx.strokeStyle='rgba(150,200,230,0.6)'; ctx.stroke();
  } else if(p.type==='penguin'){
    ctx.fillStyle = '#eaf7ff';
    ctx.beginPath(); ctx.ellipse(0,8,26,10,0,0,Math.PI*2); ctx.fill();
    ctx.fillStyle = '#2b2b33';
    ctx.beginPath(); ctx.ellipse(0,-4,10,15,0,0,Math.PI*2); ctx.fill();
    ctx.fillStyle = '#f5f5f5';
    ctx.beginPath(); ctx.ellipse(0,0,6,9,0,0,Math.PI*2); ctx.fill();
    ctx.fillStyle = '#ffb347';
    ctx.beginPath(); ctx.moveTo(-3,-8); ctx.lineTo(3,-8); ctx.lineTo(0,-3); ctx.closePath(); ctx.fill();
  } else if(p.type==='umbrella'){
    ctx.fillStyle = '#c8a06a';
    ctx.fillRect(-2,-4,4,34);
    for(let i=0;i<6;i++){
      ctx.fillStyle = i%2===0 ? '#ff6f6f' : '#ffffff';
      ctx.beginPath();
      ctx.moveTo(0,-4);
      ctx.arc(0,-4,26,Math.PI+i*Math.PI/6,Math.PI+(i+1)*Math.PI/6);
      ctx.closePath(); ctx.fill();
    }
  } else if(p.type==='palm'){
    ctx.fillStyle = '#8a5a34';
    ctx.fillRect(-3,-4,6,34);
    ctx.fillStyle = '#4fae6a';
    for(let i=0;i<5;i++){
      const a = -Math.PI/2 + (i-2)*0.5;
      ctx.beginPath();
      ctx.ellipse(Math.cos(a)*18, -4+Math.sin(a)*18, 16, 6, a, 0, Math.PI*2);
      ctx.fill();
    }
  }
  ctx.restore();
}

/* ---------- Draw: Minecraft-ish humanoid ---------- */
function drawHumanoid(x, y, scale, animPhase, moving, isPlayer, palOverride, limbs, angle){
  ctx.save();
  ctx.translate(x,y);
  if(angle) ctx.rotate(angle);
  ctx.scale(scale, scale);

  const swing = moving ? Math.sin(animPhase)*0.55 : Math.sin(animPhase)*0.12;

  const skin = isPlayer ? '#f2c49b' : (palOverride ? palOverride.dark : '#f2c49b');
  const shirt = isPlayer ? '#ff8a3d' : (palOverride ? palOverride.base : '#7fd8ff');
  const shorts = isPlayer ? '#2ec8ff' : (palOverride ? palOverride.dark : '#2ec8ff');
  const limbOk = limbs || {armL:true, armR:true, legs:true};

  if(limbOk.legs){
    ctx.fillStyle = shorts;
    const legSwing = moving ? Math.sin(animPhase+Math.PI)*6 : 0;
    ctx.fillRect(-9, 8, 7, 14+legSwing*0.2);
    ctx.fillRect(2, 8, 7, 14-legSwing*0.2);
  }
  if(limbOk.armL){
    ctx.save();
    ctx.translate(-11,-4);
    ctx.rotate(swing);
    ctx.fillStyle = skin;
    ctx.fillRect(-4,0,7,16);
    ctx.restore();
  }
  if(limbOk.armR){
    ctx.save();
    ctx.translate(11,-4);
    ctx.rotate(-swing);
    ctx.fillStyle = skin;
    ctx.fillRect(-3,0,7,16);
    ctx.restore();
  }
  ctx.fillStyle = shirt;
  ctx.fillRect(-10,-10,20,20);
  ctx.fillStyle = isPlayer ? '#f2c49b' : (palOverride ? palOverride.base : '#f2c49b');
  ctx.fillRect(-9,-26,18,17);
  ctx.fillStyle = isPlayer ? '#5a3825' : (palOverride ? palOverride.dark : '#5a3825');
  ctx.fillRect(-9,-26,18,5);
  ctx.fillStyle = '#22303a';
  ctx.fillRect(-5,-16,3,3);
  ctx.fillRect(3,-16,3,3);
  ctx.restore();
}

function facingAngle(){
  return Math.atan2(player.facingY, player.facingX) + Math.PI/2;
}
function drawPlayer(){
  ctx.save();
  if(player.invincible>0 && Math.floor(player.invincible*20)%2===0){ ctx.globalAlpha=0.4; }
  const moving = keys.ArrowUp||keys.ArrowDown||keys.ArrowLeft||keys.ArrowRight;
  const yOff = player.ride ? -(player.ride.r*0.55) : 0;
  drawHumanoid(player.x, player.y+yOff, player.ride?0.85:1.0, player.animPhase, moving, true, null, null, facingAngle());
  ctx.restore();
}

/* ---------- Draw: ice monster ---------- */
function drawEnemy(e){
  const flash = e.hitFlash>0;
  const frostAlpha = clamp(e.freezeLevel/3,0,1)*0.85;

  ctx.save();
  ctx.translate(e.x, e.y);
  drawHumanoidRaw(0,0,1.05,e.wobblePhase,e.freezeLevel<3,e.pal, {armL:true,armR:true,legs:true}, flash);

  if(frostAlpha>0.02){
    ctx.globalAlpha = frostAlpha;
    ctx.fillStyle = '#ffffff';
    ctx.beginPath();
    ctx.ellipse(0,-6,20,26,0,0,Math.PI*2);
    ctx.fill();
    ctx.globalAlpha = 1;
    if(e.freezeLevel>=2){
      ctx.strokeStyle = 'rgba(255,255,255,0.9)';
      ctx.lineWidth = 2;
      for(let a=0;a<4;a++){
        const ang = a*Math.PI/2 + 0.4;
        ctx.beginPath();
        ctx.moveTo(0,-8);
        ctx.lineTo(Math.cos(ang)*24, -8+Math.sin(ang)*24);
        ctx.stroke();
      }
    }
  }
  ctx.restore();
}

function drawHumanoidRaw(x,y,scale,animPhase,moving,pal,limbOk,flash){
  ctx.save();
  ctx.translate(x,y);
  ctx.scale(scale,scale);
  const swing = moving ? Math.sin(animPhase)*0.5 : Math.sin(animPhase)*0.08;
  const bodyCol = flash ? '#ffffff' : pal.base;
  const darkCol = flash ? '#ffffff' : pal.dark;

  if(limbOk.legs){
    ctx.fillStyle = darkCol;
    ctx.fillRect(-9, 8, 7, 14);
    ctx.fillRect(2, 8, 7, 14);
  }
  if(limbOk.armL){
    ctx.save();
    ctx.translate(-11,-4);
    ctx.rotate(swing);
    ctx.fillStyle = darkCol;
    ctx.fillRect(-4,0,7,15);
    ctx.restore();
  }
  if(limbOk.armR){
    ctx.save();
    ctx.translate(11,-4);
    ctx.rotate(-swing);
    ctx.fillStyle = darkCol;
    ctx.fillRect(-3,0,7,15);
    ctx.restore();
  }
  ctx.fillStyle = bodyCol;
  ctx.fillRect(-10,-10,20,20);
  ctx.fillStyle = bodyCol;
  ctx.fillRect(-9,-26,18,17);
  ctx.fillStyle = '#22303a';
  ctx.fillRect(-5,-16,3,3);
  ctx.fillRect(3,-16,3,3);
  ctx.restore();
}

/* ---------- Draw: ice pieces ---------- */
function drawIcePiece(obj){
  ctx.save();
  ctx.translate(obj.x, obj.y);
  ctx.rotate(obj.rot);
  ctx.fillStyle = obj.pal ? obj.pal.base : ICE_COLOR.base;
  ctx.beginPath();
  const spikes=7;
  for(let i=0;i<spikes;i++){
    const a = i/spikes*Math.PI*2;
    const rr = obj.r*(0.85+0.15*Math.sin(i*3+obj.r));
    const px = Math.cos(a)*rr, py=Math.sin(a)*rr;
    if(i===0) ctx.moveTo(px,py); else ctx.lineTo(px,py);
  }
  ctx.closePath(); ctx.fill();
  ctx.strokeStyle = obj.pal ? obj.pal.dark : 'rgba(140,190,220,0.7)';
  ctx.lineWidth = 2;
  ctx.stroke();
  ctx.fillStyle='rgba(255,255,255,0.4)';
  ctx.beginPath(); ctx.arc(-obj.r*0.25,-obj.r*0.25,obj.r*0.3,0,Math.PI*2); ctx.fill();
  ctx.restore();

  if(obj === lockOnTarget){
    ctx.save();
    ctx.translate(obj.x, obj.y);
    ctx.strokeStyle = '#ff9800';
    ctx.lineWidth = 3;
    const rr = obj.r + 14 + Math.sin(animClock*4)*2;
    const rot = animClock*1.3;
    for(let c=0;c<4;c++){
      const a = rot + c*Math.PI/2;
      ctx.beginPath();
      ctx.arc(0,0,rr,a, a+0.35);
      ctx.stroke();
    }
    ctx.restore();
  }
}

function drawIceShot(s){
  ctx.save();
  ctx.translate(s.x, s.y);
  ctx.fillStyle = s.col.base;
  ctx.strokeStyle = s.col.dark;
  ctx.lineWidth = 2;
  ctx.beginPath();
  const rr=7;
  ctx.moveTo(-rr,-rr*0.6); ctx.lineTo(rr,-rr*0.6); ctx.lineTo(rr,rr*0.6); ctx.lineTo(-rr,rr*0.6);
  ctx.closePath(); ctx.fill(); ctx.stroke();
  ctx.restore();
}

function drawParticle(p){
  ctx.save();
  ctx.globalAlpha = clamp(p.life,0,1);
  ctx.translate(p.x,p.y);
  ctx.rotate(p.rot);
  ctx.fillStyle = p.color;
  ctx.fillRect(-p.size/2,-p.size/2,p.size,p.size);
  ctx.restore();
}

function drawScorePopup(p){
  ctx.save();
  ctx.globalAlpha = clamp(p.life,0,1);
  ctx.font = 'bold 20px sans-serif';
  ctx.textAlign = 'center';
  ctx.lineWidth = 4;
  ctx.strokeStyle = '#0c2f4a';
  ctx.fillStyle = '#fff566';
  ctx.strokeText(p.text, p.x, p.y);
  ctx.fillText(p.text, p.x, p.y);
  ctx.restore();
}

function draw(){
  ctx.save();
  if(shakeTime>0){
    ctx.translate((Math.random()-0.5)*shakeMag, (Math.random()-0.5)*shakeMag);
  }
  drawSky();
  drawOceanField();
  for(const p of bgProps) drawBgProp(p);
  for(const obj of ice) drawIcePiece(obj);
  for(const e of enemies) drawEnemy(e);
  drawPlayer();
  for(const s of iceShots) drawIceShot(s);
  for(const p of particles) drawParticle(p);
  for(const p of scorePopups) drawScorePopup(p);
  ctx.restore();
}

/* ---------- Loop ---------- */
let lastTime = null;
function loop(ts){
  if(lastTime===null) lastTime = ts;
  let dt = (ts-lastTime)/1000;
  lastTime = ts;
  if(dt>0.05) dt=0.05;

  if(state==='playing') update(dt);
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

/* ---------- Overlay control ---------- */
const overlay = document.getElementById('overlay');
const overlayTitle = document.getElementById('overlayTitle');
const overlaySub = document.getElementById('overlaySub');
const startBtn = document.getElementById('startBtn');

function showOverlay(title, sub, btnText){
  overlayTitle.textContent = title;
  overlaySub.textContent = sub;
  startBtn.textContent = btnText;
  overlay.style.display='flex';
}
function hideOverlay(){ overlay.style.display='none'; }

startBtn.addEventListener('click', ()=>{
  ensureAudio();
  resetGame();
  state='playing';
  hideOverlay();
});
})();
</script>
</body>
</html>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとか要望は、 コメント欄か、Xのリプライ欄に書いてみて下さい。 ひまをみて対応します。 (未管理著作物裁定制度に定められた問い合わせも受付中。)
流氷でサーフィングする「IceSurfSafari」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1