426 lines
13 KiB
HTML
Executable File
426 lines
13 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Neon Blitz ++ (Canvas)</title>
|
|
<style>
|
|
html, body { margin:0; height:100%; background:#000; color:#fff; font-family: Courier, monospace; }
|
|
#wrap { display:flex; flex-direction:column; align-items:center; gap:8px; padding:12px; }
|
|
canvas { background:#000; border:1px solid #0ff; box-shadow: 0 0 12px #0ff; }
|
|
.hud { text-align:center; color:#fff; }
|
|
.hint { color:#aaa; font-size:12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="wrap">
|
|
<canvas id="game" width="960" height="720"></canvas>
|
|
<div class="hud" id="hud"></div>
|
|
<div class="hint">Flèches pour bouger • Espace: tir • L: laser • R: restart</div>
|
|
</div>
|
|
|
|
<script>
|
|
(() => {
|
|
// ----- Canvas & timing -----
|
|
const canvas = document.getElementById('game');
|
|
const ctx = canvas.getContext('2d');
|
|
const hudEl = document.getElementById('hud');
|
|
const W = canvas.width, H = canvas.height;
|
|
|
|
let last = performance.now();
|
|
let accumulator = 0;
|
|
const FPS = 60;
|
|
const DT = 1 / FPS;
|
|
|
|
// ----- Audio (WebAudio simple beeps) -----
|
|
let audioCtx = null;
|
|
function ensureAudio() {
|
|
if (!audioCtx) {
|
|
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e){}
|
|
}
|
|
}
|
|
function beep(freq=600, ms=80, type='square', vol=0.08) {
|
|
if (!audioCtx) return;
|
|
const osc = audioCtx.createOscillator();
|
|
const gain = audioCtx.createGain();
|
|
osc.type = type;
|
|
osc.frequency.value = freq;
|
|
gain.gain.value = vol;
|
|
osc.connect(gain);
|
|
gain.connect(audioCtx.destination);
|
|
const t = audioCtx.currentTime;
|
|
osc.start(t);
|
|
osc.stop(t + ms/1000);
|
|
}
|
|
|
|
// ----- State -----
|
|
const state = {
|
|
running: true,
|
|
score: 0,
|
|
lives: 3,
|
|
kills: 0,
|
|
startTime: performance.now(),
|
|
highscore: Number(localStorage.getItem('neonblitz_highscore')) || 0,
|
|
bossAlive: false,
|
|
gameOver: false,
|
|
mission: { survive: 60, kills: 30 },
|
|
shake: 0,
|
|
};
|
|
|
|
// ----- Player -----
|
|
const player = {
|
|
x: W/2, y: H - 80,
|
|
w: 22, h: 28,
|
|
speed: 360, // px/s
|
|
color: '#00FFFF'
|
|
};
|
|
|
|
// ----- Arrays -----
|
|
const bullets = []; // {x,y,vx,vy,kind,life}
|
|
const enemies = []; // {x,y,r,hp,speed,color}
|
|
let boss = null; // {x,y,w,h,hp,speed,dir}
|
|
|
|
// ----- Input -----
|
|
const keys = { ArrowLeft:false, ArrowRight:false, ArrowUp:false, ArrowDown:false };
|
|
let spaceHeld = false;
|
|
let lHeld = false;
|
|
let fireCd = 0; // seconds
|
|
const FIRE_COOLDOWN = 0.18;
|
|
const FIRE_COOLDOWN_LASER = 0.28;
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
if (!audioCtx) ensureAudio();
|
|
if (e.code in keys) keys[e.code] = true;
|
|
if (e.code === 'Space') spaceHeld = true;
|
|
if (e.code === 'KeyL') lHeld = true;
|
|
if (e.code === 'KeyR') restart();
|
|
});
|
|
window.addEventListener('keyup', (e) => {
|
|
if (e.code in keys) keys[e.code] = false;
|
|
if (e.code === 'Space') spaceHeld = false;
|
|
if (e.code === 'KeyL') lHeld = false;
|
|
});
|
|
|
|
// ----- Utils -----
|
|
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
|
function rand(min, max) { return Math.random()*(max-min)+min; }
|
|
function choice(arr) { return arr[(Math.random()*arr.length)|0]; }
|
|
function dist(ax, ay, bx, by) { return Math.hypot(ax-bx, ay-by); }
|
|
function circleRectColl(cx, cy, r, rx, ry, rw, rh) {
|
|
const nx = clamp(cx, rx, rx+rw);
|
|
const ny = clamp(cy, ry, ry+rh);
|
|
const dx = cx - nx, dy = cy - ny;
|
|
return (dx*dx + dy*dy) <= r*r;
|
|
}
|
|
|
|
// ----- Spawns -----
|
|
function spawnEnemy() {
|
|
const colors = ['#FF00FF', '#39FF14', '#FFD700', '#FF1493'];
|
|
const e = {
|
|
x: rand(40, W-40),
|
|
y: -40,
|
|
r: rand(12, 20),
|
|
hp: choice([1,1,1,2]),
|
|
speed: 120 + Math.min(220, state.score*0.6),
|
|
color: choice(colors)
|
|
};
|
|
enemies.push(e);
|
|
}
|
|
function spawnBoss() {
|
|
boss = {
|
|
x: W/2 - 80,
|
|
y: -120,
|
|
w: 160, h: 100,
|
|
hp: 30,
|
|
speed: 120,
|
|
dir: 1
|
|
};
|
|
state.bossAlive = true;
|
|
}
|
|
|
|
// ----- Shooting -----
|
|
function fire(kind='normal') {
|
|
if (fireCd > 0 || state.gameOver) return;
|
|
const isLaser = (kind === 'laser');
|
|
const b = {
|
|
x: player.x, y: player.y - player.h/2,
|
|
vx: 0,
|
|
vy: isLaser ? -480 : -760,
|
|
kind,
|
|
life: 1.5
|
|
};
|
|
bullets.push(b);
|
|
fireCd = isLaser ? FIRE_COOLDOWN_LASER : FIRE_COOLDOWN;
|
|
beep(isLaser ? 900 : 700, isLaser ? 65 : 45, 'square', 0.06);
|
|
state.shake = Math.min(10, state.shake + (isLaser ? 1.2 : 0.8));
|
|
}
|
|
|
|
// ----- Restart -----
|
|
function restart() {
|
|
enemies.length = 0;
|
|
bullets.length = 0;
|
|
boss = null;
|
|
Object.assign(state, {
|
|
running: true,
|
|
score: 0,
|
|
lives: 3,
|
|
kills: 0,
|
|
startTime: performance.now(),
|
|
bossAlive: false,
|
|
gameOver: false,
|
|
mission: state.mission, // keep current thresholds
|
|
shake: 0
|
|
});
|
|
player.x = W/2; player.y = H - 80;
|
|
}
|
|
|
|
// ----- Update -----
|
|
let enemySpawnTimer = 0;
|
|
const ENEMY_SPAWN_INTERVAL = 0.55; // seconds
|
|
function update(dt) {
|
|
if (!state.running) return;
|
|
|
|
// cooldowns
|
|
fireCd = Math.max(0, fireCd - dt);
|
|
|
|
// inputs: movement
|
|
const ax = (keys.ArrowRight ? 1 : 0) - (keys.ArrowLeft ? 1 : 0);
|
|
const ay = (keys.ArrowDown ? 1 : 0) - (keys.ArrowUp ? 1 : 0);
|
|
player.x = clamp(player.x + ax * player.speed * dt, 24, W-24);
|
|
player.y = clamp(player.y + ay * player.speed * dt, 40, H-40);
|
|
|
|
// inputs: shooting
|
|
if (spaceHeld) fire('normal');
|
|
if (lHeld) fire('laser');
|
|
|
|
// spawn enemies
|
|
enemySpawnTimer -= dt;
|
|
if (enemySpawnTimer <= 0 && !state.gameOver) {
|
|
spawnEnemy();
|
|
enemySpawnTimer = ENEMY_SPAWN_INTERVAL * rand(0.6, 1.4);
|
|
}
|
|
|
|
// boss trigger
|
|
if (state.score >= 200 && !state.bossAlive) spawnBoss();
|
|
|
|
// bullets update
|
|
for (let i = bullets.length-1; i >= 0; i--) {
|
|
const b = bullets[i];
|
|
b.x += b.vx * dt;
|
|
b.y += b.vy * dt;
|
|
b.life -= dt;
|
|
if (b.y < -40 || b.life <= 0) bullets.splice(i,1);
|
|
}
|
|
|
|
// enemies update & collisions
|
|
for (let i = enemies.length-1; i >= 0; i--) {
|
|
const e = enemies[i];
|
|
e.y += e.speed * dt;
|
|
|
|
// collide with player
|
|
if (circleRectColl(e.x, e.y, e.r, player.x - player.w/2, player.y - player.h/2, player.w, player.h)) {
|
|
enemies.splice(i,1);
|
|
if (!state.gameOver) {
|
|
state.lives -= 1;
|
|
state.shake = Math.min(18, state.shake + 4.0);
|
|
beep(280, 180, 'sawtooth', 0.07);
|
|
if (state.lives <= 0) {
|
|
state.gameOver = true;
|
|
if (state.score > state.highscore) {
|
|
state.highscore = state.score;
|
|
localStorage.setItem('neonblitz_highscore', String(state.highscore));
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// collide with bullets
|
|
let hit = false;
|
|
for (let j = bullets.length-1; j >= 0; j--) {
|
|
const b = bullets[j];
|
|
if (dist(e.x, e.y, b.x, b.y) <= e.r + (b.kind === 'laser' ? 14 : 10)) {
|
|
// laser traverse: keep moving but consume life faster
|
|
e.hp -= (b.kind === 'laser' ? 2 : 1);
|
|
bullets.splice(j,1);
|
|
state.score += (b.kind === 'laser' ? 6 : 4);
|
|
state.kills += (e.hp <= 0 ? 1 : 0);
|
|
state.shake = Math.min(14, state.shake + 2.0);
|
|
beep(500, 80, 'triangle', 0.06);
|
|
if (e.hp <= 0) {
|
|
enemies.splice(i,1);
|
|
state.score += 10;
|
|
hit = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (hit) continue;
|
|
|
|
// off-screen enemy
|
|
if (e.y > H + 60) {
|
|
enemies.splice(i,1);
|
|
if (!state.gameOver) {
|
|
state.lives -= 1;
|
|
if (state.lives <= 0) {
|
|
state.gameOver = true;
|
|
if (state.score > state.highscore) {
|
|
state.highscore = state.score;
|
|
localStorage.setItem('neonblitz_highscore', String(state.highscore));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// boss update & collisions
|
|
if (boss && state.bossAlive && !state.gameOver) {
|
|
// move boss down initially then horizontal pacing
|
|
if (boss.y < 120) boss.y += 60 * dt;
|
|
else {
|
|
boss.x += boss.dir * boss.speed * dt;
|
|
if (boss.x < 40) { boss.x = 40; boss.dir = 1; }
|
|
if (boss.x + boss.w > W-40) { boss.x = W-40 - boss.w; boss.dir = -1; }
|
|
}
|
|
// bullets vs boss
|
|
for (let j = bullets.length-1; j >= 0; j--) {
|
|
const b = bullets[j];
|
|
if (circleRectColl(b.x, b.y, (b.kind==='laser'?12:8), boss.x, boss.y, boss.w, boss.h)) {
|
|
boss.hp -= (b.kind === 'laser' ? 2 : 1);
|
|
bullets.splice(j,1);
|
|
state.score += (b.kind === 'laser' ? 10 : 6);
|
|
state.shake = Math.min(16, state.shake + 3.0);
|
|
beep(420, 120, 'triangle', 0.07);
|
|
if (boss.hp <= 0) {
|
|
state.score += 200;
|
|
state.bossAlive = false;
|
|
boss = null;
|
|
beep(240, 220, 'sawtooth', 0.08);
|
|
}
|
|
}
|
|
}
|
|
// boss collide player
|
|
if (circleRectColl(player.x, player.y, 18, boss.x, boss.y, boss.w, boss.h)) {
|
|
state.lives -= 1;
|
|
state.shake = Math.min(20, state.shake + 6.0);
|
|
beep(260, 220, 'square', 0.08);
|
|
if (state.lives <= 0) {
|
|
state.gameOver = true;
|
|
if (state.score > state.highscore) {
|
|
state.highscore = state.score;
|
|
localStorage.setItem('neonblitz_highscore', String(state.highscore));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// missions
|
|
const elapsed = (performance.now() - state.startTime) / 1000;
|
|
if (!state.gameOver && elapsed >= state.mission.survive && state.kills >= state.mission.kills) {
|
|
state.score += 500;
|
|
state.mission.survive += 60;
|
|
state.mission.kills += 30;
|
|
beep(1000, 200, 'square', 0.08);
|
|
}
|
|
|
|
// screen shake
|
|
state.shake *= 0.88;
|
|
if (state.shake < 0.15) state.shake = 0;
|
|
}
|
|
|
|
// ----- Draw -----
|
|
function draw() {
|
|
const dx = state.shake ? rand(-state.shake*0.3, state.shake*0.3) : 0;
|
|
const dy = state.shake ? rand(-state.shake*0.3, state.shake*0.3) : 0;
|
|
|
|
ctx.save();
|
|
ctx.translate(dx, dy);
|
|
|
|
// background grid neon
|
|
ctx.fillStyle = '#000';
|
|
ctx.fillRect(0,0,W,H);
|
|
ctx.strokeStyle = 'rgba(0,255,255,0.12)';
|
|
ctx.lineWidth = 1;
|
|
for (let x=0; x<=W; x+=48) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
|
|
for (let y=0; y<=H; y+=48) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
|
|
|
|
// player
|
|
ctx.fillStyle = player.color;
|
|
ctx.beginPath();
|
|
// triangle pointing up
|
|
ctx.moveTo(player.x, player.y - player.h/2);
|
|
ctx.lineTo(player.x - player.w/2, player.y + player.h/2);
|
|
ctx.lineTo(player.x + player.w/2, player.y + player.h/2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// bullets
|
|
for (const b of bullets) {
|
|
ctx.fillStyle = (b.kind === 'laser') ? '#FFD700' : '#FF00FF';
|
|
if (b.kind === 'laser') {
|
|
ctx.fillRect(b.x - 4, b.y - 14, 8, 28);
|
|
} else {
|
|
ctx.beginPath();
|
|
ctx.arc(b.x, b.y, 5, 0, Math.PI*2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// enemies
|
|
for (const e of enemies) {
|
|
ctx.fillStyle = e.color;
|
|
ctx.beginPath();
|
|
ctx.arc(e.x, e.y, e.r, 0, Math.PI*2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// boss
|
|
if (boss && state.bossAlive) {
|
|
ctx.fillStyle = '#FF4500';
|
|
ctx.fillRect(boss.x, boss.y, boss.w, boss.h);
|
|
// boss hp bar
|
|
ctx.fillStyle = '#222';
|
|
ctx.fillRect(40, 40, W-80, 16);
|
|
const ratio = Math.max(0, boss.hp/30);
|
|
ctx.fillStyle = '#FF4500';
|
|
ctx.fillRect(40, 40, (W-80)*ratio, 16);
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.strokeRect(40, 40, W-80, 16);
|
|
}
|
|
|
|
ctx.restore();
|
|
|
|
// HUD
|
|
const elapsed = ((performance.now()-state.startTime)/1000)|0;
|
|
hudEl.innerHTML =
|
|
`Score: ${state.score} ` +
|
|
`Vies: ${state.lives} ` +
|
|
`Kills: ${state.kills} ` +
|
|
`Highscore: ${state.highscore} ` +
|
|
`Mission: survivre ${state.mission.survive}s (${elapsed}s) / détruire ${state.mission.kills} (${state.kills})` +
|
|
(state.gameOver ? `<br/><span style="color:#FF1493;font-weight:bold">GAME OVER — R pour recommencer</span>` : '');
|
|
}
|
|
|
|
// ----- Main loop -----
|
|
function frame(t) {
|
|
const dtMs = (t - last) / 1000;
|
|
last = t;
|
|
accumulator += dtMs;
|
|
// fixe-step update
|
|
while (accumulator >= DT) {
|
|
update(DT);
|
|
accumulator -= DT;
|
|
}
|
|
draw();
|
|
requestAnimationFrame(frame);
|
|
}
|
|
|
|
// ----- Start -----
|
|
restart();
|
|
requestAnimationFrame(frame);
|
|
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|