🎮ArcadeLab

Piano Fire · your music

by SonicBear35
607 lines17.0 KB
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>Piano Fire · your music</title>
  <style>
    * {
      box-sizing: border-box;
      user-select: none;
      -webkit-tap-highlight-color: transparent;
    }
    body {
      margin: 0;
      min-height: 100vh;
      background: radial-gradient(circle at 20% 30%, #1a0f1e, #0c0712);
      display: flex;
      justify-content: center;
      align-items: center;
      font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
      padding: 12px;
    }
    .game-container {
      background: #1e1528;
      border-radius: 48px 48px 32px 32px;
      box-shadow: 0 20px 40px rgba(0,0,0,0.8), 0 0 0 1px #5a3a5e;
      padding: 20px 16px 28px;
      max-width: 440px;
      width: 100%;
      transition: 0.2s;
    }
    .screen {
      background: #120b1a;
      border-radius: 28px;
      padding: 14px 10px 10px;
      box-shadow: inset 0 8px 14px rgba(0,0,0,0.7);
      margin-bottom: 14px;
    }
    .canvas-wrapper {
      position: relative;
      border-radius: 20px;
      overflow: hidden;
      background: #0f0818;
      box-shadow: 0 0 0 1px #6a4a6e;
    }
    #gameCanvas {
      display: block;
      width: 100%;
      height: auto;
      aspect-ratio: 360 / 620;
      background: #0f0818;
      touch-action: none;
      cursor: pointer;
    }
    .ui-bar {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin: 12px 4px 6px;
      color: #f0d6ff;
      font-weight: 600;
    }
    .score-box {
      background: #21162b;
      padding: 6px 18px;
      border-radius: 40px;
      font-size: 1.3rem;
      font-weight: 700;
      color: #ffb3f0;
      box-shadow: inset 0 2px 8px #0a0610, 0 2px 0 #6a3e6e;
      letter-spacing: 0.5px;
    }
    .stars-box {
      display: flex;
      gap: 4px;
      background: #1d1225;
      padding: 4px 16px;
      border-radius: 40px;
      border: 1px solid #6a456e;
      font-size: 1.5rem;
      line-height: 1.4;
    }
    .control-group {
      display: flex;
      gap: 8px;
    }
    .ctrl-btn {
      background: #32223b;
      border: none;
      color: #f2dcff;
      font-size: 1.2rem;
      font-weight: 700;
      padding: 6px 16px;
      border-radius: 40px;
      box-shadow: 0 4px 0 #140e1a, 0 2px 10px rgba(0,0,0,0.6);
      transition: 0.06s linear;
      cursor: pointer;
    }
    .ctrl-btn:active {
      transform: translateY(3px);
      box-shadow: 0 1px 0 #140e1a;
    }
    .song-panel {
      background: #1b1225;
      border-radius: 40px;
      padding: 14px 12px;
      margin-top: 12px;
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: center;
      gap: 10px 8px;
      border: 1px solid #4d3152;
    }
    .file-label {
      background: #5d3a63;
      padding: 8px 22px;
      border-radius: 50px;
      color: white;
      font-weight: 600;
      font-size: 0.9rem;
      box-shadow: 0 3px 0 #2f1c33;
      transition: 0.06s;
      cursor: pointer;
    }
    .file-label:active {
      transform: translateY(2px);
      box-shadow: 0 1px 0 #2f1c33;
    }
    .song-name {
      color: #dbb6f0;
      background: #100a18;
      padding: 6px 16px;
      border-radius: 30px;
      border: 1px solid #583d5e;
      font-size: 0.8rem;
      max-width: 140px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .timer-box {
      background: #1d1225;
      padding: 4px 16px;
      border-radius: 40px;
      color: #ffc8f0;
      font-weight: 700;
      font-size: 1.2rem;
      border: 1px solid #7a4d7a;
    }
    input[type="file"] { display: none; }
    .hint { color: #9a7a9e; font-size: 0.7rem; margin-left: 4px; }
  </style>
</head>
<body>
<div class="game-container">
  <div class="screen">
    <div class="canvas-wrapper">
      <canvas id="gameCanvas" width="360" height="620"></canvas>
    </div>
    <div class="ui-bar">
      <div class="score-box">🪙 <span id="scoreDisplay">0</span></div>
      <div class="stars-box" id="starsDisplay">☆☆☆</div>
      <div class="timer-box" id="timerDisplay">30s</div>
    </div>
  </div>

  <div class="song-panel">
    <label>
      <input type="file" id="musicFileInput" accept=".json,.txt,.music">
      <span class="file-label" id="fileUploadLabel">🎵 upload your music</span>
    </label>
    <span class="song-name" id="currentSongLabel">no song</span>
    <span class="hint">(only you)</span>
    <button class="ctrl-btn" id="restartBtn" style="padding:4px 18px;">⟳</button>
  </div>
</div>

<script>
  (function() {
    const canvas = document.getElementById('gameCanvas');
    const ctx = canvas.getContext('2d');
    const scoreSpan = document.getElementById('scoreDisplay');
    const starsSpan = document.getElementById('starsDisplay');
    const timerSpan = document.getElementById('timerDisplay');
    const songLabel = document.getElementById('currentSongLabel');

    const W = 360, H = 620;
    canvas.width = W; canvas.height = H;
    const LANE_COUNT = 4;
    const LANE_W = W / LANE_COUNT;
    const TILE_H = 68;
    const TILE_SPEED = 2.9;
    const HIT_ZONE_Y = 490;
    const HIT_ZONE_H = 44;

    // game state
    let tiles = [];
    let score = 0;
    let stars = 0;
    let timeLeft = 30; // seconds
    let gameActive = false;
    let gameOverFlag = false;
    let gameFinished = false; // reached 3 stars
    let animId = null;
    let frameCount = 0;

    // music data
    let musicNotes = [];
    let loadedSongName = 'no song';
    let songStartTime = 0;
    let currentPlayhead = 0;
    let songPaused = true;

    // default demo song (so it works out of box)
    function generateDemo() {
      const notes = [];
      for (let i = 0; i < 40; i++) {
        const lane = i % 4;
        const time = i * 220 + 60;
        notes.push({ lane, time });
      }
      for (let i = 0; i < 18; i++) {
        const lane = (i + 2) % 4;
        const time = 1400 + i * 190;
        notes.push({ lane, time });
      }
      notes.sort((a,b) => a.time - b.time);
      return notes;
    }
    musicNotes = generateDemo();
    loadedSongName = 'demo';
    songLabel.textContent = loadedSongName;

    // reset flags for notes
    function resetNoteSpawned() {
      musicNotes.forEach(n => n._spawned = false);
    }
    resetNoteSpawned();

    // UI updates
    function updateScoreDisplay() {
      scoreSpan.textContent = score;
    }
    function updateStars() {
      stars = 0;
      if (score >= 10) stars = 1;
      if (score >= 25) stars = 2;
      if (score >= 45) stars = 3;
      let str = '';
      for (let i=0; i<3; i++) str += (i < stars) ? '★' : '☆';
      starsSpan.textContent = str;
      if (stars >= 3 && gameActive && !gameOverFlag && !gameFinished) {
        // finished with 3 stars
        gameFinished = true;
        gameActive = false;
        songPaused = true;
        gameOverFlag = false; // show win state
      }
    }
    function updateTimer() {
      timerSpan.textContent = Math.ceil(timeLeft) + 's';
    }

    // spawn tiles based on playhead
    function spawnTiles(playheadMs) {
      if (!gameActive && !gameOverFlag && !gameFinished) return;
      const margin = 60;
      for (let note of musicNotes) {
        if (!note._spawned && note.time <= playheadMs + margin) {
          note._spawned = true;
          const lane = note.lane;
          // avoid stacking same lane too close
          const blocked = tiles.some(t => t.lane === lane && t.y < TILE_H + 20);
          if (!blocked) {
            tiles.push({
              lane: lane,
              y: -TILE_H - 10,
              time: note.time,
              active: true
            });
          }
        }
      }
    }

    function updateTiles() {
      if (!gameActive) return;
      for (let i = tiles.length-1; i>=0; i--) {
        const t = tiles[i];
        t.y += TILE_SPEED;
        if (t.y > H + 30) {
          tiles.splice(i,1);
          // miss -> game over (unless finished)
          if (gameActive && !gameOverFlag && !gameFinished) {
            gameOver();
            return;
          }
        }
      }
    }

    function gameOver() {
      if (gameFinished) return;
      gameActive = false;
      gameOverFlag = true;
      songPaused = true;
    }

    // hit
    function handleTap(laneIndex) {
      if (!gameActive || gameOverFlag || gameFinished) return;
      // find closest tile in lane (lowest y)
      let target = null;
      let targetIdx = -1;
      for (let i = tiles.length-1; i>=0; i--) {
        if (tiles[i].lane === laneIndex && tiles[i].active) {
          target = tiles[i];
          targetIdx = i;
          break;
        }
      }
      if (!target) {
        gameOver();
        return;
      }
      const y = target.y;
      const hitLow = HIT_ZONE_Y - HIT_ZONE_H/2;
      const hitHigh = HIT_ZONE_Y + HIT_ZONE_H/2;
      if (y >= hitLow && y <= hitHigh) {
        tiles.splice(targetIdx, 1);
        score++;
        updateScoreDisplay();
        updateStars();
        // coin collect effect (visual only)
      } else {
        gameOver();
      }
    }

    // ----- drawing (pink theme) -----
    function draw() {
      ctx.clearRect(0, 0, W, H);

      // lanes
      for (let i=0; i<LANE_COUNT; i++) {
        const x = i*LANE_W;
        ctx.fillStyle = '#1b1124';
        ctx.fillRect(x, 0, LANE_W-1, H);
        ctx.fillStyle = '#281d30';
        ctx.fillRect(x+1, 0, LANE_W-2, H);
        ctx.strokeStyle = '#4d2f55';
        ctx.lineWidth = 1.5;
        ctx.beginPath();
        ctx.moveTo(x+LANE_W, 0);
        ctx.lineTo(x+LANE_W, H);
        ctx.stroke();
      }

      // hit zone (glowing pink)
      const hitY = HIT_ZONE_Y;
      ctx.shadowColor = '#ff66c0';
      ctx.shadowBlur = 24;
      ctx.fillStyle = 'rgba(255, 80, 180, 0.15)';
      ctx.fillRect(0, hitY - HIT_ZONE_H/2, W, HIT_ZONE_H);
      ctx.shadowBlur = 0;
      ctx.strokeStyle = '#ff7ac8';
      ctx.lineWidth = 2.5;
      ctx.beginPath();
      ctx.moveTo(0, hitY - HIT_ZONE_H/2);
      ctx.lineTo(W, hitY - HIT_ZONE_H/2);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(0, hitY + HIT_ZONE_H/2);
      ctx.lineTo(W, hitY + HIT_ZONE_H/2);
      ctx.stroke();

      // tiles (pink)
      for (let t of tiles) {
        const x = t.lane * LANE_W;
        const y = t.y;
        const grad = ctx.createLinearGradient(x, y, x+LANE_W, y+TILE_H);
        grad.addColorStop(0, '#ff77c8');
        grad.addColorStop(0.5, '#e84393');
        grad.addColorStop(1, '#b8216b');
        ctx.fillStyle = grad;
        ctx.shadowColor = '#ff66b0';
        ctx.shadowBlur = 22;
        ctx.beginPath();
        ctx.roundRect(x+5, y, LANE_W-10, TILE_H-3, 12);
        ctx.fill();
        ctx.shadowBlur = 0;
        // inner glow
        ctx.fillStyle = 'rgba(255,200,230,0.3)';
        ctx.beginPath();
        ctx.roundRect(x+10, y+5, LANE_W-20, 12, 6);
        ctx.fill();
        // note
        ctx.fillStyle = '#fff0f5';
        ctx.font = 'bold 28px "Segoe UI", sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText('♩', x + LANE_W/2, y + TILE_H/2 + 2);
      }

      // win / game over overlay
      if (gameFinished) {
        ctx.fillStyle = 'rgba(20,6,30,0.7)';
        ctx.fillRect(0,0,W,H);
        ctx.fillStyle = '#ffb3e6';
        ctx.font = 'bold 38px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText('✨ 3 stars!', W/2, H/2 - 30);
        ctx.fillStyle = '#f0c0e0';
        ctx.font = '20px sans-serif';
        ctx.fillText('🎵 perfect!', W/2, H/2 + 30);
      } else if (gameOverFlag) {
        ctx.fillStyle = 'rgba(10,4,16,0.7)';
        ctx.fillRect(0,0,W,H);
        ctx.fillStyle = '#ff99cc';
        ctx.font = 'bold 36px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText('💔 GAME OVER', W/2, H/2 - 20);
        ctx.fillStyle = '#cc99bb';
        ctx.font = '18px sans-serif';
        ctx.fillText('restart ↺', W/2, H/2 + 40);
      } else if (!gameActive && !gameOverFlag && !gameFinished) {
        ctx.fillStyle = 'rgba(12,6,18,0.6)';
        ctx.fillRect(0,0,W,H);
        ctx.fillStyle = '#dbb0e0';
        ctx.font = '22px sans-serif';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText('🎹 upload your music', W/2, H/2 - 10);
        ctx.fillStyle = '#a07aae';
        ctx.font = '16px sans-serif';
        ctx.fillText('then tap play (restart)', W/2, H/2 + 40);
      }

      // song name
      ctx.fillStyle = '#a080aa';
      ctx.font = '12px monospace';
      ctx.textAlign = 'right';
      ctx.textBaseline = 'bottom';
      ctx.fillText(loadedSongName || '—', W-10, H-8);
    }

    // roundRect
    CanvasRenderingContext2D.prototype.roundRect = function (x,y,w,h,r) {
      if (w<2*r) r=w/2; if (h<2*r) r=h/2;
      this.moveTo(x+r,y);
      this.lineTo(x+w-r,y);
      this.quadraticCurveTo(x+w,y,x+w,y+r);
      this.lineTo(x+w,y+h-r);
      this.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
      this.lineTo(x+r,y+h);
      this.quadraticCurveTo(x,y+h,x,y+h-r);
      this.lineTo(x,y+r);
      this.quadraticCurveTo(x,y,x+r,y);
      this.closePath();
      return this;
    };

    // ----- game loop -----
    let lastTimestamp = 0;
    function gameLoop(ts) {
      if (!lastTimestamp) lastTimestamp = ts;
      const delta = Math.min(ts - lastTimestamp, 30);
      lastTimestamp = ts;

      if (gameActive && !songPaused) {
        currentPlayhead += delta;
        spawnTiles(currentPlayhead);
        updateTiles();

        // timer
        if (gameActive) {
          timeLeft -= delta / 1000;
          if (timeLeft <= 0) {
            timeLeft = 0;
            updateTimer();
            // time up -> game over if not finished
            if (!gameFinished && gameActive) {
              gameOver();
            }
          } else {
            updateTimer();
          }
        }
        updateStars();
      }

      draw();
      animId = requestAnimationFrame(gameLoop);
    }

    // ----- reset / start -----
    function resetGame() {
      tiles = [];
      score = 0;
      stars = 0;
      timeLeft = 30;
      gameFinished = false;
      gameOverFlag = false;
      gameActive = true;
      songPaused = false;
      currentPlayhead = 0;
      resetNoteSpawned();
      songStartTime = performance.now();
      updateScoreDisplay();
      updateStars();
      updateTimer();
      // spawn initial tiles
      spawnTiles(0);
    }

    function stopAndReset() {
      gameActive = false;
      gameOverFlag = false;
      gameFinished = false;
      songPaused = true;
      tiles = [];
      score = 0;
      stars = 0;
      timeLeft = 30;
      currentPlayhead = 0;
      resetNoteSpawned();
      updateScoreDisplay();
      updateStars();
      updateTimer();
      draw();
    }

    // load music from file (only you)
    function loadMusicFile(file) {
      const reader = new FileReader();
      reader.onload = (e) => {
        try {
          const content = e.target.result;
          let parsed = JSON.parse(content);
          if (Array.isArray(parsed) && parsed.length>0 && parsed[0].lane!==undefined && parsed[0].time!==undefined) {
            musicNotes = parsed.map(n => ({ lane: Number(n.lane), time: Number(n.time) }));
            musicNotes.sort((a,b) => a.time - b.time);
            resetNoteSpawned();
            loadedSongName = file.name.replace(/\.[^.]+$/, '');
            songLabel.textContent = loadedSongName;
            stopAndReset();
            // auto start after load?
            resetGame();
          } else {
            alert('Format: array of {lane, time}');
          }
        } catch(err) {
          alert('Invalid JSON file. Use [{"lane":0,"time":100}, ...]');
        }
      };
      reader.readAsText(file);
    }

    // ----- event binding -----
    canvas.addEventListener('click', (e) => {
      const rect = canvas.getBoundingClientRect();
      const scaleX = canvas.width / rect.width;
      const x = (e.clientX - rect.left) * scaleX;
      if (x<0 || x>W) return;
      const lane = Math.floor(x / LANE_W);
      if (lane>=0 && lane<LANE_COUNT) handleTap(lane);
    });
    canvas.addEventListener('touchstart', (e) => {
      e.preventDefault();
      const rect = canvas.getBoundingClientRect();
      const touch = e.touches[0];
      const scaleX = canvas.width / rect.width;
      const x = (touch.clientX - rect.left) * scaleX;
      if (x<0 || x>W) return;
      const lane = Math.floor(x / LANE_W);
      if (lane>=0 && lane<LANE_COUNT) handleTap(lane);
    }, { passive: false });

    document.getElementById('restartBtn').addEventListener('click', () => {
      stopAndReset();
      resetGame();
    });

    document.getElementById('musicFileInput').addEventListener('change', (e) => {
      const file = e.target.files[0];
      if (file) loadMusicFile(file);
      e.target.value = '';
    });

    // start loop
    animId = requestAnimationFrame(gameLoop);
    // auto start with demo
    setTimeout(() => { resetGame(); }, 300);

    // ensure timer updates even when idle
    setInterval(() => {
      if (!gameActive && !gameOverFlag && !gameFinished) {
        updateTimer();
      }
    }, 500);
  })();
</script>
</body>
</html>

Game Source: Piano Fire · your music

Creator: SonicBear35

Libraries: none

Complexity: complex (607 lines, 17.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: piano-fire-your-music-sonicbear35" to link back to the original. Then publish at arcadelab.ai/publish.