流氷でサーフィングする「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>

コメント