Hướng dẫn làm game Geometry với Claude AI

Trong bài viết dưới đây, GameVui sẽ hướng dẫn bạn từng bước tạo một tựa game Geometry của riêng mình bằng Claude AI: với nhân vật có thể nhảy tránh chướng ngại vật và quan trọng nhất là bạn có thể chạy nó ngay trên trình duyệt sau khi tạo xong.

Cách làm game Geometry với Claude AI

Bước 1: Trước tiên, bạn hãy mở ứng dụng Claude AI rồi nhập Prompt dưới đây

Bạn là lập trình viên game HTML5 chuyên nghiệp. Hãy tạo một game 2D platformer giống phong cách Geometry Dash (rhythm-based auto runner), chạy mượt trên máy tính + điện thoại (responsive touch + keyboard).
🧩 1. YÊU CẦU CHUNG
Tạo một file duy nhất `index.html`, chứa đầy đủ:

* HTML + CSS + JavaScript thuần (không framework)
* Dùng `<canvas>` để render game
* Không cần asset ngoài (mọi thứ vẽ bằng shape)
* Tối ưu chạy mượt 60 FPS
Game phải giống phong cách:

* nhân vật tự chạy
* nhảy tránh chướng ngại vật
* tốc độ tăng dần
* retry ngay khi chết (restart nhanh)
🎮 2. GAMEPLAY CORE (GIỐNG GEOMETRY DASH)
🟨 Player (người chơi)

* Là 1 hình vuông (cube)
* Tự chạy từ trái sang phải (thực tế: nền chạy ngược)
* Có trọng lực (gravity)
* Nhảy bằng:
   * PC: Space / click chuột
   * Mobile: chạm màn hình
🧱 Obstacles

* Spike (tam giác)
* Block (hình chữ nhật)
* Pit (hố rơi)
* Tạo ngẫu nhiên theo pattern
⚙️ Physics

* Gravity realistic nhưng đơn giản
* Jump lực cố định
* Collision AABB (box collision)
* Nếu chạm chướng ngại → restart level ngay
🎵 3. RHYTHM / SPEED SYSTEM

* Game có nhạc nền (có thể placeholder)
* Obstacles spawn theo nhịp (beat simulation)
* Tốc độ tăng theo thời gian:
   * dễ → trung bình → khó → rất nhanh
* Camera auto scroll ngang
🖥️ 4. UI / UX
Hiển thị:

* Score / distance
* Best score (localStorage)
* Restart button
* Start screen:
   * “Tap to Play”
* Death screen:
   * “Try Again”
Hiệu ứng:

* Flash khi chết
* Shake nhẹ camera khi crash
* Glow neon style giống Geometry Dash
📱 5. MOBILE + PC SUPPORT
PC:

* Space = jump
* Click = jump
* R = restart
Mobile:

* Touch anywhere = jump
* Fullscreen support
Responsive:

* Canvas tự co giãn theo màn hình
* Giữ tỉ lệ 16:9
🧠 6. GAME ENGINE STRUCTURE
Code phải tách logic rõ:

* Game loop (`requestAnimationFrame`)
* Player class
* Obstacle class
* Collision system
* Level generator
* Input handler
* UI manager
🌍 7. LEVEL DESIGN SYSTEM
Tạo system:

* Pattern generator (không random hoàn toàn)
* Ví dụ pattern:
   * spike-jump-spike
   * gap + 2 spikes
   * long block + timing jump
* Difficulty scaling theo distance
🎨 8. VISUAL STYLE

* Background gradient neon (dark blue / purple)
* Obstacles màu đỏ neon
* Player màu vàng / xanh neon
* Glow effect (shadow blur)
* Minimalist geometry style
🔊 9. SOUND (OPTIONAL)

* Jump sound
* Death sound
* Background beat loop (placeholder ok)
🧪 10. DEBUG / DEV MODE (OPTIONAL)

* Show hitbox toggle
* FPS counter
* Speed multiplier adjust
🚀 11. OUTPUT REQUIREMENT
Trả về:

* Chỉ 1 file `index.html`
* Code đầy đủ chạy được ngay
* Không giải thích dài dòng
* Không dùng thư viện ngoài
🔥 BONUS (nếu có thể)

* Add “Wave mode” (giống Geometry Dash advanced mode)
* Add particle effect khi nhảy
* Add trail phía sau player
* Add level progression (Level 1, Level 2...)
💡 Mục tiêu cuối:
Tạo ra một game HTML5 giống Geometry Dash bản mini, chơi được ngay trên trình duyệt, mượt trên cả điện thoại và máy tính.

Bước 2: Sau đó, Claude AI sẽ phân tích Prompt của bạn rồi tạo file index.html để bạn tải về chơi thử

Tải file index.html

Bước 3: Bạn có thể yêu cầu Claude AI tiến hành một chỉnh sửa để game hấp dẫn hơn với các Prompt sau

dịch tất cả ngôn ngữ sang tiếng việt, bổ sung nút dừng chơi, cho giao diện sáng hơn (tham khảo ảnh), thay đổi các chướng ngại vật đa dạng hơn
bổ sung nhạc nền sôi động, sử dụng nhạc mà tôi tải lên, bỏ mục tốc độ ở góc trái, tạo thêm đường viền bo cho khối vuông nổi bật

Và đây là file index.html hoàn chỉnh

<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<!-- Gợi ý kích thước cho iframe embed (GameVui, Blogger, v.v.) -->
<meta name="game-width"  content="960">
<meta name="game-height" content="540">
<title>GeoRush — Việt Nam Dash</title>
<!-- Kích thước cố định cho iframe embed -->
<meta name="iframe-width"  content="960">
<meta name="iframe-height" content="540">
<style>
  /* Ngăn trang bị scroll/resize làm sai height */
  :root { --vh: 100vh; }
</style>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  html {
    width: 100vw;
    height: 100vh;
    max-height: 100vh;
    overflow: hidden;
  }
  body {
    width: 100vw;
    height: 100vh;
    max-height: 100vh;
    overflow: hidden;
    background: #1a3a6e;
    font-family: 'Courier New', monospace;
    /* touch-action: none removed — was blocking admin page interactions */
  }
  #gameCanvas {
    touch-action: none; /* only block touch-scroll on the canvas itself */
  }
  #wrapper {
    position: fixed;
    top: 0; left: 0;
    width: 100vw;
    height: 100vh;
    background: #1a3a6e;
    overflow: hidden;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  #gameCanvas {
    display: block;
    image-rendering: pixelated;
    cursor: pointer;
    outline: none;
  }
  #uiOverlay {
    position: fixed; top: 0; left: 0;
    width: 100%; height: 100%;
    pointer-events: none;
    display: flex; align-items: flex-start; justify-content: flex-end;
  }
  #btnPause {
    pointer-events: all;
    margin: 10px 12px 0 0;
    width: 44px; height: 44px;
    border-radius: 50%;
    border: 2.5px solid rgba(255,255,255,0.7);
    background: rgba(10,30,80,0.72);
    color: #fff;
    font-size: 18px;
    cursor: pointer;
    display: flex; align-items: center; justify-content: center;
    transition: background 0.15s;
    z-index: 10;
  }
  #btnPause:hover { background: rgba(30,80,200,0.9); }
  #fps {
    position: fixed; top: 8px; left: 8px;
    color: rgba(255,255,255,0.4);
    font-size: 11px; font-family: monospace;
    pointer-events: none;
    display: none;
  }
</style>
</head>
<body style="height:540px!important;max-height:540px!important;overflow:hidden!important">
<div id="wrapper"><canvas id="gameCanvas" tabindex="0"></canvas></div>
<div id="uiOverlay">
  <button id="btnPause" title="Tạm dừng / Tiếp tục">⏸</button>
</div>
<div id="fps">60 FPS</div>

<script>
// ============================================================
//  GeoRush — Geometry Dash–style HTML5 Game  (Tiếng Việt)
//  Giao diện sáng, chướng ngại vật đa dạng, nút dừng
// ============================================================
(function() {
"use strict";

// ─── Canvas & Responsive ────────────────────────────────────
const canvas = document.getElementById('gameCanvas');
const ctx    = canvas.getContext('2d');
const DESIGN_W = 960, DESIGN_H = 540;
let scale = 1;

function resize() {
  // Lấy kích thước container = wrapper (hoặc window)
  const wrap = document.getElementById('wrapper');
  const ww = wrap ? wrap.clientWidth  : (window.innerWidth  || 960);
  const wh = wrap ? wrap.clientHeight : (window.innerHeight || 540);

  // Scale theo MIN để vừa cả 2 chiều, không bị crop, không bị méo
  const scaleW = ww / DESIGN_W;
  const scaleH = wh / DESIGN_H;
  scale = Math.min(scaleW, scaleH);

  const dispW = Math.round(DESIGN_W * scale);
  const dispH = Math.round(DESIGN_H * scale);

  canvas.width  = DESIGN_W;
  canvas.height = DESIGN_H;
  // CSS: canvas co giãn đúng tỉ lệ, flex center trong wrapper
  canvas.style.width    = dispW + 'px';
  canvas.style.height   = dispH + 'px';
  canvas.style.position = 'relative';
  canvas.style.left     = '';
  canvas.style.top      = '';
}
resize();
window.addEventListener('resize', resize);

// ─── Constants ──────────────────────────────────────────────
const GROUND_Y   = DESIGN_H - 90;
const TILE       = 40;
const GRAVITY    = 0.64;
const JUMP_FORCE = -13.8;
const BASE_SPEED = 5.5;
const MAX_SPEED  = 14;
const SPEED_INC  = 0.0015;

// Palette sáng giống Geometry Dash
const COL = {
  // Sky blues
  sky1: '#1b4fa8', sky2: '#1e3c8a', sky3: '#162d6e',
  // BG blocks
  bgBlock: '#1a4db5', bgBlockEdge: '#2060d0',
  // Ground
  ground: '#1638a0', groundTop: '#4a9fff', groundLine: '#5bb0ff',
  // Player: xanh neon
  player: '#44ff55', playerGlow: '#00ff44', playerDark: '#009922',
  trail: '#88ffaa',
  // Obstacles
  spikeCol: '#ff3355', spikeGlow: '#ff0033',
  blockObs: '#ff6633', blockObsGlow: '#ff4400',
  sawCol:   '#ffcc00', sawGlow: '#ffaa00',
  bombCol:  '#ff44ff', bombGlow: '#cc00cc',
  laserCol: '#00ffff', laserGlow: '#0099cc',
  ringCol:  '#ffff00', ringGlow: '#ccaa00',
  // UI
  uiPanel: 'rgba(10,25,80,0.75)',
  score: '#ffffff', best: '#ffdd44',
  gridLine: 'rgba(255,255,255,0.06)',
};

// ─── Audio Engine (Web Audio API — zero external files) ──────
let audioCtx = null;
let masterGain = null;
let musicPlaying = false;
let musicNodes = [];  // track all running nodes to stop cleanly

function ensureAudio() {
  if (!audioCtx) {
    try {
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      masterGain = audioCtx.createGain();
      masterGain.gain.value = 0.55;
      masterGain.connect(audioCtx.destination);
    } catch(e) {}
  }
  if (audioCtx && audioCtx.state === 'suspended') {
    audioCtx.resume().catch(()=>{});
  }
}

// ── Utility: tạo note ngắn ──
function note(freq, start, dur, type='square', vol=0.18, target=null) {
  if (!audioCtx) return;
  try {
    const o = audioCtx.createOscillator();
    const g = audioCtx.createGain();
    const dst = target || masterGain;
    o.connect(g); g.connect(dst);
    o.type = type;
    o.frequency.value = freq;
    g.gain.setValueAtTime(0, start);
    g.gain.linearRampToValueAtTime(vol, start + 0.01);
    g.gain.exponentialRampToValueAtTime(0.001, start + dur);
    o.start(start);
    o.stop(start + dur + 0.01);
    musicNodes.push(o);
  } catch(e) {}
}

// ── Kick drum (bass thud) ──
function kick(t) {
  if (!audioCtx) return;
  try {
    const o = audioCtx.createOscillator();
    const g = audioCtx.createGain();
    o.connect(g); g.connect(masterGain);
    o.type = 'sine';
    o.frequency.setValueAtTime(160, t);
    o.frequency.exponentialRampToValueAtTime(40, t + 0.08);
    g.gain.setValueAtTime(0.9, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + 0.18);
    o.start(t); o.stop(t + 0.2);
    musicNodes.push(o);
  } catch(e) {}
}

// ── Snare (white noise burst) ──
function snare(t) {
  if (!audioCtx) return;
  try {
    const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.1, audioCtx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
    const src = audioCtx.createBufferSource();
    const g   = audioCtx.createGain();
    const flt = audioCtx.createBiquadFilter();
    flt.type = 'highpass'; flt.frequency.value = 2000;
    src.buffer = buf;
    src.connect(flt); flt.connect(g); g.connect(masterGain);
    g.gain.setValueAtTime(0.35, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
    src.start(t); src.stop(t + 0.12);
    musicNodes.push(src);
  } catch(e) {}
}

// ── Hi-hat ──
function hihat(t, vol=0.1) {
  if (!audioCtx) return;
  try {
    const buf = audioCtx.createBuffer(1, audioCtx.sampleRate * 0.05, audioCtx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
    const src = audioCtx.createBufferSource();
    const g   = audioCtx.createGain();
    const flt = audioCtx.createBiquadFilter();
    flt.type = 'highpass'; flt.frequency.value = 8000;
    src.buffer = buf;
    src.connect(flt); flt.connect(g); g.connect(masterGain);
    g.gain.setValueAtTime(vol, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + 0.04);
    src.start(t); src.stop(t + 0.06);
    musicNodes.push(src);
  } catch(e) {}
}

// ── Bass synth ──
function bass(freq, t, dur) {
  note(freq, t, dur, 'sawtooth', 0.28);
}

// ── Lead synth ──
function lead(freq, t, dur) {
  note(freq, t, dur, 'square', 0.12);
}

// ── Chord pad ──
function pad(freqs, t, dur) {
  for (const f of freqs) note(f, t, dur, 'sine', 0.06);
}

// ── Sequencer: lập lịch 1 bar (4 beats) ──
const BPM       = 138;
const BEAT_SEC  = 60 / BPM;
const BAR_SEC   = BEAT_SEC * 4;

// Scale: A minor pentatonic (A C D E G)
const SCALE = [110, 130.81, 146.83, 164.81, 196, 220, 261.63, 293.66, 329.63, 392, 440];
// Chord progressions (chord = array of freq)
const CHORDS = [
  [220, 261.63, 329.63],   // Am
  [196, 246.94, 293.66],   // G
  [174.61, 220, 261.63],   // F
  [196, 246.94, 329.63],   // G7ish
];

let scheduleId = null;
let nextBarTime = 0;
let barCount    = 0;

function scheduleBar(barStart) {
  const b = BEAT_SEC;
  const chord = CHORDS[barCount % CHORDS.length];

  // Kick: beat 1 & 3
  kick(barStart);
  kick(barStart + b * 2);
  // Extra kick on beat 4 sometimes
  if (barCount % 2 === 1) kick(barStart + b * 3.5);

  // Snare: beat 2 & 4
  snare(barStart + b);
  snare(barStart + b * 3);

  // Hi-hats: every 8th note
  for (let i = 0; i < 8; i++) {
    hihat(barStart + b * i * 0.5, i % 2 === 0 ? 0.12 : 0.07);
  }

  // Bass line — root + fifth
  bass(chord[0] / 2, barStart,           b * 0.9);
  bass(chord[0] / 2, barStart + b,       b * 0.45);
  bass(chord[1] / 2, barStart + b * 1.5, b * 0.45);
  bass(chord[2] / 2, barStart + b * 2,   b * 0.9);
  bass(chord[0] / 2, barStart + b * 3,   b * 0.45);

  // Chord pad (atmospheric)
  pad(chord, barStart, BAR_SEC * 0.95);

  // Lead melody — simple 4-note motif, varies each bar
  const mel = [
    [SCALE[4], SCALE[6], SCALE[7], SCALE[6]],
    [SCALE[7], SCALE[6], SCALE[4], SCALE[3]],
    [SCALE[4], SCALE[5], SCALE[7], SCALE[9]],
    [SCALE[7], SCALE[7], SCALE[6], SCALE[4]],
  ][barCount % 4];
  for (let i = 0; i < 4; i++) {
    lead(mel[i], barStart + b * i, b * 0.8);
  }

  barCount++;
}

function musicLoop() {
  if (!musicPlaying || !audioCtx) return;
  const now = audioCtx.currentTime;
  // Schedule 2 bars ahead
  while (nextBarTime < now + BAR_SEC * 2) {
    scheduleBar(nextBarTime);
    nextBarTime += BAR_SEC;
  }
  scheduleId = setTimeout(musicLoop, (BAR_SEC * 500)); // check every ~half bar
}

function startMusic() {
  if (!audioCtx) return;
  stopMusic();
  musicPlaying = true;
  barCount = 0;
  nextBarTime = audioCtx.currentTime + 0.1;
  musicLoop();
}

function stopMusic() {
  musicPlaying = false;
  if (scheduleId) { clearTimeout(scheduleId); scheduleId = null; }
  // Stop all nodes
  for (const n of musicNodes) { try { n.stop(); } catch(e){} }
  musicNodes = [];
}

function pauseMusic()  { musicPlaying = false; if (scheduleId) clearTimeout(scheduleId); }
function resumeMusic() {
  if (!musicPlaying && audioCtx) {
    musicPlaying = true;
    nextBarTime = audioCtx.currentTime + 0.05;
    musicLoop();
  }
}

// ── SFX ──
function playBeep(freq, dur, type='square', vol=0.13) {
  if (!audioCtx) return;
  try {
    const o = audioCtx.createOscillator();
    const g = audioCtx.createGain();
    o.connect(g); g.connect(masterGain || audioCtx.destination);
    o.type = type; o.frequency.value = freq;
    g.gain.setValueAtTime(vol, audioCtx.currentTime);
    g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + dur);
    o.start(audioCtx.currentTime); o.stop(audioCtx.currentTime + dur + 0.01);
  } catch(e) {}
}
function sfxJump()  { playBeep(520, 0.08, 'square', 0.08); }
function sfxDeath() {
  playBeep(180, 0.1, 'sawtooth', 0.15);
  setTimeout(() => playBeep(90, 0.2, 'sawtooth', 0.1), 70);
}
function sfxPause() { playBeep(380, 0.07, 'sine', 0.07); }
let beatTimer = 0;
function tickBeat(f) { beatTimer++; }

// ─── Particles ───────────────────────────────────────────────
const particles = [];
function spawnParticles(x, y, color, count=8) {
  for (let i = 0; i < count; i++) {
    const a = (Math.PI*2/count)*i + Math.random()*0.5;
    const s = 2 + Math.random()*5;
    particles.push({ x, y, vx: Math.cos(a)*s, vy: Math.sin(a)*s-2,
      life:1, decay:0.032+Math.random()*0.03, size:3+Math.random()*4, color });
  }
}
function spawnTrail(x, y) {
  particles.push({
    x: x+Math.random()*6-3, y: y+TILE/2+Math.random()*4,
    vx: -1-Math.random()*2, vy: -0.4+Math.random()*0.6,
    life:0.65, decay:0.055, size:2+Math.random()*3, color:COL.trail, isTrail:true
  });
}
function updateParticles() {
  // Giới hạn tối đa 60 particle
  if (particles.length > 60) particles.splice(0, particles.length - 60);
  for (let i=particles.length-1;i>=0;i--) {
    const p=particles[i]; p.x+=p.vx; p.y+=p.vy; p.vy+=0.14; p.life-=p.decay;
    if(p.life<=0) particles.splice(i,1);
  }
}
function drawParticles() {
  for (const p of particles) {
    ctx.globalAlpha = p.life;
    ctx.shadowColor = p.color; ctx.shadowBlur = p.isTrail ? 4 : 10;
    ctx.fillStyle   = p.color;
    ctx.beginPath(); ctx.arc(p.x, p.y, p.size*p.life, 0, Math.PI*2); ctx.fill();
  }
  ctx.globalAlpha = 1; ctx.shadowBlur = 0;
}

// ─── Player ──────────────────────────────────────────────────
class Player {
  constructor() { this.reset(); }
  reset() {
    this.x=140; this.y=GROUND_Y-TILE;
    this.vy=0; this.onGround=true;
    this.angle=0; this.dead=false;
    this.jumpBuffer=0; this.attempt=1;
  }
  get cx(){ return this.x+TILE/2; }
  get cy(){ return this.y+TILE/2; }
  get hitX(){ return this.x+4; }
  get hitY(){ return this.y+4; }
  get hitW(){ return TILE-8; }
  get hitH(){ return TILE-8; }
  jump() {
    if (this.onGround || this.jumpBuffer > 0) {
      this.vy=JUMP_FORCE; this.onGround=false; this.jumpBuffer=0;
      sfxJump();
      spawnParticles(this.x+TILE/2, this.y+TILE, '#aaffcc', 5);
    }
  }
  update() {
    if (this.dead) return;
    if (this.jumpBuffer>0) this.jumpBuffer--;
    this.vy += GRAVITY; this.y += this.vy;
    if (this.y >= GROUND_Y-TILE) {
      this.y=GROUND_Y-TILE; this.vy=0; this.onGround=true;
    } else { this.onGround=false; }
    if (!this.onGround) { this.angle+=0.12; }
    else {
      const t=Math.round(this.angle/(Math.PI/2))*(Math.PI/2);
      this.angle+=(t-this.angle)*0.3;
    }
    if (Math.random()<0.25) spawnTrail(this.x, this.y);
  }
  draw() {
    ctx.save();
    ctx.translate(this.cx, this.cy);
    ctx.rotate(this.angle);
    const r=6, hs=TILE/2;

    // Outer glow ring
    ctx.shadowColor=COL.playerGlow; ctx.shadowBlur=7;
    ctx.strokeStyle='rgba(100,255,140,0.7)'; ctx.lineWidth=3;
    roundRectPath(ctx,-hs-3,-hs-3,TILE+6,TILE+6,r+3); ctx.stroke();

    // Body fill với rounded corners
    ctx.shadowColor=COL.playerGlow; ctx.shadowBlur=5;
    ctx.fillStyle=COL.player;
    roundRectPath(ctx,-hs,-hs,TILE,TILE,r); ctx.fill();

    // Viền sáng ngoài
    ctx.shadowBlur=0;
    ctx.strokeStyle='rgba(180,255,200,0.9)'; ctx.lineWidth=2.5;
    roundRectPath(ctx,-hs,-hs,TILE,TILE,r); ctx.stroke();

    // Viền tối bên trong
    ctx.strokeStyle=COL.playerDark; ctx.lineWidth=1.5;
    roundRectPath(ctx,-hs+3,-hs+3,TILE-6,TILE-6,r-2); ctx.stroke();

    // Highlight trên
    ctx.fillStyle='rgba(255,255,255,0.28)';
    roundRectPath(ctx,-hs+3,-hs+3,TILE-6,TILE/2-3,r-2); ctx.fill();

    // Mắt
    ctx.fillStyle='#003311';
    ctx.beginPath(); ctx.arc(7,-5,3.5,0,Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.arc(-7,-5,3.5,0,Math.PI*2); ctx.fill();

    // Miệng cười
    ctx.strokeStyle='#003311'; ctx.lineWidth=2.5;
    ctx.beginPath(); ctx.arc(0, 3, 7, 0.25, Math.PI-0.25); ctx.stroke();
    ctx.restore();
  }
}

// ─── Obstacle Types ──────────────────────────────────────────
// spike, dspike, tspike(3), block, blockfloat, pit,
// saw, bomb, laserH, laserV, ring, stairblock
class Obstacle {
  constructor(x, type, opts={}) {
    this.x=x; this.type=type; this.dead=false;
    this.animT=0;
    switch(type) {
      case 'spike':
        this.w=TILE; this.h=TILE; this.y=GROUND_Y-TILE; break;
      case 'dspike':
        this.w=TILE*2; this.h=TILE; this.y=GROUND_Y-TILE; break;
      case 'tspike':
        this.w=TILE*3; this.h=TILE; this.y=GROUND_Y-TILE; break;
      case 'block':
        this.w=opts.w||TILE; this.h=opts.h||TILE; this.y=GROUND_Y-this.h; break;
      case 'blockfloat':
        this.w=opts.w||TILE*2; this.h=TILE*0.55; this.y=GROUND_Y-TILE*2.5; break;
      case 'blockhigh':
        this.w=opts.w||TILE; this.h=TILE*1.5; this.y=GROUND_Y-TILE*1.5; break;
      case 'pit':
        this.w=opts.w||TILE*2; this.h=DESIGN_H-GROUND_Y; this.y=GROUND_Y; break;
      case 'saw':
        this.w=TILE*1.1; this.h=TILE*1.1; this.y=GROUND_Y-TILE*1.1;
        this.spinAngle=0; break;
      case 'bomb':
        this.w=TILE*0.9; this.h=TILE*0.9; this.y=GROUND_Y-TILE*0.9;
        this.bobOff=Math.random()*Math.PI*2; break;
      case 'laserH':
        this.w=opts.w||TILE*3; this.h=8; this.y=GROUND_Y-TILE*1.8;
        this.phase=Math.random()*Math.PI*2; break;
      case 'ring':
        this.w=TILE*1.2; this.h=TILE*1.2; this.y=GROUND_Y-TILE*2.0;
        this.pulse=0; break;
      case 'stairblock':
        this.steps=[
          {x:0,        w:TILE, h:TILE,   dy:0},
          {x:TILE+4,   w:TILE, h:TILE*2, dy:-TILE},
          {x:TILE*2+8, w:TILE, h:TILE*3, dy:-TILE*2},
        ];
        this.w=TILE*3+16; this.h=TILE*3; this.y=GROUND_Y-TILE*3; break;
      default:
        this.w=TILE; this.h=TILE; this.y=GROUND_Y-TILE;
    }
    this.hbPad = (type==='pit')?0:5;
    // Stair uses custom hitboxes
  }
  get hitX(){ return this.x+this.hbPad; }
  get hitY(){
    if(this.type==='spike'||this.type==='dspike'||this.type==='tspike')
      return this.y+this.hbPad*2;
    return this.y+this.hbPad;
  }
  get hitW(){ return this.w-this.hbPad*2; }
  get hitH(){ return this.h-this.hbPad*2; }

  getStairHitboxes() {
    if (this.type!=='stairblock') return null;
    return this.steps.map(s => ({
      x: this.x + s.x + 3,
      y: GROUND_Y - s.h + 3,
      w: s.w - 6,
      h: s.h - 6
    }));
  }

  update() {
    this.animT += 0.05;
    if (this.type==='saw') this.spinAngle = (this.spinAngle||0) + 0.12;
    if (this.type==='laserH') this.phase = (this.phase||0) + 0.04;
  }

  draw() {
    ctx.save();
    this.update();
    switch(this.type) {
      case 'spike':  drawSpikeN(this.x,this.y,TILE,TILE); break;
      case 'dspike': drawSpikeN(this.x,this.y,TILE,TILE); drawSpikeN(this.x+TILE,this.y,TILE,TILE); break;
      case 'tspike': drawSpikeN(this.x,this.y,TILE,TILE); drawSpikeN(this.x+TILE,this.y,TILE,TILE); drawSpikeN(this.x+TILE*2,this.y,TILE,TILE); break;
      case 'block': case 'blockhigh': drawBlock(this.x,this.y,this.w,this.h,'#ff6633','#ff4400'); break;
      case 'blockfloat': drawBlock(this.x,this.y,this.w,this.h,'#ff8800','#cc5500'); break;
      case 'pit': break; // ground gap handles it
      case 'saw':   drawSaw(this.x+this.w/2, this.y+this.h/2, this.w/2, this.spinAngle); break;
      case 'bomb':  drawBomb(this.x+this.w/2, this.y+this.h/2+Math.sin(this.animT*2+(this.bobOff||0))*4, this.w/2); break;
      case 'laserH':drawLaserH(this.x, this.y, this.w, this.h, this.animT); break;
      case 'ring':  drawRing(this.x+this.w/2, this.y+this.h/2, this.w/2, this.animT); break;
      case 'stairblock': drawStairs(this.x, this.steps); break;
    }
    ctx.restore();
  }
}

function drawSpikeN(x,y,w,h) {
  ctx.shadowColor=COL.spikeGlow; ctx.shadowBlur=7;
  ctx.fillStyle=COL.spikeCol;
  ctx.beginPath(); ctx.moveTo(x+w/2,y); ctx.lineTo(x+w,y+h); ctx.lineTo(x,y+h); ctx.closePath(); ctx.fill();
  ctx.shadowBlur=0;
  ctx.strokeStyle='rgba(255,200,200,0.55)'; ctx.lineWidth=1.5;
  ctx.beginPath(); ctx.moveTo(x+w/2,y+3); ctx.lineTo(x+w-3,y+h-3); ctx.stroke();
}

function drawBlock(x,y,w,h,col,glow) {
  const r=5;
  // Outer glow border
  ctx.shadowColor=glow; ctx.shadowBlur=8;
  ctx.strokeStyle=glow; ctx.lineWidth=3;
  roundRectPath(ctx,x-2,y-2,w+4,h+4,r+2); ctx.stroke();

  // Body
  ctx.shadowColor=glow; ctx.shadowBlur=5;
  ctx.fillStyle=col;
  roundRectPath(ctx,x,y,w,h,r); ctx.fill();
  ctx.shadowBlur=0;

  // Highlight trên
  ctx.fillStyle='rgba(255,255,255,0.22)';
  roundRectPath(ctx,x+2,y+2,w-4,Math.min(h/2-2,18),r-1); ctx.fill();

  // Đáy tối
  ctx.fillStyle='rgba(0,0,0,0.25)';
  roundRectPath(ctx,x+2,y+h-6,w-4,4,2); ctx.fill();

  // Viền sáng
  ctx.strokeStyle='rgba(255,255,255,0.35)'; ctx.lineWidth=2;
  roundRectPath(ctx,x,y,w,h,r); ctx.stroke();
}

function drawSaw(cx,cy,r,angle) {
  const teeth=8;
  ctx.save();
  ctx.translate(cx,cy); ctx.rotate(angle);
  ctx.shadowColor=COL.sawGlow; ctx.shadowBlur=8;
  ctx.fillStyle=COL.sawCol;
  ctx.beginPath();
  for(let i=0;i<teeth*2;i++){
    const a=i*(Math.PI/teeth);
    const rad= (i%2===0) ? r : r*0.55;
    i===0 ? ctx.moveTo(Math.cos(a)*rad,Math.sin(a)*rad)
           : ctx.lineTo(Math.cos(a)*rad,Math.sin(a)*rad);
  }
  ctx.closePath(); ctx.fill();
  ctx.shadowBlur=0;
  ctx.fillStyle='rgba(0,0,0,0.35)';
  ctx.beginPath(); ctx.arc(0,0,r*0.28,0,Math.PI*2); ctx.fill();
  ctx.restore();
}

function drawBomb(cx,cy,r) {
  ctx.save();
  ctx.translate(cx,cy);
  // Body
  ctx.shadowColor=COL.bombGlow; ctx.shadowBlur=8;
  ctx.fillStyle=COL.bombCol;
  ctx.beginPath(); ctx.arc(0,0,r,0,Math.PI*2); ctx.fill();
  ctx.shadowBlur=0;
  // Highlight
  ctx.fillStyle='rgba(255,255,255,0.3)';
  ctx.beginPath(); ctx.arc(-r*0.25,-r*0.25,r*0.3,0,Math.PI*2); ctx.fill();
  // Fuse
  ctx.strokeStyle='#996622'; ctx.lineWidth=2.5;
  ctx.beginPath(); ctx.moveTo(0,-r); ctx.quadraticCurveTo(r*0.7,-r*1.4,r*0.5,-r*1.8); ctx.stroke();
  // Spark
  ctx.fillStyle='#ffee44';
  ctx.shadowColor='#ffff00'; ctx.shadowBlur=8;
  ctx.beginPath(); ctx.arc(r*0.5,-r*1.8,3,0,Math.PI*2); ctx.fill();
  ctx.restore();
}

function drawLaserH(x,y,w,h,t) {
  // Laser beam — flickers
  const alpha = 0.7+0.3*Math.sin(t*8);
  ctx.save();
  ctx.globalAlpha = alpha;
  ctx.shadowColor = COL.laserGlow; ctx.shadowBlur = 5;
  // Outer glow
  const grad = ctx.createLinearGradient(0,y-h,0,y+h*3);
  grad.addColorStop(0,'rgba(0,255,255,0)');
  grad.addColorStop(0.5,COL.laserCol);
  grad.addColorStop(1,'rgba(0,255,255,0)');
  ctx.fillStyle = grad;
  ctx.fillRect(x, y-h, w, h*4);
  // Core
  ctx.fillStyle = '#ffffff';
  ctx.fillRect(x, y, w, h);
  ctx.restore();
  // Emitters at ends
  ctx.save();
  ctx.fillStyle='#006699'; ctx.shadowColor=COL.laserGlow; ctx.shadowBlur=5;
  ctx.fillRect(x-8, y-h, 10, h*3);
  ctx.fillRect(x+w-2, y-h, 10, h*3);
  ctx.restore();
}

function drawRing(cx,cy,r,t) {
  const pulse=0.9+0.1*Math.sin(t*4);
  ctx.save(); ctx.translate(cx,cy);
  ctx.shadowColor=COL.ringGlow; ctx.shadowBlur=5;
  ctx.strokeStyle=COL.ringCol; ctx.lineWidth=4;
  ctx.globalAlpha=0.9;
  ctx.beginPath(); ctx.arc(0,0,r*pulse,0,Math.PI*2); ctx.stroke();
  ctx.strokeStyle='rgba(255,255,150,0.4)'; ctx.lineWidth=10;
  ctx.beginPath(); ctx.arc(0,0,r*pulse*0.85,0,Math.PI*2); ctx.stroke();
  ctx.restore();
}

function drawStairs(bx, steps) {
  steps.forEach(s => {
    const sx = bx + s.x;
    const sy = GROUND_Y - s.h;
    drawBlock(sx, sy, s.w, s.h, '#ff6633', '#ff4400');
  });
}

// ─── Patterns ────────────────────────────────────────────────
// [type, xOffset, opts]
const PATTERNS_EASY = [
  [ ['spike'] ],
  [ ['spike',0],['spike',TILE+8] ],
  [ ['dspike'] ],
  [ ['block',0,{w:TILE}] ],
  [ ['block',0,{w:TILE*2}] ],
  [ ['pit',0,{w:TILE*2}] ],
  [ ['saw'] ],
  [ ['bomb'] ],
];
const PATTERNS_MED = [
  [ ['spike',0],['spike',TILE*2+8] ],
  [ ['dspike',0],['spike',TILE*3] ],
  [ ['block',0,{w:TILE}],['spike',TILE+4] ],
  [ ['pit',0,{w:TILE*3}] ],
  [ ['blockfloat',0],['spike',TILE*3] ],
  [ ['spike',0],['blockfloat',TILE*1.5,{w:TILE*2}] ],
  [ ['tspike'] ],
  [ ['laserH',0,{w:TILE*3}] ],
  [ ['ring'] ],
  [ ['saw'],['spike',TILE*2] ],
  [ ['bomb'],['spike',TILE*2+8] ],
  [ ['blockhigh',0,{w:TILE}] ],
];
const PATTERNS_HARD = [
  [ ['tspike',0],['spike',TILE*4] ],
  [ ['dspike',0],['dspike',TILE*3] ],
  [ ['pit',0,{w:TILE*4}] ],
  [ ['stairblock'] ],
  [ ['laserH',0,{w:TILE*4}],['spike',TILE*5] ],
  [ ['saw'],['bomb',TILE*3+8],['spike',TILE*6] ],
  [ ['ring'],['spike',TILE*3] ],
  [ ['blockhigh',0,{w:TILE}],['spike',TILE+6],['spike',TILE*2+6] ],
  [ ['blockfloat',0],['spike',TILE*3],['spike',TILE*5] ],
  [ ['tspike',0],['laserH',TILE*4,{w:TILE*3}] ],
  [ ['stairblock',0],['spike',TILE*4+16] ],
  [ ['saw'],['saw',TILE*3],['saw',TILE*6] ],
];

class LevelGenerator {
  constructor() { this.reset(); }
  reset() {
    this.obstacles=[]; this.nextSpawn=DESIGN_W+150;
    this.gapMin=210; this.gapMax=350;
  }
  gap(speed) {
    const f=Math.max(0.58,1-(speed-BASE_SPEED)/(MAX_SPEED-BASE_SPEED)*0.32);
    return (this.gapMin+Math.random()*(this.gapMax-this.gapMin))*f;
  }
  spawnPattern(x,speed) {
    const d=(speed-BASE_SPEED)/(MAX_SPEED-BASE_SPEED);
    let pool;
    if      (d<0.25) pool=[...PATTERNS_EASY];
    else if (d<0.55) pool=[...PATTERNS_EASY,...PATTERNS_MED];
    else if (d<0.80) pool=[...PATTERNS_MED,...PATTERNS_HARD];
    else             pool=[...PATTERNS_HARD];
    const pat=pool[Math.floor(Math.random()*pool.length)];
    for (const p of pat) {
      const [type,dx=0,opts={}]=p;
      this.obstacles.push(new Obstacle(x+dx,type,opts));
    }
    return x+this.gap(speed);
  }
  update(camX,speed) {
    while(this.nextSpawn < camX+DESIGN_W+400)
      this.nextSpawn=this.spawnPattern(this.nextSpawn,speed);
    for(let i=this.obstacles.length-1;i>=0;i--)
      if(this.obstacles[i].x+this.obstacles[i].w < camX-100)
        this.obstacles.splice(i,1);
  }
  getVisible(camX) {
    return this.obstacles.filter(o=>o.x+o.w>camX-50 && o.x<camX+DESIGN_W+50);
  }
}

// ─── AABB Collision ──────────────────────────────────────────
function aabb(ax,ay,aw,ah,bx,by,bw,bh) {
  return ax<bx+bw && ax+aw>bx && ay<by+bh && ay+ah>by;
}

// ─── Camera Shake ────────────────────────────────────────────
let shakeTimer=0, shakeAmt=0;
function triggerShake(a=6,f=18){ shakeTimer=f; shakeAmt=a; }
function getShake(){
  if(shakeTimer<=0) return {x:0,y:0};
  const t=shakeTimer/18; shakeTimer--;
  return {x:(Math.random()-.5)*shakeAmt*t, y:(Math.random()-.5)*shakeAmt*t};
}
let flashAlpha=0;
function triggerFlash(){ flashAlpha=1.0; }

// ─── Background Blocks (trang trí giống GD) ─────────────────
const BG_BLOCKS = Array.from({length:10},()=>({
  x: Math.random()*DESIGN_W,
  y: Math.random()*(GROUND_Y-60)+10,
  w: TILE*(1+Math.floor(Math.random()*3)),
  h: TILE*(1+Math.floor(Math.random()*4)),
  alpha: 0.08+Math.random()*0.12,
  speed: 0.3+Math.random()*0.5,
}));

function drawBackground(camX,speed) {
  // Sky gradient sáng xanh
  const grd=ctx.createLinearGradient(0,0,0,DESIGN_H);
  grd.addColorStop(0,'#1b4fa8');
  grd.addColorStop(0.6,'#1a3a90');
  grd.addColorStop(1,'#162c80');
  ctx.fillStyle=grd;
  ctx.fillRect(0,0,DESIGN_W,DESIGN_H);

  // Grid lines
  ctx.strokeStyle=COL.gridLine; ctx.lineWidth=1;
  const gs=160;
  const offX=(camX*0.18)%gs;
  for(let x=-offX;x<DESIGN_W+gs;x+=gs){
    ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,GROUND_Y); ctx.stroke();
  }
  for(let y=0;y<GROUND_Y;y+=gs){
    ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(DESIGN_W,y); ctx.stroke();
  }

  // BG blocks — parallax
  for(const b of BG_BLOCKS){
    b.x -= b.speed*(speed/BASE_SPEED);
    if(b.x+b.w<0) { b.x=DESIGN_W+b.w; b.y=Math.random()*(GROUND_Y-60)+10; }
    ctx.fillStyle=`rgba(60,120,220,${b.alpha})`;
    ctx.fillRect(b.x,b.y,b.w,b.h);
    ctx.strokeStyle=`rgba(100,160,255,${b.alpha*1.4})`;
    ctx.lineWidth=1; ctx.strokeRect(b.x,b.y,b.w,b.h);
  }
}

function drawGround(pits,camX) {
  // Nền đất
  ctx.fillStyle=COL.ground;
  ctx.fillRect(0,GROUND_Y,DESIGN_W,DESIGN_H-GROUND_Y);

  // Top glow line
  ctx.shadowColor=COL.groundLine; ctx.shadowBlur=7;
  ctx.fillStyle=COL.groundLine;
  ctx.fillRect(0,GROUND_Y,DESIGN_W,3);
  ctx.shadowBlur=0;

  // Brick pattern
  ctx.strokeStyle='rgba(30,60,160,0.4)'; ctx.lineWidth=1;
  for(let x=0;x<DESIGN_W;x+=TILE)
    ctx.strokeRect(x,GROUND_Y,TILE,DESIGN_H-GROUND_Y);

  // Xóa hố
  for(const pit of pits){
    const px=pit.x-camX;
    ctx.fillStyle='#0d1e5a';
    ctx.fillRect(px,GROUND_Y,pit.w,DESIGN_H-GROUND_Y);
    ctx.fillStyle='rgba(0,0,0,0.85)';
    ctx.fillRect(px,GROUND_Y+3,pit.w,DESIGN_H-GROUND_Y);
    ctx.fillRect(px,GROUND_Y-4,pit.w,20);
  }
}

// ─── UI ──────────────────────────────────────────────────────
let attemptCount = 0;

function drawText(txt,x,y,sz,col,align='left',glow=null,shadow=0){
  ctx.font=`bold ${sz}px "Courier New",monospace`;
  ctx.textAlign=align; ctx.textBaseline='middle';
  if(glow){ ctx.shadowColor=glow; ctx.shadowBlur=shadow||20; }
  ctx.fillStyle=col; ctx.fillText(txt,x,y);
  ctx.shadowBlur=0;
}

function drawHUD(score,best,speed,attempt) {
  // Panel bán trong
  ctx.fillStyle='rgba(10,25,80,0.55)';
  ctx.fillRect(0,0,DESIGN_W,52);
  ctx.fillRect(0,DESIGN_H-28,DESIGN_W,28);

  const dist=Math.floor(score/10);
  drawText(`${dist} m`, DESIGN_W-18, 26, 22, '#ffffff', 'right', '#aaddff', 14);
  drawText(`KỶ LỤC ${Math.floor(best/10)} m`, DESIGN_W-18, 50, 12, COL.best, 'right', COL.best, 8);

  // Attempt
  drawText(`Lần ${attempt}`, 16, 26, 14, 'rgba(255,255,255,0.7)', 'left');



  // Controls hint bottom
  drawText('SPACE / CHẠM = NHẢY   R = CHƠI LẠI   P = DỪNG', DESIGN_W/2, DESIGN_H-14, 10, 'rgba(255,255,255,0.3)', 'center');
}

function drawStartScreen() {
  ctx.save(); ctx.textAlign='center';

  // Panel trung tâm
  ctx.fillStyle='rgba(10,30,90,0.82)';
  roundRect(ctx, DESIGN_W/2-230, DESIGN_H/2-130, 460, 280, 18);
  ctx.fill();
  ctx.strokeStyle='rgba(100,180,255,0.4)'; ctx.lineWidth=2;
  roundRect(ctx, DESIGN_W/2-230, DESIGN_H/2-130, 460, 280, 18);
  ctx.stroke();

  ctx.shadowColor='#00aaff'; ctx.shadowBlur=50;
  ctx.font='bold 66px "Courier New",monospace';
  ctx.fillStyle='#ffffff';
  ctx.fillText('GeoRush', DESIGN_W/2, DESIGN_H/2-72);

  ctx.shadowColor='#44aaff'; ctx.shadowBlur=8;
  ctx.font='bold 18px "Courier New",monospace';
  ctx.fillStyle='#88ccff';
  ctx.fillText('VIỆT NAM DASH', DESIGN_W/2, DESIGN_H/2-26);

  const p=0.6+0.4*Math.abs(Math.sin(Date.now()/480));
  ctx.globalAlpha=p;
  ctx.shadowColor='#ffee00'; ctx.shadowBlur=7;
  ctx.font='bold 24px "Courier New",monospace';
  ctx.fillStyle='#ffee00';
  ctx.fillText('▶  CHẠM / SPACE ĐỂ CHƠI  ◀', DESIGN_W/2, DESIGN_H/2+34);
  ctx.globalAlpha=1;

  ctx.shadowBlur=0;
  ctx.font='13px "Courier New",monospace';
  ctx.fillStyle='rgba(200,220,255,0.5)';
  ctx.fillText('Nhảy: Space / Nhấp chuột / Chạm màn hình', DESIGN_W/2, DESIGN_H/2+75);
  ctx.fillText('Dừng: P hoặc nút ⏸ | Chơi lại: R', DESIGN_W/2, DESIGN_H/2+96);
  ctx.restore();
}

function drawPauseScreen() {
  ctx.save();
  ctx.fillStyle='rgba(5,15,60,0.78)';
  ctx.fillRect(0,0,DESIGN_W,DESIGN_H);
  ctx.textAlign='center';

  ctx.shadowColor='#44aaff'; ctx.shadowBlur=30;
  ctx.font='bold 60px "Courier New",monospace';
  ctx.fillStyle='#88ccff';
  ctx.fillText('TẠM DỪNG', DESIGN_W/2, DESIGN_H/2-50);
  ctx.shadowBlur=0;
  ctx.font='bold 22px "Courier New",monospace';
  ctx.fillStyle='#ffffff';

  const p=0.6+0.4*Math.abs(Math.sin(Date.now()/450));
  ctx.globalAlpha=p;
  ctx.shadowColor='#44ffcc'; ctx.shadowBlur=6;
  ctx.fillText('▶  NHẤN P / SPACE ĐỂ TIẾP TỤC  ◀', DESIGN_W/2, DESIGN_H/2+20);
  ctx.globalAlpha=1; ctx.shadowBlur=0;
  ctx.font='14px "Courier New",monospace';
  ctx.fillStyle='rgba(200,220,255,0.5)';
  ctx.fillText('R = Chơi lại từ đầu', DESIGN_W/2, DESIGN_H/2+65);
  ctx.restore();
}

function drawDeathScreen(score,best,attempt) {
  ctx.save();
  ctx.fillStyle='rgba(5,10,50,0.72)';
  ctx.fillRect(0,0,DESIGN_W,DESIGN_H);
  ctx.textAlign='center';

  // Panel
  ctx.fillStyle='rgba(20,40,110,0.88)';
  roundRect(ctx,DESIGN_W/2-220,DESIGN_H/2-140,440,290,18);
  ctx.fill();
  ctx.strokeStyle='rgba(255,80,100,0.5)'; ctx.lineWidth=2;
  roundRect(ctx,DESIGN_W/2-220,DESIGN_H/2-140,440,290,18);
  ctx.stroke();

  ctx.shadowColor='#ff2244'; ctx.shadowBlur=35;
  ctx.font='bold 52px "Courier New",monospace';
  ctx.fillStyle='#ff4466';
  ctx.fillText('THẤT BẠI!', DESIGN_W/2, DESIGN_H/2-80);
  ctx.shadowBlur=0;

  ctx.font='bold 18px "Courier New",monospace';
  ctx.fillStyle='#ffffff';
  ctx.fillText(`Quãng đường: ${Math.floor(score/10)} m`, DESIGN_W/2, DESIGN_H/2-28);

  ctx.fillStyle=COL.best;
  ctx.shadowColor=COL.best; ctx.shadowBlur=8;
  ctx.fillText(`Kỷ lục: ${Math.floor(best/10)} m`, DESIGN_W/2, DESIGN_H/2+8);

  ctx.shadowBlur=0;
  ctx.fillStyle='rgba(200,200,255,0.6)';
  ctx.font='14px "Courier New",monospace';
  ctx.fillText(`Lần thử: ${attempt}`, DESIGN_W/2, DESIGN_H/2+42);

  const p=0.6+0.4*Math.abs(Math.sin(Date.now()/380));
  ctx.globalAlpha=p;
  ctx.shadowColor='#44ffcc'; ctx.shadowBlur=7;
  ctx.font='bold 22px "Courier New",monospace';
  ctx.fillStyle='#44ffcc';
  ctx.fillText('↺  CHƠI LẠI (R hoặc CHẠM)  ↺', DESIGN_W/2, DESIGN_H/2+88);
  ctx.globalAlpha=1; ctx.shadowBlur=0;
  ctx.restore();
}

function roundRectPath(ctx,x,y,w,h,r){
  r = Math.min(r, w/2, h/2);
  ctx.beginPath();
  ctx.moveTo(x+r,y);
  ctx.lineTo(x+w-r,y); ctx.quadraticCurveTo(x+w,y,x+w,y+r);
  ctx.lineTo(x+w,y+h-r); ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
  ctx.lineTo(x+r,y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-r);
  ctx.lineTo(x,y+r); ctx.quadraticCurveTo(x,y,x+r,y);
  ctx.closePath();
}

function roundRect(ctx,x,y,w,h,r){
  ctx.beginPath();
  ctx.moveTo(x+r,y);
  ctx.lineTo(x+w-r,y); ctx.quadraticCurveTo(x+w,y,x+w,y+r);
  ctx.lineTo(x+w,y+h-r); ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
  ctx.lineTo(x+r,y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-r);
  ctx.lineTo(x,y+r); ctx.quadraticCurveTo(x,y,x+r,y);
  ctx.closePath();
}

// ─── Debug ───────────────────────────────────────────────────
let debugMode=false;
const fpsEl=document.getElementById('fps');
let fpsNow=60, fpsLast=performance.now(), fpsCount=0;

function drawDebug(player,obstacles,camX) {
  if(!debugMode) return;
  ctx.strokeStyle='#00ff00'; ctx.lineWidth=1.5;
  ctx.strokeRect(player.hitX-camX,player.hitY,player.hitW,player.hitH);
  ctx.strokeStyle='#ff00ff';
  for(const o of obstacles){
    if(o.type==='pit') continue;
    if(o.type==='stairblock'){
      const hbs=o.getStairHitboxes();
      for(const h of hbs) ctx.strokeRect(h.x-camX,h.y,h.w,h.h);
    } else {
      ctx.strokeRect(o.hitX-camX,o.hitY,o.hitW,o.hitH);
    }
  }
}

// ─── Input ───────────────────────────────────────────────────
function onJump() {
  ensureAudio();
  if(game.state==='playing') { game.player.jump(); }
  else if(game.state==='start'||game.state==='dead') { game.startGame(); }
  else if(game.state==='paused') { game.resume(); }
}
function onPause() {
  ensureAudio();
  if(game.state==='playing')    { game.pause(); }
  else if(game.state==='paused'){ game.resume(); }
}

// ── Input: chặn scroll trang khi trong iframe ──────────────
function handleKey(e) {
  // Ngăn Space / Arrow cuộn trang cha (kể cả trong iframe)
  if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) {
    e.preventDefault();
    e.stopPropagation();
    e.stopImmediatePropagation();
  }
  if(!e.repeat) {
    if(e.code==='Space'||e.code==='ArrowUp') onJump();
  }
  if(e.code==='KeyP'||e.code==='Escape') onPause();
  if(e.code==='KeyR') game.restartGame();
  if(e.code==='KeyD'){ debugMode=!debugMode; fpsEl.style.display=debugMode?'block':'none'; }
}
// Bắt ở capture phase trên document để ưu tiên hơn trang cha
document.addEventListener('keydown', handleKey, { passive: false, capture: true });
// Cũng bắt trên canvas để chắc chắn
canvas.addEventListener('keydown', handleKey, { passive: false });

canvas.addEventListener('mousedown', e => {
  // Chỉ focus canvas khi user click trực tiếp vào game
  // KHÔNG gọi e.preventDefault() ở đây — để trang admin cha vẫn nhận event
  canvas.focus();
  onJump();
});
canvas.addEventListener('touchstart', e => {
  e.preventDefault(); // chặn scroll chỉ trên canvas
  canvas.focus();
  onJump();
}, { passive: false });

// Auto-focus canvas khi load lần đầu
function focusCanvas() { try { canvas.focus(); } catch(e){} }
window.addEventListener('load', focusCanvas);
// KHÔNG bắt click trên document — sẽ block click của trang admin cha khi nhúng iframe
// canvas.addEventListener('blur') KHÔNG tự lấy lại focus — để admin page hoạt động bình thường

// Pause button
document.getElementById('btnPause').addEventListener('click', e=>{
  e.stopPropagation(); ensureAudio(); onPause();
});
document.getElementById('btnPause').addEventListener('touchstart', e=>{
  e.stopPropagation(); e.preventDefault(); ensureAudio(); onPause();
},{passive:false});

// Update pause button icon
// Dừng update khi tab/iframe bị ẩn (tiết kiệm CPU tối đa)
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    if (game.state === 'playing') game.pause();
  }
});

function updatePauseBtn(){
  const btn=document.getElementById('btnPause');
  if(game.state==='paused') btn.textContent='▶';
  else btn.textContent='⏸';
  btn.style.display=(game.state==='start')?'none':'flex';
}

// ─── Game ─────────────────────────────────────────────────────
const game = {
  state: 'start',
  score: 0,
  best:  parseInt(localStorage.getItem('georush_best')||'0'),
  speed: BASE_SPEED,
  camX:  0,
  frame: 0,
  player: new Player(),
  level:  new LevelGenerator(),

  startGame() {
    attemptCount++;
    this.state='playing'; this.score=0; this.speed=BASE_SPEED;
    this.camX=0; this.frame=0;
    particles.length=0;
    this.player.reset(); this.player.attempt=attemptCount;
    this.level.reset();
    startMusic();
    updatePauseBtn();
  },
  restartGame() { this.startGame(); },
  pause() {
    if(this.state!=='playing') return;
    this.state='paused'; sfxPause(); pauseMusic(); updatePauseBtn();
  },
  resume() {
    if(this.state!=='paused') return;
    this.state='playing'; sfxPause(); resumeMusic(); updatePauseBtn();
  },
  die() {
    if(this.state!=='playing') return;
    this.state='dead';
    stopMusic(); sfxDeath(); triggerFlash(); triggerShake(10,22);
    spawnParticles(this.player.cx,this.player.cy,COL.player,10);
    if(this.score>this.best){
      this.best=this.score;
      localStorage.setItem('georush_best',this.best);
    }
    updatePauseBtn();
  },

  update() {
    if(this.state!=='playing') return;
    this.frame++; this.score++;
    this.speed=Math.min(MAX_SPEED,BASE_SPEED+this.score*SPEED_INC);
    this.camX=this.player.x-140;
    this.player.update();
    this.level.update(this.camX,this.speed);
    const visible=this.level.getVisible(this.camX);
    this.player.x+=this.speed;
    tickBeat(this.frame);

    if(this.player.y>DESIGN_H+20){ this.die(); return; }

    // Collision
    for(const obs of visible){
      if(obs.type==='pit'){
        const px=this.player.hitX, pxe=px+this.player.hitW;
        const ox=obs.hitX, oxe=ox+obs.hitW;
        if(px<oxe&&pxe>ox&&this.player.onGround) this.player.onGround=false;
      } else if(obs.type==='stairblock'){
        const hbs=obs.getStairHitboxes();
        for(const h of hbs){
          if(aabb(this.player.hitX,this.player.hitY,this.player.hitW,this.player.hitH,
                  h.x,h.y,h.w,h.h)){ this.die(); return; }
        }
      } else if(obs.type==='laserH'||obs.type==='ring'){
        // Only collide with core hitbox
        if(aabb(this.player.hitX,this.player.hitY,this.player.hitW,this.player.hitH,
                obs.hitX,obs.hitY,obs.hitW,obs.hitH)){ this.die(); return; }
      } else {
        if(aabb(this.player.hitX,this.player.hitY,this.player.hitW,this.player.hitH,
                obs.hitX,obs.hitY,obs.hitW,obs.hitH)){ this.die(); return; }
      }
    }

    // Pit ground removal
    let overPit=false;
    for(const obs of visible){
      if(obs.type!=='pit') continue;
      const px=this.player.x+TILE*0.2, pxe=this.player.x+TILE*0.8;
      if(px<obs.x+obs.w-this.camX&&pxe>obs.x-this.camX){ overPit=true; break; }
    }
    if(overPit&&this.player.onGround) this.player.onGround=false;

    updateParticles();
  },

  draw() {
    // Clear frame
    ctx.clearRect(0, 0, DESIGN_W, DESIGN_H);
    // FPS
    fpsCount++;
    const now=performance.now();
    if(now-fpsLast>=500){
      fpsNow=Math.round(fpsCount*1000/(now-fpsLast));
      fpsCount=0; fpsLast=now;
      if(debugMode) fpsEl.textContent=`${fpsNow} FPS`;
    }

    ctx.save();
    const sh=getShake();
    if(sh.x||sh.y) ctx.translate(sh.x,sh.y);

    if(this.state==='start'){
      drawBackground(0,BASE_SPEED);
      drawGround([],0);
      this.player.x=140; this.player.y=GROUND_Y-TILE;
      this.player.draw();
      drawStartScreen();
    } else {
      const camX=this.camX;
      drawBackground(camX,this.speed);
      const visible=this.level.getVisible(camX);
      const pits=visible.filter(o=>o.type==='pit');
      drawGround(pits,camX);

      ctx.save(); ctx.translate(-camX,0);
      for(const o of visible){ if(o.type!=='pit') o.draw(); }
      ctx.restore();

      ctx.save(); ctx.translate(-camX,0);
      drawParticles();
      ctx.restore();

      if(this.state==='playing'||this.state==='paused'){
        ctx.save(); ctx.translate(-camX,0);
        this.player.draw();
        ctx.restore();
      }

      drawHUD(this.score,this.best,this.speed,attemptCount);
      drawDebug(this.player,visible,camX);

      if(this.state==='paused') drawPauseScreen();
      if(this.state==='dead'){
        drawDeathScreen(this.score,this.best,attemptCount);
      }
    }

    // Flash
    if(flashAlpha>0){
      ctx.fillStyle=`rgba(255,50,80,${flashAlpha})`;
      ctx.fillRect(0,0,DESIGN_W,DESIGN_H);
      flashAlpha=Math.max(0,flashAlpha-0.065);
    }

    ctx.restore();
  }
};

// ─── Loop ─────────────────────────────────────────────────────
let lastTime=0;
const TARGET_FPS = 60;
const FRAME_MIN = 1000 / TARGET_FPS;
let loopStarted = false;

function loop(ts){
  requestAnimationFrame(loop);
  // Không update khi tab/iframe mất focus (tiết kiệm CPU)
  if (document.hidden) return;
  const elapsed = ts - lastTime;
  if (elapsed < FRAME_MIN - 1) return;
  lastTime = ts;
  game.update();
  game.draw();
}

function startLoop() {
  if (loopStarted) return;
  loopStarted = true;
  requestAnimationFrame(ts=>{ lastTime=ts; requestAnimationFrame(loop); });
}

updatePauseBtn();

// Chỉ vẽ 1 frame tĩnh ban đầu (start screen), KHÔNG chạy loop nặng
// Loop thật chỉ bắt đầu khi user tương tác lần đầu
game.draw();

// Khởi động loop khi user tương tác (click/touch/key) lần đầu
function onFirstInteraction() {
  startLoop();
  document.removeEventListener('keydown',   onFirstInteraction);
  document.removeEventListener('mousedown', onFirstInteraction);
  document.removeEventListener('touchstart',onFirstInteraction);
}
document.addEventListener('keydown',    onFirstInteraction, { once: true });
document.addEventListener('mousedown',  onFirstInteraction, { once: true });
document.addEventListener('touchstart', onFirstInteraction, { once: true });

})();
</script>
</body>
</html>
Thứ Sáu, 29/05/2026 16:43
31 👨 3
Xem thêm: Claude
0 Bình luận
Sắp xếp theo