first commit
This commit is contained in:
Executable
+425
@@ -0,0 +1,425 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user