๐ŸŽฎArcadeLab

Neon Catch

by AtomicGlider60
457 lines12.0 KB
โ–ถ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Neon Catch</title>
<style>
html { height: 100%; }
body {
  margin: 0; height: 100%; overflow: hidden;
  background: #080616; color: #fff;
  font-family: "Trebuchet MS", system-ui, sans-serif;
  user-select: none; touch-action: manipulation;
}
#game { position: fixed; inset: 0; width: 100%; height: 100%; cursor: crosshair; }
#hud {
  position: fixed; top: 0; left: 0; right: 0;
  display: flex; justify-content: space-between; align-items: center;
  padding: 14px 24px; letter-spacing: 3px; pointer-events: none;
  text-shadow: 0 0 12px #ff2d75;
}
#hud b { font-size: 1.9rem; margin-left: 6px; }
#combo { font-size: 1.4rem; font-weight: bold; color: #ffd60a; text-shadow: 0 0 14px #ffd60a; }
#combo.pulse { animation: pulse 0.25s ease-out; }
#time.low { color: #ff2d75; animation: blink 0.5s infinite; }
@keyframes pulse { 0% { transform: scale(1.6); } 100% { transform: scale(1); } }
@keyframes blink { 50% { opacity: 0.3; } }
#overlay {
  position: fixed; inset: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 16px; padding: 20px; text-align: center;
  background: radial-gradient(circle, rgba(25, 12, 60, 0.88), rgba(5, 3, 15, 0.96));
  transition: opacity 0.3s;
}
#overlay.hidden { opacity: 0; pointer-events: none; }
h1 {
  margin: 0; font-size: clamp(2.5rem, 10vw, 5rem); letter-spacing: 8px; color: #fff;
  text-shadow: 0 0 20px #ff2d75, 0 0 50px #ff2d75;
}
h1 span { color: #22d3ee; text-shadow: 0 0 20px #22d3ee, 0 0 50px #22d3ee; }
#msg { max-width: 460px; margin: 0; font-size: 1.1rem; line-height: 1.5; color: #cbd5e1; }
.legend { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }
.legend span {
  padding: 6px 14px; border: 1px solid var(--c); border-radius: 999px;
  color: var(--c); box-shadow: 0 0 12px var(--c); font-weight: bold;
}
#start {
  margin-top: 6px; padding: 14px 48px; font-size: 1.3rem; font-weight: bold;
  letter-spacing: 4px; color: #fff; background: transparent;
  border: 2px solid #22d3ee; border-radius: 10px;
  box-shadow: 0 0 20px #22d3ee, inset 0 0 20px rgba(34, 211, 238, 0.3);
  cursor: pointer; transition: transform 0.15s, background 0.15s;
}
#start:hover { background: rgba(34, 211, 238, 0.25); transform: scale(1.06); }
#best { margin: 0; color: #ffd60a; letter-spacing: 2px; }
</style>
</head>
<body>
<canvas id="game"></canvas>

<div id="hud">
  <div>SCORE <b id="score">0</b></div>
  <div id="combo">x1</div>
  <div>TIME <b id="time">30</b></div>
</div>

<div id="overlay">
  <h1>NEON<span>CATCH</span></h1>
  <p id="msg">Tap the glowing orbs before they fade. Build combos for a bigger multiplier!</p>
  <div class="legend">
    <span style="--c:#ff2d75">โ— +1</span>
    <span style="--c:#ffd60a">โ˜… +5</span>
    <span style="--c:#22ff9a">+ +2 sec</span>
    <span style="--c:#8b5cf6">โœ• AVOID</span>
  </div>
  <button id="start">START</button>
  <p id="best"></p>
</div>

<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");
const $ = (id) => document.getElementById(id);
const scoreEl = $("score");
const timeEl = $("time");
const comboEl = $("combo");
const overlay = $("overlay");
const msg = $("msg");
const bestEl = $("best");
const startBtn = $("start");

let W, H, dpr;
function resize() {
  dpr = window.devicePixelRatio || 1;
  W = window.innerWidth;
  H = window.innerHeight;
  canvas.width = W * dpr;
  canvas.height = H * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener("resize", resize);
resize();

const TYPES = {
  normal: { color: "#ff2d75", life: 2.2, r: 34, label: "" },
  gold: { color: "#ffd60a", life: 1.1, r: 26, label: "โ˜…" },
  time: { color: "#22ff9a", life: 1.8, r: 30, label: "+" },
  bomb: { color: "#8b5cf6", life: 2.6, r: 36, label: "โœ•" },
};

function pickType() {
  const n = Math.random();
  if (n < 0.08) return "gold";
  if (n < 0.15) return "time";
  if (n < 0.33) return "bomb";
  return "normal";
}

let running = false;
let score = 0;
let time = 30;
let elapsed = 0;
let combo = 0;
let spawnT = 0;
let shake = 0;
let flash = 0;
let last = 0;
let targets = [];
let parts = [];
let pops = [];
let trail = [];
let best = 0;
try {
  best = +localStorage.getItem("neon-best") || 0;
} catch (e) {}
if (best) bestEl.textContent = "BEST: " + best;

const stars = Array.from({ length: 130 }, () => ({
  x: Math.random(),
  y: Math.random(),
  z: Math.random() * 0.9 + 0.1,
}));

const mult = () => Math.min(8, 1 + Math.floor(combo / 5));

let ac;
function beep(freq, dur, type = "sine", vol = 0.07, slide = 0) {
  if (!ac) return;
  const o = ac.createOscillator();
  const g = ac.createGain();
  const t = ac.currentTime;
  o.type = type;
  o.frequency.setValueAtTime(freq, t);
  if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), t + dur);
  g.gain.setValueAtTime(vol, t);
  g.gain.exponentialRampToValueAtTime(0.001, t + dur);
  o.connect(g).connect(ac.destination);
  o.start();
  o.stop(t + dur);
}

function spawn() {
  const type = pickType();
  const def = TYPES[type];
  const r = def.r;
  const a = Math.random() * Math.PI * 2;
  const sp = 30 + Math.random() * 50 + elapsed * 2;
  targets.push({
    type,
    r,
    x: r + Math.random() * Math.max(1, W - 2 * r),
    y: 80 + r + Math.random() * Math.max(1, H - 80 - 2 * r),
    vx: Math.cos(a) * sp,
    vy: Math.sin(a) * sp,
    age: 0,
    life: def.life * Math.max(0.55, 1 - elapsed * 0.012),
  });
}

function burst(x, y, color, n = 22) {
  for (let i = 0; i < n; i++) {
    const a = Math.random() * Math.PI * 2;
    const s = 80 + Math.random() * 300;
    parts.push({
      x, y,
      vx: Math.cos(a) * s,
      vy: Math.sin(a) * s,
      life: 0.5 + Math.random() * 0.5,
      age: 0,
      color,
    });
  }
}

function pop(x, y, text, color) {
  pops.push({ x, y, text, color, age: 0 });
}

function updateHud() {
  scoreEl.textContent = score;
  timeEl.textContent = Math.ceil(time);
  timeEl.classList.toggle("low", time <= 5);
}

function updateCombo() {
  comboEl.textContent = combo > 1 ? `x${mult()} ยท ${combo} STREAK` : "x1";
  comboEl.classList.remove("pulse");
  void comboEl.offsetWidth;
  comboEl.classList.add("pulse");
}

function startGame() {
  if (!ac) {
    try { ac = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {}
  }
  score = 0;
  time = 30;
  elapsed = 0;
  combo = 0;
  spawnT = 0;
  targets = [];
  parts = [];
  pops = [];
  running = true;
  updateHud();
  updateCombo();
  overlay.classList.add("hidden");
  beep(330, 0.25, "triangle", 0.08, 400);
}

function endGame() {
  running = false;
  const newBest = score > best;
  if (newBest) {
    best = score;
    try { localStorage.setItem("neon-best", best); } catch (e) {}
  }
  const rank =
    score < 20 ? "Rookie" : score < 50 ? "Gamer" : score < 90 ? "Pro" : "Legend";
  msg.innerHTML =
    `TIME'S UP!<br><span style="font-size:2.2rem;color:#ffd60a">${score}</span><br>` +
    `Rank: <b>${rank}</b>${newBest ? " ยท NEW BEST!" : ""}`;
  bestEl.textContent = "BEST: " + best;
  startBtn.textContent = "PLAY AGAIN";
  overlay.classList.remove("hidden");
  beep(220, 0.6, "sawtooth", 0.08, -150);
}

function collect(t, i) {
  targets.splice(i, 1);
  const c = TYPES[t.type].color;

  if (t.type === "bomb") {
    time -= 3;
    combo = 0;
    shake = 18;
    flash = 0.35;
    burst(t.x, t.y, c, 45);
    pop(t.x, t.y, "-3s", c);
    beep(90, 0.35, "sawtooth", 0.1, -50);
  } else if (t.type === "time") {
    time += 2;
    combo++;
    burst(t.x, t.y, c, 26);
    pop(t.x, t.y, "+2s", c);
    beep(660, 0.2, "sine", 0.08, 400);
  } else {
    const pts = (t.type === "gold" ? 5 : 1) * mult();
    score += pts;
    combo++;
    shake = t.type === "gold" ? 8 : 3;
    burst(t.x, t.y, c, t.type === "gold" ? 40 : 22);
    pop(t.x, t.y, "+" + pts, c);
    beep(440 + Math.min(combo, 20) * 30, 0.12, "triangle", 0.08);
  }
  updateCombo();
  updateHud();
}

canvas.addEventListener("pointerdown", (e) => {
  if (!running) return;
  for (let i = targets.length - 1; i >= 0; i--) {
    const t = targets[i];
    if (Math.hypot(e.clientX - t.x, e.clientY - t.y) <= t.r * 1.2) {
      collect(t, i);
      return;
    }
  }
  if (combo > 0) {
    combo = 0;
    updateCombo();
  }
  beep(120, 0.1, "square", 0.04);
});

window.addEventListener("pointermove", (e) => {
  trail.push({ x: e.clientX, y: e.clientY, t: performance.now() });
  if (trail.length > 25) trail.shift();
});

startBtn.addEventListener("click", startGame);

function update(dt) {
  elapsed += dt;
  time -= dt;

  spawnT -= dt;
  if (spawnT <= 0) {
    spawn();
    spawnT = Math.max(0.3, 0.85 - elapsed * 0.014);
  }

  for (let i = targets.length - 1; i >= 0; i--) {
    const t = targets[i];
    t.age += dt;
    t.x += t.vx * dt;
    t.y += t.vy * dt;
    if (t.x < t.r || t.x > W - t.r) t.vx *= -1;
    if (t.y < 70 + t.r || t.y > H - t.r) t.vy *= -1;
    if (t.age >= t.life) {
      if (t.type === "normal" && combo > 0) {
        combo = 0;
        updateCombo();
      }
      burst(t.x, t.y, "#556", 6);
      targets.splice(i, 1);
    }
  }

  updateHud();
  if (time <= 0) {
    time = 0;
    updateHud();
    endGame();
  }
}

function updateFx(dt) {
  for (let i = parts.length - 1; i >= 0; i--) {
    const p = parts[i];
    p.age += dt;
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.vx *= 0.97;
    p.vy *= 0.97;
    if (p.age >= p.life) parts.splice(i, 1);
  }
  for (let i = pops.length - 1; i >= 0; i--) {
    pops[i].age += dt;
    pops[i].y -= 60 * dt;
    if (pops[i].age > 0.8) pops.splice(i, 1);
  }
  stars.forEach((s) => {
    s.x -= s.z * dt * 0.02;
    if (s.x < 0) s.x = 1;
  });
  shake *= 0.88;
  flash *= 0.9;
}

function draw() {
  ctx.fillStyle = "rgba(8, 6, 22, 0.35)";
  ctx.fillRect(0, 0, W, H);

  stars.forEach((s) => {
    ctx.fillStyle = `rgba(180, 200, 255, ${s.z * 0.8})`;
    ctx.fillRect(s.x * W, s.y * H, s.z * 2, s.z * 2);
  });

  ctx.save();
  if (shake > 0.5) ctx.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake);

  targets.forEach((t) => {
    const def = TYPES[t.type];
    const k = Math.min(1, t.age / 0.15);
    const r = t.r * (1 - Math.pow(1 - k, 3));
    const left = 1 - t.age / t.life;

    ctx.shadowColor = def.color;
    ctx.shadowBlur = 25;
    ctx.fillStyle = def.color;
    ctx.beginPath();
    ctx.arc(t.x, t.y, r * 0.7, 0, Math.PI * 2);
    ctx.fill();

    ctx.lineWidth = 4;
    ctx.strokeStyle = def.color;
    ctx.beginPath();
    ctx.arc(t.x, t.y, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * left);
    ctx.stroke();

    ctx.shadowBlur = 0;
    if (def.label) {
      ctx.fillStyle = "#0b0820";
      ctx.font = `bold ${r * 0.8}px sans-serif`;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillText(def.label, t.x, t.y + 1);
    }
  });

  ctx.globalCompositeOperation = "lighter";
  parts.forEach((p) => {
    const a = 1 - p.age / p.life;
    ctx.globalAlpha = a;
    ctx.fillStyle = p.color;
    ctx.beginPath();
    ctx.arc(p.x, p.y, 3 * a + 1, 0, Math.PI * 2);
    ctx.fill();
  });
  ctx.globalAlpha = 1;
  ctx.globalCompositeOperation = "source-over";

  pops.forEach((p) => {
    ctx.globalAlpha = 1 - p.age / 0.8;
    ctx.fillStyle = p.color;
    ctx.shadowColor = p.color;
    ctx.shadowBlur = 12;
    ctx.font = "bold 26px sans-serif";
    ctx.textAlign = "center";
    ctx.fillText(p.text, p.x, p.y - 30);
  });
  ctx.globalAlpha = 1;
  ctx.shadowBlur = 0;
  ctx.restore();

  const now = performance.now();
  ctx.lineCap = "round";
  for (let i = 1; i < trail.length; i++) {
    const age = now - trail[i].t;
    if (age > 250) continue;
    ctx.strokeStyle = `rgba(34, 211, 238, ${1 - age / 250})`;
    ctx.lineWidth = 4 * (1 - age / 250);
    ctx.beginPath();
    ctx.moveTo(trail[i - 1].x, trail[i - 1].y);
    ctx.lineTo(trail[i].x, trail[i].y);
    ctx.stroke();
  }

  if (flash > 0.01) {
    ctx.fillStyle = `rgba(139, 92, 246, ${flash})`;
    ctx.fillRect(0, 0, W, H);
  }
}

function loop(ts) {
  const dt = Math.min(0.05, (ts - last) / 1000 || 0);
  last = ts;
  if (running) update(dt);
  updateFx(dt);
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>

Game Source: Neon Catch

Creator: AtomicGlider60

Libraries: none

Complexity: complex (457 lines, 12.0 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: neon-catch-atomicglider60" to link back to the original. Then publish at arcadelab.ai/publish.