<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>bbibbabbbi 님의 블로그</title>
    <link>https://bbibbabbbi.tistory.com/</link>
    <description>안녕하세요.</description>
    <language>ko</language>
    <pubDate>Mon, 5 Oct 2026 17:00:47 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>bbibbabbbi</managingEditor>
    <image>
      <title>bbibbabbbi 님의 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/8191692/attach/1d0dfd6ee0f54fa7bdaeaa0901c5c109</url>
      <link>https://bbibbabbbi.tistory.com</link>
    </image>
    <item>
      <title>&amp;lt;아벨의 유산: 성검의 부활&amp;gt;</title>
      <link>https://bbibbabbbi.tistory.com/75</link>
      <description>&lt;style&gt;
  #abel-game-container {
    position: relative;
    width: 500px;
    margin: 0 auto;
    background-color: #000 !important;
    color: #33ff33 !important;
    font-family: 'Courier New', Courier, monospace !important;
    padding: 15px !important;
    border: 4px solid #fff !important;
    border-radius: 8px !important;
    box-sizing: border-box !important;
    user-select: none !important;
  }
  #abel-game-header { text-align: center; border-bottom: 2px dashed #33ff33; padding-bottom: 10px; margin-bottom: 10px; }
  #abel-game-header h2 { margin: 0; color: #fff !important; font-size: 20px !important; }
  
  .abel-ui-row { display: flex; justify-content: space-between; margin-bottom: 5px; font-weight: bold; font-size: 14px;}
  .abel-ui-row span { background: #222 !important; padding: 4px 8px; border-radius: 4px; color: #33ff33 !important; }
  .stat-val { color: #ffffff !important; }
  
  #abel-canvas {
    background-color: #000 !important;
    border: 1px solid #555 !important;
    display: block;
    margin: 0 auto;
    outline: none;
  }
  
  #abel-log {
    margin-top: 10px; height: 100px; background-color: #111 !important;
    border: 1px solid #33ff33 !important; padding: 10px !important; overflow-y: auto !important;
    font-size: 13px !important; line-height: 1.5 !important;
  }
  #abel-log p { margin: 0 !important; padding-bottom: 2px !important; }
  #abel-log p.log-system { color: #bbbbbb !important; }
  #abel-log p.log-combat { color: #ff5555 !important; font-weight: bold !important; }
  #abel-log p.log-good { color: #55ffff !important; font-weight: bold !important; }
  #abel-log p.log-warn { color: #ffff55 !important; }

  /* 모달창 공통 */
  .abel-modal {
    display: none; position: absolute; top: 180px; left: 50px;
    width: 400px; height: 260px; background: rgba(0, 0, 0, 0.95) !important;
    border: 2px solid #ffff55 !important; border-radius: 8px !important; z-index: 10;
    padding: 20px !important; box-sizing: border-box !important; color: #fff !important; text-align: center;
  }
  .shop-btn {
    display: block; width: 100%; margin: 8px 0; padding: 8px !important;
    background: #333 !important; color: #fff !important; border: 1px solid #ffff55 !important; cursor: pointer;
    font-family: inherit !important; font-size: 14px !important;
  }
  .shop-btn:hover { background: #ffff55 !important; color: #000 !important; }
  .shop-btn-danger:hover { background: #ff5555 !important; color: #fff !important; border-color:#fff !important;}
&lt;/style&gt;

&lt;div id=&quot;abel-game-container&quot;&gt;
  &lt;div id=&quot;abel-game-header&quot;&gt;
    &lt;h2&gt;아벨의 유산: 성검의 부활&lt;/h2&gt;
    &lt;p style=&quot;margin: 5px 0 0 0; font-size: 12px; color: #aaa !important;&quot;&gt;성 안에서 제련소(⚒)를 찾아 장비를 강화하세요!&lt;/p&gt;
  &lt;/div&gt;
  
  &lt;div class=&quot;abel-ui-row&quot;&gt;
    &lt;span&gt;❤️ HP: &lt;span id=&quot;val-hp&quot; class=&quot;stat-val&quot;&gt;25&lt;/span&gt;/&lt;span id=&quot;val-maxhp&quot; class=&quot;stat-val&quot;&gt;25&lt;/span&gt;&lt;/span&gt;
    &lt;span&gt;⚔️ 공격: &lt;span id=&quot;val-atk&quot; class=&quot;stat-val&quot;&gt;3&lt;/span&gt;&lt;/span&gt;
    &lt;span&gt; ️ 방어: &lt;span id=&quot;val-def&quot; class=&quot;stat-val&quot;&gt;1&lt;/span&gt;&lt;/span&gt;
    &lt;span&gt;  골드: &lt;span id=&quot;val-gold&quot; class=&quot;stat-val&quot;&gt;0&lt;/span&gt;&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;abel-ui-row&quot; style=&quot;color: #aaa !important; font-size: 12px;&quot;&gt;
    &lt;span&gt;무기: &lt;span id=&quot;val-weapon&quot; class=&quot;stat-val&quot;&gt;맨주먹&lt;/span&gt;&lt;/span&gt;
    &lt;span&gt;방어구: &lt;span id=&quot;val-armor&quot; class=&quot;stat-val&quot;&gt;천옷&lt;/span&gt;&lt;/span&gt;
    &lt;span&gt;LV: &lt;span id=&quot;val-lv&quot; class=&quot;stat-val&quot;&gt;1&lt;/span&gt; (&lt;span id=&quot;val-exp&quot; class=&quot;stat-val&quot;&gt;0&lt;/span&gt;)&lt;/span&gt;
  &lt;/div&gt;

  &lt;div id=&quot;abel-shop-modal&quot; class=&quot;abel-modal&quot;&gt;
    &lt;h3 style=&quot;color: #ffff55 !important; margin-top: 0;&quot;&gt;마을 상점&lt;/h3&gt;
    &lt;p style=&quot;color: #fff !important;&quot;&gt;골드: &lt;span id=&quot;shop-gold&quot; style=&quot;color:#fff !important&quot;&gt;0&lt;/span&gt;G&lt;/p&gt;
    &lt;button class=&quot;shop-btn&quot; onclick=&quot;buyItem('weapon')&quot;&gt;강철검 구매 - 50G&lt;/button&gt;
    &lt;button class=&quot;shop-btn&quot; onclick=&quot;buyItem('armor')&quot;&gt;가죽갑옷 구매 - 40G&lt;/button&gt;
    &lt;button class=&quot;shop-btn&quot; onclick=&quot;buyItem('heal')&quot;&gt;완전 회복 - 10G&lt;/button&gt;
    &lt;button class=&quot;shop-btn&quot; onclick=&quot;closeModal('abel-shop-modal')&quot; style=&quot;background: #552222 !important;&quot;&gt;나가기&lt;/button&gt;
  &lt;/div&gt;

  &lt;div id=&quot;abel-smith-modal&quot; class=&quot;abel-modal&quot; style=&quot;border-color:#ff7700 !important;&quot;&gt;
    &lt;h3 style=&quot;color: #ff7700 !important; margin-top: 0;&quot;&gt;드워프 제련소&lt;/h3&gt;
    &lt;p style=&quot;color: #fff !important; font-size: 12px;&quot;&gt;1강은 안전, 2강부터 실패 시 파괴! (1회 30G)&lt;br/&gt;골드: &lt;span id=&quot;smith-gold&quot; style=&quot;color:#ffff55 !important&quot;&gt;0&lt;/span&gt;G&lt;/p&gt;
    &lt;button class=&quot;shop-btn shop-btn-danger&quot; onclick=&quot;upgradeItem('weapon')&quot;&gt;무기 강화 도전 (공+2)&lt;/button&gt;
    &lt;button class=&quot;shop-btn shop-btn-danger&quot; onclick=&quot;upgradeItem('armor')&quot;&gt;방어구 강화 도전 (방+2)&lt;/button&gt;
    &lt;button class=&quot;shop-btn&quot; onclick=&quot;closeModal('abel-smith-modal')&quot; style=&quot;background: #552222 !important;&quot;&gt;나가기&lt;/button&gt;
  &lt;/div&gt;

  &lt;canvas id=&quot;abel-canvas&quot; width=&quot;400&quot; height=&quot;400&quot; tabindex=&quot;0&quot;&gt;&lt;/canvas&gt;

  &lt;div id=&quot;abel-log&quot;&gt;
    &lt;p class=&quot;log-entry log-system&quot;&gt;여행을 시작합니다. 행운을 빕니다!&lt;/p&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
  const canvas = document.getElementById('abel-canvas');
  const ctx = canvas.getContext('2d');
  const TILE_SIZE = 40;

  const TILES = {
    0: { char: '.', color: '#22aa22', walk: true },
    1: { char: '▲', color: '#666666', walk: false },
    2: { char: '≈', color: '#3355ff', walk: false },
    3: { char: '♜', color: '#ffffff', walk: true },
    4: { char: '$', color: '#ffff55', walk: true },
    5: { char: '#', color: '#444444', walk: false },
    6: { char: '·', color: '#555555', walk: true }, // [수정됨] 성 바닥 텍스처와 밝기 추가
    7: { char: '▼', color: '#ffffff', walk: true },
    8: { char: 'K', color: '#ffcc00', walk: false },
    10: { char: '⚒', color: '#ff7700', walk: true }
  };

  const maps = {
    world: [
      [2,2,2,1,1,1,0,0,1,1],
      [2,0,0,0,1,0,0,0,0,1],
      [2,0,0,0,0,0,4,0,0,1],
      [1,0,0,1,1,0,0,0,0,2],
      [1,0,0,0,1,0,0,0,0,2],
      [1,0,3,0,0,0,0,0,1,1],
      [0,0,0,0,0,0,0,0,0,1],
      [0,0,0,1,0,0,1,1,0,1],
      [0,0,0,0,0,0,0,0,0,1],
      [2,2,2,1,1,0,0,0,1,1]
    ],
    castle: [
      [5,5,5,5,5,5,5,5,5,5],
      [5,6,6,6,8,6,6,6,6,5],
      [5,6,6,6,6,6,6,6,6,5],
      [5,6,10,5,5,5,5,6,6,5],
      [5,6,6,5,6,6,5,6,6,5],
      [5,6,6,5,6,6,5,6,6,5],
      [5,6,6,6,6,6,6,6,6,5],
      [5,5,6,6,6,6,6,6,5,5],
      [5,5,5,5,7,5,5,5,5,5],
      [5,5,5,5,5,5,5,5,5,5]
    ]
  };

  let currentMap = 'world';
  let activeMonsters = [];
  let activeFountains = [];

  let player = {
    x: 5, y: 5, hp: 25, maxHp: 25, lv: 1, exp: 0, gold: 0,
    baseAtk: 3, baseDef: 1,
    weapon: { name: '맨주먹', atk: 0, owned: false, level: 0 },
    armor: { name: '천옷', def: 0, owned: false, level: 0 },
    char: '@', color: '#ffffff'
  };

  function getPAtk() { return player.baseAtk + player.weapon.atk + (player.weapon.level * 2); }
  function getPDef() { return player.baseDef + player.armor.def + (player.armor.level * 2); }

  function addLog(msg, type = 'system') {
    const logDiv = document.getElementById('abel-log');
    const p = document.createElement('p');
    p.className = `log-entry log-${type}`;
    p.innerText = msg;
    logDiv.appendChild(p);
    if (logDiv.childElementCount &gt; 40) logDiv.removeChild(logDiv.firstElementChild);
    logDiv.scrollTop = logDiv.scrollHeight;
  }

  function updateUI() {
    document.getElementById('val-hp').innerText = player.hp;
    document.getElementById('val-maxhp').innerText = player.maxHp;
    document.getElementById('val-atk').innerText = getPAtk();
    document.getElementById('val-def').innerText = getPDef();
    document.getElementById('val-gold').innerText = player.gold;
    document.getElementById('val-lv').innerText = player.lv;
    document.getElementById('val-exp').innerText = player.exp;
    
    let wText = player.weapon.owned ? `${player.weapon.name}${player.weapon.level &gt; 0 ? ' +'+player.weapon.level : ''}` : player.weapon.name;
    let aText = player.armor.owned ? `${player.armor.name}${player.armor.level &gt; 0 ? ' +'+player.armor.level : ''}` : player.armor.name;
    
    document.getElementById('val-weapon').innerText = wText;
    document.getElementById('val-armor').innerText = aText;
  }

  function spawnMonster() {
    if (currentMap !== 'world' || activeMonsters.length &gt;= 5) return;
    let rx, ry;
    do {
      rx = Math.floor(Math.random() * 10);
      ry = Math.floor(Math.random() * 10);
    } while (maps[currentMap][ry][rx] !== 0 || (rx === player.x &amp;&amp; ry === player.y));

    activeMonsters.push({
      x: rx, y: ry, hp: 8 + player.lv, atk: 1 + player.lv, def: 0,
      char: 'M', color: '#ff3333'
    });
  }

  function spawnFountain() {
    if (activeFountains.length &gt;= 1) return; 
    let rx, ry;
    let isWalkable, isOccupied;
    do {
      rx = Math.floor(Math.random() * 10);
      ry = Math.floor(Math.random() * 10);
      isWalkable = (maps[currentMap][ry][rx] === 0 || maps[currentMap][ry][rx] === 6);
      isOccupied = (rx === player.x &amp;&amp; ry === player.y) || activeMonsters.some(m =&gt; m.x === rx &amp;&amp; m.y === ry);
    } while (!isWalkable || isOccupied);

    activeFountains.push({ x: rx, y: ry, map: currentMap, life: 15, char: '+', color: '#ff55ff' });
    addLog(`✨ 어딘가에 신비로운 생명의 샘이 솟아났습니다!`, 'good');
  }

  function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.font = '24px Courier New';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';

    let mapData = maps[currentMap];
    for (let y = 0; y &lt; 10; y++) {
      for (let x = 0; x &lt; 10; x++) {
        let tile = TILES[mapData[y][x]];
        ctx.fillStyle = tile.color;
        ctx.fillText(tile.char, x * TILE_SIZE + 20, y * TILE_SIZE + 20);
      }
    }

    activeFountains.forEach(f =&gt; {
      if (f.map === currentMap) { ctx.fillStyle = f.color; ctx.fillText(f.char, f.x * TILE_SIZE + 20, f.y * TILE_SIZE + 20); }
    });

    activeMonsters.forEach(m =&gt; {
      ctx.fillStyle = m.color; ctx.fillText(m.char, m.x * TILE_SIZE + 20, m.y * TILE_SIZE + 20);
    });

    ctx.fillStyle = player.color;
    ctx.fillText(player.char, player.x * TILE_SIZE + 20, player.y * TILE_SIZE + 20);
  }

  function handleBumpCombat(mIndex) {
    let m = activeMonsters[mIndex];
    let dmgToMonster = Math.max(1, getPAtk() - m.def);
    let dmgToPlayer = Math.max(0, m.atk - getPDef());

    m.hp -= dmgToMonster; player.hp -= dmgToPlayer;

    if (player.hp &lt;= 0) {
      player.hp = 0; addLog(`[사망] 몬스터에게 패배했습니다. (새로고침하여 재시작)`, 'combat');
      updateUI(); draw(); return;
    }

    if (m.hp &lt;= 0) {
      let earnedExp = 6 + player.lv; let earnedGold = 25 + Math.floor(Math.random() * 20);
      player.exp += earnedExp; player.gold += earnedGold;
      activeMonsters.splice(mIndex, 1);
      addLog(`⚔️ 처치! [${earnedGold}G 획득]`, 'good');
      
      if (player.exp &gt;= player.lv * 15) {
        player.lv++; player.exp = 0; player.maxHp += 5; player.hp = player.maxHp; player.baseAtk += 1;
        addLog(`  레벨업! (LV ${player.lv})`, 'good');
      }
    } else {
      addLog(`  타격! (적 HP:${m.hp} / 나에게 ${dmgToPlayer}피해)`, 'warn');
    }
  }

  function movePlayer(dx, dy) {
    if (player.hp &lt;= 0) return;
    if (document.getElementById('abel-shop-modal').style.display === 'block' || document.getElementById('abel-smith-modal').style.display === 'block') return;

    let nx = player.x + dx; let ny = player.y + dy;
    if (nx &lt; 0 || nx &gt;= 10 || ny &lt; 0 || ny &gt;= 10) return;

    let monsterIndex = activeMonsters.findIndex(m =&gt; m.x === nx &amp;&amp; m.y === ny);
    if (monsterIndex !== -1) { handleBumpCombat(monsterIndex); updateTurn(); return; }

    let tileId = maps[currentMap][ny][nx];

    if (tileId === 8) {
      addLog(`  국왕: &quot;오오 용사여, 무사하군! 왕가의 축복을 내리노라.&quot;`, 'good');
      if (player.hp &lt; player.maxHp) { player.hp = player.maxHp; addLog(`체력이 모두 회복되었습니다!`, 'good'); }
      updateTurn(); return;
    }

    if (!TILES[tileId].walk) return;

    player.x = nx; player.y = ny;

    if (tileId === 3) { currentMap = 'castle'; player.x = 4; player.y = 8; activeMonsters = []; addLog('성 안으로 들어왔습니다.'); }
    else if (tileId === 7) { currentMap = 'world'; player.x = 2; player.y = 6; addLog('필드로 나왔습니다.'); }
    else if (tileId === 4) { document.getElementById('shop-gold').innerText = player.gold; document.getElementById('abel-shop-modal').style.display = 'block'; }
    else if (tileId === 10) { document.getElementById('smith-gold').innerText = player.gold; document.getElementById('abel-smith-modal').style.display = 'block'; }

    let fIndex = activeFountains.findIndex(f =&gt; f.x === nx &amp;&amp; f.y === ny &amp;&amp; f.map === currentMap);
    if (fIndex !== -1) {
      let healAmt = 10; player.hp = Math.min(player.maxHp, player.hp + healAmt);
      addLog(`+ 맑은 샘물을 마시고 에너지를 회복했습니다! (+${healAmt}HP)`, 'good');
      activeFountains.splice(fIndex, 1);
    }

    updateTurn();
  }

  function updateTurn() {
    if (Math.random() &lt; 0.2) spawnMonster();
    if (Math.random() &lt; 0.1) spawnFountain();

    for (let i = activeFountains.length - 1; i &gt;= 0; i--) {
      activeFountains[i].life--;
      if (activeFountains[i].life &lt;= 0) {
        if (activeFountains[i].map === currentMap) addLog(`  생명의 샘이 흔적도 없이 말라버렸습니다.`, 'system');
        activeFountains.splice(i, 1);
      }
    }
    updateUI(); draw();
  }

  function closeModal(modalId) {
    document.getElementById(modalId).style.display = 'none';
    canvas.focus();
  }

  function buyItem(type) {
    if (type === 'weapon' &amp;&amp; player.gold &gt;= 50) {
      if(player.weapon.owned) { alert('이미 무기를 구매했습니다.'); return; }
      player.gold -= 50; player.weapon = { name: '강철검', atk: 6, owned: true, level: 0 };
      addLog('강철검을 구매했습니다!', 'good');
    } else if (type === 'armor' &amp;&amp; player.gold &gt;= 40) {
      if(player.armor.owned) { alert('이미 방어구를 구매했습니다.'); return; }
      player.gold -= 40; player.armor = { name: '가죽갑옷', def: 4, owned: true, level: 0 };
      addLog('가죽갑옷을 구매했습니다!', 'good');
    } else if (type === 'heal' &amp;&amp; player.gold &gt;= 10) {
      player.gold -= 10; player.hp = player.maxHp;
      addLog('완전 회복되었습니다.', 'good');
    } else {
      alert('골드가 부족하거나 이미 보유 중입니다.');
    }
    document.getElementById('shop-gold').innerText = player.gold; updateUI();
  }

  function upgradeItem(type) {
    let item = type === 'weapon' ? player.weapon : player.armor;
    if (!item.owned) { alert('장착 중인 장비가 없습니다. 상점에서 먼저 구매하세요.'); return; }
    if (player.gold &lt; 30) { alert('강화 비용(30G)이 부족합니다.'); return; }

    player.gold -= 30;
    
    let successRate = 0;
    if (item.level === 0) successRate = 1.0;
    else if (item.level === 1) successRate = 0.7;
    else if (item.level === 2) successRate = 0.5;
    else successRate = 0.3;

    let isSuccess = Math.random() &lt; successRate;

    if (isSuccess) {
      item.level++;
      addLog(`  [강화 성공] ${item.name}이(가) +${item.level}(으)로 강화되었습니다!`, 'good');
    } else {
      addLog(`  [강화 실패] 앗! 장비가 견디지 못하고 산산조각 났습니다...`, 'combat');
      if (type === 'weapon') player.weapon = { name: '맨주먹', atk: 0, owned: false, level: 0 };
      if (type === 'armor') player.armor = { name: '천옷', def: 0, owned: false, level: 0 };
    }

    document.getElementById('smith-gold').innerText = player.gold;
    updateUI();
  }

  canvas.addEventListener('keydown', (e) =&gt; {
    if([&quot;ArrowUp&quot;,&quot;ArrowDown&quot;,&quot;ArrowLeft&quot;,&quot;ArrowRight&quot;].indexOf(e.code) &gt; -1) e.preventDefault();
    if (e.key === 'ArrowUp') movePlayer(0, -1);
    if (e.key === 'ArrowDown') movePlayer(0, 1);
    if (e.key === 'ArrowLeft') movePlayer(-1, 0);
    if (e.key === 'ArrowRight') movePlayer(1, 0);
  });
  canvas.addEventListener('mousedown', () =&gt; canvas.focus());

  spawnMonster(); spawnMonster();
  updateUI(); draw();
&lt;/script&gt;</description>
      <category>게임</category>
      <category>html5게임</category>
      <category>고전RPG</category>
      <category>울티마스타일</category>
      <category>웹게임</category>
      <category>인디게임</category>
      <category>자바스크립트RPG</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/75</guid>
      <comments>https://bbibbabbbi.tistory.com/75#entry75comment</comments>
      <pubDate>Thu, 16 Apr 2026 22:15:34 +0900</pubDate>
    </item>
    <item>
      <title>사일로 시즌2 리뷰 | 애플TV+ SF 미스터리 드라마 추천</title>
      <link>https://bbibbabbbi.tistory.com/73</link>
      <description>&lt;div class=&quot;silo-review&quot; style=&quot;max-width: 860px; margin: 0 auto; color: #e5eefc; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Noto Sans KR',Arial,sans-serif; line-height: 1.78; word-break: keep-all;&quot;&gt;
&lt;style&gt;
    .silo-review, .silo-review * { box-sizing:border-box; }
    .silo-review a { color:#8ec5ff !important; text-decoration:none; }
    .silo-review a:hover { text-decoration:underline; }
    .silo-review .sr-wrap { background:#08111f; border:1px solid rgba(255,255,255,.08); border-radius:24px; padding:28px 22px; box-shadow:0 12px 36px rgba(0,0,0,.28); }
    .silo-review .sr-kicker { display:inline-block; margin-bottom:12px; padding:6px 12px; border-radius:999px; background:rgba(84,144,255,.12); color:#a9cbff !important; font-size:13px; font-weight:700; letter-spacing:.2px; }
    .silo-review h1, .silo-review h2, .silo-review h3 { color:#f7fbff !important; margin:0 0 14px; line-height:1.35; }
    .silo-review h1 { font-size:34px; font-weight:800; }
    .silo-review h2 { font-size:26px; margin-top:42px; }
    .silo-review h3 { font-size:20px; margin-top:24px; }
    .silo-review p, .silo-review li, .silo-review div, .silo-review span { color:#d6e2f5 !important; }
    .silo-review .sr-sub { font-size:17px; color:#b8cae6 !important; margin:0 0 18px; }
    .silo-review .sr-meta { display:flex; flex-wrap:wrap; gap:10px; margin:16px 0 24px; padding:0; list-style:none; }
    .silo-review .sr-meta li { background:#0d1a2d; border:1px solid rgba(255,255,255,.08); border-radius:999px; padding:8px 14px; font-size:14px; }
    .silo-review .sr-poster { margin:0 0 18px; overflow:hidden; border-radius:22px; border:1px solid rgba(255,255,255,.08); background:#0a1526; }
    .silo-review .sr-poster img { width:100%; display:block; }
    .silo-review .sr-caption { margin-top:8px; font-size:13px; color:#9db1d0 !important; }
    .silo-review .sr-grid { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:14px; margin:22px 0 8px; }
    .silo-review .sr-card { background:#0d1a2d; border:1px solid rgba(255,255,255,.08); border-radius:20px; padding:18px; }
    .silo-review .sr-card strong { display:block; color:#ffffff !important; margin-bottom:8px; font-size:18px; }
    .silo-review .sr-embed { position:relative; width:100%; padding-top:56.25%; margin:18px 0 8px; border-radius:22px; overflow:hidden; border:1px solid rgba(255,255,255,.08); background:#000; }
    .silo-review .sr-embed iframe { position:absolute; inset:0; width:100%; height:100%; border:0; }
    .silo-review .sr-quote { margin:18px 0; padding:16px 18px; border-left:4px solid #78b2ff; background:#0d1a2d; border-radius:14px; color:#dbe8fb !important; }
    .silo-review .sr-episodes { display:grid; gap:14px; margin-top:18px; }
    .silo-review .sr-episode { background:#0d1a2d; border:1px solid rgba(255,255,255,.08); border-radius:18px; padding:18px; }
    .silo-review .sr-episode .epno { display:inline-block; min-width:64px; margin-right:10px; padding:5px 10px; border-radius:999px; background:rgba(120,178,255,.12); color:#aed0ff !important; font-size:13px; font-weight:700; }
    .silo-review .sr-episode h3 { margin:8px 0 8px; font-size:21px; }
    .silo-review .sr-episode p { margin:8px 0 0; }
    .silo-review .sr-note { margin-top:18px; padding:14px 16px; border-radius:14px; background:#0b1524; border:1px dashed rgba(255,255,255,.12); font-size:14px; color:#adc2df !important; }
    .silo-review .sr-tags { margin-top:30px; padding-top:18px; border-top:1px solid rgba(255,255,255,.08); font-size:14px; color:#a9c1e1 !important; }
    @media (max-width: 768px) {
      .silo-review h1 { font-size:28px; }
      .silo-review h2 { font-size:23px; }
      .silo-review .sr-grid { grid-template-columns:1fr; }
      .silo-review .sr-wrap { padding:22px 16px; border-radius:20px; }
    }
  &lt;/style&gt;
&lt;!-- SEO 제목 제안: 사일로 시즌2 리뷰 | 애플TV+ SF 미스터리 드라마 추천 --&gt; &lt;!-- 해시태그 제안: #사일로 #사일로시즌2 #Silo #애플TV플러스 #미국드라마추천 #SF드라마 #미스터리드라마 #드라마리뷰 #레베카퍼거슨 --&gt;
&lt;div class=&quot;sr-wrap&quot;&gt;
&lt;div class=&quot;sr-kicker&quot;&gt;Apple TV+ &amp;middot; SF 미스터리 드라마&lt;/div&gt;
&lt;h1&gt;사일로 시즌2 리뷰 | 더 깊어지고 더 넓어진 애플TV+ SF 미스터리&lt;/h1&gt;
&lt;p class=&quot;sr-sub&quot; data-ke-size=&quot;size16&quot;&gt;시즌1이 &amp;lsquo;질문&amp;rsquo;을 던졌다면, 시즌2는 그 질문이 시스템 전체를 어떻게 흔드는지 보여줍니다. 줄리엣의 생존과 사일로 내부의 균열이 동시에 전개되면서, 폐쇄 공간 스릴러가 본격적인 저항 서사로 확장됩니다.&lt;/p&gt;
&lt;ul class=&quot;sr-meta&quot; style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;장르: SF &amp;middot; 미스터리 &amp;middot; 디스토피아&lt;/li&gt;
&lt;li&gt;플랫폼: Apple TV+&lt;/li&gt;
&lt;li&gt;시즌2: 총 10부작&lt;/li&gt;
&lt;li&gt;추천 대상: 느린 몰입형 세계관 드라마를 좋아하는 분&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;sr-poster&quot;&gt;&lt;img src=&quot;https://www.apple.com/tv-pr/shows-and-films/s/silo/images/season-02/show-home-graphic-header/key-art-01/4x1/Apple_TV_Silo_key_art_graphic_header_4_1_show_home.jpg.large_2x.jpg&quot; alt=&quot;사일로 시즌2 공식 포스터 키아트&quot; /&gt;&lt;/figure&gt;
&lt;div class=&quot;sr-caption&quot;&gt;공식 키아트: Apple TV Press 제공&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;한눈에 보는 시즌2 총평&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사일로 시즌2의 가장 큰 장점은 &lt;b&gt;세계관을 넓히면서도 긴장을 잃지 않는 점&lt;/b&gt;입니다. 시즌1이 사일로라는 구조 안에서 &amp;lsquo;무엇이 숨겨져 있는가&amp;rsquo;를 파고들었다면, 시즌2는 그 비밀이 한 사일로만의 문제가 아니라는 감각을 훨씬 선명하게 만들어 줍니다. 덕분에 이야기는 단순한 미스터리 추적극을 넘어, 체제와 통제, 기억과 진실, 그리고 생존 이후의 선택을 묻는 방향으로 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 이번 시즌은 줄리엣의 외부 서사와 사일로 내부의 정치적 긴장이 교차 편집되면서 몰입감을 끌어올립니다. 한쪽에서는 살아남기 위한 고립의 공포가, 다른 한쪽에서는 무너져 가는 질서를 붙잡으려는 권력의 조급함이 커집니다. 그래서 시즌2는 전개가 조용한 장면에서도 계속 압박감이 느껴집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 시즌1보다 더 차분하게 쌓아 올리는 구간도 있어서, 빠른 전개만 기대하면 조금 느리게 느껴질 수 있습니다. 그 대신 중후반으로 갈수록 인물들의 위치와 목적이 또렷해지고, 마지막으로 갈수록 다음 시즌을 궁금하게 만드는 힘이 분명합니다. 한마디로 정리하면, 시즌2는 &lt;b&gt;사일로라는 작품을 &amp;lsquo;재미있는 설정의 드라마&amp;rsquo;에서 &amp;lsquo;완성도 높은 장기 서사&amp;rsquo;로 끌어올린 시즌&lt;/b&gt;입니다.&lt;/p&gt;
&lt;div class=&quot;sr-grid&quot;&gt;
&lt;div class=&quot;sr-card&quot;&gt;&lt;b&gt;1. 세계관 확장&lt;/b&gt; 시즌1의 폐쇄감은 유지하면서도, 사일로 바깥과 다른 공간의 존재감을 더 강하게 드러냅니다.&lt;/div&gt;
&lt;div class=&quot;sr-card&quot;&gt;&lt;b&gt;2. 정치 스릴러 강화&lt;/b&gt; 내부 질서가 흔들리면서 권력, 감시, 정보 통제가 한층 더 전면으로 나옵니다.&lt;/div&gt;
&lt;div class=&quot;sr-card&quot;&gt;&lt;b&gt;3. 줄리엣의 존재감&lt;/b&gt; 레베카 퍼거슨은 시즌2에서도 감정선과 긴장감을 가장 안정적으로 끌고 갑니다.&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공식 예고편&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 영상은 Apple TV가 공개한 &lt;b&gt;사일로 시즌2 공식 트레일러&lt;/b&gt;입니다. 본문 중간에 넣어두면 체류 시간과 완성도가 함께 좋아집니다.&lt;/p&gt;
&lt;div class=&quot;sr-embed&quot;&gt;&lt;iframe src=&quot;https://www.youtube.com/embed/CLJDR-vFUdY&quot; width=&quot;300&quot; height=&quot;225&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;&lt;/div&gt;
&lt;div class=&quot;sr-quote&quot;&gt;시즌1을 재미있게 봤다면 시즌2는 거의 필수 감상에 가깝습니다. 이야기의 규모와 의미가 확실히 커지기 때문입니다.&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;회차별 간략 내용&lt;/h2&gt;
&lt;div class=&quot;sr-episodes&quot;&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;1화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;The Engineer&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;줄리엣은 전쟁으로 오래전에 무너진 다른 사일로에서 피난처를 찾고, 자신이 혼자라고 생각합니다. 시즌2의 출발점은 고립과 생존의 감각을 강하게 밀어붙이며, 기존 사일로와는 다른 분위기를 단번에 보여줍니다.&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;2화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Order&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;줄리엣이 이전까지 아무도 하지 않았던 일을 한 뒤, 사일로 18 내부에는 충격과 동요가 퍼집니다. 내부 질서가 흔들리기 시작하며 시즌 전체의 정치적 갈등이 본격적으로 달아오릅니다.&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;3화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Solo&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버나드는 메도스 판사에게 도움을 요청하고, 빌링스는 자신이 속고 있는 건 아닌지 의심하기 시작합니다. 줄리엣은 마침내 금고 속 남자와 만나며, 시즌2의 핵심 축 하나가 선명해집니다.&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;4화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;The Harmonium&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;줄리엣은 자신의 사일로로 돌아가 모두를 구하기 위해 위험한 여정에 나섭니다. 동시에 기계실 쪽 인물들은 메도스 판사와의 접점을 만들려 하며, 내부와 외부 서사가 균형 있게 맞물립니다.&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;5화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Descent&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;심스는 새로운 위치에 놓이고, 줄리엣은 솔로가 모든 진실을 말하고 있지 않다는 사실을 깨닫습니다. 버나드와 루카스가 중요한 발견을 하면서 미스터리가 더 넓은 방향으로 열립니다.&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;6화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Barricades&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버나드는 녹스, 셜리, 워커를 끌어내기 위한 계획을 가동하고, 빌링스는 스스로 입장을 정하려 합니다. 줄리엣은 솔로에게 목숨을 구원받지만, 그 대가로 새로운 요구를 받게 됩니다.&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;7화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;The Dive&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;루카스는 임무를 부여받고, 기계실 사람들은 강한 메시지를 던집니다. 줄리엣은 위험한 하강에 나섰다가 또 다른 위협과 마주하며, 이번 시즌 특유의 긴박한 생존 서사가 살아납니다.&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;8화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;The Book of Quinn&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;줄리엣은 솔로에게 무언가 일이 생겼다는 사실을 알아차리고, 버나드는 워커에게 제안을 건넵니다. 루카스는 살바도르 퀸의 후손들을 만나며 사일로의 과거를 푸는 단서에 가까워집니다.&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;9화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;The Safeguard&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌링스의 믿음은 흔들리고, 루카스는 더 아래층으로 향합니다. 녹스는 배신자를 특정하고, 줄리엣은 솔로의 과거에 대한 진실을 마주합니다. 시즌 피날레 직전의 압박감이 매우 강한 화입니다.&lt;/p&gt;
&lt;/section&gt;
&lt;section class=&quot;sr-episode&quot;&gt;&lt;span class=&quot;epno&quot;&gt;10화&lt;/span&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Into the Fire&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시즌 피날레. 반란 세력이 움직이기 시작하고 줄리엣 역시 결단에 나섭니다. 시즌2는 여기서 큰 갈등을 정리하는 동시에, 더 큰 이야기가 남아 있음을 강하게 예고합니다.&lt;/p&gt;
&lt;/section&gt;
&lt;/div&gt;</description>
      <category>영화&amp;amp;애니 리뷰</category>
      <category>사일로 #사일로시즌2 #Silo #애플TV플러스 #미국드라마추천 #SF드라마 #미스터리드라마 #드라마리뷰 #레베카퍼거슨</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/73</guid>
      <comments>https://bbibbabbbi.tistory.com/73#entry73comment</comments>
      <pubDate>Mon, 23 Mar 2026 11:50:10 +0900</pubDate>
    </item>
    <item>
      <title>사일로 시즌1 리뷰 | 애플TV+ SF 미스터리 드라마 추천</title>
      <link>https://bbibbabbbi.tistory.com/72</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
  &lt;meta charset=&quot;UTF-8&quot; /&gt;
  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
  &lt;title&gt;사일로 시즌1 리뷰 | 애플TV+ SF 미스터리 드라마 추천&lt;/title&gt;
  &lt;meta name=&quot;description&quot; content=&quot;애플TV+ 미국 드라마 사일로 시즌1 리뷰. 공식 키아트와 공식 예고편을 함께 정리하고, 스포일러를 과하게 늘리지 않는 방식으로 작품의 분위기·장점·아쉬운 점을 깔끔하게 정리했습니다.&quot; /&gt;
  &lt;meta name=&quot;keywords&quot; content=&quot;사일로 시즌1 리뷰, Silo 리뷰, 애플TV+ 드라마 추천, 미국 드라마 리뷰, SF 미스터리 드라마, 레베카 퍼거슨&quot; /&gt;
  &lt;meta property=&quot;og:type&quot; content=&quot;article&quot; /&gt;
  &lt;meta property=&quot;og:title&quot; content=&quot;사일로 시즌1 리뷰 | 애플TV+ SF 미스터리 드라마 추천&quot; /&gt;
  &lt;meta property=&quot;og:description&quot; content=&quot;공식 키아트와 공식 예고편과 함께 보는 사일로 시즌1 리뷰. 폐쇄 공간, 정보 통제, 진실 추적의 긴장감을 깔끔하게 정리했습니다.&quot; /&gt;
  &lt;meta property=&quot;og:image&quot; content=&quot;https://www.apple.com/tv-pr/articles/2023/04/apple-tv-brings-hugh-howeys-bestselling-trilogy-to-life-in-new-trailer-for-silo-premiering-globally-friday-may-5/images/big-image/big-image-01/Apple_TV_Silo_key_art_big_image_post.jpg.large.jpg&quot; /&gt;
  &lt;meta name=&quot;twitter:card&quot; content=&quot;summary_large_image&quot; /&gt;
  &lt;meta name=&quot;twitter:title&quot; content=&quot;사일로 시즌1 리뷰 | 애플TV+ SF 미스터리 드라마 추천&quot; /&gt;
  &lt;meta name=&quot;twitter:description&quot; content=&quot;공식 키아트와 공식 예고편과 함께 보는 사일로 시즌1 리뷰.&quot; /&gt;
  &lt;meta name=&quot;twitter:image&quot; content=&quot;https://www.apple.com/tv-pr/articles/2023/04/apple-tv-brings-hugh-howeys-bestselling-trilogy-to-life-in-new-trailer-for-silo-premiering-globally-friday-may-5/images/big-image/big-image-01/Apple_TV_Silo_key_art_big_image_post.jpg.large.jpg&quot; /&gt;
  &lt;script type=&quot;application/ld+json&quot;&gt;
  {
    &quot;@context&quot;: &quot;https://schema.org&quot;,
    &quot;@type&quot;: &quot;Review&quot;,
    &quot;itemReviewed&quot;: {
      &quot;@type&quot;: &quot;TVSeries&quot;,
      &quot;name&quot;: &quot;Silo&quot;,
      &quot;numberOfEpisodes&quot;: 10,
      &quot;genre&quot;: [&quot;Science Fiction&quot;, &quot;Mystery&quot;, &quot;Drama&quot;],
      &quot;publisher&quot;: {
        &quot;@type&quot;: &quot;Organization&quot;,
        &quot;name&quot;: &quot;Apple TV+&quot;
      }
    },
    &quot;author&quot;: {
      &quot;@type&quot;: &quot;Person&quot;,
      &quot;name&quot;: &quot;Company Rabbit&quot;
    },
    &quot;reviewRating&quot;: {
      &quot;@type&quot;: &quot;Rating&quot;,
      &quot;ratingValue&quot;: &quot;4.5&quot;,
      &quot;bestRating&quot;: &quot;5&quot;
    },
    &quot;reviewBody&quot;: &quot;사일로 시즌1은 폐쇄 공간과 정보 통제, 계급 구조, 진실 추적을 치밀하게 엮은 SF 미스터리 드라마다. 초반은 차분하게 세계관을 쌓고, 중반 이후에는 긴장과 몰입감이 점점 커진다.&quot;
  }
  &lt;/script&gt;
  &lt;style&gt;
    .silo-review, .silo-review * { box-sizing: border-box; }
    .silo-review {
      max-width: 860px;
      margin: 0 auto;
      padding: 24px 20px 48px;
      color: #e7eef9 !important;
      font-family: -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto, &quot;Noto Sans KR&quot;, Arial, sans-serif;
      line-height: 1.78;
      word-break: keep-all;
    }
    .silo-review h1, .silo-review h2, .silo-review h3,
    .silo-review p, .silo-review li, .silo-review div, .silo-review span,
    .silo-review strong, .silo-review em, .silo-review a {
      color: inherit !important;
    }
    .silo-review .hero,
    .silo-review .section,
    .silo-review .info-box,
    .silo-review .quote-box,
    .silo-review .recommend-box,
    .silo-review .video-box,
    .silo-review .tags-box {
      background: #0d1626 !important;
      border: 1px solid rgba(148, 163, 184, 0.16) !important;
      border-radius: 22px;
      box-shadow: 0 10px 30px rgba(0,0,0,0.16);
    }
    .silo-review .hero { padding: 28px; margin-bottom: 22px; }
    .silo-review .eyebrow {
      display: inline-block;
      margin-bottom: 12px;
      padding: 6px 12px;
      border-radius: 999px;
      background: rgba(96, 165, 250, 0.12) !important;
      color: #b9d6ff !important;
      font-size: 13px;
      font-weight: 700;
      letter-spacing: .02em;
    }
    .silo-review h1 {
      margin: 0 0 14px;
      font-size: 38px;
      line-height: 1.25;
      font-weight: 800;
      color: #ffffff !important;
    }
    .silo-review h2 {
      margin: 0 0 14px;
      font-size: 28px;
      line-height: 1.35;
      font-weight: 800;
      color: #ffffff !important;
    }
    .silo-review h3 {
      margin: 0 0 10px;
      font-size: 21px;
      line-height: 1.4;
      font-weight: 700;
      color: #ffffff !important;
    }
    .silo-review p { margin: 0 0 16px; font-size: 17px; color: #dbe7f6 !important; }
    .silo-review .lead {
      font-size: 18px;
      color: #f2f7ff !important;
    }
    .silo-review .meta {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      margin: 18px 0 0;
    }
    .silo-review .meta span {
      display: inline-flex;
      align-items: center;
      padding: 8px 12px;
      border-radius: 999px;
      background: rgba(255,255,255,0.05) !important;
      color: #d5e4fb !important;
      font-size: 14px;
      font-weight: 600;
    }
    .silo-review .poster-wrap { margin: 24px 0 6px; }
    .silo-review img.poster {
      width: 100%;
      display: block;
      border-radius: 18px;
      border: 1px solid rgba(255,255,255,0.08);
    }
    .silo-review .caption {
      margin-top: 10px;
      font-size: 13px;
      color: #9db1cd !important;
    }
    .silo-review .section { padding: 28px; margin-top: 22px; }
    .silo-review .info-grid {
      display: grid;
      grid-template-columns: repeat(3, minmax(0,1fr));
      gap: 14px;
      margin-top: 20px;
    }
    .silo-review .info-box,
    .silo-review .quote-box,
    .silo-review .recommend-box,
    .silo-review .video-box,
    .silo-review .tags-box { padding: 22px; }
    .silo-review .info-box strong,
    .silo-review .recommend-box strong { display: block; margin-bottom: 10px; font-size: 18px; color: #ffffff !important; }
    .silo-review .info-box p,
    .silo-review .recommend-box p { margin: 0; font-size: 15px; color: #d7e3f2 !important; }
    .silo-review .video-box iframe {
      width: 100%;
      aspect-ratio: 16 / 9;
      border: 0;
      border-radius: 16px;
      display: block;
      margin-top: 14px;
    }
    .silo-review ul.clean-list {
      margin: 6px 0 0;
      padding-left: 20px;
    }
    .silo-review ul.clean-list li {
      margin-bottom: 10px;
      font-size: 17px;
      color: #dbe7f6 !important;
    }
    .silo-review .rating {
      font-size: 17px;
      font-weight: 700;
      color: #ffe08a !important;
      margin-top: 6px;
    }
    .silo-review .tags-box {
      margin-top: 20px;
      background: #101a2c !important;
    }
    .silo-review .tags-line {
      font-size: 15px;
      color: #bcd1ee !important;
      line-height: 1.9;
    }
    .silo-review a.inline-link {
      color: #9fd0ff !important;
      text-decoration: underline;
      text-underline-offset: 3px;
    }
    .silo-review .divider {
      height: 1px;
      margin: 24px 0;
      background: linear-gradient(90deg, rgba(255,255,255,0.0), rgba(255,255,255,0.16), rgba(255,255,255,0.0));
      border: 0;
    }
    @media (max-width: 720px) {
      .silo-review { padding: 16px 8px 36px; }
      .silo-review .hero, .silo-review .section { padding: 22px 18px; }
      .silo-review h1 { font-size: 30px; }
      .silo-review h2 { font-size: 24px; }
      .silo-review p, .silo-review ul.clean-list li { font-size: 16px; }
      .silo-review .info-grid { grid-template-columns: 1fr; }
    }
  &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;article class=&quot;silo-review&quot;&gt;
    &lt;section class=&quot;hero&quot;&gt;
      &lt;div class=&quot;eyebrow&quot;&gt;애플TV+ SF 미스터리 드라마 리뷰&lt;/div&gt;
      &lt;h1&gt;사일로 시즌1 리뷰 | 애플TV+에서 만나는 묵직한 SF 미스터리&lt;/h1&gt;
      &lt;p class=&quot;lead&quot;&gt;《사일로》 시즌1은 “밖은 왜 위험한가”, “누가 진실을 통제하는가”, “사람들은 왜 의심하지 않게 되었는가”를 끝까지 밀어붙이는 작품입니다. 거대한 세계관을 한 번에 설명하기보다, 인물의 선택과 사건의 흔들림을 따라가며 천천히 퍼즐을 맞추게 만드는 방식이 특히 인상적입니다.&lt;/p&gt;
      &lt;div class=&quot;meta&quot;&gt;
        &lt;span&gt;장르: SF · 미스터리 · 드라마&lt;/span&gt;
        &lt;span&gt;시즌1 총 10부작&lt;/span&gt;
        &lt;span&gt;플랫폼: Apple TV+&lt;/span&gt;
      &lt;/div&gt;
      &lt;div class=&quot;poster-wrap&quot;&gt;
        &lt;img class=&quot;poster&quot; src=&quot;https://www.apple.com/tv-pr/articles/2023/04/apple-tv-brings-hugh-howeys-bestselling-trilogy-to-life-in-new-trailer-for-silo-premiering-globally-friday-may-5/images/big-image/big-image-01/Apple_TV_Silo_key_art_big_image_post.jpg.large.jpg&quot; alt=&quot;애플TV+ 드라마 사일로 공식 키아트&quot; loading=&quot;lazy&quot; /&gt;
        &lt;p class=&quot;caption&quot;&gt;공식 이미지: Apple TV Press 제공 공식 키아트&lt;/p&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;section class=&quot;section&quot;&gt;
      &lt;h2&gt;사일로 시즌1 한눈에 보기&lt;/h2&gt;
      &lt;p&gt;독성으로 오염된 바깥세상 때문에 인류가 거대한 지하 구조물 안에서 살아간다는 설정만으로도 흥미롭지만, 《사일로》는 거기서 멈추지 않습니다. 이 드라마는 생존 규칙, 계급 구조, 정보 통제, 그리고 금기 뒤에 숨은 진실을 차분하면서도 집요하게 파고듭니다.&lt;/p&gt;
      &lt;p&gt;특히 레베카 퍼거슨이 연기한 줄리엣은 영웅적인 방식보다도, 묵묵하게 흔적을 추적하고 의심을 멈추지 않는 인물로 그려집니다. 덕분에 시청자는 줄리엣을 따라가면서 사일로라는 세계를 더 깊게 이해하게 됩니다.&lt;/p&gt;
      &lt;div class=&quot;info-grid&quot;&gt;
        &lt;div class=&quot;info-box&quot;&gt;
          &lt;strong&gt;1. 폐쇄 공간의 압박감&lt;/strong&gt;
          &lt;p&gt;끝없이 이어지는 계단과 층별 구조만으로도 권력, 계급, 불안을 시각적으로 전달합니다.&lt;/p&gt;
        &lt;/div&gt;
        &lt;div class=&quot;info-box&quot;&gt;
          &lt;strong&gt;2. 질문이 질문을 부르는 전개&lt;/strong&gt;
          &lt;p&gt;비밀을 급하게 소비하지 않고, 의심의 조각을 차곡차곡 쌓으며 몰입도를 끌어올립니다.&lt;/p&gt;
        &lt;/div&gt;
        &lt;div class=&quot;info-box&quot;&gt;
          &lt;strong&gt;3. 감정선이 살아 있는 미스터리&lt;/strong&gt;
          &lt;p&gt;세계관만 차갑게 보이지 않도록, 줄리엣의 상실과 선택이 이야기의 중심을 단단히 잡아줍니다.&lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/section&gt;

    &lt;section class=&quot;section video-box&quot;&gt;
      &lt;h2&gt;공식 예고편&lt;/h2&gt;
      &lt;p&gt;아래 영상은 Apple TV가 공개한 《사일로》 시즌1 공식 예고편입니다. 글 중간에 배치하면 체류 시간과 콘텐츠 완성도를 함께 높이기 좋습니다.&lt;/p&gt;
      &lt;iframe src=&quot;https://www.youtube.com/embed/8ZYhuvIv1pA&quot; width=&quot;300&quot; height=&quot;225&quot; allow=&quot;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share&quot; frameborder=&quot;&quot; allowfullscreen=&quot;true&quot;&gt;&lt;/iframe&gt;
    &lt;/section&gt;

    &lt;section class=&quot;section&quot;&gt;
      &lt;h2&gt;전체 리뷰&lt;/h2&gt;
      &lt;p&gt;《사일로》 시즌1의 가장 큰 장점은 단순히 설정이 흥미로운 SF에 머물지 않는다는 점입니다. 드라마는 “이 세계가 왜 이렇게 유지되는가”를 구조적으로 보여줍니다. 사람들은 규칙을 따르고, 질문은 위험해지며, 진실은 권력과 함께 움직입니다. 이런 세계를 설명하는 방식이 과하지 않고 차분해서 더 무섭게 느껴집니다.&lt;/p&gt;
      &lt;p&gt;또한 이 작품은 공간 연출이 매우 뛰어납니다. 사일로는 단순한 배경이 아니라, 권력과 노동, 정보 격차가 층층이 쌓인 구조물처럼 기능합니다. 그래서 인물들이 어느 층에 서 있는지, 어떤 직업을 갖고 있는지, 누구와 연결되는지만 봐도 긴장감이 달라집니다.&lt;/p&gt;
      &lt;p&gt;배우들의 연기도 안정적입니다. 특히 레베카 퍼거슨은 감정을 과장하지 않으면서도, 불신과 상실, 집요함을 설득력 있게 끌고 갑니다. 줄리엣이라는 인물이 단순한 주인공이 아니라 이 세계의 균열을 발견하는 관찰자로 작동하기 때문에, 시즌 전체의 몰입감이 더 살아납니다.&lt;/p&gt;
      &lt;hr class=&quot;divider&quot; /&gt;
      &lt;h3&gt;좋았던 점&lt;/h3&gt;
      &lt;ul class=&quot;clean-list&quot;&gt;
        &lt;li&gt;세계관을 한 번에 설명하지 않고 서서히 드러내는 방식이 매력적입니다.&lt;/li&gt;
        &lt;li&gt;폐쇄 공간의 답답함과 통제 사회의 공포를 시각적으로 잘 살립니다.&lt;/li&gt;
        &lt;li&gt;주인공의 감정선이 살아 있어 차가운 설정극으로 끝나지 않습니다.&lt;/li&gt;
      &lt;/ul&gt;
      &lt;h3 style=&quot;margin-top:24px;&quot;&gt;아쉬운 점&lt;/h3&gt;
      &lt;ul class=&quot;clean-list&quot;&gt;
        &lt;li&gt;초반 전개가 아주 빠른 편은 아니라서, 즉각적인 사건 폭발을 기대하면 다소 느리게 느낄 수 있습니다.&lt;/li&gt;
        &lt;li&gt;액션 중심 작품을 기대한 시청자보다는 분위기와 미스터리 축적을 좋아하는 쪽에 더 잘 맞습니다.&lt;/li&gt;
      &lt;/ul&gt;
      &lt;p class=&quot;rating&quot;&gt;총평 평점: 4.5 / 5&lt;/p&gt;
    &lt;/section&gt;

    &lt;section class=&quot;section recommend-box&quot;&gt;
      &lt;h2&gt;이런 분께 추천합니다&lt;/h2&gt;
      &lt;p&gt;디스토피아 세계관을 좋아하는 분, 미스터리를 한 번에 다 푸는 작품보다 조금씩 진실에 접근하는 방식을 선호하는 분, 그리고 영상미와 공간 연출이 강한 SF 드라마를 찾는 분께 특히 잘 맞습니다.&lt;/p&gt;
      &lt;p&gt;반대로 빠른 액션이나 즉각적인 반전 중심 전개를 기대한다면 초반은 느리게 느껴질 수 있습니다. 하지만 중반 이후부터는 긴장감이 확실히 살아나기 때문에, 차분하게 몰입하는 타입의 시청자에게는 만족도가 높은 작품입니다.&lt;/p&gt;
    &lt;/section&gt;

    &lt;section class=&quot;section&quot;&gt;
      &lt;h2&gt;마무리&lt;/h2&gt;
      &lt;p&gt;《사일로》 시즌1은 폐쇄된 공간의 공포와 정보 통제의 불안을 차분하게 축적하면서도, 마지막에는 더 큰 질문을 남기는 드라마입니다. 단순히 “설정이 신선한 작품”이 아니라, 왜 사람들은 시스템을 믿게 되고 또 왜 어떤 사람은 끝내 의심을 멈추지 못하는지를 묻는 작품이라는 점에서 오래 남습니다.&lt;/p&gt;
      &lt;p&gt;한 편씩 가볍게 소비하기보다는, 분위기와 단서를 차근차근 따라가며 보는 쪽이 훨씬 잘 맞는 작품입니다. SF 미스터리 장르를 좋아한다면 충분히 추천할 만합니다.&lt;/p&gt;
      &lt;p&gt;공식 정보와 영상은 &lt;a class=&quot;inline-link&quot; href=&quot;https://www.apple.com/tv-pr/originals/silo/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Apple TV Press&lt;/a&gt;와 &lt;a class=&quot;inline-link&quot; href=&quot;https://www.youtube.com/watch?v=8ZYhuvIv1pA&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Apple TV 공식 유튜브&lt;/a&gt;에서 확인할 수 있습니다.&lt;/p&gt;
    &lt;/section&gt;
    
  &lt;/article&gt;
&lt;/body&gt;
&lt;/html&gt;</description>
      <category>영화&amp;amp;애니 리뷰</category>
      <category>사일로 #Silo #사일로시즌1 #애플TV플러스 #미국드라마추천 #SF드라마 #디스토피아드라마 #드라마리뷰 #레베카퍼거슨</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/72</guid>
      <comments>https://bbibbabbbi.tistory.com/72#entry72comment</comments>
      <pubDate>Mon, 23 Mar 2026 11:17:46 +0900</pubDate>
    </item>
    <item>
      <title>데이터 러시</title>
      <link>https://bbibbabbbi.tistory.com/70</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;title&gt;DATA RUSH — 데이터 러시&lt;/title&gt;
&lt;style&gt;
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&amp;family=Exo+2:wght@400;600;700;900&amp;display=swap');
/* Share Tech Mono: SIL OFL 1.1 | Exo 2: SIL OFL 1.1 */

:root {
  --bg:   #010a14;
  --cyan: #00e8ff;
  --pink: #ff0099;
  --lime: #aaff22;
  --yel:  #ffdd00;
  --wall: #005577;
  --bdr:  #00e8ff1a;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
  background:var(--bg);
  display:flex; justify-content:center; align-items:flex-start;
  min-height:100vh; padding:20px 0 40px;
  font-family:'Exo 2',sans-serif;
  overflow-y:auto; overflow-x:hidden;
}
#gw { position:relative; width:480px; }

/* HEADER */
#hd {
  text-align:center; padding:10px 0 7px;
  background:linear-gradient(180deg,#010508,var(--bg));
  border:1px solid var(--bdr); border-bottom:none; position:relative; overflow:hidden;
}
#hd::before {
  content:''; position:absolute; inset:0;
  background:repeating-linear-gradient(0deg,transparent 0,transparent 3px,#00e8ff05 3px,#00e8ff05 4px);
}
#hd h1 {
  font-family:'Share Tech Mono',monospace; font-size:20px;
  letter-spacing:7px; position:relative;
  background:linear-gradient(90deg,var(--cyan),#88ffee,var(--pink),var(--cyan));
  background-size:300% auto; -webkit-background-clip:text; -webkit-text-fill-color:transparent;
  animation:hshift 5s linear infinite; filter:drop-shadow(0 0 10px #00e8ff55);
}
@keyframes hshift{0%{background-position:0%}100%{background-position:300%}}

/* HUD */
#hud {
  display:flex; justify-content:space-between; align-items:stretch;
  background:#040e18; border-left:1px solid var(--bdr); border-right:1px solid var(--bdr);
}
.hc {
  flex:1; display:flex; flex-direction:column;
  align-items:center; justify-content:center; padding:5px 4px;
  border-right:1px solid #00e8ff0d; gap:1px;
}
.hc:last-child{border-right:none;}
.hl{ font-family:'Share Tech Mono',monospace; font-size:7px; color:#224433; letter-spacing:2px; }
.hv{ font-family:'Share Tech Mono',monospace; font-size:12px; line-height:1; }
#sV{ color:var(--yel); text-shadow:0 0 8px #ffdd0055; }
#liV{ color:var(--pink); text-shadow:0 0 8px #ff009955; }
#lV{ color:#aa88ff; text-shadow:0 0 8px #aa88ff44; }
#dV{ color:var(--cyan); text-shadow:0 0 8px #00e8ff44; }

#muBtn {
  font-family:'Share Tech Mono',monospace; font-size:8px;
  padding:3px 7px; background:transparent;
  border:1px solid #00e8ff33; color:#00e8ff77;
  cursor:pointer; border-radius:2px; align-self:center;
  margin:0 4px; transition:all .15s; white-space:nowrap;
}
#muBtn:hover{ background:#00e8ff15; color:var(--cyan); }
#muBtn.on{ color:var(--cyan); border-color:var(--cyan); }

/* CANVAS */
canvas { display:block; border:1px solid var(--bdr); border-top:none; cursor:default; }

/* OVERLAY */
#ov {
  position:absolute; top:38px; left:0; right:0; bottom:44px;
  display:flex; flex-direction:column; justify-content:center; align-items:center;
  background:rgba(1,8,18,.94); z-index:10; gap:14px;
  border-left:1px solid var(--bdr); border-right:1px solid var(--bdr);
}
#ov.hidden{display:none;}
#ov h2 {
  font-family:'Share Tech Mono',monospace; font-size:26px;
  text-align:center; line-height:1.6; color:var(--cyan);
  text-shadow:0 0 22px #00e8ff88,0 0 44px #00e8ff33;
}
.os{ font-size:14px; color:#446655; letter-spacing:1.5px; text-align:center; line-height:2; }
.or{ font-family:'Share Tech Mono',monospace; font-size:13px; color:var(--yel);
     text-shadow:0 0 10px #ffdd0055; text-align:center; line-height:2.1; }
.or.hidden{display:none;}

/* virus legend */
.vleg{ display:flex; gap:12px; flex-wrap:wrap; justify-content:center; font-size:11px; color:#445566; }
.vleg span{ display:flex; align-items:center; gap:5px; }
.vdot{ width:11px; height:11px; border-radius:2px; display:inline-block; }

.btn {
  font-family:'Share Tech Mono',monospace; font-size:10px;
  letter-spacing:3px; padding:12px 30px;
  background:transparent; border:2px solid var(--cyan);
  color:var(--cyan); cursor:pointer;
  position:relative; overflow:hidden; transition:color .18s;
}
.btn::before{
  content:''; position:absolute; inset:0; background:var(--cyan);
  transform:scaleX(0); transform-origin:left; transition:transform .18s; z-index:-1;
}
.btn:hover::before{transform:scaleX(1);} .btn:hover{color:#010812;}

/* CONTROLS */
#ctrl {
  display:flex; justify-content:center; align-items:center;
  flex-wrap:wrap; gap:5px 12px; padding:8px 14px;
  background:#040e18; border:1px solid #00e8ff0d; border-top:none;
  font-size:11px; color:#2a4455; letter-spacing:1px;
}
#ctrl .ci{ display:flex; align-items:center; gap:5px; white-space:nowrap; }
#ctrl kbd {
  display:inline-block; background:#020c16;
  border:1px solid #00e8ff33; border-bottom:2px solid #00e8ff55;
  border-radius:3px; padding:1px 7px;
  font-family:'Exo 2',sans-serif; font-size:11px; color:var(--cyan);
}
#ctrl .sep{ color:#00e8ff10; font-size:15px; }

/* MOBILE */
#mob {
  display:none; justify-content:center; align-items:center;
  padding:10px 20px; background:#040e18;
  border:1px solid #00e8ff0d; border-top:none; gap:28px;
}
.dpad{ display:grid; grid-template-columns:repeat(3,50px); grid-template-rows:repeat(3,50px); gap:3px; }
.db {
  background:#00e8ff0e; border:1px solid #00e8ff33; color:var(--cyan);
  font-size:21px; border-radius:4px;
  display:flex; align-items:center; justify-content:center;
  cursor:pointer; user-select:none; -webkit-user-select:none; touch-action:none;
}
.db:active{background:#00e8ff22;}
@media(max-width:500px){#gw{width:100vw;} canvas{width:100vw;height:auto;} #mob{display:flex;}}
@media(pointer:coarse){#mob{display:flex;}}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div id=&quot;gw&quot;&gt;
&lt;div id=&quot;hd&quot;&gt;&lt;h1&gt;DATA RUSH&lt;/h1&gt;&lt;/div&gt;
&lt;div id=&quot;hud&quot;&gt;
  &lt;div class=&quot;hc&quot;&gt;&lt;span class=&quot;hl&quot;&gt;SCORE&lt;/span&gt;&lt;span class=&quot;hv&quot; id=&quot;sV&quot;&gt;0&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;hc&quot;&gt;&lt;span class=&quot;hl&quot;&gt;LIFE&lt;/span&gt;&lt;span class=&quot;hv&quot; id=&quot;liV&quot;&gt;♥♥♥&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;hc&quot;&gt;&lt;span class=&quot;hl&quot;&gt;LEVEL&lt;/span&gt;&lt;span class=&quot;hv&quot; id=&quot;lV&quot;&gt;1&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;hc&quot;&gt;&lt;span class=&quot;hl&quot;&gt;DATA&lt;/span&gt;&lt;span class=&quot;hv&quot; id=&quot;dV&quot;&gt;0/0&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;hc&quot; style=&quot;flex:0.7&quot;&gt;&lt;button id=&quot;muBtn&quot; class=&quot;on&quot;&gt;♪ ON&lt;/button&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;canvas id=&quot;c&quot; width=&quot;480&quot; height=&quot;480&quot;&gt;&lt;/canvas&gt;
&lt;div id=&quot;ov&quot;&gt;
  &lt;h2 id=&quot;ovT&quot;&gt;DATA&lt;br&gt;RUSH&lt;/h2&gt;
  &lt;p class=&quot;os&quot; id=&quot;ovS&quot;&gt;네트워크 그리드에서 모든 데이터를 수집하라!&lt;br&gt;바이러스에 닿으면 접속 해제!&lt;/p&gt;
  &lt;div class=&quot;vleg&quot;&gt;
    &lt;span&gt;&lt;span class=&quot;vdot&quot; style=&quot;background:#ff2d78&quot;&gt;&lt;/span&gt;TRACER&lt;/span&gt;
    &lt;span&gt;&lt;span class=&quot;vdot&quot; style=&quot;background:#ff8800&quot;&gt;&lt;/span&gt;FLANKER&lt;/span&gt;
    &lt;span&gt;&lt;span class=&quot;vdot&quot; style=&quot;background:#aaff22&quot;&gt;&lt;/span&gt;PATROL&lt;/span&gt;
    &lt;span&gt;&lt;span class=&quot;vdot&quot; style=&quot;background:#aa44ff&quot;&gt;&lt;/span&gt;PHANTOM&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;or hidden&quot; id=&quot;ovR&quot;&gt;&lt;/div&gt;
  &lt;button class=&quot;btn&quot; id=&quot;startBtn&quot;&gt;CONNECT&lt;/button&gt;
&lt;/div&gt;
&lt;div id=&quot;ctrl&quot;&gt;
  &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;←&lt;/kbd&gt;&lt;kbd&gt;→&lt;/kbd&gt;&lt;kbd&gt;↑&lt;/kbd&gt;&lt;kbd&gt;↓&lt;/kbd&gt; 이동&lt;/span&gt;
  &lt;span class=&quot;sep&quot;&gt;|&lt;/span&gt;
  &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;WASD&lt;/kbd&gt; 이동&lt;/span&gt;
  &lt;span class=&quot;sep&quot;&gt;|&lt;/span&gt;
  &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;P&lt;/kbd&gt; 일시정지&lt;/span&gt;
&lt;/div&gt;
&lt;div id=&quot;mob&quot;&gt;
  &lt;div class=&quot;dpad&quot;&gt;
    &lt;div&gt;&lt;/div&gt;&lt;div class=&quot;db&quot; id=&quot;mU&quot;&gt;▲&lt;/div&gt;&lt;div&gt;&lt;/div&gt;
    &lt;div class=&quot;db&quot; id=&quot;mL&quot;&gt;◀&lt;/div&gt;&lt;div style=&quot;background:#00e8ff05;border:1px solid #00e8ff11;border-radius:4px;&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;db&quot; id=&quot;mR&quot;&gt;▶&lt;/div&gt;
    &lt;div&gt;&lt;/div&gt;&lt;div class=&quot;db&quot; id=&quot;mD&quot;&gt;▼&lt;/div&gt;&lt;div&gt;&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
/* ══════════════════════════════════════════════════════════════════
   DATA RUSH  —  Original Maze Action Game
   ─────────────────────────────────────────────────────────────────
   저작권 안전 선언:
   · 장르: &quot;미로+수집+추격&quot; — 1970년대부터 존재하는 공공 게임플레이
     장르 자체는 저작권 보호 대상이 아님 (미국 저작권청 공식 입장)
   · 캐릭터/세계관: 해커봇 + 사이버네트워크 — 완전 오리지널
   · 미로: DFS 절차적 생성 — 매 레벨 다른 미로 (고정 맵 없음)
   · 그래픽: Canvas 2D 직접 드로잉 — 이미지 파일 없음
   · BGM/SFX: Web Audio API 코드 생성 — 외부 음원 없음
   · 폰트: Share Tech Mono (SIL OFL 1.1) + Exo 2 (SIL OFL 1.1)
   · 코드: 이번 대화에서 직접 작성한 오리지널
══════════════════════════════════════════════════════════════════ */

const canvas = document.getElementById('c');
const ctx    = canvas.getContext('2d');
const W = 480, H = 480;

// ══ GRID CONFIG ══
const GCOLS = 19, GROWS = 19;     // odd — DFS maze
const CELL  = Math.floor(480 / GCOLS); // 25 (rounds down; we'll center)
const OX    = Math.floor((W - GCOLS * CELL) / 2); // left offset
const OY    = Math.floor((H - GROWS * CELL) / 2); // top offset
const SPEED = 6.5; // cells/sec
const MOVE_MS = 1000 / SPEED;     // ms per cell

// ══ AUDIO ENGINE ══════════════════════════════════════════════════
const AC = new (window.AudioContext || window.webkitAudioContext)();
let mGain, sGain, musicOn = true;
let seqId = null, seqStep = 0, seqPhrase = 0;

function initAudio() {
  const master = AC.createGain(); master.gain.value = 0.55;
  mGain = AC.createGain(); mGain.gain.value = 0.28;
  sGain = AC.createGain(); sGain.gain.value = 0.65;
  mGain.connect(master); sGain.connect(master);
  master.connect(AC.destination);
}

// ─ BGM: Tense atmospheric chiptune ─
const BPM = 118, BS = 60 / BPM;
const N = { C3:130.8, D3:146.8, Eb3:155.6, F3:174.6, G3:196, Ab3:207.7,
            Bb3:233.1, C4:261.6, Eb4:311.1, F4:349.2, G4:392, Ab4:415.3,
            Bb4:466.2, C5:523.2, Eb5:622.3, F5:698.5, G5:784, r:0 };

const BASS_A = ['C3','r','C3','C3', 'Bb3','r','Bb3','r',
                'Ab3','r','Ab3','Ab3','G3','r','G3','r'];
const MEL_A  = ['C5','r','Eb5','r', 'G4','F4','Eb4','D3',
                'C5','r','Bb4','Ab4','G4','r','r','r'];
const MEL_B  = ['Eb5','r','F5','Eb5','D3','C5','Bb4','Ab4',
                'G4','Ab4','Bb4','C5','Eb5','r','D3','r'];

function bgmStep(step) {
  if (!musicOn) return;
  const t = AC.currentTime, s = step % 16, dur = BS * 0.5;
  // Bass
  const bf = N[BASS_A[s]];
  if (bf) { tone('square', bf, 0.3, dur * 0.8, t); }
  // Melody
  const mel = seqPhrase % 2 === 0 ? MEL_A : MEL_B;
  const mf  = N[mel[s]];
  if (mf) { tone('triangle', mf, 0.22, dur * 0.75, t); }
  // Pad chord (every 4 steps)
  if (s % 4 === 0) {
    [N.C4, N.Eb4, N.G4].forEach(f =&gt; toneSmooth(f, 0.08, BS * 2, t));
  }
  // Kick
  if (s === 0 || s === 8) {
    const o = AC.createOscillator(), g = AC.createGain();
    o.type = 'sine'; o.frequency.setValueAtTime(160, t);
    o.frequency.exponentialRampToValueAtTime(28, t + 0.1);
    g.gain.setValueAtTime(0.4, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
    o.connect(g); g.connect(mGain); o.start(t); o.stop(t + 0.12);
  }
  // Hi-hat
  if (s % 2 !== 0) {
    noise(0.05, 0.03, t);
  }
  // Snare
  if (s === 4 || s === 12) { noise(0.14, 0.07, t); }
}

function tone(type, freq, vol, dur, when) {
  const o = AC.createOscillator(), g = AC.createGain();
  o.type = type; o.frequency.value = freq;
  g.gain.setValueAtTime(0, when); g.gain.linearRampToValueAtTime(vol, when + 0.01);
  g.gain.exponentialRampToValueAtTime(0.001, when + dur);
  o.connect(g); g.connect(mGain); o.start(when); o.stop(when + dur + 0.01);
}

function toneSmooth(freq, vol, dur, when) {
  const o = AC.createOscillator(), g = AC.createGain();
  o.type = 'sine'; o.frequency.value = freq;
  g.gain.setValueAtTime(0, when); g.gain.linearRampToValueAtTime(vol, when + 0.08);
  g.gain.exponentialRampToValueAtTime(0.001, when + dur);
  o.connect(g); g.connect(mGain); o.start(when); o.stop(when + dur);
}

function noise(vol, dur, when) {
  const n = Math.floor(AC.sampleRate * dur);
  const buf = AC.createBuffer(1, n, AC.sampleRate);
  const d = buf.getChannelData(0);
  const dc = dur * 0.4;
  for (let i = 0; i &lt; n; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (AC.sampleRate * dc));
  const src = AC.createBufferSource(), g = AC.createGain();
  src.buffer = buf; g.gain.value = vol;
  src.connect(g); g.connect(mGain); src.start(when);
}

function startBGM() {
  if (seqId) clearInterval(seqId);
  seqStep = 0;
  const iv = BS * 500;
  seqId = setInterval(() =&gt; {
    if (!gameRunning || paused) return;
    bgmStep(seqStep); seqStep++;
    if (seqStep % 16 === 0) seqPhrase++;
    seqStep %= 32;
  }, iv);
}
function stopBGM() { if (seqId) { clearInterval(seqId); seqId = null; } }

// ─ SFX ─
function sfxCollect() {
  const f = 440 + Math.random() * 300;
  const o = AC.createOscillator(), g = AC.createGain();
  o.type = 'sine'; o.frequency.value = f;
  g.gain.setValueAtTime(0.18, AC.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime + 0.06);
  o.connect(g); g.connect(sGain); o.start(); o.stop(AC.currentTime + 0.07);
}

function sfxPower() {
  [262, 330, 392, 524].forEach((f, i) =&gt; {
    const o = AC.createOscillator(), g = AC.createGain();
    o.type = 'square'; o.frequency.value = f;
    const t0 = AC.currentTime + i * 0.06;
    g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(0.25, t0 + 0.02);
    g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.1);
    o.connect(g); g.connect(sGain); o.start(t0); o.stop(t0 + 0.12);
  });
}

function sfxDie() {
  [440, 330, 220, 110].forEach((f, i) =&gt; {
    const o = AC.createOscillator(), g = AC.createGain();
    o.type = 'sawtooth'; o.frequency.value = f;
    const t0 = AC.currentTime + i * 0.09;
    g.gain.setValueAtTime(0.3, t0); g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.12);
    o.connect(g); g.connect(sGain); o.start(t0); o.stop(t0 + 0.14);
  });
}

function sfxEatVirus() {
  noise(0.4, 0.2, AC.currentTime);
  const o = AC.createOscillator(), g = AC.createGain();
  o.type = 'sine'; o.frequency.setValueAtTime(800, AC.currentTime);
  o.frequency.exponentialRampToValueAtTime(100, AC.currentTime + 0.2);
  g.gain.setValueAtTime(0.3, AC.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime + 0.2);
  o.connect(g); g.connect(sGain); o.start(); o.stop(AC.currentTime + 0.22);
}

function sfxLevel() {
  [523, 659, 784, 1047, 784, 1047].forEach((f, i) =&gt; {
    const o = AC.createOscillator(), g = AC.createGain();
    o.type = 'square'; o.frequency.value = f;
    const t0 = AC.currentTime + i * 0.08;
    g.gain.setValueAtTime(0, t0); g.gain.linearRampToValueAtTime(0.28, t0 + 0.02);
    g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.14);
    o.connect(g); g.connect(sGain); o.start(t0); o.stop(t0 + 0.16);
  });
}

document.getElementById('muBtn').addEventListener('click', () =&gt; {
  if (AC.state === 'suspended') AC.resume();
  musicOn = !musicOn;
  mGain.gain.value = musicOn ? 0.28 : 0;
  const b = document.getElementById('muBtn');
  b.textContent = musicOn ? '♪ ON' : '♪ OFF';
  b.classList.toggle('on', musicOn);
});

// ══ MAZE GENERATION (DFS + extra loops) ═══════════════════════════
// grid[r][c]: true = wall, false = path
let grid, totalDots, dots, powerNodes;

function buildMaze(level) {
  const G = Array.from({ length: GROWS }, () =&gt; new Array(GCOLS).fill(true));

  // DFS on &quot;room&quot; cells (odd indices)
  const RR = Math.floor(GROWS / 2), RC = Math.floor(GCOLS / 2);
  const vis = Array.from({ length: RR }, () =&gt; new Array(RC).fill(false));
  const stk = [[0, 0]];
  vis[0][0] = true;

  while (stk.length) {
    const [rr, rc] = stk[stk.length - 1];
    const gr = rr * 2 + 1, gc = rc * 2 + 1;
    G[gr][gc] = false;
    const nbrs = [[0,1],[0,-1],[1,0],[-1,0]]
      .map(([dr, dc]) =&gt; [rr+dr, rc+dc])
      .filter(([nr, nc]) =&gt; nr&gt;=0 &amp;&amp; nr&lt;RR &amp;&amp; nc&gt;=0 &amp;&amp; nc&lt;RC &amp;&amp; !vis[nr][nc]);
    if (nbrs.length) {
      const [nr, nc] = nbrs[Math.floor(Math.random() * nbrs.length)];
      G[(rr+nr)+1][(rc+nc)+1] = false; // wall between rooms
      vis[nr][nc] = true; stk.push([nr, nc]);
    } else stk.pop();
  }

  // Extra loops: punch extra passages based on level (more open = harder navigation)
  const extraChance = 0.18 + level * 0.025;
  for (let r = 1; r &lt; GROWS - 1; r++) {
    for (let c = 1; c &lt; GCOLS - 1; c++) {
      if (G[r][c] &amp;&amp; Math.random() &lt; extraChance) {
        // Only open if it connects two paths
        const adj = [[0,1],[0,-1],[1,0],[-1,0]]
          .filter(([dr,dc]) =&gt; !G[r+dr][c+dc]).length;
        if (adj &gt;= 2) G[r][c] = false;
      }
    }
  }

  return G;
}

function placeDots(G) {
  const dotsMap = Array.from({ length: GROWS }, () =&gt; new Array(GCOLS).fill(0));
  // 0=none, 1=data node, 2=power node
  let count = 0;
  for (let r = 0; r &lt; GROWS; r++) {
    for (let c = 0; c &lt; GCOLS; c++) {
      if (!G[r][c]) { dotsMap[r][c] = 1; count++; }
    }
  }
  // Place 4 power nodes near corners of open area
  const corners = [];
  [[2,2],[2,GCOLS-3],[GROWS-3,2],[GROWS-3,GCOLS-3]].forEach(([r,c]) =&gt; {
    // find nearest open cell
    for (let dr=-2; dr&lt;=2; dr++) for (let dc=-2; dc&lt;=2; dc++) {
      const nr=r+dr, nc=c+dc;
      if (nr&gt;=0&amp;&amp;nr&lt;GROWS&amp;&amp;nc&gt;=0&amp;&amp;nc&lt;GCOLS&amp;&amp;!G[nr][nc]) {
        corners.push([nr,nc]); return;
      }
    }
  });
  corners.forEach(([r,c]) =&gt; { if (dotsMap[r][c]===1) { dotsMap[r][c]=2; } });
  return { dotsMap, count };
}

// ══ PLAYER ════════════════════════════════════════════════════════
let player;
function initPlayer() {
  // Start at first open cell near top-left
  let sr=1,sc=1;
  for(let r=1;r&lt;GROWS;r++){for(let c=1;c&lt;GCOLS;c++){if(!grid[r][c]){sr=r;sc=c;break;}}}
  player = {
    r:sr, c:sc, x:sr, y:sc,     // r,c = grid; x,y = smooth (decimal)
    dir:[0,0], nextDir:[0,0],
    prog:0, moving:false,
    powered:false, powerTimer:0,
    trail:[], frameN:0,
  };
}

// ══ VIRUSES ═══════════════════════════════════════════════════════
const VCOLS = ['#ff2d78','#ff8800','#aaff22','#aa44ff'];
const VNAMES = ['TRACER','FLANKER','PATROL','PHANTOM'];
let viruses=[], virusEaten=0;

function initViruses(level) {
  virusEaten=0;
  // Spawn from center area
  const baseR = Math.floor(GROWS/2), baseC = Math.floor(GCOLS/2);
  const offsets = [[0,0],[0,2],[2,0],[2,2]];
  const count = Math.min(4, 1 + Math.floor((level-1)/2));
  viruses=[];
  for(let i=0;i&lt;count;i++) {
    let vr=baseR+offsets[i][0], vc=baseC+offsets[i][1];
    while(grid[vr][vc]){ vr=Math.max(1,vr-1); vc=Math.max(1,vc-1); }
    viruses.push({
      r:vr, c:vc, x:vr, y:vc,
      dir:[0,0], prog:0, moving:false,
      type:i, color:VCOLS[i],
      speed: (0.65 + level*0.04 + i*0.02),
      scatterTimer: 80+i*30, scatter:true,
      eaten:false, respawnTimer:0,
      stepClock:0,
    });
  }
}

// ─ BFS shortest path ─
function bfs(sr,sc,er,ec) {
  if(sr===er&amp;&amp;sc===ec) return null;
  const vis=new Set(), q=[[sr,sc,null]]; vis.add(sr+','+sc);
  while(q.length){
    const [r,c,first]=q.shift();
    for(const [dr,dc] of [[0,1],[0,-1],[1,0],[-1,0]]){
      const nr=r+dr,nc=c+dc;
      if(nr&lt;0||nr&gt;=GROWS||nc&lt;0||nc&gt;=GCOLS||grid[nr][nc]) continue;
      const k=nr+','+nc; if(vis.has(k)) continue; vis.add(k);
      const dir=[dr,dc], f=first||dir;
      if(nr===er&amp;&amp;nc===ec) return f;
      q.push([nr,nc,f]);
    }
  }
  return null;
}

function virusAI(v) {
  const { r,c,type,scatter,scatterTimer } = v;
  // Scatter corners for each virus
  const scatterTargets = [[1,1],[1,GCOLS-2],[GROWS-2,1],[GROWS-2,GCOLS-2]];
  const pr=Math.round(player.x), pc=Math.round(player.y);

  if(type===0) { // TRACER: direct BFS chase
    if(scatter) return bfs(r,c,...scatterTargets[0]) || rndDir(r,c);
    return bfs(r,c,pr,pc) || rndDir(r,c);
  }
  if(type===1) { // FLANKER: targets 4 cells ahead of player
    const [pd,qd]=player.dir;
    const tr=Math.min(GROWS-1,Math.max(0,pr+pd*4));
    const tc=Math.min(GCOLS-1,Math.max(0,pc+qd*4));
    const target=grid[tr][tc]?[pr,pc]:[tr,tc];
    return bfs(r,c,...target) || rndDir(r,c);
  }
  if(type===2) { // PATROL: wander randomly, rarely chases
    if(scatter||Math.random()&lt;0.6) return rndDir(r,c);
    return bfs(r,c,pr,pc) || rndDir(r,c);
  }
  if(type===3) { // PHANTOM: unpredictable — chases if distance&gt;8, scatters close
    const dist=Math.abs(r-pr)+Math.abs(c-pc);
    if(dist&gt;8) return bfs(r,c,pr,pc)||rndDir(r,c);
    else return bfs(r,c,...scatterTargets[3])||rndDir(r,c);
  }
  return rndDir(r,c);
}

function rndDir(r,c) {
  const dirs=[[0,1],[0,-1],[1,0],[-1,0]]
    .filter(([dr,dc])=&gt;{const nr=r+dr,nc=c+dc;return nr&gt;=0&amp;&amp;nr&lt;GROWS&amp;&amp;nc&gt;=0&amp;&amp;nc&lt;GCOLS&amp;&amp;!grid[nr][nc];});
  return dirs.length?dirs[Math.floor(Math.random()*dirs.length)]:[0,0];
}

// ══ GAME STATE ════════════════════════════════════════════════════
let score=0,lives=3,level=1,dotsLeft=0;
let gameRunning=false,paused=false,animId,lastTs=0;
let particles=[], scanLine=0, glitchTimer=0;
let comboCount=0, comboTimer=0;
let powerFlash=null;

// ── INPUT ──
const keys={};
let mobDir=null;
document.addEventListener('keydown',e=&gt;{
  keys[e.code]=true;
  if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code))e.preventDefault();
  const dm={ArrowUp:[-1,0],ArrowDown:[1,0],ArrowLeft:[0,-1],ArrowRight:[0,1],
             KeyW:[-1,0],KeyS:[1,0],KeyA:[0,-1],KeyD:[0,1]}[e.code];
  if(dm) player.nextDir=dm;
  if(e.code==='KeyP'&amp;&amp;gameRunning)togglePause();
});
document.addEventListener('keyup',e=&gt;keys[e.code]=false);

function mBtn(id,dir){ const el=document.getElementById(id); if(!el)return;
  el.addEventListener('touchstart',e=&gt;{e.preventDefault();player.nextDir=dir;},{passive:false});
  el.addEventListener('mousedown',()=&gt;player.nextDir=dir); }
mBtn('mU',[-1,0]); mBtn('mD',[1,0]); mBtn('mL',[0,-1]); mBtn('mR',[0,1]);

let mouseOver=false;
canvas.addEventListener('mouseenter',()=&gt;mouseOver=true);
canvas.addEventListener('mouseleave',()=&gt;mouseOver=false);
window.addEventListener('wheel',e=&gt;{if(mouseOver&amp;&amp;gameRunning&amp;&amp;!paused)e.preventDefault();},{passive:false});

// ── HUD ──
function updateHUD(){
  document.getElementById('sV').textContent=score;
  const safeL=Math.max(0,lives);
  const h='♥'.repeat(safeL)+'♡'.repeat(Math.max(0,3-safeL));
  document.getElementById('liV').textContent=h||'✗';
  document.getElementById('liV').style.color=lives&lt;=1?'#ff4444':'#ff0099';
  document.getElementById('lV').textContent=level;
  document.getElementById('dV').textContent=(totalDots-Math.max(0,dotsLeft))+'/'+totalDots;
}

// ─ PARTICLES ─
function spawnP(x,y,col,n=10,sp=3){
  for(let i=0;i&lt;n;i++){
    const a=Math.random()*Math.PI*2,s=.5+Math.random()*sp;
    particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,
      life:1,decay:.022+Math.random()*.03,size:1.5+Math.random()*4,color:col});
  }
}
function spawnText(x,y,text,col){
  particles.push({text,x,y,vx:0,vy:-1.2,life:1,decay:.014,size:13,color:col});
}

// ══ INIT LEVEL ════════════════════════════════════════════════════
function initLevel(){
  grid=buildMaze(level);
  const {dotsMap,count}=placeDots(grid);
  dots=dotsMap; totalDots=count; dotsLeft=count;
  initPlayer(); initViruses(level);
  particles=[]; comboCount=0; comboTimer=0; glitchTimer=0; powerFlash=null;
  updateHUD();
}

// ══ MAIN LOOP ═════════════════════════════════════════════════════
function loop(ts){
  animId=requestAnimationFrame(loop);
  const dt=Math.min(ts-lastTs,50); lastTs=ts;
  if(paused||!gameRunning)return;
  update(dt); draw();
}

function update(dt){
  player.frameN++;

  // Scan line
  scanLine=(scanLine+dt*0.04)%H;

  // Glitch
  glitchTimer-=dt;

  // Combo
  if(comboTimer&gt;0){comboTimer-=dt;if(comboTimer&lt;=0)comboCount=0;}

  // Power timer
  if(player.powered){
    player.powerTimer-=dt;
    if(player.powerTimer&lt;=0){
      player.powered=false;
      viruses.forEach(v=&gt;v.eaten=false);
    }
  }

  // Player movement
  updatePlayer(dt);

  // Virus movement
  viruses.forEach(v=&gt;updateVirus(v,dt));

  // Dot collection
  const pr=player.r, pc=player.c;
  if(dots[pr][pc]===1){
    dots[pr][pc]=0; dotsLeft--; score+=10; comboCount++;
    comboTimer=1200;
    if(comboCount&gt;=5) { score+=comboCount*5; spawnText(px(pc),py(pr),`COMBO x${comboCount}!`,'#ffdd00'); }
    sfxCollect(); updateHUD();
    spawnP(px(pc),py(pr),'#00e8ff',4,1.5);
    if(dotsLeft===0) levelClear();
  }
  if(dots[pr][pc]===2){
    dots[pr][pc]=0; dotsLeft--; score+=50;
    player.powered=true; player.powerTimer=8000;
    sfxPower(); updateHUD();
    spawnP(px(pc),py(pr),'#ff00cc',20,3);
    spawnText(px(pc),py(pr)-10,'FIREWALL!','#ff00cc');
    powerFlash={alpha:0.35};
    viruses.forEach(v=&gt;{ v.scatter=true; v.scatterTimer=200; });
    if(dotsLeft===0) levelClear();
  }

  // Power flash fade
  if(powerFlash) powerFlash.alpha=Math.max(0,powerFlash.alpha-0.015);

  // Virus collision
  viruses.forEach(v=&gt;{
    if(v.eaten||v.respawnTimer&gt;0) return;
    const dist=Math.abs(v.x-player.x)+Math.abs(v.y-player.y);
    if(dist&lt;0.7){
      if(player.powered){
        v.eaten=true; v.respawnTimer=6000;
        virusEaten++; score+=200*virusEaten;
        sfxEatVirus();
        spawnP(px(Math.round(v.y)),py(Math.round(v.x)),v.color,20,4);
        spawnText(px(Math.round(v.y)),py(Math.round(v.x)),`+${200*virusEaten}`,'#ffdd00');
        updateHUD();
      } else { hitPlayer(); }
    }
  });

  // Virus respawn
  viruses.forEach(v=&gt;{
    if(v.respawnTimer&gt;0){
      v.respawnTimer-=dt;
      if(v.respawnTimer&lt;=0){
        v.eaten=false;
        const mr=Math.floor(GROWS/2),mc=Math.floor(GCOLS/2);
        v.r=mr; v.c=mc; v.x=mr; v.y=mc; v.moving=false;
        v.scatter=true; v.scatterTimer=100;
      }
    }
  });

  // Particles
  particles=particles.filter(p=&gt;{
    p.x+=p.vx; p.y+=p.vy; if(!p.text)p.vy+=0.04;
    p.life-=p.decay; return p.life&gt;0;
  });
}

function updatePlayer(dt){
  if(!player.moving){
    // Try nextDir first, then current dir
    for(const d of [player.nextDir, player.dir]){
      const [dr,dc]=d;
      if(dr===0&amp;&amp;dc===0) continue;
      const nr=player.r+dr, nc=player.c+dc;
      if(nr&gt;=0&amp;&amp;nr&lt;GROWS&amp;&amp;nc&gt;=0&amp;&amp;nc&lt;GCOLS&amp;&amp;!grid[nr][nc]){
        player.r=nr; player.c=nc; player.dir=d;
        player.moving=true; player.prog=0;
        if(d===player.nextDir) player.nextDir=[0,0];
        break;
      }
    }
  }
  if(player.moving){
    player.prog+=dt/MOVE_MS;
    if(player.prog&gt;=1){
      player.prog=1; player.moving=false;
      player.x=player.r; player.y=player.c;
    } else {
      const [dr,dc]=player.dir;
      player.x=(player.r-dr)+dr*player.prog;
      player.y=(player.c-dc)+dc*player.prog;
    }
  }
  // Trail
  player.trail.unshift({x:player.y,y:player.x});
  if(player.trail.length&gt;8) player.trail.pop();
}

function updateVirus(v,dt){
  if(v.eaten||v.respawnTimer&gt;0) return;
  if(v.scatterTimer&gt;0) v.scatterTimer--;
  else v.scatter=false;

  if(!v.moving){
    const dir=virusAI(v);
    if(dir&amp;&amp;(dir[0]!==0||dir[1]!==0)){
      const nr=v.r+dir[0], nc=v.c+dir[1];
      if(nr&gt;=0&amp;&amp;nr&lt;GROWS&amp;&amp;nc&gt;=0&amp;&amp;nc&lt;GCOLS&amp;&amp;!grid[nr][nc]){
        v.r=nr; v.c=nc; v.dir=dir; v.moving=true; v.prog=0;
      } else { v.dir=rndDir(v.r,v.c)||[0,0]; }
    }
  }
  if(v.moving){
    const spd=player.powered?v.speed*0.45:v.speed;
    v.prog+=dt/(MOVE_MS/spd);
    if(v.prog&gt;=1){
      v.prog=1; v.moving=false; v.x=v.r; v.y=v.c;
    } else {
      const [dr,dc]=v.dir;
      v.x=(v.r-dr)+dr*v.prog;
      v.y=(v.c-dc)+dc*v.prog;
    }
  }
}

// ─ helpers ─
function px(c){ return OX + c * CELL + CELL/2; }
function py(r){ return OY + r * CELL + CELL/2; }

function hitPlayer(){
  if(lives&lt;=0) return;          // guard: endGame 대기 중 재진입 방지
  lives--; sfxDie();
  glitchTimer=800;
  invincible=3000;               // 충분히 길게 설정해 연속 피격 차단
  spawnP(px(Math.round(player.y)),py(Math.round(player.x)),'#00e8ff',30,5);
  updateHUD();
  if(lives&lt;=0){ gameRunning=false; setTimeout(()=&gt;endGame(),600); return; }
  // Re-init positions
  initPlayer(); initViruses(level);
}

function levelClear(){
  sfxLevel(); score+=500*level; level++;
  spawnP(W/2,H/2,'#ffdd00',40,6);
  spawnText(W/2,H/2-20,`LEVEL ${level}!`,'#ffdd00');
  setTimeout(()=&gt;{ initLevel(); updateHUD(); },1200);
}

// ══ DRAW ══════════════════════════════════════════════════════════
function draw(){
  // BG
  ctx.fillStyle='#010a14'; ctx.fillRect(0,0,W,H);

  // Grid bg lines
  ctx.save(); ctx.strokeStyle='#00e8ff06'; ctx.lineWidth=1;
  for(let x=OX;x&lt;=OX+GCOLS*CELL;x+=CELL){ ctx.beginPath();ctx.moveTo(x,OY);ctx.lineTo(x,OY+GROWS*CELL);ctx.stroke(); }
  for(let y=OY;y&lt;=OY+GROWS*CELL;y+=CELL){ ctx.beginPath();ctx.moveTo(OX,y);ctx.lineTo(OX+GCOLS*CELL,y);ctx.stroke(); }
  ctx.restore();

  // Glitch effect
  const glitch=glitchTimer&gt;0;
  if(glitch){
    glitchTimer-=16;
    ctx.save();
    const sl=Math.random()*H;
    ctx.fillStyle='rgba(0,232,255,0.06)'; ctx.fillRect(0,sl,W,4);
    ctx.fillStyle='rgba(255,0,153,0.04)'; ctx.fillRect(0,sl+2,W,2);
    ctx.restore();
  }

  // Power flash overlay
  if(powerFlash&amp;&amp;powerFlash.alpha&gt;0.01){
    ctx.save(); ctx.globalAlpha=powerFlash.alpha;
    ctx.fillStyle='#ff00cc'; ctx.fillRect(0,0,W,H); ctx.restore();
  }

  drawMaze();
  drawDots();
  drawTrail();
  drawViruses();
  drawPlayer();
  drawParticles();
  drawPowerBar();
  drawScanline();

  // Vignette
  const vig=ctx.createRadialGradient(W/2,H/2,H*0.3,W/2,H/2,H*0.72);
  vig.addColorStop(0,'transparent'); vig.addColorStop(1,'rgba(0,0,0,0.5)');
  ctx.fillStyle=vig; ctx.fillRect(0,0,W,H);
}

function drawMaze(){
  for(let r=0;r&lt;GROWS;r++){
    for(let c=0;c&lt;GCOLS;c++){
      if(!grid[r][c]) continue;
      const x=OX+c*CELL, y=OY+r*CELL;

      // Count adjacent paths for visual variety
      const adjPath=[[0,1],[0,-1],[1,0],[-1,0]]
        .filter(([dr,dc])=&gt;{const nr=r+dr,nc=c+dc;return nr&gt;=0&amp;&amp;nr&lt;GROWS&amp;&amp;nc&gt;=0&amp;&amp;nc&lt;GCOLS&amp;&amp;!grid[nr][nc];}).length;

      // Wall base
      const wg=ctx.createLinearGradient(x,y,x+CELL,y+CELL);
      wg.addColorStop(0,'#01121e'); wg.addColorStop(1,'#010e18');
      ctx.fillStyle=wg; ctx.fillRect(x,y,CELL,CELL);

      // Neon wall glow lines along edges
      ctx.save();
      ctx.strokeStyle=adjPath&gt;0?'#00688844':'#004d6622';
      ctx.lineWidth=1.5;
      // Top edge
      if(r&gt;0&amp;&amp;!grid[r-1][c]){ ctx.shadowBlur=6;ctx.shadowColor='#00e8ff33';
        ctx.strokeStyle='#00e8ff33'; ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x+CELL,y); ctx.stroke(); }
      // Bottom edge
      if(r&lt;GROWS-1&amp;&amp;!grid[r+1][c]){ ctx.beginPath(); ctx.moveTo(x,y+CELL); ctx.lineTo(x+CELL,y+CELL); ctx.stroke(); }
      // Left edge
      if(c&gt;0&amp;&amp;!grid[r][c-1]){ ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x,y+CELL); ctx.stroke(); }
      // Right edge
      if(c&lt;GCOLS-1&amp;&amp;!grid[r][c+1]){ ctx.beginPath(); ctx.moveTo(x+CELL,y); ctx.lineTo(x+CELL,y+CELL); ctx.stroke(); }
      ctx.restore();

      // Circuit node at corners
      if(adjPath===0){
        ctx.save(); ctx.fillStyle='#002233';
        ctx.fillRect(x+CELL*0.25,y+CELL*0.25,CELL*0.5,CELL*0.5);
        ctx.restore();
      }
    }
  }
}

function drawDots(){
  const t=Date.now()*0.005;
  for(let r=0;r&lt;GROWS;r++){
    for(let c=0;c&lt;GCOLS;c++){
      if(dots[r][c]===0) continue;
      const x=px(c), y=py(r);
      if(dots[r][c]===1){
        // Data node — small diamond
        ctx.save();
        ctx.fillStyle='#00aacc'; ctx.shadowBlur=6; ctx.shadowColor='#00e8ff';
        const s=2.5;
        ctx.beginPath();
        ctx.moveTo(x,y-s); ctx.lineTo(x+s,y); ctx.lineTo(x,y+s); ctx.lineTo(x-s,y);
        ctx.closePath(); ctx.fill();
        ctx.restore();
      } else {
        // Power node — pulsing hexagon
        const pulse=0.8+0.2*Math.sin(t+r+c);
        const radius=(CELL*0.34)*pulse;
        ctx.save();
        ctx.shadowBlur=18*pulse; ctx.shadowColor='#ff00cc';
        ctx.fillStyle='#ff00cc33'; ctx.strokeStyle='#ff00cc'; ctx.lineWidth=2;
        ctx.beginPath();
        for(let i=0;i&lt;6;i++){
          const a=i*Math.PI/3-Math.PI/6;
          i===0?ctx.moveTo(x+radius*Math.cos(a),y+radius*Math.sin(a))
               :ctx.lineTo(x+radius*Math.cos(a),y+radius*Math.sin(a));
        }
        ctx.closePath(); ctx.fill(); ctx.stroke();
        // Inner glow
        ctx.fillStyle='#ff66ee'; ctx.shadowBlur=8;
        ctx.beginPath(); ctx.arc(x,y,3*pulse,0,Math.PI*2); ctx.fill();
        ctx.restore();
      }
    }
  }
}

function drawTrail(){
  player.trail.forEach((t,i)=&gt;{
    const a=(1-(i/player.trail.length))*0.3;
    ctx.save(); ctx.globalAlpha=a;
    ctx.fillStyle=player.powered?'#ff00cc':'#00e8ff';
    ctx.beginPath(); ctx.arc(OX+t.x*CELL+CELL/2,OY+t.y*CELL+CELL/2,(CELL*0.22)*(1-i/player.trail.length),0,Math.PI*2);
    ctx.fill(); ctx.restore();
  });
}

function drawPlayer(){
  const cx=OX+player.y*CELL+CELL/2, cy=OY+player.x*CELL+CELL/2;
  const powered=player.powered, t=Date.now()*0.008;
  const r=CELL*0.38;

  ctx.save(); ctx.translate(cx,cy);

  // Power aura
  if(powered){
    const pPulse=0.7+0.3*Math.sin(t);
    ctx.save();
    ctx.strokeStyle=`rgba(255,0,204,${pPulse})`;
    ctx.lineWidth=2.5; ctx.shadowBlur=20; ctx.shadowColor='#ff00cc';
    ctx.beginPath(); ctx.arc(0,0,r+6,0,Math.PI*2); ctx.stroke();
    ctx.restore();
  }

  // Body — hexagonal data bot
  const col=powered?'#ff00cc':'#00e8ff';
  ctx.shadowBlur=powered?20:14; ctx.shadowColor=col;
  ctx.fillStyle='#010d1a'; ctx.strokeStyle=col; ctx.lineWidth=2;
  ctx.beginPath();
  for(let i=0;i&lt;6;i++){
    const a=i*Math.PI/3 + (player.dir[1]===1?0:player.dir[1]===-1?Math.PI:
                            player.dir[0]===1?Math.PI/2:player.dir[0]===-1?-Math.PI/2:0);
    i===0?ctx.moveTo(r*Math.cos(a),r*Math.sin(a)):ctx.lineTo(r*Math.cos(a),r*Math.sin(a));
  }
  ctx.closePath(); ctx.fill(); ctx.stroke();

  // Inner circuit pattern
  ctx.strokeStyle=col+'55'; ctx.lineWidth=1; ctx.shadowBlur=0;
  ctx.beginPath(); ctx.arc(0,0,r*0.45,0,Math.PI*2); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(-r*0.3,0); ctx.lineTo(r*0.3,0); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(0,-r*0.3); ctx.lineTo(0,r*0.3); ctx.stroke();

  // Core glow
  const cg=ctx.createRadialGradient(0,0,0,0,0,r*0.25);
  cg.addColorStop(0,col); cg.addColorStop(1,col+'00');
  ctx.fillStyle=cg; ctx.shadowBlur=8; ctx.shadowColor=col;
  ctx.beginPath(); ctx.arc(0,0,r*0.25,0,Math.PI*2); ctx.fill();

  // Direction indicator (antenna)
  const [dr,dc]=player.dir;
  if(dr!==0||dc!==0){
    const ax=dc*r*0.5, ay=dr*r*0.5;
    ctx.strokeStyle=col; ctx.lineWidth=2.5; ctx.shadowBlur=10; ctx.shadowColor=col;
    ctx.beginPath(); ctx.moveTo(ax*0.4,ay*0.4); ctx.lineTo(ax,ay); ctx.stroke();
    ctx.fillStyle=col; ctx.shadowBlur=8;
    ctx.beginPath(); ctx.arc(ax,ay,3,0,Math.PI*2); ctx.fill();
  }

  ctx.restore();
}

function drawViruses(){
  const t=Date.now()*0.006;
  viruses.forEach(v=&gt;{
    if(v.respawnTimer&gt;0) return;
    const cx=OX+v.y*CELL+CELL/2, cy=OY+v.x*CELL+CELL/2;
    const col=v.color;
    const r=CELL*0.36;

    ctx.save(); ctx.translate(cx,cy);

    if(v.eaten){
      // Ghost eyes only
      ctx.fillStyle='#00e8ff88'; ctx.shadowBlur=6; ctx.shadowColor='#00e8ff';
      ctx.beginPath(); ctx.arc(-r*0.25,-r*0.1,3,0,Math.PI*2); ctx.fill();
      ctx.beginPath(); ctx.arc( r*0.25,-r*0.1,3,0,Math.PI*2); ctx.fill();
      ctx.restore(); return;
    }

    if(player.powered){
      // Scared mode — blue flashing
      const sc=Math.sin(t*3)&gt;0||player.powerTimer&gt;3000;
      const scCol=sc?'#2244aa':'#ffffff';
      ctx.shadowBlur=10; ctx.shadowColor=scCol;
      ctx.fillStyle=scCol+'44'; ctx.strokeStyle=scCol; ctx.lineWidth=2;
    } else {
      ctx.shadowBlur=14; ctx.shadowColor=col;
      ctx.fillStyle=col+'22'; ctx.strokeStyle=col; ctx.lineWidth=2;
    }

    // Each virus has distinct shape
    if(v.type===0){ // TRACER: triangle
      ctx.beginPath();
      ctx.moveTo(0,-r); ctx.lineTo(r*0.86,r*0.5); ctx.lineTo(-r*0.86,r*0.5);
      ctx.closePath();
    } else if(v.type===1){ // FLANKER: diamond
      ctx.beginPath();
      ctx.moveTo(0,-r); ctx.lineTo(r*0.7,0); ctx.lineTo(0,r); ctx.lineTo(-r*0.7,0);
      ctx.closePath();
    } else if(v.type===2){ // PATROL: square (rotated)
      ctx.save(); ctx.rotate(t*0.5);
      ctx.beginPath(); ctx.rect(-r*0.72,-r*0.72,r*1.44,r*1.44);
    } else { // PHANTOM: star
      ctx.beginPath();
      for(let i=0;i&lt;8;i++){
        const a=i*Math.PI/4;
        const rr=i%2===0?r:r*0.45;
        i===0?ctx.moveTo(rr*Math.cos(a),rr*Math.sin(a))
             :ctx.lineTo(rr*Math.cos(a),rr*Math.sin(a));
      }
      ctx.closePath();
    }
    ctx.fill(); ctx.stroke();
    if(v.type===2) ctx.restore();

    // Eyes
    ctx.shadowBlur=0;
    ctx.fillStyle='#ffffff';
    ctx.beginPath(); ctx.arc(-r*0.22,-r*0.1,r*0.14,0,Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.arc( r*0.22,-r*0.1,r*0.14,0,Math.PI*2); ctx.fill();
    ctx.fillStyle='#000';
    ctx.beginPath(); ctx.arc(-r*0.22,-r*0.1,r*0.07,0,Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.arc( r*0.22,-r*0.1,r*0.07,0,Math.PI*2); ctx.fill();

    ctx.restore();
  });
}

function drawParticles(){
  particles.forEach(p=&gt;{
    ctx.save(); ctx.globalAlpha=Math.max(0,p.life);
    if(p.text){
      ctx.font=`700 ${p.size}px 'Exo 2',sans-serif`;
      ctx.fillStyle=p.color; ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.shadowBlur=10; ctx.shadowColor=p.color;
      ctx.fillText(p.text,p.x,p.y);
    } else {
      ctx.fillStyle=p.color; ctx.shadowBlur=8; ctx.shadowColor=p.color;
      ctx.beginPath(); ctx.arc(p.x,p.y,p.size*p.life,0,Math.PI*2); ctx.fill();
    }
    ctx.restore();
  });
}

function drawPowerBar(){
  if(!player.powered) return;
  const pct=player.powerTimer/8000;
  const bw=200, bx=(W-bw)/2, by=H-10;
  ctx.save();
  ctx.fillStyle='#ff00cc18'; ctx.fillRect(bx,by,bw,5);
  ctx.fillStyle='#ff00cc'; ctx.shadowBlur=8; ctx.shadowColor='#ff00cc';
  ctx.fillRect(bx,by,bw*pct,5);
  ctx.fillStyle='#ff66ee'; ctx.font=&quot;8px 'Share Tech Mono',monospace&quot;;
  ctx.textAlign='center'; ctx.textBaseline='bottom'; ctx.shadowBlur=0;
  ctx.fillText('FIREWALL ACTIVE',W/2,by-2);
  ctx.restore();
}

function drawScanline(){
  ctx.save(); ctx.globalAlpha=0.04;
  ctx.fillStyle='#00e8ff';
  const sl=(scanLine)%H;
  ctx.fillRect(0,sl,W,2);
  ctx.restore();
  // CRT lines
  ctx.save(); ctx.globalAlpha=0.025;
  for(let y=0;y&lt;H;y+=2){ ctx.fillStyle='#000'; ctx.fillRect(0,y,W,1); }
  ctx.restore();
}

// ══ GAME CONTROL ══════════════════════════════════════════════════
function startGame(){
  score=0; lives=3; level=1; virusEaten=0;
  gameRunning=true; paused=false;
  initLevel();
  document.getElementById('ov').classList.add('hidden');
  if(animId) cancelAnimationFrame(animId);
  if(AC.state==='suspended') AC.resume();
  startBGM();
  lastTs=performance.now();
  animId=requestAnimationFrame(loop);
}

function endGame(){
  if(seqId===null&amp;&amp;!gameRunning&amp;&amp;document.getElementById('ov').classList.contains('hidden')===false) return;
  gameRunning=false;
  cancelAnimationFrame(animId); stopBGM();
  document.getElementById('ovT').textContent='DISCONNECTED';
  document.getElementById('ovS').textContent='바이러스에 의해 연결이 끊겼습니다.';
  const r=document.getElementById('ovR');
  r.innerHTML=`SCORE: ${score}&lt;br&gt;&lt;span style=&quot;font-size:11px;color:#335544&quot;&gt;LEVEL ${level} · DATA ${totalDots-dotsLeft}/${totalDots}&lt;/span&gt;`;
  r.classList.remove('hidden');
  document.getElementById('startBtn').textContent='RECONNECT';
  document.getElementById('ov').classList.remove('hidden');
}

function togglePause(){
  paused=!paused;
  if(paused){
    document.getElementById('ovT').textContent='PAUSED';
    document.getElementById('ovS').textContent='P키로 재개';
    document.getElementById('ovR').classList.add('hidden');
    document.getElementById('startBtn').textContent='RESUME';
    document.getElementById('ov').classList.remove('hidden');
    draw();
  } else {
    lastTs=performance.now();
    document.getElementById('ov').classList.add('hidden');
  }
}

document.getElementById('startBtn').addEventListener('click',()=&gt;{
  if(AC.state==='suspended') AC.resume();
  if(paused) togglePause(); else startGame();
});

// Init
initAudio();
ctx.fillStyle='#010a14'; ctx.fillRect(0,0,W,H);
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</description>
      <category>게임</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/70</guid>
      <comments>https://bbibbabbbi.tistory.com/70#entry70comment</comments>
      <pubDate>Sun, 22 Mar 2026 08:04:10 +0900</pubDate>
    </item>
    <item>
      <title>사이버 스트라이크</title>
      <link>https://bbibbabbbi.tistory.com/69</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;title&gt;CYBER STRIKE — 사이버 스트라이크&lt;/title&gt;
&lt;style&gt;
@import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&amp;family=Exo+2:wght@400;600;700;800&amp;display=swap');

:root {
  --bg:   #04080f;
  --cyan: #00ffee;
  --pink: #ff2d78;
  --lime: #aaff00;
  --yel:  #ffdd00;
  --bdr:  #00ffee18;
}

* { margin:0; padding:0; box-sizing:border-box; }

body {
  background:var(--bg);
  display:flex; justify-content:center; align-items:flex-start;
  min-height:100vh; padding:20px 0 40px;
  font-family:'Exo 2',sans-serif;
  overflow-y:auto; overflow-x:hidden;
}

#gw { position:relative; width:480px; }

/* HEADER */
#hd {
  text-align:center; padding:10px 0 7px;
  background:linear-gradient(180deg,#020509,var(--bg));
  border:1px solid var(--bdr); border-bottom:none;
  position:relative; overflow:hidden;
}
#hd::after {
  content:''; position:absolute;
  bottom:0; left:10%; right:10%; height:1px;
  background:linear-gradient(90deg,transparent,#00ffee44,transparent);
}
#hd h1 {
  font-family:'Silkscreen',monospace; font-size:18px; font-weight:700;
  letter-spacing:6px;
  background:linear-gradient(90deg,var(--cyan),#88ffee,var(--pink),var(--cyan));
  background-size:300% auto;
  -webkit-background-clip:text; -webkit-text-fill-color:transparent;
  animation:hs 5s linear infinite;
  filter:drop-shadow(0 0 8px #00ffee44);
}
@keyframes hs{0%{background-position:0%}100%{background-position:300%}}

/* HUD */
#hud {
  display:flex; justify-content:space-between; align-items:stretch;
  background:#060c14;
  border-left:1px solid var(--bdr); border-right:1px solid var(--bdr);
}
.hcell {
  flex:1; display:flex; flex-direction:column;
  align-items:center; justify-content:center;
  padding:5px 4px; border-right:1px solid #00ffee0d; gap:1px;
}
.hcell:last-child{border-right:none;}
.hl{ font-family:'Silkscreen',monospace; font-size:7px; color:#224433; letter-spacing:2px; }
.hv{ font-family:'Silkscreen',monospace; font-size:12px; line-height:1; }
#scoreV { color:var(--yel); text-shadow:0 0 8px #ffdd0055; }
#lifeV  { color:var(--pink); text-shadow:0 0 8px #ff2d7855; }
#levelV { color:#aa88ff; text-shadow:0 0 8px #aa88ff44; }
#waveV  { color:var(--lime); text-shadow:0 0 8px #aaff0044; }

/* MUSIC TOGGLE */
#musicBtn {
  font-family:'Silkscreen',monospace; font-size:8px;
  padding:4px 8px; background:transparent;
  border:1px solid #00ffee33; color:#00ffee88;
  cursor:pointer; border-radius:2px; align-self:center;
  margin:0 5px; transition:all 0.15s; white-space:nowrap;
}
#musicBtn:hover{ background:#00ffee15; color:var(--cyan); }
#musicBtn.on{ color:var(--cyan); border-color:var(--cyan); }

/* CANVAS */
canvas {
  display:block;
  border:1px solid var(--bdr); border-top:none;
  cursor:crosshair;
}

/* OVERLAY */
#ov {
  position:absolute; top:38px; left:0; right:0; bottom:44px;
  display:flex; flex-direction:column;
  justify-content:center; align-items:center;
  background:rgba(2,5,12,0.93);
  z-index:10; gap:14px;
  border-left:1px solid var(--bdr); border-right:1px solid var(--bdr);
}
#ov.hidden{display:none;}
#ov h2 {
  font-family:'Silkscreen',monospace; font-size:24px;
  text-align:center; line-height:1.6;
  color:var(--cyan); text-shadow:0 0 20px #00ffee88,0 0 40px #00ffee33;
}
.ov-sub{ font-size:14px; color:#557766; letter-spacing:1.5px; text-align:center; line-height:2; }
.ov-res{ font-family:'Silkscreen',monospace; font-size:13px; color:var(--yel);
         text-shadow:0 0 10px #ffdd0055; text-align:center; line-height:2; }
.ov-res.hidden{display:none;}

/* Legend */
.legend {
  display:flex; gap:12px; font-size:12px; color:#446655;
  flex-wrap:wrap; justify-content:center;
}
.legend span{ display:flex; align-items:center; gap:5px; }
.ldot{ width:12px; height:12px; border-radius:50%; display:inline-block; }

.btn {
  font-family:'Silkscreen',monospace; font-size:10px;
  letter-spacing:3px; padding:12px 30px;
  background:transparent; border:2px solid var(--cyan);
  color:var(--cyan); cursor:pointer;
  position:relative; overflow:hidden; transition:color 0.18s;
}
.btn::before{
  content:''; position:absolute; inset:0;
  background:var(--cyan); transform:scaleX(0);
  transform-origin:left; transition:transform 0.18s; z-index:-1;
}
.btn:hover::before{transform:scaleX(1);}
.btn:hover{color:#020509;}

/* CONTROLS */
#ctrl {
  display:flex; justify-content:center; align-items:center;
  flex-wrap:wrap; gap:5px 14px; padding:8px 14px;
  background:#060c14; border:1px solid #00ffee10; border-top:none;
  font-size:11px; color:#334455; letter-spacing:1px;
}
#ctrl .ci{ display:flex; align-items:center; gap:5px; white-space:nowrap; }
#ctrl kbd {
  display:inline-block; background:#040a10;
  border:1px solid #00ffee33; border-bottom:2px solid #00ffee55;
  border-radius:3px; padding:1px 7px;
  font-family:'Exo 2',sans-serif; font-size:11px; color:var(--cyan);
}
#ctrl .sep{ color:#00ffee10; font-size:16px; }

/* MOBILE */
#mob {
  display:none; justify-content:space-between; align-items:center;
  padding:10px 20px; background:#060c14;
  border:1px solid #00ffee10; border-top:none; gap:10px;
}
.dpad{ display:grid; grid-template-columns:repeat(3,48px); grid-template-rows:repeat(2,48px); gap:3px; }
.db {
  background:#00ffee0e; border:1px solid #00ffee33; color:var(--cyan);
  font-size:20px; border-radius:4px;
  display:flex; align-items:center; justify-content:center;
  cursor:pointer; user-select:none; -webkit-user-select:none; touch-action:none;
}
.db:active{ background:#00ffee22; }
.fire-btn {
  width:76px; height:76px; border-radius:50%;
  background:#ff2d7811; border:2px solid var(--pink); color:var(--pink);
  font-family:'Silkscreen',monospace; font-size:9px; font-weight:700;
  cursor:pointer; user-select:none; -webkit-user-select:none; touch-action:none;
  display:flex; align-items:center; justify-content:center;
}
.fire-btn:active{ background:#ff2d7833; }

@media(max-width:500px){ #gw{width:100vw;} canvas{width:100vw;height:auto;} #mob{display:flex;} }
@media(pointer:coarse){ #mob{display:flex;} }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div id=&quot;gw&quot;&gt;

&lt;div id=&quot;hd&quot;&gt;&lt;h1&gt;CYBER STRIKE&lt;/h1&gt;&lt;/div&gt;

&lt;div id=&quot;hud&quot;&gt;
  &lt;div class=&quot;hcell&quot;&gt;&lt;span class=&quot;hl&quot;&gt;SCORE&lt;/span&gt;&lt;span class=&quot;hv&quot; id=&quot;scoreV&quot;&gt;0&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;hcell&quot;&gt;&lt;span class=&quot;hl&quot;&gt;LIFE&lt;/span&gt;&lt;span class=&quot;hv&quot; id=&quot;lifeV&quot;&gt;♥♥♥&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;hcell&quot;&gt;&lt;span class=&quot;hl&quot;&gt;LEVEL&lt;/span&gt;&lt;span class=&quot;hv&quot; id=&quot;levelV&quot;&gt;1&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;hcell&quot;&gt;&lt;span class=&quot;hl&quot;&gt;WAVE&lt;/span&gt;&lt;span class=&quot;hv&quot; id=&quot;waveV&quot;&gt;1&lt;/span&gt;&lt;/div&gt;
  &lt;div class=&quot;hcell&quot; style=&quot;flex:0.8;&quot;&gt;
    &lt;button id=&quot;musicBtn&quot; class=&quot;on&quot;&gt;♪ ON&lt;/button&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;canvas id=&quot;c&quot; width=&quot;480&quot; height=&quot;520&quot;&gt;&lt;/canvas&gt;

&lt;div id=&quot;ov&quot;&gt;
  &lt;h2 id=&quot;ovT&quot;&gt;CYBER&lt;br&gt;STRIKE&lt;/h2&gt;
  &lt;p class=&quot;ov-sub&quot; id=&quot;ovS&quot;&gt;웨이브를 생존하고 보스를 격파하라!&lt;br&gt;적에게 부딪히거나 총알에 맞으면 피해&lt;/p&gt;
  &lt;div class=&quot;legend&quot;&gt;
    &lt;span&gt;&lt;span class=&quot;ldot&quot; style=&quot;background:#ff2d78&quot;&gt;&lt;/span&gt;일반 적&lt;/span&gt;
    &lt;span&gt;&lt;span class=&quot;ldot&quot; style=&quot;background:#ff8800&quot;&gt;&lt;/span&gt;탱커 적&lt;/span&gt;
    &lt;span&gt;&lt;span class=&quot;ldot&quot; style=&quot;background:#aa44ff&quot;&gt;&lt;/span&gt;보스&lt;/span&gt;
    &lt;span&gt;&lt;span class=&quot;ldot&quot; style=&quot;background:#ffdd00&quot;&gt;&lt;/span&gt;파워업&lt;/span&gt;
  &lt;/div&gt;
  &lt;div class=&quot;ov-res hidden&quot; id=&quot;ovR&quot;&gt;&lt;/div&gt;
  &lt;button class=&quot;btn&quot; id=&quot;startBtn&quot;&gt;START&lt;/button&gt;
&lt;/div&gt;

&lt;div id=&quot;ctrl&quot;&gt;
  &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;←&lt;/kbd&gt;&lt;kbd&gt;→&lt;/kbd&gt;&lt;kbd&gt;↑&lt;/kbd&gt;&lt;kbd&gt;↓&lt;/kbd&gt; 이동&lt;/span&gt;
  &lt;span class=&quot;sep&quot;&gt;|&lt;/span&gt;
  &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;SPACE&lt;/kbd&gt; 발사&lt;/span&gt;
  &lt;span class=&quot;sep&quot;&gt;|&lt;/span&gt;
  &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;P&lt;/kbd&gt; 일시정지&lt;/span&gt;
&lt;/div&gt;

&lt;div id=&quot;mob&quot;&gt;
  &lt;div class=&quot;dpad&quot;&gt;
    &lt;div&gt;&lt;/div&gt;&lt;div class=&quot;db&quot; id=&quot;mU&quot;&gt;▲&lt;/div&gt;&lt;div&gt;&lt;/div&gt;
    &lt;div class=&quot;db&quot; id=&quot;mL&quot;&gt;◀&lt;/div&gt;&lt;div style=&quot;background:#00ffee08;border:1px solid #00ffee11;border-radius:4px;&quot;&gt;&lt;/div&gt;
    &lt;div class=&quot;db&quot; id=&quot;mR&quot;&gt;▶&lt;/div&gt;
  &lt;/div&gt;
  &lt;div style=&quot;display:flex;flex-direction:column;gap:8px;align-items:center;&quot;&gt;
    &lt;div class=&quot;db&quot; id=&quot;mD&quot; style=&quot;width:48px;height:48px;&quot;&gt;▼&lt;/div&gt;
    &lt;div class=&quot;fire-btn&quot; id=&quot;mFire&quot;&gt;FIRE&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;

&lt;script&gt;
/* ══════════════════════════════════════════════════════════
   CYBER STRIKE — Web Action Game
   ── 사운드 저작권 안내 ──────────────────────────────────
   모든 BGM과 효과음은 Web Audio API로 코드가 실시간 생성.
   외부 음원 파일 없음. 저작권 문제 없음. 상업 사용 가능.
   ── 그래픽 저작권 ──────────────────────────────────────
   모든 그래픽: Canvas 2D API 직접 드로잉. 이미지 없음.
   폰트: Silkscreen(SIL OFL 1.1) + Exo 2(SIL OFL 1.1)
   ── 코드 ───────────────────────────────────────────────
   이번 대화에서 직접 작성한 오리지널 코드.
══════════════════════════════════════════════════════════ */

// ══ AUDIO ENGINE ══════════════════════════════════════════
const AC = new (window.AudioContext || window.webkitAudioContext)();
let masterGain, musicGain, sfxGain, musicOn = true;
let musicSeqId = null, musicStep = 0, musicBeat = 0;

function initAudio() {
  masterGain = AC.createGain(); masterGain.gain.value = 0.6;
  musicGain  = AC.createGain(); musicGain.gain.value  = 0.35;
  sfxGain    = AC.createGain(); sfxGain.gain.value    = 0.7;
  musicGain.connect(masterGain);
  sfxGain.connect(masterGain);
  masterGain.connect(AC.destination);
}

// ─ SFX ─────────────────────────────────────────────────
function sfxShoot() {
  const o = AC.createOscillator(), g = AC.createGain();
  o.type = 'square';
  o.frequency.setValueAtTime(900, AC.currentTime);
  o.frequency.exponentialRampToValueAtTime(400, AC.currentTime+0.06);
  g.gain.setValueAtTime(0.18, AC.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+0.06);
  o.connect(g); g.connect(sfxGain);
  o.start(); o.stop(AC.currentTime+0.06);
}

function sfxEnemyDie(big=false) {
  const n=AC.sampleRate*(big?0.35:0.2);
  const buf=AC.createBuffer(1,n,AC.sampleRate);
  const d=buf.getChannelData(0);
  const dc=big?0.12:0.07;
  for(let i=0;i&lt;n;i++) d[i]=(Math.random()*2-1)*Math.exp(-i/(AC.sampleRate*dc));
  const src=AC.createBufferSource(), g=AC.createGain();
  src.buffer=buf;
  g.gain.setValueAtTime(big?0.5:0.35,AC.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+(big?0.35:0.2));
  src.connect(g); g.connect(sfxGain); src.start();
}

function sfxHit() {
  const o=AC.createOscillator(), g=AC.createGain();
  o.type='sawtooth';
  o.frequency.setValueAtTime(220,AC.currentTime);
  o.frequency.exponentialRampToValueAtTime(55,AC.currentTime+0.15);
  g.gain.setValueAtTime(0.4,AC.currentTime);
  g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+0.15);
  o.connect(g); g.connect(sfxGain); o.start(); o.stop(AC.currentTime+0.15);
}

function sfxPowerup() {
  const notes=[440,554,659,880];
  notes.forEach((f,i)=&gt;{
    const o=AC.createOscillator(), g=AC.createGain();
    o.type='triangle';
    o.frequency.setValueAtTime(f,AC.currentTime+i*0.07);
    g.gain.setValueAtTime(0,AC.currentTime+i*0.07);
    g.gain.linearRampToValueAtTime(0.22,AC.currentTime+i*0.07+0.03);
    g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+i*0.07+0.12);
    o.connect(g); g.connect(sfxGain);
    o.start(AC.currentTime+i*0.07);
    o.stop(AC.currentTime+i*0.07+0.12);
  });
}

function sfxBossDie() {
  for(let i=0;i&lt;4;i++){
    setTimeout(()=&gt;{
      const n=AC.sampleRate*0.4;
      const buf=AC.createBuffer(1,n,AC.sampleRate);
      const d=buf.getChannelData(0);
      for(let j=0;j&lt;n;j++) d[j]=(Math.random()*2-1)*Math.exp(-j/(AC.sampleRate*0.15));
      const src=AC.createBufferSource(), g=AC.createGain();
      src.buffer=buf;
      g.gain.setValueAtTime(0.6,AC.currentTime);
      g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+0.4);
      src.connect(g); g.connect(sfxGain); src.start();
    },i*120);
  }
}

function sfxLevelUp() {
  const melody=[523,659,784,1047];
  melody.forEach((f,i)=&gt;{
    const o=AC.createOscillator(), g=AC.createGain();
    o.type='square';
    o.frequency.setValueAtTime(f,AC.currentTime+i*0.1);
    g.gain.setValueAtTime(0,AC.currentTime+i*0.1);
    g.gain.linearRampToValueAtTime(0.3,AC.currentTime+i*0.1+0.04);
    g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+i*0.1+0.18);
    o.connect(g); g.connect(sfxGain);
    o.start(AC.currentTime+i*0.1);
    o.stop(AC.currentTime+i*0.1+0.18);
  });
}

function sfxWaveStart() {
  [300,400].forEach((f,i)=&gt;{
    const o=AC.createOscillator(),g=AC.createGain();
    o.type='sine';
    o.frequency.setValueAtTime(f,AC.currentTime+i*0.14);
    g.gain.setValueAtTime(0.3,AC.currentTime+i*0.14);
    g.gain.exponentialRampToValueAtTime(0.001,AC.currentTime+i*0.14+0.2);
    o.connect(g); g.connect(sfxGain);
    o.start(AC.currentTime+i*0.14); o.stop(AC.currentTime+i*0.14+0.2);
  });
}

// ─ BACKGROUND MUSIC (Chiptune Sequencer) ─────────────────
// Scale: C minor — C D♭ E♭ F G A♭ B♭
// Frequencies for notes (note name -&gt; Hz)
const NOTE = {
  C3:130.8,D3:146.8,Eb3:155.6,F3:174.6,G3:196,Ab3:207.7,Bb3:233.1,C4:261.6,
  D4:293.7,Eb4:311.1,F4:349.2,G4:392,Ab4:415.3,Bb4:466.2,C5:523.2,D5:587.3,
  Eb5:622.3,F5:698.5,G5:784,Bb5:932.3,r:0
};

const BPM = 148;
const BEAT_S = 60/BPM;

// Bass pattern (repeats every 16 steps, each step = 1/8 note)
const BASS = [
  'C3','r','C3','r', 'Bb3','r','Bb3','r',
  'Ab3','r','Ab3','r', 'G3','r','G3','r',
];
// Chord stabs (every 4 steps)
const CHORD_PAT = [0,0,1,0, 0,0,1,0, 0,1,0,0, 0,0,1,0];
// Melody (16 steps, two 16-step phrases)
const MELODY_A = [
  'C5','r','Eb5','r', 'G4','G4','F4','Eb4',
  'C5','r','Bb4','Ab4', 'G4','r','r','r',
];
const MELODY_B = [
  'Bb4','r','Ab4','G4', 'F4','Eb4','F4','G4',
  'Ab4','r','Bb4','r', 'C5','Bb4','Ab4','G4',
];

let mPhrase = 0;

function playMusicStep(step) {
  if(!musicOn) return;
  const t = AC.currentTime;
  const dur = BEAT_S*0.5;
  const s = step % 16;

  // Bass
  const bf = NOTE[BASS[s]];
  if(bf) {
    const o=AC.createOscillator(),g=AC.createGain();
    o.type='square'; o.frequency.value=bf;
    g.gain.setValueAtTime(0,t);
    g.gain.linearRampToValueAtTime(0.35,t+0.01);
    g.gain.exponentialRampToValueAtTime(0.001,t+dur*0.85);
    o.connect(g); g.connect(musicGain);
    o.start(t); o.stop(t+dur);
  }

  // Chord stabs
  if(CHORD_PAT[s]) {
    [[0,1],[3,1],[7,1]].forEach(([semi,_])=&gt;{
      const base=NOTE['C4']*Math.pow(2,semi/12);
      const o=AC.createOscillator(),g=AC.createGain();
      o.type='triangle'; o.frequency.value=base;
      g.gain.setValueAtTime(0,t);
      g.gain.linearRampToValueAtTime(0.12,t+0.01);
      g.gain.exponentialRampToValueAtTime(0.001,t+dur*0.5);
      o.connect(g); g.connect(musicGain);
      o.start(t); o.stop(t+dur);
    });
  }

  // Melody
  const mel = mPhrase===0?MELODY_A:MELODY_B;
  const mf  = NOTE[mel[s]];
  if(mf) {
    const o=AC.createOscillator(),g=AC.createGain();
    o.type='pulse'||'square'; o.frequency.value=mf;
    g.gain.setValueAtTime(0,t);
    g.gain.linearRampToValueAtTime(0.28,t+0.01);
    g.gain.exponentialRampToValueAtTime(0.001,t+dur*0.78);
    o.connect(g); g.connect(musicGain);
    o.start(t); o.stop(t+dur);
  }

  // Hi-hat (every 2nd step)
  if(s%2===1) {
    const n=AC.sampleRate*0.04;
    const buf=AC.createBuffer(1,n,AC.sampleRate);
    const d=buf.getChannelData(0);
    for(let i=0;i&lt;n;i++) d[i]=(Math.random()*2-1)*Math.exp(-i/(n*0.35));
    const src=AC.createBufferSource(),g=AC.createGain();
    src.buffer=buf; g.gain.value=0.06;
    src.connect(g); g.connect(musicGain); src.start(t);
  }

  // Kick (steps 0 and 8)
  if(s===0||s===8) {
    const o=AC.createOscillator(),g=AC.createGain();
    o.type='sine';
    o.frequency.setValueAtTime(180,t);
    o.frequency.exponentialRampToValueAtTime(30,t+0.12);
    g.gain.setValueAtTime(0.55,t);
    g.gain.exponentialRampToValueAtTime(0.001,t+0.12);
    o.connect(g); g.connect(musicGain);
    o.start(t); o.stop(t+0.14);
  }
  // Snare (steps 4 and 12)
  if(s===4||s===12) {
    const n=AC.sampleRate*0.08;
    const buf=AC.createBuffer(1,n,AC.sampleRate);
    const d=buf.getChannelData(0);
    for(let i=0;i&lt;n;i++) d[i]=(Math.random()*2-1)*Math.exp(-i/(n*0.4));
    const src=AC.createBufferSource(),g=AC.createGain();
    src.buffer=buf; g.gain.value=0.18;
    src.connect(g); g.connect(musicGain); src.start(t);
  }
}

function startMusic() {
  if(musicSeqId) clearInterval(musicSeqId);
  musicStep=0; musicBeat=0;
  const interval=BEAT_S*500; // ms per 1/8 note
  musicSeqId=setInterval(()=&gt;{
    if(!gameRunning&amp;&amp;!paused) return;
    playMusicStep(musicStep);
    musicStep++;
    if(musicStep%16===0){ mPhrase=(mPhrase+1)%2; }
    musicStep%=32;
  },interval);
}

function stopMusic() {
  if(musicSeqId){ clearInterval(musicSeqId); musicSeqId=null; }
}

document.getElementById('musicBtn').addEventListener('click',()=&gt;{
  if(AC.state==='suspended') AC.resume();
  musicOn=!musicOn;
  musicGain.gain.value=musicOn?0.35:0;
  const btn=document.getElementById('musicBtn');
  btn.textContent=musicOn?'♪ ON':'♪ OFF';
  btn.classList.toggle('on',musicOn);
});

// ══ GAME ENGINE ═══════════════════════════════════════════
const canvas=document.getElementById('c');
const ctx=canvas.getContext('2d');
const W=480,H=520;

// ── Grid / config ──
let score=0,lives=3,level=1,wave=1;
let gameRunning=false,paused=false,animId,lastTs=0;
let waveTimer=0,waveBreak=false,waveBreakTimer=0;
let invincible=0,flashEffect=null;
let particles=[];

// ── Player ──
const PLR={
  x:W/2, y:H-60, w:28, h:36,
  speed:4.2, shootCd:0, shootRate:14,
  multiShot:false, multiTimer:0,
  shield:false, shieldTimer:0,
  speedBoost:false, speedTimer:0,
};

// ── Bullets ──
let pBullets=[], eBullets=[];
let enemies=[], powerups=[], boss=null;
let bossActive=false;
const stars=Array.from({length:100},()=&gt;({
  x:Math.random()*W, y:Math.random()*H,
  size:Math.random()*1.5, speed:0.3+Math.random()*1.5,
  alpha:0.2+Math.random()*0.7
}));

// ── Input ──
const keys={};
let mob={up:false,down:false,left:false,right:false,fire:false};
document.addEventListener('keydown',e=&gt;{
  keys[e.code]=true;
  if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code))e.preventDefault();
  if(e.code==='KeyP'&amp;&amp;gameRunning)togglePause();
});
document.addEventListener('keyup',e=&gt;keys[e.code]=false);

function holdBtn(id,key){
  const el=document.getElementById(id);if(!el)return;
  const on=()=&gt;mob[key]=true,off=()=&gt;mob[key]=false;
  el.addEventListener('touchstart',e=&gt;{e.preventDefault();on();},{passive:false});
  el.addEventListener('touchend',  e=&gt;{e.preventDefault();off();},{passive:false});
  el.addEventListener('mousedown',on); el.addEventListener('mouseup',off);
  el.addEventListener('mouseleave',off);
}
holdBtn('mU','up'); holdBtn('mD','down');
holdBtn('mL','left'); holdBtn('mR','right');
holdBtn('mFire','fire');

let mouseOver=false;
canvas.addEventListener('mouseenter',()=&gt;mouseOver=true);
canvas.addEventListener('mouseleave',()=&gt;mouseOver=false);
window.addEventListener('wheel',e=&gt;{if(mouseOver&amp;&amp;gameRunning&amp;&amp;!paused)e.preventDefault();},{passive:false});

// ── HUD ──
function updateHUD(){
  document.getElementById('scoreV').textContent=score;
  const h='♥'.repeat(lives)+'♡'.repeat(Math.max(0,3-lives));
  document.getElementById('lifeV').textContent=h||'✗';
  document.getElementById('lifeV').style.color=lives&lt;=1?'#ff4444':'#ff2d78';
  document.getElementById('levelV').textContent=level;
  document.getElementById('waveV').textContent=wave;
}

// ── SPAWN SYSTEMS ──
const ETYPES=[
  {color:'#ff2d78',hp:1,pts:10,size:14,speed:1.2,shoot:0,kind:'fighter'},
  {color:'#ff8800',hp:3,pts:25,size:18,speed:0.8,shoot:0.005,kind:'tanker'},
  {color:'#ff2d78',hp:1,pts:15,size:13,speed:1.6,shoot:0,kind:'fast'},
];

function spawnEnemy(){
  const lvFactor=Math.min(3,Math.floor(level*0.4));
  const typIdx=Math.min(2,Math.floor(Math.random()*(1+lvFactor)));
  const t={...ETYPES[typIdx]};
  t.hp+=Math.floor(level/4);
  t.maxHp=t.hp;
  t.speed+=level*0.04;
  enemies.push({
    x:20+Math.random()*(W-40), y:-30,
    ...t,
    vx:(Math.random()-0.5)*0.8,
    shootTimer:Math.random()*120,
    angle:0,
  });
}

function spawnBoss(){
  sfxWaveStart();
  boss={
    x:W/2, y:80, vx:1.8+level*0.15,
    hp:60+level*20, maxHp:60+level*20,
    phase:0, shootTimer:0, shootRate:35-level*1.5,
    angle:0,
  };
  bossActive=true;
}

function spawnPowerup(x,y){
  const types=[
    {type:'multishot',color:'#ffdd00',label:'★'},
    {type:'shield',   color:'#44aaff',label:' '},
    {type:'speed',    color:'#aaff00',label:'⚡'},
    {type:'life',     color:'#ff6eb4',label:'♥'},
  ];
  const t=types[Math.floor(Math.random()*types.length)];
  powerups.push({x,y,vy:1.2,...t,life:300,pulse:0});
}

// ── WAVE LOGIC ──
let enemyBudget=0, enemySpawnTimer=0, enemiesSpawned=0, waveTotal=0;

function startWave(){
  waveBreak=false;
  enemies=[]; eBullets=[];
  bossActive=false; boss=null;
  const isBossWave=(wave%5===0);
  waveTotal=isBossWave?0:8+wave*2+level*2;
  enemyBudget=waveTotal;
  enemiesSpawned=0;
  enemySpawnTimer=0;
  sfxWaveStart();
  if(isBossWave) setTimeout(spawnBoss,1200);
}

function checkWaveComplete(){
  const bossWave=(wave%5===0);
  if(bossWave){
    if(!bossActive&amp;&amp;!boss) advanceWave();
  } else {
    if(enemiesSpawned&gt;=waveTotal&amp;&amp;enemies.length===0&amp;&amp;powerups.length===0) advanceWave();
  }
}

function advanceWave(){
  waveBreak=true;
  waveBreakTimer=3000;
  score+=100*wave;
  wave++;
  if(wave%3===0){ level++; sfxLevelUp(); }
  updateHUD();
}

// ── PARTICLES ──
function spawnParticles(x,y,col,n=12,sp=3){
  for(let i=0;i&lt;n;i++){
    const a=Math.random()*Math.PI*2;
    const s=0.5+Math.random()*sp;
    particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,
      life:1,decay:0.02+Math.random()*0.03,size:2+Math.random()*5,color:col});
  }
}

// ── MAIN LOOP ──
function loop(ts){
  animId=requestAnimationFrame(loop);
  const dt=Math.min(ts-lastTs,50); lastTs=ts;
  if(paused||!gameRunning)return;

  update(dt);
  draw();
}

function update(dt){
  // Player move
  const spd=PLR.speedBoost?PLR.speed*1.6:PLR.speed;
  const isL=keys['ArrowLeft']||keys['KeyA']||mob.left;
  const isR=keys['ArrowRight']||keys['KeyD']||mob.right;
  const isU=keys['ArrowUp']||keys['KeyW']||mob.up;
  const isD=keys['ArrowDown']||keys['KeyS']||mob.down;
  const isFire=keys['Space']||mob.fire;

  if(isL) PLR.x=Math.max(PLR.w/2,PLR.x-spd);
  if(isR) PLR.x=Math.min(W-PLR.w/2,PLR.x+spd);
  if(isU) PLR.y=Math.max(PLR.h/2+30,PLR.y-spd);
  if(isD) PLR.y=Math.min(H-PLR.h/2,PLR.y+spd);

  // Shoot
  if(PLR.shootCd&gt;0)PLR.shootCd--;
  if(isFire&amp;&amp;PLR.shootCd===0){
    if(PLR.multiShot){
      pBullets.push({x:PLR.x-8,y:PLR.y-PLR.h/2,vy:-11,vx:-0.8});
      pBullets.push({x:PLR.x,  y:PLR.y-PLR.h/2,vy:-12,vx:0});
      pBullets.push({x:PLR.x+8,y:PLR.y-PLR.h/2,vy:-11,vx:0.8});
    } else {
      pBullets.push({x:PLR.x,y:PLR.y-PLR.h/2,vy:-12,vx:0});
    }
    sfxShoot();
    PLR.shootCd=PLR.multiShot?10:14;
  }

  // Power timers
  if(PLR.multiTimer&gt;0){PLR.multiTimer-=dt;if(PLR.multiTimer&lt;=0)PLR.multiShot=false;}
  if(PLR.shieldTimer&gt;0){PLR.shieldTimer-=dt;if(PLR.shieldTimer&lt;=0)PLR.shield=false;}
  if(PLR.speedTimer&gt;0){PLR.speedTimer-=dt;if(PLR.speedTimer&lt;=0)PLR.speedBoost=false;}
  if(invincible&gt;0)invincible-=dt;
  if(flashEffect)flashEffect.alpha=Math.max(0,flashEffect.alpha-0.04);

  // Stars scroll
  stars.forEach(s=&gt;{s.y+=s.speed;if(s.y&gt;H){s.y=0;s.x=Math.random()*W;}});

  // Wave break timer
  if(waveBreak){
    waveBreakTimer-=dt;
    if(waveBreakTimer&lt;=0)startWave();
    return;
  }

  // Spawn enemies
  if(!bossActive&amp;&amp;enemiesSpawned&lt;waveTotal){
    enemySpawnTimer-=dt;
    const rate=Math.max(400,900-wave*40-level*20);
    if(enemySpawnTimer&lt;=0){
      spawnEnemy(); enemiesSpawned++;
      enemySpawnTimer=rate;
    }
  }

  // Update enemies
  enemies=enemies.filter(e=&gt;{
    e.x+=e.vx; e.y+=e.speed;
    if(e.x&lt;e.size||e.x&gt;W-e.size)e.vx*=-1;
    e.angle+=0.03;

    // Enemy shoot
    if(e.shoot&gt;0){
      e.shootTimer--;
      if(e.shootTimer&lt;=0){
        e.shootTimer=60+Math.random()*60;
        const dx=PLR.x-e.x,dy=PLR.y-e.y;
        const dist=Math.sqrt(dx*dx+dy*dy)||1;
        eBullets.push({x:e.x,y:e.y,vx:dx/dist*3,vy:dy/dist*3,color:e.color});
      }
    }

    // Out of bounds
    if(e.y&gt;H+50){ takeDamage(); return false; }
    return e.hp&gt;0;
  });

  // Update boss
  if(boss){
    boss.x+=boss.vx;
    if(boss.x&lt;60||boss.x&gt;W-60)boss.vx*=-1;
    boss.shootTimer++;
    boss.angle+=0.02;
    const rate=Math.max(15,boss.shootRate);
    if(boss.shootTimer&gt;=rate){
      boss.shootTimer=0;
      const phases=boss.hp&lt;boss.maxHp*0.5?5:3;
      for(let i=0;i&lt;phases;i++){
        const a=-Math.PI/2+(i-(phases-1)/2)*0.28;
        eBullets.push({x:boss.x,y:boss.y+40,
          vx:Math.cos(a)*3.5,vy:Math.sin(a)*3.5,
          color:'#aa44ff',big:true});
      }
      if(boss.hp&lt;boss.maxHp*0.3){
        for(let i=0;i&lt;8;i++){
          const a=(i/8)*Math.PI*2+boss.angle;
          eBullets.push({x:boss.x,y:boss.y,
            vx:Math.cos(a)*2,vy:Math.sin(a)*2,
            color:'#ff8800',big:false});
        }
      }
    }
    if(boss.hp&lt;=0){
      sfxBossDie();
      spawnParticles(boss.x,boss.y,'#aa44ff',40,6);
      spawnParticles(boss.x,boss.y,'#ffdd00',30,5);
      score+=500*level;
      if(Math.random()&lt;0.8)spawnPowerup(boss.x,boss.y);
      boss=null; bossActive=false;
      updateHUD();
    }
  }

  // Player bullets
  pBullets=pBullets.filter(b=&gt;{
    b.x+=b.vx; b.y+=b.vy;
    // Hit enemies
    let hit=false;
    for(const e of enemies){
      if(!hit&amp;&amp;Math.abs(b.x-e.x)&lt;e.size+4&amp;&amp;Math.abs(b.y-e.y)&lt;e.size+4){
        e.hp--;
        spawnParticles(b.x,b.y,e.color,5,2);
        if(e.hp&lt;=0){
          score+=e.pts; updateHUD();
          sfxEnemyDie(e.kind==='tanker');
          spawnParticles(e.x,e.y,e.color,16,3);
          if(Math.random()&lt;0.2)spawnPowerup(e.x,e.y);
        }
        hit=true;
      }
    }
    if(!hit&amp;&amp;boss&amp;&amp;Math.abs(b.x-boss.x)&lt;55&amp;&amp;Math.abs(b.y-boss.y)&lt;38){
      boss.hp--;
      spawnParticles(b.x,b.y,'#aa44ff',4,2);
      hit=true;
    }
    return !hit&amp;&amp;b.y&gt;-10&amp;&amp;b.x&gt;-10&amp;&amp;b.x&lt;W+10;
  });

  // Enemy bullets
  eBullets=eBullets.filter(b=&gt;{
    b.x+=b.vx; b.y+=b.vy;
    if(invincible&lt;=0&amp;&amp;Math.abs(b.x-PLR.x)&lt;14&amp;&amp;Math.abs(b.y-PLR.y)&lt;18){
      if(PLR.shield){
        PLR.shield=false; PLR.shieldTimer=0;
        spawnParticles(PLR.x,PLR.y,'#44aaff',14,2);
        flashEffect={color:'#44aaff',alpha:0.4};
      } else takeDamage();
      return false;
    }
    return b.x&gt;-10&amp;&amp;b.x&lt;W+10&amp;&amp;b.y&gt;-10&amp;&amp;b.y&lt;H+10;
  });

  // Player-enemy collision
  if(invincible&lt;=0){
    enemies.forEach(e=&gt;{
      if(Math.abs(PLR.x-e.x)&lt;(14+e.size*0.7)&amp;&amp;Math.abs(PLR.y-e.y)&lt;(18+e.size*0.7)){
        if(PLR.shield){ PLR.shield=false; PLR.shieldTimer=0; spawnParticles(PLR.x,PLR.y,'#44aaff',14,2); }
        else { e.hp=0; spawnParticles(e.x,e.y,e.color,10,3); takeDamage(); }
      }
    });
  }

  // Powerups
  powerups=powerups.filter(p=&gt;{
    p.y+=p.vy; p.life--; p.pulse+=0.1;
    if(Math.abs(p.x-PLR.x)&lt;20&amp;&amp;Math.abs(p.y-PLR.y)&lt;20){
      applyPower(p); sfxPowerup();
      spawnParticles(p.x,p.y,p.color,18,3);
      return false;
    }
    return p.y&lt;H+40&amp;&amp;p.life&gt;0;
  });

  // Particles
  particles=particles.filter(p=&gt;{
    p.x+=p.vx; p.y+=p.vy; p.vy+=0.04;
    p.life-=p.decay; return p.life&gt;0;
  });

  checkWaveComplete();
}

function applyPower(p){
  if(p.type==='multishot'){PLR.multiShot=true;PLR.multiTimer=8000;}
  if(p.type==='shield'){PLR.shield=true;PLR.shieldTimer=10000;}
  if(p.type==='speed'){PLR.speedBoost=true;PLR.speedTimer=6000;}
  if(p.type==='life'){lives=Math.min(5,lives+1);updateHUD();}
}

function takeDamage(){
  lives--; invincible=2200;
  sfxHit();
  flashEffect={color:'#ff2d78',alpha:0.6};
  spawnParticles(PLR.x,PLR.y,'#ff2d78',14,3);
  updateHUD();
  if(lives&lt;=0)endGame();
}

// ══ DRAW ══════════════════════════════════════════════════
function draw(){
  // BG
  ctx.fillStyle='#030710';
  ctx.fillRect(0,0,W,H);

  // Grid
  ctx.save();
  ctx.strokeStyle='#ffffff04'; ctx.lineWidth=1;
  for(let x=0;x&lt;W;x+=40){ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,H);ctx.stroke();}
  for(let y=0;y&lt;H;y+=40){ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(W,y);ctx.stroke();}
  ctx.restore();

  // Stars
  stars.forEach(s=&gt;{
    ctx.save(); ctx.globalAlpha=s.alpha;
    ctx.fillStyle='#ffffff';
    ctx.beginPath(); ctx.arc(s.x,s.y,s.size,0,Math.PI*2); ctx.fill();
    ctx.restore();
  });

  // Wave break overlay
  if(waveBreak){
    ctx.save();
    ctx.font=&quot;bold 18px 'Silkscreen',monospace&quot;;
    ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.fillStyle=`rgba(0,255,238,${0.6+0.4*Math.sin(Date.now()*0.008)})`;
    ctx.shadowBlur=20; ctx.shadowColor='#00ffee';
    ctx.fillText(`WAVE ${wave} INCOMING...`,W/2,H/2);
    ctx.restore();
  }

  // Powerups
  powerups.forEach(p=&gt;{
    const px=p.x,py=p.y;
    const pulse=0.8+Math.sin(p.pulse)*0.2;
    ctx.save();
    ctx.shadowBlur=16; ctx.shadowColor=p.color;
    ctx.strokeStyle=p.color; ctx.lineWidth=2;
    ctx.fillStyle=p.color+'22';
    ctx.beginPath();
    const r=13*pulse;
    for(let i=0;i&lt;4;i++){
      const a=i*Math.PI/2-Math.PI/4;
      ctx.moveTo(px+r*Math.cos(a),py+r*Math.sin(a));
      ctx.lineTo(px+r*0.4*Math.cos(a+Math.PI/4),py+r*0.4*Math.sin(a+Math.PI/4));
    }
    ctx.beginPath();
    for(let i=0;i&lt;4;i++){
      const a=i*Math.PI/2+Math.PI/4;
      const ir=r*0.5;
      i===0?ctx.moveTo(px+r*Math.cos(a-Math.PI/4),py+r*Math.sin(a-Math.PI/4))
           :ctx.lineTo(px+r*Math.cos(a-Math.PI/4),py+r*Math.sin(a-Math.PI/4));
      ctx.lineTo(px+ir*Math.cos(a),py+ir*Math.sin(a));
    }
    ctx.closePath(); ctx.fill(); ctx.stroke();
    ctx.fillStyle=p.color;
    ctx.font=`bold 12px 'Exo 2',sans-serif`;
    ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.fillText(p.label,px,py);
    ctx.restore();
  });

  // Enemies
  enemies.forEach(e=&gt;drawEnemy(e));

  // Boss
  if(boss)drawBoss(boss);

  // Bullets
  pBullets.forEach(b=&gt;{
    ctx.save();
    ctx.fillStyle=PLR.multiShot?'#ffaa00':'#00ffee';
    ctx.shadowBlur=10; ctx.shadowColor=ctx.fillStyle;
    ctx.fillRect(b.x-2,b.y-6,4,12);
    ctx.restore();
  });

  eBullets.forEach(b=&gt;{
    ctx.save();
    ctx.fillStyle=b.color;
    ctx.shadowBlur=10; ctx.shadowColor=b.color;
    const r=b.big?5:3;
    ctx.beginPath(); ctx.arc(b.x,b.y,r,0,Math.PI*2); ctx.fill();
    ctx.restore();
  });

  // Player
  drawPlayer();

  // Particles
  particles.forEach(p=&gt;{
    ctx.save(); ctx.globalAlpha=Math.max(0,p.life);
    ctx.fillStyle=p.color; ctx.shadowBlur=8; ctx.shadowColor=p.color;
    ctx.beginPath(); ctx.arc(p.x,p.y,p.size*p.life,0,Math.PI*2); ctx.fill();
    ctx.restore();
  });

  // Flash
  if(flashEffect&amp;&amp;flashEffect.alpha&gt;0){
    ctx.save(); ctx.globalAlpha=flashEffect.alpha;
    ctx.fillStyle=flashEffect.color;
    ctx.fillRect(0,0,W,H); ctx.restore();
  }

  // Power indicators
  drawPowerIndicators();

  // Boss HP bar
  if(boss){
    const bw=300,bx=(W-bw)/2,by=12;
    ctx.save();
    ctx.fillStyle='#220022';
    ctx.fillRect(bx,by,bw,10);
    const ratio=boss.hp/boss.maxHp;
    const col=ratio&gt;0.5?'#aa44ff':ratio&gt;0.25?'#ff8800':'#ff2d78';
    ctx.fillStyle=col; ctx.shadowBlur=8; ctx.shadowColor=col;
    ctx.fillRect(bx,by,bw*ratio,10);
    ctx.strokeStyle='#aa44ff44'; ctx.lineWidth=1;
    ctx.strokeRect(bx,by,bw,10);
    ctx.fillStyle='#aa88ff'; ctx.font=&quot;bold 9px 'Silkscreen',monospace&quot;;
    ctx.textAlign='center'; ctx.textBaseline='middle';
    ctx.shadowBlur=0;
    ctx.fillText(`BOSS — WAVE ${wave}`,W/2,by+5);
    ctx.restore();
  }

  // Vignette
  const vig=ctx.createRadialGradient(W/2,H/2,H*0.25,W/2,H/2,H*0.75);
  vig.addColorStop(0,'transparent'); vig.addColorStop(1,'rgba(0,0,0,0.55)');
  ctx.fillStyle=vig; ctx.fillRect(0,0,W,H);
}

function drawPlayer(){
  const {x,y,w,h}=PLR;
  const isSel=Math.floor(Date.now()/60)%2;
  if(invincible&gt;0&amp;&amp;isSel)return;

  ctx.save();
  ctx.translate(x,y);

  // Shield aura
  if(PLR.shield){
    const shieldPulse=0.7+0.3*Math.sin(Date.now()*0.007);
    ctx.save();
    ctx.strokeStyle=`rgba(68,170,255,${shieldPulse})`;
    ctx.lineWidth=2.5; ctx.shadowBlur=16; ctx.shadowColor='#44aaff';
    ctx.beginPath(); ctx.arc(0,0,22,0,Math.PI*2); ctx.stroke();
    ctx.restore();
  }

  // Engine glow (bottom)
  const engGrad=ctx.createRadialGradient(0,h/2,0,0,h/2,18);
  engGrad.addColorStop(0,'#00ffee88'); engGrad.addColorStop(1,'transparent');
  ctx.fillStyle=engGrad; ctx.fillRect(-18,h/2-10,36,24);

  // Engine flame
  const fSize=6+Math.sin(Date.now()*0.025)*3;
  ctx.fillStyle='#00ffee'; ctx.shadowBlur=12; ctx.shadowColor='#00ffee';
  ctx.beginPath();
  ctx.moveTo(-5,h/2); ctx.lineTo(5,h/2);
  ctx.lineTo(2,h/2+fSize); ctx.lineTo(-2,h/2+fSize); ctx.closePath();
  ctx.fill();
  ctx.fillStyle='#aaffee';
  ctx.beginPath(); ctx.arc(0,h/2+3,3,0,Math.PI*2); ctx.fill();

  // Ship body
  ctx.shadowBlur=16; ctx.shadowColor='#00ffee';
  ctx.fillStyle='#0a2030';
  ctx.strokeStyle='#00ffee';
  ctx.lineWidth=1.5;
  ctx.beginPath();
  ctx.moveTo(0,-h/2);
  ctx.lineTo(w/2, h/3);
  ctx.lineTo(w/2-4, h/2);
  ctx.lineTo(-w/2+4, h/2);
  ctx.lineTo(-w/2, h/3);
  ctx.closePath();
  ctx.fill(); ctx.stroke();

  // Cockpit
  ctx.fillStyle='#004455'; ctx.shadowBlur=8; ctx.shadowColor='#00ffee66';
  ctx.beginPath(); ctx.ellipse(0,-h/6,6,9,0,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#00ffee44';
  ctx.beginPath(); ctx.ellipse(0,-h/6,4,7,0,0,Math.PI*2); ctx.fill();

  // Wing stripes
  ctx.strokeStyle='#00ffee44'; ctx.lineWidth=1;
  ctx.shadowBlur=0;
  ctx.beginPath(); ctx.moveTo(-4,0); ctx.lineTo(-w/2+4,h/3); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(4,0);  ctx.lineTo(w/2-4,h/3);  ctx.stroke();

  // Speed boost effect
  if(PLR.speedBoost){
    ctx.strokeStyle='#aaff00'; ctx.lineWidth=1.5;
    ctx.shadowBlur=8; ctx.shadowColor='#aaff00';
    for(let i=0;i&lt;3;i++){
      const yy=h/2+8+i*8;
      const width=12-i*3;
      ctx.globalAlpha=0.4-i*0.1;
      ctx.beginPath(); ctx.moveTo(-width,yy); ctx.lineTo(width,yy); ctx.stroke();
    }
  }

  ctx.restore();
}

function drawEnemy(e){
  ctx.save(); ctx.translate(e.x,e.y);
  ctx.shadowBlur=14; ctx.shadowColor=e.color;
  ctx.fillStyle=e.color+'33'; ctx.strokeStyle=e.color; ctx.lineWidth=2;

  const s=e.size;
  if(e.kind==='fighter'){
    ctx.beginPath();
    ctx.moveTo(0,s); ctx.lineTo(-s,s*0.3); ctx.lineTo(-s*0.4,-s);
    ctx.lineTo(s*0.4,-s); ctx.lineTo(s,s*0.3); ctx.closePath();
  } else if(e.kind==='tanker'){
    ctx.beginPath();
    ctx.arc(0,0,s,0,Math.PI*2);
    ctx.fill(); ctx.stroke();
    ctx.strokeStyle=e.color+'66'; ctx.lineWidth=2;
    ctx.beginPath(); ctx.arc(0,0,s*0.6,0,Math.PI*2); ctx.stroke();
  } else { // fast
    ctx.beginPath();
    ctx.moveTo(0,-s*1.1); ctx.lineTo(s*0.7,s); ctx.lineTo(-s*0.7,s); ctx.closePath();
  }
  ctx.fill(); ctx.stroke();

  // HP bar (for tankers)
  if(e.maxHp&gt;1){
    ctx.shadowBlur=0;
    ctx.fillStyle='#ffffff18'; ctx.fillRect(-s,-s-8,s*2,4);
    ctx.fillStyle=e.color;
    ctx.fillRect(-s,-s-8,s*2*(e.hp/e.maxHp),4);
  }

  ctx.restore();
}

function drawBoss(b){
  ctx.save(); ctx.translate(b.x,b.y);
  const p=0.6+0.4*Math.sin(Date.now()*0.005);
  ctx.shadowBlur=28*p; ctx.shadowColor='#aa44ff';

  // Body
  ctx.fillStyle='#1a0028'; ctx.strokeStyle='#aa44ff'; ctx.lineWidth=3;
  ctx.beginPath(); ctx.rect(-50,-28,100,56); ctx.fill(); ctx.stroke();

  // Wings
  ctx.beginPath();
  ctx.moveTo(-50,-8); ctx.lineTo(-88,-28); ctx.lineTo(-88,20); ctx.lineTo(-50,20);
  ctx.moveTo(50,-8);  ctx.lineTo(88,-28);  ctx.lineTo(88,20);  ctx.lineTo(50,20);
  ctx.fill(); ctx.stroke();

  // Eye
  const eyeGrad=ctx.createRadialGradient(0,0,0,0,0,14);
  eyeGrad.addColorStop(0,'#ffdd00'); eyeGrad.addColorStop(1,'#ff8800');
  ctx.fillStyle=eyeGrad; ctx.shadowBlur=20; ctx.shadowColor='#ffdd00';
  ctx.beginPath(); ctx.arc(0,0,14,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#1a0028';
  ctx.beginPath(); ctx.arc(0,0,6,0,Math.PI*2); ctx.fill();

  // Rotating ring
  ctx.save(); ctx.rotate(b.angle*2);
  ctx.strokeStyle='#aa44ff66'; ctx.lineWidth=2;
  ctx.setLineDash([8,6]);
  ctx.beginPath(); ctx.arc(0,0,20,0,Math.PI*2); ctx.stroke();
  ctx.setLineDash([]);
  ctx.restore();

  // Wing accents
  ctx.strokeStyle='#aa44ff55'; ctx.lineWidth=1.5;
  [-1,1].forEach(side=&gt;{
    ctx.beginPath();
    ctx.moveTo(side*50,0); ctx.lineTo(side*75,0); ctx.stroke();
  });

  ctx.restore();
}

function drawPowerIndicators(){
  let ix=8;
  const draw=(cond,timer,max,col,label)=&gt;{
    if(!cond)return;
    ctx.save();
    ctx.fillStyle=col+'22'; ctx.strokeStyle=col; ctx.lineWidth=1.5;
    ctx.shadowBlur=6; ctx.shadowColor=col;
    const pct=Math.min(1,timer/max);
    ctx.fillRect(ix,H-16,50*pct,6);
    ctx.strokeRect(ix,H-16,50,6);
    ctx.fillStyle=col;
    ctx.font=&quot;8px 'Silkscreen',monospace&quot;; ctx.textAlign='left';
    ctx.shadowBlur=0; ctx.fillText(label,ix,H-20);
    ctx.restore(); ix+=58;
  };
  draw(PLR.shield,PLR.shieldTimer,10000,'#44aaff','SHIELD');
  draw(PLR.multiShot,PLR.multiTimer,8000,'#ffdd00','MULTI');
  draw(PLR.speedBoost,PLR.speedTimer,6000,'#aaff00','SPEED');
}

// ══ GAME CONTROL ══
function startGame(){
  score=0; lives=3; level=1; wave=1;
  gameRunning=true; paused=false;
  invincible=0; flashEffect=null;
  PLR.x=W/2; PLR.y=H-60; PLR.shootCd=0;
  PLR.multiShot=false; PLR.shield=false; PLR.speedBoost=false;
  PLR.multiTimer=0; PLR.shieldTimer=0; PLR.speedTimer=0;
  particles=[]; pBullets=[]; eBullets=[]; powerups=[];
  enemies=[]; boss=null; bossActive=false;
  updateHUD();
  document.getElementById('ov').classList.add('hidden');
  if(animId)cancelAnimationFrame(animId);
  if(AC.state==='suspended')AC.resume();
  startMusic();
  lastTs=performance.now();
  animId=requestAnimationFrame(loop);
  startWave();
}

function endGame(){
  gameRunning=false;
  cancelAnimationFrame(animId);
  stopMusic();
  document.getElementById('ovT').textContent='GAME OVER';
  document.getElementById('ovS').textContent='전투에서 패배했습니다...';
  const r=document.getElementById('ovR');
  r.innerHTML=`SCORE: ${score}&lt;br&gt;&lt;span style=&quot;font-size:11px;color:#557766&quot;&gt;WAVE ${wave} · LEVEL ${level}&lt;/span&gt;`;
  r.classList.remove('hidden');
  document.getElementById('startBtn').textContent='RETRY';
  document.getElementById('ov').classList.remove('hidden');
}

function togglePause(){
  paused=!paused;
  if(paused){
    document.getElementById('ovT').textContent='PAUSED';
    document.getElementById('ovS').textContent='P키로 재개';
    document.getElementById('ovR').classList.add('hidden');
    document.getElementById('startBtn').textContent='RESUME';
    document.getElementById('ov').classList.remove('hidden');
    draw();
  } else {
    lastTs=performance.now();
    document.getElementById('ov').classList.add('hidden');
  }
}

document.getElementById('startBtn').addEventListener('click',()=&gt;{
  if(AC.state==='suspended')AC.resume();
  if(paused)togglePause(); else startGame();
});

// Init
initAudio();
ctx.fillStyle='#030710';
ctx.fillRect(0,0,W,H);
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</description>
      <category>게임</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/69</guid>
      <comments>https://bbibbabbbi.tistory.com/69#entry69comment</comments>
      <pubDate>Fri, 20 Mar 2026 18:17:48 +0900</pubDate>
    </item>
    <item>
      <title>타일 링크</title>
      <link>https://bbibbabbbi.tistory.com/68</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;title&gt;TILE LINK — 타일 링크&lt;/title&gt;
&lt;style&gt;
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&amp;family=Josefin+Sans:wght@400;600;700&amp;display=swap');

:root {
  --bg:    #120e09;
  --panel: #1c160f;
  --amber: #e09030;
  --gold:  #f5cc58;
  --bdr:   #4a320a28;
  --dim:   #6b4e28;
}

* { margin:0; padding:0; box-sizing:border-box; }

body {
  background: var(--bg);
  display: flex;
  justify-content: center;
  align-items: flex-start;
  min-height: 100vh;
  padding: 20px 0 40px;
  font-family: 'Josefin Sans', sans-serif;
  overflow-y: auto;
  overflow-x: hidden;
}

#gameWrapper { position: relative; width: 480px; }

/* ── HEADER ── */
#header {
  text-align: center;
  padding: 11px 0 8px;
  background: linear-gradient(180deg, #08050200 0%, #08050200 100%);
  background-color: #0a0703;
  border: 1px solid var(--bdr);
  border-bottom: none;
  position: relative;
  overflow: hidden;
}
#header::after {
  content:'';
  position: absolute;
  bottom:0; left:15%; right:15%; height:1px;
  background: linear-gradient(90deg, transparent, #e0903044, transparent);
}
#header h1 {
  font-family: 'Playfair Display', serif;
  font-style: italic;
  font-size: 23px;
  font-weight: 900;
  letter-spacing: 5px;
  background: linear-gradient(180deg, var(--gold) 0%, var(--amber) 55%, #a06010 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  filter: drop-shadow(0 0 10px #e0903044);
}

/* ── HUD ── */
#hud {
  display: flex;
  justify-content: space-between;
  align-items: stretch;
  background: var(--panel);
  border-left: 1px solid var(--bdr);
  border-right: 1px solid var(--bdr);
}
.hcell {
  flex: 1;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  padding: 5px 4px;
  border-right: 1px solid #4a320a1a;
  gap: 2px;
}
.hcell:last-child { border-right: none; }
.hl {
  font-size: 8px; font-weight: 700;
  color: var(--dim); letter-spacing: 2px;
}
.hv {
  font-family: 'Playfair Display', serif;
  font-size: 15px; font-weight: 700; line-height: 1;
}
#timeVal  { color: #e08040; text-shadow: 0 0 6px #e0804044; }
#scoreVal { color: var(--gold); text-shadow: 0 0 6px #f5cc5844; }
#pairsVal { color: #88cc88; }

#hintBtn {
  font-family: 'Josefin Sans', sans-serif;
  font-size: 9px; font-weight: 700;
  letter-spacing: 1px;
  padding: 4px 8px;
  background: transparent;
  border: 1px solid var(--amber);
  color: var(--amber);
  cursor: pointer;
  border-radius: 2px;
  transition: all 0.15s;
  align-self: center;
  margin: 0 5px;
  white-space: nowrap;
}
#hintBtn:hover:not(:disabled) { background: var(--amber); color: #120e09; }
#hintBtn:disabled { opacity: 0.25; cursor: default; }

/* ── CANVAS ── */
canvas {
  display: block;
  border: 1px solid var(--bdr);
  border-top: none;
  cursor: pointer;
  touch-action: none;
}

/* ── OVERLAY ── */
#overlay {
  position: absolute;
  top: 38px; left: 0; right: 0; bottom: 44px;
  display: flex; flex-direction: column;
  justify-content: center; align-items: center;
  background: rgba(8,5,2,0.93);
  z-index: 10;
  gap: 14px;
  border-left: 1px solid var(--bdr);
  border-right: 1px solid var(--bdr);
}
#overlay.hidden { display: none; }

#overlay h2 {
  font-family: 'Playfair Display', serif;
  font-style: italic;
  font-size: 28px; font-weight: 900;
  text-align: center; line-height: 1.4;
  background: linear-gradient(180deg, var(--gold), var(--amber));
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  filter: drop-shadow(0 0 14px #e0903055);
}
.ov-sub {
  font-size: 14px; color: #7a5530;
  letter-spacing: 1px; text-align: center; line-height: 2;
}
.ov-result {
  font-family: 'Playfair Display', serif;
  font-size: 14px; color: var(--gold);
  text-shadow: 0 0 10px #f5cc5855;
  text-align: center; line-height: 2.1;
}
.ov-result.hidden { display: none; }

/* Symbol legend */
.sym-preview {
  display: flex; gap: 8px;
  flex-wrap: wrap; justify-content: center;
  max-width: 320px;
}
.sym-preview canvas { border-radius: 4px; }

.btn {
  font-family: 'Josefin Sans', sans-serif;
  font-size: 12px; font-weight: 700;
  letter-spacing: 4px; text-transform: uppercase;
  padding: 12px 34px;
  background: transparent;
  border: 2px solid var(--amber);
  color: var(--amber);
  cursor: pointer;
  position: relative; overflow: hidden;
  transition: color 0.18s;
}
.btn::before {
  content:''; position:absolute; inset:0;
  background: var(--amber);
  transform: scaleX(0); transform-origin: left;
  transition: transform 0.18s; z-index:-1;
}
.btn:hover::before { transform: scaleX(1); }
.btn:hover { color: #120e09; }

/* ── CONTROLS BAR ── */
#controls {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
  gap: 5px 14px;
  padding: 8px 14px;
  background: var(--panel);
  border: 1px solid #4a320a18;
  border-top: none;
  font-size: 12px; color: #5a3e1e; letter-spacing: 1px;
}
#controls .ci { display:flex; align-items:center; gap:5px; white-space:nowrap; }
#controls kbd {
  display: inline-block;
  background: #0c0803;
  border: 1px solid #6a4a2244;
  border-bottom: 2px solid #6a4a2266;
  border-radius: 3px;
  padding: 1px 7px;
  font-family: 'Josefin Sans', sans-serif;
  font-size: 12px; color: var(--amber);
}
#controls .sep { color: #4a320a18; font-size:16px; }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div id=&quot;gameWrapper&quot;&gt;

  &lt;div id=&quot;header&quot;&gt;&lt;h1&gt;Tile Link&lt;/h1&gt;&lt;/div&gt;

  &lt;div id=&quot;hud&quot;&gt;
    &lt;div class=&quot;hcell&quot;&gt;
      &lt;span class=&quot;hl&quot;&gt;TIME&lt;/span&gt;
      &lt;span class=&quot;hv&quot; id=&quot;timeVal&quot;&gt;3:00&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;hcell&quot;&gt;
      &lt;span class=&quot;hl&quot;&gt;SCORE&lt;/span&gt;
      &lt;span class=&quot;hv&quot; id=&quot;scoreVal&quot;&gt;0&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;hcell&quot;&gt;
      &lt;span class=&quot;hl&quot;&gt;PAIRS&lt;/span&gt;
      &lt;span class=&quot;hv&quot; id=&quot;pairsVal&quot;&gt;24&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;hcell&quot; style=&quot;flex:0.9&quot;&gt;
      &lt;button id=&quot;hintBtn&quot;&gt;  HINT &lt;span id=&quot;hintCount&quot;&gt;×3&lt;/span&gt;&lt;/button&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;canvas id=&quot;c&quot; width=&quot;480&quot; height=&quot;400&quot;&gt;&lt;/canvas&gt;

  &lt;div id=&quot;overlay&quot;&gt;
    &lt;h2 id=&quot;ovTitle&quot;&gt;Tile Link&lt;/h2&gt;
    &lt;p class=&quot;ov-sub&quot; id=&quot;ovSub&quot;&gt;
      같은 그림의 타일 두 개를 찾아 연결하세요&lt;br&gt;
      연결선은 최대 2번까지 꺾일 수 있습니다
    &lt;/p&gt;
    &lt;div class=&quot;ov-result hidden&quot; id=&quot;ovResult&quot;&gt;&lt;/div&gt;
    &lt;button class=&quot;btn&quot; id=&quot;startBtn&quot;&gt;START GAME&lt;/button&gt;
  &lt;/div&gt;

  &lt;div id=&quot;controls&quot;&gt;
    &lt;span class=&quot;ci&quot;&gt; ️ 클릭으로 타일 선택&lt;/span&gt;
    &lt;span class=&quot;sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;H&lt;/kbd&gt; 힌트&lt;/span&gt;
    &lt;span class=&quot;sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;P&lt;/kbd&gt; 일시정지&lt;/span&gt;
    &lt;span class=&quot;sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ci&quot;&gt;이동 불가 시 자동 셔플&lt;/span&gt;
  &lt;/div&gt;

&lt;/div&gt;

&lt;script&gt;
/* ══════════════════════════════════════════════════
   TILE LINK — Shisen-Sho Style Tile Matching Game
   저작권 안전 확인:
   · 모든 그래픽: Canvas 2D API로 직접 그림 (이미지 없음)
   · Playfair Display: SIL OFL 1.1
   · Josefin Sans:     SIL OFL 1.1
   · 게임 코드: 이번 대화에서 직접 작성한 오리지널
══════════════════════════════════════════════════ */

// ── roundRect polyfill ──
if (!CanvasRenderingContext2D.prototype.roundRect) {
  CanvasRenderingContext2D.prototype.roundRect = function(x,y,w,h,r) {
    r = Math.min(r, w/2, h/2);
    this.moveTo(x+r, y);
    this.arcTo(x+w,y,   x+w,y+h, r);
    this.arcTo(x+w,y+h, x,  y+h, r);
    this.arcTo(x,  y+h, x,  y,   r);
    this.arcTo(x,  y,   x+w,y,   r);
    this.closePath();
  };
}

const canvas = document.getElementById('c');
const ctx    = canvas.getContext('2d');
const W = 480, H = 400;

// ── GRID CONFIG ──
const COLS = 8, ROWS = 6;    // 8×6 = 48 tiles = 12 symbols × 4 copies
const CELL = 54;
const GL = (W - COLS*CELL) / 2;   // 24
const GT = (H - ROWS*CELL) / 2;   // 38

// ── 12 ORIGINAL SYMBOLS (Canvas-drawn, no external assets) ──
const NSYM = 12;
const SYM_COLORS = [
  '#ff6b6b','#ff922b','#ffd43b','#51cf66',
  '#38d9a9','#4dabf7','#cc5de8','#f783ac',
  '#a9e34b','#74c0fc','#e599f7','#ff8cc8'
];
const SYM_NAMES = [
  '원','삼각','마름모','별','육각','십자',
  '8각별','하트','꽃','화살','클로버','번개'
];

function drawSymbol(c, cx, cy, r, type) {
  const col = SYM_COLORS[type];
  c.save();
  c.fillStyle = col;
  c.strokeStyle = col;
  c.lineWidth = Math.max(1.5, r*0.12);
  c.lineCap = 'round'; c.lineJoin = 'round';
  c.shadowBlur = r * 0.55;
  c.shadowColor = col;

  switch(type) {
    case 0: { // Circle with inner ring
      c.beginPath(); c.arc(cx,cy,r,0,Math.PI*2); c.fill();
      c.strokeStyle = 'rgba(255,255,255,0.42)';
      c.lineWidth = r*0.14;
      c.shadowBlur = 0;
      c.beginPath(); c.arc(cx,cy,r*0.52,0,Math.PI*2); c.stroke();
      break;
    }
    case 1: { // Upward triangle
      c.beginPath();
      c.moveTo(cx, cy-r);
      c.lineTo(cx+r*0.866, cy+r*0.5);
      c.lineTo(cx-r*0.866, cy+r*0.5);
      c.closePath(); c.fill(); break;
    }
    case 2: { // Diamond
      c.beginPath();
      c.moveTo(cx, cy-r);
      c.lineTo(cx+r*0.64, cy);
      c.lineTo(cx, cy+r);
      c.lineTo(cx-r*0.64, cy);
      c.closePath(); c.fill(); break;
    }
    case 3: { // 5-pointed star
      c.beginPath();
      for (let i=0;i&lt;10;i++){
        const a=(i*Math.PI/5)-Math.PI/2;
        const rr=i%2===0?r:r*0.38;
        i===0?c.moveTo(cx+rr*Math.cos(a),cy+rr*Math.sin(a))
             :c.lineTo(cx+rr*Math.cos(a),cy+rr*Math.sin(a));
      }
      c.closePath(); c.fill(); break;
    }
    case 4: { // Regular hexagon
      c.beginPath();
      for(let i=0;i&lt;6;i++){
        const a=i*Math.PI/3-Math.PI/6;
        i===0?c.moveTo(cx+r*Math.cos(a),cy+r*Math.sin(a))
             :c.lineTo(cx+r*Math.cos(a),cy+r*Math.sin(a));
      }
      c.closePath(); c.fill(); break;
    }
    case 5: { // Plus cross
      const a=r*0.34;
      c.beginPath();
      c.moveTo(cx-a,cy-r); c.lineTo(cx+a,cy-r); c.lineTo(cx+a,cy-a);
      c.lineTo(cx+r,cy-a); c.lineTo(cx+r,cy+a); c.lineTo(cx+a,cy+a);
      c.lineTo(cx+a,cy+r); c.lineTo(cx-a,cy+r); c.lineTo(cx-a,cy+a);
      c.lineTo(cx-r,cy+a); c.lineTo(cx-r,cy-a); c.lineTo(cx-a,cy-a);
      c.closePath(); c.fill(); break;
    }
    case 6: { // 8-pointed star
      c.beginPath();
      for(let i=0;i&lt;8;i++){
        const a=i*Math.PI/4-Math.PI/2;
        const rr=i%2===0?r:r*0.4;
        i===0?c.moveTo(cx+rr*Math.cos(a),cy+rr*Math.sin(a))
             :c.lineTo(cx+rr*Math.cos(a),cy+rr*Math.sin(a));
      }
      c.closePath(); c.fill(); break;
    }
    case 7: { // Heart
      const s=r*0.9;
      c.beginPath();
      c.moveTo(cx, cy+s*0.7);
      c.bezierCurveTo(cx-s*1.1,cy,     cx-s*1.1,cy-s*0.85, cx,     cy-s*0.22);
      c.bezierCurveTo(cx+s*1.1,cy-s*0.85, cx+s*1.1,cy,     cx, cy+s*0.7);
      c.fill(); break;
    }
    case 8: { // 6-petal flower
      for(let i=0;i&lt;6;i++){
        const a=i*Math.PI/3;
        c.beginPath();
        c.ellipse(cx+r*0.42*Math.cos(a), cy+r*0.42*Math.sin(a),
                  r*0.4, r*0.2, a, 0, Math.PI*2);
        c.fill();
      }
      c.shadowBlur=0;
      c.fillStyle='rgba(255,255,255,0.55)';
      c.beginPath(); c.arc(cx,cy,r*0.2,0,Math.PI*2); c.fill(); break;
    }
    case 9: { // Right arrow
      c.beginPath();
      c.moveTo(cx-r*0.8, cy-r*0.28);
      c.lineTo(cx+r*0.05,cy-r*0.28);
      c.lineTo(cx+r*0.05,cy-r*0.65);
      c.lineTo(cx+r*0.9, cy);
      c.lineTo(cx+r*0.05,cy+r*0.65);
      c.lineTo(cx+r*0.05,cy+r*0.28);
      c.lineTo(cx-r*0.8, cy+r*0.28);
      c.closePath(); c.fill(); break;
    }
    case 10: { // 4-leaf clover
      const off=r*0.38, cr=r*0.48;
      [[0,-1],[1,0],[0,1],[-1,0]].forEach(([dx,dy])=&gt;{
        c.beginPath(); c.arc(cx+dx*off,cy+dy*off,cr,0,Math.PI*2); c.fill();
      });
      c.shadowBlur=0;
      c.beginPath(); c.arc(cx,cy,r*0.22,0,Math.PI*2); c.fill(); break;
    }
    case 11: { // Lightning bolt
      c.beginPath();
      c.moveTo(cx+r*0.22, cy-r);
      c.lineTo(cx-r*0.28, cy+r*0.08);
      c.lineTo(cx+r*0.14, cy+r*0.08);
      c.lineTo(cx-r*0.22, cy+r);
      c.lineTo(cx+r*0.28, cy-r*0.08);
      c.lineTo(cx-r*0.14, cy-r*0.08);
      c.closePath(); c.fill(); break;
    }
  }
  c.restore();
}

// ── GAME STATE ──
let grid;       // [ROWS][COLS] = 0..11 or -1 (empty)
let selected;   // null | {x,y}
let hintPair;   // null | [{x,y},{x,y}]
let anim;       // null | {path,type,ax,ay,bx,by,t,alpha}
let particles;
let score, pairsLeft, hints, timeLeft, timerId;
let gameRunning, paused, animId, lastTs;
let noShuffleAttempts;

// ── PATHFINDING (BFS, max 2 turns) ──
const PD = [[1,0],[-1,0],[0,1],[0,-1]];

function passable(x, y) {
  if (x&lt;0||x&gt;=COLS||y&lt;0||y&gt;=ROWS) return true;  // border: always open
  return grid[y][x] === -1;
}

function findPath(ax,ay,bx,by) {
  // BFS with state {x,y,dir,turns,path}
  // Allows 1-cell border outside grid; ≤2 direction changes
  const vis = new Set();
  const q   = [{x:ax,y:ay,dir:-1,turns:0,path:[[ax,ay]]}];
  vis.add(`${ax},${ay},-1,0`);
  let qi=0;
  while(qi&lt;q.length){
    const {x,y,dir,turns,path} = q[qi++];
    for(let d=0;d&lt;4;d++){
      const nx=x+PD[d][0], ny=y+PD[d][1];
      // Extended bounds: 1 cell outside grid
      if(nx&lt;-1||nx&gt;COLS||ny&lt;-1||ny&gt;ROWS) continue;
      const nt=(dir!==-1&amp;&amp;d!==dir)?turns+1:turns;
      if(nt&gt;2) continue;
      if(nx===bx&amp;&amp;ny===by) return [...path,[nx,ny]]; // found!
      if(!passable(nx,ny)) continue;
      const k=`${nx},${ny},${d},${nt}`;
      if(!vis.has(k)){
        vis.add(k);
        q.push({x:nx,y:ny,dir:d,turns:nt,path:[...path,[nx,ny]]});
      }
    }
  }
  return null;
}

function findAnyPair() {
  const cells=[];
  for(let r=0;r&lt;ROWS;r++) for(let c=0;c&lt;COLS;c++)
    if(grid[r][c]!==-1) cells.push({x:c,y:r,t:grid[r][c]});
  // Randomise for variety
  for(let i=cells.length-1;i&gt;0;i--){
    const j=Math.floor(Math.random()*(i+1));
    [cells[i],cells[j]]=[cells[j],cells[i]];
  }
  for(let i=0;i&lt;cells.length;i++) for(let j=i+1;j&lt;cells.length;j++){
    if(cells[i].t===cells[j].t &amp;&amp; findPath(cells[i].x,cells[i].y,cells[j].x,cells[j].y))
      return [cells[i],cells[j]];
  }
  return null;
}

function hasAnyMove() { return findAnyPair()!==null; }

// ── SHUFFLE ──
function shuffleBoard(){
  const pos=[], types=[];
  for(let r=0;r&lt;ROWS;r++) for(let c=0;c&lt;COLS;c++)
    if(grid[r][c]!==-1){ pos.push([c,r]); types.push(grid[r][c]); }
  for(let i=types.length-1;i&gt;0;i--){
    const j=Math.floor(Math.random()*(i+1));
    [types[i],types[j]]=[types[j],types[i]];
  }
  pos.forEach(([c,r],i)=&gt;grid[r][c]=types[i]);
  if(!hasAnyMove()&amp;&amp;++noShuffleAttempts&lt;12) shuffleBoard();
}

// ── INIT ──
function startGame(){
  // Build 12 symbols × 4 = 48
  const types=[];
  for(let t=0;t&lt;NSYM;t++) for(let i=0;i&lt;4;i++) types.push(t);
  for(let i=types.length-1;i&gt;0;i--){
    const j=Math.floor(Math.random()*(i+1));
    [types[i],types[j]]=[types[j],types[i]];
  }
  grid=[];
  let idx=0;
  for(let r=0;r&lt;ROWS;r++){ grid.push([]); for(let c=0;c&lt;COLS;c++) grid[r].push(types[idx++]); }

  selected=null; hintPair=null; anim=null; particles=[];
  score=0; pairsLeft=COLS*ROWS/2; hints=3;
  timeLeft=180; noShuffleAttempts=0;
  gameRunning=true; paused=false;
  updateHUD();
  document.getElementById('hintBtn').disabled=false;
  document.getElementById('hintCount').textContent='×3';
  document.getElementById('overlay').classList.add('hidden');
  if(timerId) clearInterval(timerId);
  timerId=setInterval(tickTimer,1000);
  if(animId) cancelAnimationFrame(animId);
  lastTs=performance.now();
  animId=requestAnimationFrame(loop);
}

// ── TIMER ──
function tickTimer(){
  if(!gameRunning||paused) return;
  timeLeft--;
  updateHUD();
  if(timeLeft&lt;=0){ clearInterval(timerId); endGame('timeout'); }
}

// ── HUD ──
function updateHUD(){
  const m=Math.floor(timeLeft/60), s=timeLeft%60;
  document.getElementById('timeVal').textContent=`${m}:${s.toString().padStart(2,'0')}`;
  document.getElementById('timeVal').style.color=timeLeft&lt;=30?'#ff4444':'#e08040';
  document.getElementById('scoreVal').textContent=score;
  document.getElementById('pairsVal').textContent=pairsLeft;
}

// ── CLICK HANDLING ──
function getPos(e){
  const rect=canvas.getBoundingClientRect();
  const sx=W/rect.width, sy=H/rect.height;
  let cx,cy;
  if(e.touches||e.changedTouches){
    const t=e.changedTouches?e.changedTouches[0]:e.touches[0];
    cx=(t.clientX-rect.left)*sx; cy=(t.clientY-rect.top)*sy;
  } else { cx=(e.clientX-rect.left)*sx; cy=(e.clientY-rect.top)*sy; }
  const gx=Math.floor((cx-GL)/CELL), gy=Math.floor((cy-GT)/CELL);
  return {gx,gy};
}

function handleInput(e){
  if(!gameRunning||paused||anim) return;
  const {gx,gy}=getPos(e);
  if(gx&lt;0||gx&gt;=COLS||gy&lt;0||gy&gt;=ROWS) return;
  if(grid[gy][gx]===-1) return;
  handleTileClick(gx,gy);
}

canvas.addEventListener('click', handleInput);
canvas.addEventListener('touchend', e=&gt;{ e.preventDefault(); handleInput(e); }, {passive:false});

// scroll lock
let mouseOver=false;
canvas.addEventListener('mouseenter',()=&gt;mouseOver=true);
canvas.addEventListener('mouseleave',()=&gt;mouseOver=false);
window.addEventListener('wheel',e=&gt;{ if(mouseOver&amp;&amp;gameRunning&amp;&amp;!paused) e.preventDefault(); },{passive:false});

function handleTileClick(gx,gy){
  hintPair=null;
  if(!selected){
    selected={x:gx,y:gy};
  } else if(selected.x===gx&amp;&amp;selected.y===gy){
    selected=null;
  } else {
    const {x:ax,y:ay}=selected;
    const [bx,by]=[gx,gy];
    if(grid[ay][ax]===grid[by][bx]){
      const path=findPath(ax,ay,bx,by);
      if(path){
        selected=null;
        const type=grid[ay][ax];
        anim={path,type,ax,ay,bx,by,t:0,alpha:1};
      } else {
        // Can't connect — red flash particles, switch selection
        flashInvalid(gx,gy);
        selected={x:gx,y:gy};
      }
    } else {
      selected={x:gx,y:gy};
    }
  }
}

function flashInvalid(gx,gy){
  const px=GL+gx*CELL+CELL/2, py=GT+gy*CELL+CELL/2;
  for(let i=0;i&lt;8;i++){
    const a=Math.random()*Math.PI*2;
    particles.push({x:px,y:py,vx:Math.cos(a)*2.5,vy:Math.sin(a)*2.5,
      life:1,decay:0.07,size:3,color:'#ff4444'});
  }
}

// ── HINT ──
function doHint(){
  if(hints&lt;=0||anim||!gameRunning||paused) return;
  const pair=findAnyPair();
  if(pair){ hintPair=pair; hints--; }
  else { selected=null; shuffleBoard(); noShuffleAttempts=0; }
  document.getElementById('hintCount').textContent=`×${hints}`;
  if(hints===0) document.getElementById('hintBtn').disabled=true;
}
document.getElementById('hintBtn').addEventListener('click',doHint);

// ── KEYBOARD ──
document.addEventListener('keydown',e=&gt;{
  if(e.code==='KeyP'&amp;&amp;gameRunning) togglePause();
  if(e.code==='KeyH') doHint();
});

// ── PAUSE ──
function togglePause(){
  paused=!paused;
  if(paused){
    document.getElementById('ovTitle').textContent='일시 정지';
    document.getElementById('ovSub').textContent='P키 또는 버튼으로 재개';
    document.getElementById('ovResult').classList.add('hidden');
    document.getElementById('startBtn').textContent='CONTINUE';
    document.getElementById('overlay').classList.remove('hidden');
  } else {
    lastTs=performance.now();
    document.getElementById('overlay').classList.add('hidden');
  }
}

// ── END GAME ──
function endGame(reason){
  gameRunning=false;
  cancelAnimationFrame(animId);
  clearInterval(timerId);
  document.getElementById('ovTitle').textContent=
    reason==='win'?'  완 성 !':reason==='timeout'?'⏰ 시간 초과':'게임 오버';
  document.getElementById('ovSub').textContent=
    reason==='win'?'모든 타일을 연결했습니다!':'아쉽네요, 다시 도전해 보세요.';
  const rs=document.getElementById('ovResult');
  rs.innerHTML=`SCORE: ${score}&lt;br&gt;&lt;span style=&quot;font-size:11px;color:#7a5530&quot;&gt;남은 쌍: ${pairsLeft} · 남은 시간: ${timeLeft}초&lt;/span&gt;`;
  rs.classList.remove('hidden');
  document.getElementById('startBtn').textContent='PLAY AGAIN';
  document.getElementById('overlay').classList.remove('hidden');
}
document.getElementById('startBtn').addEventListener('click',()=&gt;{
  if(paused) togglePause(); else startGame();
});

// ── PARTICLES ──
function spawnMatch(px,py,col){
  for(let i=0;i&lt;18;i++){
    const a=(i/18)*Math.PI*2;
    const sp=0.8+Math.random()*3.5;
    particles.push({x:px,y:py,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,
      life:1,decay:0.018+Math.random()*0.025,size:2+Math.random()*5,color:col});
  }
  // Score popup
  particles.push({text:'+'+Math.max(10,Math.floor(timeLeft/3)),x:px,y:py-10,
    vx:0,vy:-1.2,life:1,decay:0.014,size:14,color:col});
}

// ── MAIN LOOP ──
function loop(ts){
  animId=requestAnimationFrame(loop);
  const dt=Math.min(ts-lastTs,50); lastTs=ts;
  if(!gameRunning||paused) return;

  // Update particles
  particles=particles.filter(p=&gt;{
    p.x+=p.vx; p.y+=p.vy;
    if(!p.text) p.vy+=0.04;
    p.life-=p.decay;
    return p.life&gt;0;
  });

  // Update anim
  if(anim){
    anim.t+=dt;
    if(anim.t&gt;=520){
      // Complete the match
      const col=SYM_COLORS[anim.type];
      const px1=GL+anim.ax*CELL+CELL/2, py1=GT+anim.ay*CELL+CELL/2;
      const px2=GL+anim.bx*CELL+CELL/2, py2=GT+anim.by*CELL+CELL/2;
      grid[anim.ay][anim.ax]=-1;
      grid[anim.by][anim.bx]=-1;
      pairsLeft--;
      score+=Math.max(10, Math.floor(timeLeft/3));
      spawnMatch(px1,py1,col);
      spawnMatch(px2,py2,col);
      anim=null;
      updateHUD();
      if(pairsLeft===0){ setTimeout(()=&gt;endGame('win'),400); return; }
      if(!hasAnyMove()){ setTimeout(()=&gt;{ shuffleBoard(); selected=null; hintPair=null; },600); }
    }
  }

  draw();
}

// ── DRAW ──
function draw(){
  // Background
  ctx.fillStyle='#0c0906';
  ctx.fillRect(0,0,W,H);

  // Grid border subtle glow
  ctx.save();
  ctx.strokeStyle='#4a320a22';
  ctx.lineWidth=1;
  ctx.strokeRect(GL-1,GT-1,COLS*CELL+2,ROWS*CELL+2);
  ctx.restore();

  // Draw all tiles
  for(let r=0;r&lt;ROWS;r++){
    for(let c=0;c&lt;COLS;c++){
      const type=grid[r][c];
      if(type===-1) continue;

      const isSel = selected &amp;&amp; selected.x===c &amp;&amp; selected.y===r;
      const isHint= hintPair &amp;&amp; hintPair.some(h=&gt;h.x===c&amp;&amp;h.y===r);
      const isAnim= anim &amp;&amp; ((anim.ax===c&amp;&amp;anim.ay===r)||(anim.bx===c&amp;&amp;anim.by===r));
      drawTile(c,r,type,isSel,isHint,isAnim);
    }
  }

  // Draw path animation
  if(anim) drawAnimPath();

  // Draw particles
  drawParticles();

  // Vignette
  const vig=ctx.createRadialGradient(W/2,H/2,H*0.22,W/2,H/2,H*0.72);
  vig.addColorStop(0,'transparent');
  vig.addColorStop(1,'rgba(0,0,0,0.45)');
  ctx.fillStyle=vig;
  ctx.fillRect(0,0,W,H);
}

function drawTile(gx,gy,type,isSel,isHint,isAnim){
  const x=GL+gx*CELL, y=GT+gy*CELL;
  const pad=3, rad=7;
  const col=SYM_COLORS[type];

  ctx.save();

  // Tile glow (selected/hint)
  if(isSel){
    ctx.shadowBlur=18; ctx.shadowColor=col;
  } else if(isHint){
    const p=0.5+0.5*Math.sin(Date.now()*0.009);
    ctx.shadowBlur=14*p; ctx.shadowColor='#ffcc00';
  } else if(isAnim){
    const t=anim.t/520;
    ctx.globalAlpha=Math.max(0,1-t*1.6);
    ctx.shadowBlur=20*t; ctx.shadowColor=col;
  } else {
    ctx.shadowBlur=5; ctx.shadowColor='rgba(0,0,0,0.6)';
    ctx.shadowOffsetX=1; ctx.shadowOffsetY=2;
  }

  // Tile body gradient (warm ivory)
  ctx.beginPath();
  ctx.roundRect(x+pad,y+pad,CELL-pad*2,CELL-pad*2,rad);
  const tg=ctx.createLinearGradient(x+pad,y+pad,x+pad,y+CELL-pad);
  tg.addColorStop(0,'#f6eedc');
  tg.addColorStop(0.5,'#eee0c4');
  tg.addColorStop(1,'#e4d0aa');
  ctx.fillStyle=tg;
  ctx.fill();

  // Tile border
  ctx.shadowBlur=0; ctx.shadowOffsetX=0; ctx.shadowOffsetY=0;
  if(isSel){
    ctx.strokeStyle=col; ctx.lineWidth=2.5;
    ctx.shadowBlur=10; ctx.shadowColor=col;
  } else if(isHint){
    const p=0.5+0.5*Math.sin(Date.now()*0.009);
    ctx.strokeStyle=`rgba(255,204,0,${0.6+0.4*p})`; ctx.lineWidth=2.5;
    ctx.shadowBlur=8*p; ctx.shadowColor='#ffcc00';
  } else {
    ctx.strokeStyle='#c8aa7a'; ctx.lineWidth=0.8;
  }
  ctx.beginPath();
  ctx.roundRect(x+pad,y+pad,CELL-pad*2,CELL-pad*2,rad);
  ctx.stroke();

  ctx.shadowBlur=0; ctx.shadowOffsetX=0; ctx.shadowOffsetY=0;

  // Top highlight
  ctx.fillStyle='rgba(255,255,255,0.18)';
  ctx.beginPath();
  ctx.roundRect(x+pad+2,y+pad+2,CELL-pad*2-4,8,{upperLeft:5,upperRight:5,lowerLeft:0,lowerRight:0});
  ctx.fill();

  // Symbol
  const symR=(CELL/2-pad-4)*0.88;
  drawSymbol(ctx, x+CELL/2, y+CELL/2, symR, type);

  ctx.restore();
}

function drawAnimPath(){
  if(!anim||!anim.path) return;
  const path=anim.path;
  const progress=Math.min(1,anim.t/300); // line draws in first 300ms
  const fadeOut=anim.t&gt;350?(1-(anim.t-350)/170):1;
  const col=SYM_COLORS[anim.type];

  ctx.save();
  ctx.globalAlpha=Math.max(0,fadeOut)*0.9;
  ctx.strokeStyle=col;
  ctx.lineWidth=4;
  ctx.lineCap='round'; ctx.lineJoin='round';
  ctx.shadowBlur=14; ctx.shadowColor=col;
  ctx.setLineDash([8,4]);

  const drawCount=Math.max(2,Math.ceil(progress*(path.length-1))+1);
  ctx.beginPath();
  for(let i=0;i&lt;Math.min(drawCount,path.length);i++){
    const [px,py]=path[i];
    const screenX=GL+px*CELL+CELL/2;
    const screenY=GT+py*CELL+CELL/2;
    i===0?ctx.moveTo(screenX,screenY):ctx.lineTo(screenX,screenY);
  }
  ctx.stroke();

  // Endpoint glows
  if(progress&gt;0.8){
    [[anim.ax,anim.ay],[anim.bx,anim.by]].forEach(([gx,gy])=&gt;{
      const px=GL+gx*CELL+CELL/2, py=GT+gy*CELL+CELL/2;
      ctx.fillStyle=col;
      ctx.shadowBlur=20;
      ctx.beginPath();
      ctx.arc(px,py,7*fadeOut,0,Math.PI*2);
      ctx.fill();
    });
  }
  ctx.restore();
}

function drawParticles(){
  particles.forEach(p=&gt;{
    ctx.save();
    ctx.globalAlpha=Math.max(0,p.life);
    if(p.text){
      ctx.font=`700 ${p.size}px 'Josefin Sans', sans-serif`;
      ctx.fillStyle=p.color;
      ctx.textAlign='center'; ctx.textBaseline='middle';
      ctx.shadowBlur=8; ctx.shadowColor=p.color;
      ctx.fillText(p.text,p.x,p.y);
    } else {
      ctx.fillStyle=p.color;
      ctx.shadowBlur=8; ctx.shadowColor=p.color;
      ctx.beginPath();
      ctx.arc(p.x,p.y,p.size*p.life,0,Math.PI*2);
      ctx.fill();
    }
    ctx.restore();
  });
}

// ── INITIAL RENDER ──
ctx.fillStyle='#0c0906';
ctx.fillRect(0,0,W,H);
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</description>
      <category>게임</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/68</guid>
      <comments>https://bbibbabbbi.tistory.com/68#entry68comment</comments>
      <pubDate>Thu, 19 Mar 2026 22:50:36 +0900</pubDate>
    </item>
    <item>
      <title>서펜트 블리츠</title>
      <link>https://bbibbabbbi.tistory.com/67</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;title&gt;SERPENT BLITZ — 서펜트 블리츠&lt;/title&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Silkscreen:wght@400;700&amp;family=Rajdhani:wght@400;600;700&amp;display=swap');

  :root {
    --bg:     #050810;
    --panel:  #080d1a;
    --snake1: #00ffaa;
    --snake2: #00cc88;
    --food:   #ff4466;
    --power:  #ffcc00;
    --bomb:   #ff6600;
    --shield: #44aaff;
    --border: #00ffaa22;
    --text:   #aaddcc;
  }

  * { margin:0; padding:0; box-sizing:border-box; }

  body {
    background: var(--bg);
    display: flex;
    justify-content: center;
    align-items: flex-start;
    min-height: 100vh;
    padding: 20px 0 40px;
    font-family: 'Rajdhani', sans-serif;
    overflow-y: auto;
    overflow-x: hidden;
  }

  #gameWrapper {
    position: relative;
    width: 480px;
  }

  /* ── HEADER ── */
  #header {
    text-align: center;
    padding: 11px 0 8px;
    background: linear-gradient(180deg, #020510 0%, var(--bg) 100%);
    border: 1px solid var(--border);
    border-bottom: none;
    position: relative;
    overflow: hidden;
  }
  #header::after {
    content: '';
    position: absolute;
    bottom: 0; left: 10%; right: 10%; height: 1px;
    background: linear-gradient(90deg, transparent, #00ffaa44, transparent);
  }
  #header h1 {
    font-family: 'Silkscreen', monospace;
    font-size: 19px;
    font-weight: 700;
    letter-spacing: 5px;
    background: linear-gradient(90deg, #00ffaa, #44ffdd, #00ffaa);
    background-size: 200% auto;
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    animation: hue-shift 4s linear infinite;
    filter: drop-shadow(0 0 8px #00ffaa55);
  }
  @keyframes hue-shift {
    0%   { background-position: 0% center; }
    100% { background-position: 200% center; }
  }

  /* ── HUD ── */
  #hud {
    display: flex;
    justify-content: space-between;
    align-items: stretch;
    background: var(--panel);
    border-left: 1px solid var(--border);
    border-right: 1px solid var(--border);
  }
  .hud-cell {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 5px 4px;
    border-right: 1px solid #00ffaa11;
    gap: 2px;
  }
  .hud-cell:last-child { border-right: none; }
  .hud-label {
    font-family: 'Silkscreen', monospace;
    font-size: 7px;
    color: #335544;
    letter-spacing: 2px;
  }
  .hud-val {
    font-family: 'Silkscreen', monospace;
    font-size: 13px;
    line-height: 1;
  }
  #scoreVal  { color: var(--snake1); text-shadow: 0 0 8px #00ffaa66; }
  #hiVal     { color: #ffcc00;       text-shadow: 0 0 8px #ffcc0055; }
  #comboVal  { color: #ff88cc;       text-shadow: 0 0 8px #ff88cc55; }
  #levelVal  { color: #44aaff;       text-shadow: 0 0 8px #44aaff55; }
  #lengthVal { color: #aaaadd; }

  /* ── CANVAS ── */
  canvas {
    display: block;
    border: 1px solid var(--border);
    border-top: none;
    background: var(--bg);
    cursor: default;
  }

  /* ── OVERLAY ── */
  #overlay {
    position: absolute;
    top: 37px; left: 0; right: 0; bottom: 52px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    background: rgba(3,5,12,0.92);
    z-index: 10;
    gap: 14px;
    border-left: 1px solid var(--border);
    border-right: 1px solid var(--border);
  }
  #overlay.hidden { display: none; }

  #overlay h2 {
    font-family: 'Silkscreen', monospace;
    font-size: 26px;
    line-height: 1.6;
    text-align: center;
    color: var(--snake1);
    text-shadow: 0 0 20px #00ffaa88, 0 0 40px #00ffaa33;
  }
  .ov-sub {
    font-size: 16px;
    color: #557766;
    letter-spacing: 2px;
    text-align: center;
    line-height: 1.9;
  }
  .ov-score {
    font-family: 'Silkscreen', monospace;
    font-size: 14px;
    color: #ffcc00;
    text-shadow: 0 0 10px #ffcc0066;
    text-align: center;
    line-height: 2;
  }
  .ov-score.hidden { display: none; }

  /* Legend inside overlay */
  .legend {
    display: flex;
    gap: 14px;
    font-size: 13px;
    color: #557766;
    letter-spacing: 1px;
    flex-wrap: wrap;
    justify-content: center;
  }
  .legend span { display: flex; align-items: center; gap: 5px; }

  .btn {
    font-family: 'Silkscreen', monospace;
    font-size: 11px;
    letter-spacing: 3px;
    padding: 12px 30px;
    background: transparent;
    border: 2px solid var(--snake1);
    color: var(--snake1);
    cursor: pointer;
    position: relative;
    overflow: hidden;
    transition: color 0.18s;
  }
  .btn::before {
    content: '';
    position: absolute; inset: 0;
    background: var(--snake1);
    transform: scaleX(0);
    transform-origin: left;
    transition: transform 0.18s;
    z-index: -1;
  }
  .btn:hover::before { transform: scaleX(1); }
  .btn:hover { color: #050810; }

  /* ── CONTROLS BAR ── */
  #controls {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-wrap: wrap;
    gap: 5px 14px;
    padding: 9px 14px;
    background: var(--panel);
    border: 1px solid #00ffaa18;
    border-top: none;
    font-size: 13px;
    color: #336655;
    letter-spacing: 1.5px;
  }
  #controls .ci { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
  #controls kbd {
    display: inline-block;
    background: #081510;
    border: 1px solid #00ffaa44;
    border-bottom: 2px solid #00ffaa66;
    border-radius: 3px;
    padding: 1px 7px;
    font-family: 'Rajdhani', sans-serif;
    font-size: 13px;
    color: var(--snake1);
  }
  #controls .sep { color: #00ffaa14; font-size: 16px; }

  /* ── MOBILE ── */
  #mobileControls {
    display: none;
    justify-content: center;
    align-items: center;
    padding: 10px 16px;
    background: var(--panel);
    border: 1px solid #00ffaa18;
    border-top: none;
  }
  .dpad {
    display: grid;
    grid-template-columns: repeat(3, 52px);
    grid-template-rows: repeat(3, 52px);
    gap: 3px;
  }
  .db {
    background: #00ffaa0e;
    border: 1px solid #00ffaa33;
    color: var(--snake1);
    font-size: 22px;
    border-radius: 5px;
    display: flex; align-items: center; justify-content: center;
    cursor: pointer;
    user-select: none; -webkit-user-select: none;
    touch-action: none;
  }
  .db:active { background: #00ffaa22; }

  @media (max-width: 500px) {
    #gameWrapper { width: 100vw; }
    canvas { width: 100vw; height: auto; }
    #mobileControls { display: flex; }
  }
  @media (pointer: coarse) { #mobileControls { display: flex; } }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div id=&quot;gameWrapper&quot;&gt;

  &lt;!-- HEADER --&gt;
  &lt;div id=&quot;header&quot;&gt;&lt;h1&gt;SERPENT BLITZ&lt;/h1&gt;&lt;/div&gt;

  &lt;!-- HUD --&gt;
  &lt;div id=&quot;hud&quot;&gt;
    &lt;div class=&quot;hud-cell&quot;&gt;
      &lt;span class=&quot;hud-label&quot;&gt;SCORE&lt;/span&gt;
      &lt;span class=&quot;hud-val&quot; id=&quot;scoreVal&quot;&gt;0&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;hud-cell&quot;&gt;
      &lt;span class=&quot;hud-label&quot;&gt;BEST&lt;/span&gt;
      &lt;span class=&quot;hud-val&quot; id=&quot;hiVal&quot;&gt;0&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;hud-cell&quot;&gt;
      &lt;span class=&quot;hud-label&quot;&gt;COMBO&lt;/span&gt;
      &lt;span class=&quot;hud-val&quot; id=&quot;comboVal&quot;&gt;x1&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;hud-cell&quot;&gt;
      &lt;span class=&quot;hud-label&quot;&gt;LEVEL&lt;/span&gt;
      &lt;span class=&quot;hud-val&quot; id=&quot;levelVal&quot;&gt;1&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;hud-cell&quot;&gt;
      &lt;span class=&quot;hud-label&quot;&gt;LENGTH&lt;/span&gt;
      &lt;span class=&quot;hud-val&quot; id=&quot;lengthVal&quot;&gt;3&lt;/span&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;!-- CANVAS --&gt;
  &lt;canvas id=&quot;c&quot; width=&quot;480&quot; height=&quot;480&quot;&gt;&lt;/canvas&gt;

  &lt;!-- OVERLAY --&gt;
  &lt;div id=&quot;overlay&quot;&gt;
    &lt;h2 id=&quot;ovTitle&quot;&gt;SERPENT&lt;br&gt;BLITZ&lt;/h2&gt;
    &lt;p class=&quot;ov-sub&quot; id=&quot;ovSub&quot;&gt;먹이를 먹고 몸집을 키워라!&lt;br&gt;벽과 자신의 몸에 부딪히면 게임 오버&lt;/p&gt;
    &lt;div class=&quot;legend&quot;&gt;
      &lt;span&gt;&lt;span style=&quot;display:inline-block;width:14px;height:14px;background:#ff4466;border-radius:50%;box-shadow:0 0 6px #ff4466&quot;&gt;&lt;/span&gt;먹이 +점수&lt;/span&gt;
      &lt;span&gt;&lt;span style=&quot;display:inline-block;width:14px;height:14px;background:#ffcc00;border-radius:50%;box-shadow:0 0 6px #ffcc00&quot;&gt;&lt;/span&gt;파워업&lt;/span&gt;
      &lt;span&gt;&lt;span style=&quot;display:inline-block;width:14px;height:14px;background:#ff6600;border-radius:3px;box-shadow:0 0 6px #ff6600&quot;&gt;&lt;/span&gt;폭탄&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;ov-score hidden&quot; id=&quot;ovScore&quot;&gt;&lt;/div&gt;
    &lt;button class=&quot;btn&quot; id=&quot;startBtn&quot;&gt;START GAME&lt;/button&gt;
  &lt;/div&gt;

  &lt;!-- CONTROLS --&gt;
  &lt;div id=&quot;controls&quot;&gt;
    &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;↑&lt;/kbd&gt;&lt;kbd&gt;↓&lt;/kbd&gt;&lt;kbd&gt;←&lt;/kbd&gt;&lt;kbd&gt;→&lt;/kbd&gt; 방향&lt;/span&gt;
    &lt;span class=&quot;sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;WASD&lt;/kbd&gt; 방향&lt;/span&gt;
    &lt;span class=&quot;sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ci&quot;&gt;&lt;kbd&gt;P&lt;/kbd&gt; 일시정지&lt;/span&gt;
  &lt;/div&gt;

  &lt;!-- MOBILE --&gt;
  &lt;div id=&quot;mobileControls&quot;&gt;
    &lt;div class=&quot;dpad&quot;&gt;
      &lt;div&gt;&lt;/div&gt;
      &lt;div class=&quot;db&quot; id=&quot;mU&quot;&gt;▲&lt;/div&gt;
      &lt;div&gt;&lt;/div&gt;
      &lt;div class=&quot;db&quot; id=&quot;mL&quot;&gt;◀&lt;/div&gt;
      &lt;div style=&quot;border-radius:5px;background:#00ffaa08;border:1px solid #00ffaa11;&quot;&gt;&lt;/div&gt;
      &lt;div class=&quot;db&quot; id=&quot;mR&quot;&gt;▶&lt;/div&gt;
      &lt;div&gt;&lt;/div&gt;
      &lt;div class=&quot;db&quot; id=&quot;mD&quot;&gt;▼&lt;/div&gt;
      &lt;div&gt;&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

&lt;/div&gt;

&lt;script&gt;
/* ══════════════════════════════════════════
   SERPENT BLITZ  —  Snake-Style Action Game
   100% original code. No third-party assets.
   Fonts: Silkscreen + Rajdhani (SIL OFL 1.1)
══════════════════════════════════════════ */
const canvas = document.getElementById('c');
const ctx    = canvas.getContext('2d');
const W = 480, H = 480;

// ── GRID ──
const CELL  = 20;
const COLS  = W / CELL;   // 24
const ROWS  = H / CELL;   // 24

// ── GAME STATE ──
let snake, dir, nextDir, food, powerups, bombs;
let score, hiScore = 0, combo, comboTimer;
let level, speed, gameRunning, paused, animId;
let frameAcc = 0, lastTime = 0;
let particles = [];
let shieldActive = false, shieldTimer = 0;
let slowActive   = false,  slowTimer   = 0;
let flashEffect  = null;   // { color, alpha }
let foodBlink    = 0;

// ── INPUT ──
const DIRS = { up:[0,-1], down:[0,1], left:[-1,0], right:[1,0] };
let pendingDir = null;

function setDir(d) {
  // Prevent 180° reversal
  if (dir[0] + d[0] === 0 &amp;&amp; dir[1] + d[1] === 0) return;
  pendingDir = d;
}

document.addEventListener('keydown', e =&gt; {
  const map = {
    ArrowUp:DIRS.up, ArrowDown:DIRS.down, ArrowLeft:DIRS.left, ArrowRight:DIRS.right,
    KeyW:DIRS.up, KeyS:DIRS.down, KeyA:DIRS.left, KeyD:DIRS.right
  };
  if (map[e.code]) { setDir(map[e.code]); e.preventDefault(); }
  if (e.code === 'KeyP' &amp;&amp; gameRunning) togglePause();
  if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
});

// Mobile
function mBtn(id, d) {
  const el = document.getElementById(id);
  if (!el) return;
  el.addEventListener('touchstart', e =&gt; { e.preventDefault(); setDir(d); }, { passive: false });
  el.addEventListener('mousedown',  () =&gt; setDir(d));
}
mBtn('mU', DIRS.up); mBtn('mD', DIRS.down); mBtn('mL', DIRS.left); mBtn('mR', DIRS.right);

// Scroll lock
let mouseOver = false;
canvas.addEventListener('mouseenter', () =&gt; mouseOver = true);
canvas.addEventListener('mouseleave', () =&gt; mouseOver = false);
window.addEventListener('wheel', e =&gt; { if (mouseOver &amp;&amp; gameRunning &amp;&amp; !paused) e.preventDefault(); }, { passive: false });

// ── GRID HELPERS ──
function rnd(max) { return Math.floor(Math.random() * max); }

function freeCell() {
  // Find a cell not occupied by snake / food / powerups / bombs
  const occupied = new Set(snake.map(s =&gt; s[0]+','+s[1]));
  if (food)     occupied.add(food[0]+','+food[1]);
  powerups.forEach(p =&gt; occupied.add(p.x+','+p.y));
  bombs.forEach(b =&gt; occupied.add(b.x+','+b.y));
  let attempts = 200;
  while (attempts-- &gt; 0) {
    const x = 1 + rnd(COLS-2), y = 1 + rnd(ROWS-2);
    if (!occupied.has(x+','+y)) return [x, y];
  }
  return null;
}

function spawnFood() {
  food = freeCell();
}

// Power-up types
const POWER_TYPES = [
  { type:'slow',   color:'#44aaff', glyph:'⏱', label:'SLOW TIME' },
  { type:'shrink', color:'#ff88cc', glyph:'✂', label:'SHRINK' },
  { type:'shield', color:'#aaddff', glyph:' ', label:'SHIELD' },
  { type:'score2', color:'#ffcc00', glyph:'★', label:'2X SCORE' },
];

function spawnPowerup() {
  if (powerups.length &gt;= 2) return;
  const cell = freeCell();
  if (!cell) return;
  const t = POWER_TYPES[Math.floor(Math.random() * POWER_TYPES.length)];
  powerups.push({ x: cell[0], y: cell[1], ...t, life: 300 });
}

function spawnBomb() {
  if (bombs.length &gt;= 3) return;
  const cell = freeCell();
  if (!cell) return;
  bombs.push({ x: cell[0], y: cell[1], life: 250 + rnd(150), pulse: 0 });
}

// ── PARTICLES ──
function spawnParticles(px, py, col, n = 12, spread = 4) {
  for (let i = 0; i &lt; n; i++) {
    const a = Math.random() * Math.PI * 2;
    const sp = 0.5 + Math.random() * spread;
    particles.push({
      x: px, y: py,
      vx: Math.cos(a)*sp, vy: Math.sin(a)*sp,
      life: 1, decay: 0.022 + Math.random()*0.03,
      size: 2 + Math.random()*5, color: col
    });
  }
}

function spawnTextParticle(px, py, text, col) {
  particles.push({ text, x: px, y: py, vy: -1.5, vx: 0,
    life: 1, decay: 0.012, size: 14, color: col });
}

// ── INIT ──
function startGame() {
  const startX = Math.floor(COLS/2), startY = Math.floor(ROWS/2);
  snake    = [[startX,startY],[startX-1,startY],[startX-2,startY]];
  dir      = DIRS.right;
  pendingDir = null;
  food     = null;
  powerups = [];
  bombs    = [];
  score    = 0;
  combo    = 1;
  comboTimer = 0;
  level    = 1;
  speed    = 7;   // steps/sec
  particles = [];
  shieldActive = false; shieldTimer = 0;
  slowActive   = false; slowTimer   = 0;
  flashEffect  = null;
  gameRunning  = true;
  paused       = false;
  frameAcc     = 0;
  lastTime     = performance.now();

  spawnFood();
  updateHUD();
  document.getElementById('overlay').classList.add('hidden');
  if (animId) cancelAnimationFrame(animId);
  animId = requestAnimationFrame(loop);
}

// ── HUD ──
function updateHUD() {
  document.getElementById('scoreVal').textContent  = score;
  document.getElementById('hiVal').textContent     = hiScore;
  document.getElementById('comboVal').textContent  = `x${combo}`;
  document.getElementById('levelVal').textContent  = level;
  document.getElementById('lengthVal').textContent = snake.length;
}

// ── MAIN LOOP ──
function loop(ts) {
  animId = requestAnimationFrame(loop);
  if (paused || !gameRunning) return;

  const dt = Math.min(ts - lastTime, 100);
  lastTime = ts;

  const effectiveSpeed = slowActive ? speed * 0.45 : speed;
  frameAcc += dt * effectiveSpeed / 1000;

  // Particle update (every frame)
  particles = particles.filter(p =&gt; {
    p.x += p.vx; p.y += p.vy; p.life -= p.decay;
    if (!p.text) p.vy += 0.04;
    return p.life &gt; 0;
  });

  // Timers
  if (shieldTimer &gt; 0) { shieldTimer -= dt; if (shieldTimer &lt;= 0) shieldActive = false; }
  if (slowTimer   &gt; 0) { slowTimer   -= dt; if (slowTimer   &lt;= 0) slowActive   = false; }
  if (comboTimer  &gt; 0) { comboTimer  -= dt; if (comboTimer  &lt;= 0) { combo = 1; updateHUD(); } }
  if (flashEffect) {
    flashEffect.alpha -= 0.04;
    if (flashEffect.alpha &lt;= 0) flashEffect = null;
  }
  foodBlink = (foodBlink + dt * 0.006) % (Math.PI * 2);

  // Power-up / bomb tick
  powerups.forEach(p =&gt; p.life--);
  powerups = powerups.filter(p =&gt; p.life &gt; 0);
  bombs.forEach(b =&gt; { b.life--; b.pulse += 0.12; });
  bombs = bombs.filter(b =&gt; b.life &gt; 0);

  // Step
  if (frameAcc &gt;= 1) {
    frameAcc -= 1;
    step();
  }

  draw();
}

// ── STEP (one snake move) ──
function step() {
  if (pendingDir) { dir = pendingDir; pendingDir = null; }

  const head = snake[0];
  const nx = head[0] + dir[0];
  const ny = head[1] + dir[1];

  // Wall collision
  if (nx &lt; 0 || nx &gt;= COLS || ny &lt; 0 || ny &gt;= ROWS) {
    if (shieldActive) { shieldActive = false; shieldTimer = 0; flashEffect = { color:'#44aaff', alpha:0.5 }; return; }
    return die();
  }

  // Self collision
  const selfHit = snake.some((s, i) =&gt; i &gt; 0 &amp;&amp; s[0] === nx &amp;&amp; s[1] === ny);
  if (selfHit) {
    if (shieldActive) { shieldActive = false; shieldTimer = 0; flashEffect = { color:'#44aaff', alpha:0.5 }; return; }
    return die();
  }

  // Bomb collision
  const bombHit = bombs.findIndex(b =&gt; b.x === nx &amp;&amp; b.y === ny);
  if (bombHit !== -1) {
    if (shieldActive) {
      bombs.splice(bombHit, 1);
      flashEffect = { color:'#44aaff', alpha:0.5 };
      spawnParticles(nx*CELL+CELL/2, ny*CELL+CELL/2, '#44aaff', 16);
      shieldActive = false; shieldTimer = 0;
    } else {
      spawnParticles(nx*CELL+CELL/2, ny*CELL+CELL/2, '#ff6600', 20);
      flashEffect = { color:'#ff6600', alpha:0.6 };
      return die();
    }
    return;
  }

  // Move snake
  snake.unshift([nx, ny]);

  // Food check
  if (food &amp;&amp; nx === food[0] &amp;&amp; ny === food[1]) {
    const pts = 10 * combo * level;
    score += pts;
    comboTimer = 3000;
    combo = Math.min(8, combo + 1);
    spawnParticles(nx*CELL+CELL/2, ny*CELL+CELL/2, '#ff4466', 14);
    spawnTextParticle(nx*CELL+CELL/2, ny*CELL+CELL/2-10, `+${pts}`, '#ff4466');
    if (score &gt; hiScore) hiScore = score;
    spawnFood();
    // Level up every 10 foods
    if ((snake.length - 3) % 10 === 0 &amp;&amp; snake.length &gt; 3) {
      level++;
      speed = 7 + level * 0.9;
      flashEffect = { color: '#00ffaa', alpha: 0.3 };
      spawnTextParticle(W/2, H/2, `LEVEL ${level}!`, '#00ffaa');
      if (level % 3 === 0) spawnBomb();
    }
    if (Math.random() &lt; 0.3) spawnPowerup();
    if (level &gt; 2 &amp;&amp; Math.random() &lt; 0.15) spawnBomb();
  } else {
    snake.pop();
  }

  // Power-up check
  const pwIdx = powerups.findIndex(p =&gt; p.x === nx &amp;&amp; p.y === ny);
  if (pwIdx !== -1) {
    const pw = powerups[pwIdx];
    powerups.splice(pwIdx, 1);
    spawnParticles(nx*CELL+CELL/2, ny*CELL+CELL/2, pw.color, 18);
    spawnTextParticle(nx*CELL+CELL/2, ny*CELL+CELL/2-10, pw.label, pw.color);
    applyPower(pw.type);
  }

  updateHUD();

  // Periodic spawns
  if (Math.random() &lt; 0.015) spawnPowerup();
}

function applyPower(type) {
  if (type === 'slow')   { slowActive = true;   slowTimer   = 5000; flashEffect = { color:'#44aaff', alpha:0.2 }; }
  if (type === 'shield') { shieldActive = true;  shieldTimer = 8000; flashEffect = { color:'#aaddff', alpha:0.2 }; }
  if (type === 'shrink') {
    const remove = Math.min(5, snake.length - 3);
    for (let i = 0; i &lt; remove; i++) snake.pop();
    updateHUD();
  }
  if (type === 'score2') {
    combo = Math.min(8, combo + 2);
    comboTimer = 5000;
    updateHUD();
  }
}

function die() {
  gameRunning = false;
  cancelAnimationFrame(animId);
  spawnParticles(snake[0][0]*CELL+CELL/2, snake[0][1]*CELL+CELL/2, '#ff4466', 30, 6);
  flashEffect = { color: '#ff0033', alpha: 0.7 };

  // Final draw with flash
  draw();

  setTimeout(() =&gt; showGameOver(), 600);
}

function showGameOver() {
  document.getElementById('ovTitle').textContent    = 'GAME OVER';
  document.getElementById('ovSub').textContent      = '뱀이 쓰러졌습니다...';
  const sc = document.getElementById('ovScore');
  sc.innerHTML = `SCORE: ${score}&lt;br&gt;&lt;span style=&quot;font-size:11px;color:#557766&quot;&gt;BEST: ${hiScore} · LV.${level} · LENGTH: ${snake.length}&lt;/span&gt;`;
  sc.classList.remove('hidden');
  document.getElementById('startBtn').textContent   = 'PLAY AGAIN';
  document.getElementById('overlay').classList.remove('hidden');
}

function togglePause() {
  paused = !paused;
  if (paused) {
    document.getElementById('ovTitle').textContent  = 'PAUSED';
    document.getElementById('ovSub').textContent    = 'P키로 재개';
    document.getElementById('ovScore').classList.add('hidden');
    document.getElementById('startBtn').textContent = 'RESUME';
    document.getElementById('overlay').classList.remove('hidden');
    draw();
  } else {
    lastTime = performance.now();
    document.getElementById('overlay').classList.add('hidden');
  }
}

document.getElementById('startBtn').addEventListener('click', () =&gt; {
  if (paused) togglePause();
  else startGame();
});

// ── DRAW ──
function draw() {
  // Background
  ctx.fillStyle = '#050810';
  ctx.fillRect(0, 0, W, H);

  // Subtle grid
  ctx.save();
  ctx.strokeStyle = '#ffffff05';
  ctx.lineWidth = 1;
  for (let x = 0; x &lt;= W; x += CELL) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
  for (let y = 0; y &lt;= H; y += CELL) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
  ctx.restore();

  // Border wall glow
  ctx.save();
  ctx.shadowBlur = 12;
  ctx.shadowColor = '#00ffaa33';
  ctx.strokeStyle = '#00ffaa22';
  ctx.lineWidth = 2;
  ctx.strokeRect(1, 1, W-2, H-2);
  ctx.restore();

  // ── BOMBS ──
  bombs.forEach(b =&gt; {
    const bx = b.x*CELL + CELL/2, by = b.y*CELL + CELL/2;
    const pulse = 0.8 + Math.sin(b.pulse) * 0.2;
    const r = CELL*0.42*pulse;
    ctx.save();
    ctx.shadowBlur = 16 * pulse;
    ctx.shadowColor = '#ff6600';
    // Bomb body
    ctx.fillStyle = '#cc3300';
    ctx.beginPath(); ctx.arc(bx, by, r, 0, Math.PI*2); ctx.fill();
    ctx.strokeStyle = '#ff6600';
    ctx.lineWidth = 2;
    ctx.stroke();
    // Fuse
    ctx.strokeStyle = '#ffaa44';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(bx, by - r);
    ctx.quadraticCurveTo(bx+6, by-r-8, bx+4, by-r-14);
    ctx.stroke();
    // Spark
    if (Math.sin(b.pulse*2) &gt; 0) {
      ctx.fillStyle = '#ffdd00';
      ctx.shadowColor = '#ffdd00';
      ctx.beginPath(); ctx.arc(bx+4, by-r-14, 3, 0, Math.PI*2); ctx.fill();
    }
    // Timer ring
    const ratio = b.life / 400;
    ctx.strokeStyle = ratio &lt; 0.3 ? '#ff3300' : '#ff8844';
    ctx.lineWidth = 3;
    ctx.shadowBlur = 0;
    ctx.beginPath();
    ctx.arc(bx, by, r+4, -Math.PI/2, -Math.PI/2 + ratio*Math.PI*2);
    ctx.stroke();
    ctx.restore();
  });

  // ── POWER-UPS ──
  powerups.forEach(p =&gt; {
    const px = p.x*CELL + CELL/2, py = p.y*CELL + CELL/2;
    const pulse = 0.85 + Math.sin(Date.now()*0.006)*0.15;
    const r = CELL*0.38*pulse;
    ctx.save();
    ctx.shadowBlur = 18;
    ctx.shadowColor = p.color;
    // Diamond shape
    ctx.fillStyle = p.color + '33';
    ctx.strokeStyle = p.color;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(px, py - r*1.2);
    ctx.lineTo(px + r, py);
    ctx.lineTo(px, py + r*1.2);
    ctx.lineTo(px - r, py);
    ctx.closePath();
    ctx.fill(); ctx.stroke();
    // Glyph
    ctx.fillStyle = p.color;
    ctx.font = `bold ${CELL*0.55}px serif`;
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.fillText(p.glyph, px, py);
    // Fade warning
    if (p.life &lt; 60) {
      ctx.globalAlpha = 0.3 + 0.7*(p.life/60);
    }
    ctx.restore();
  });

  // ── FOOD ──
  if (food) {
    const fx = food[0]*CELL + CELL/2, fy = food[1]*CELL + CELL/2;
    const r  = CELL*0.38 + Math.sin(foodBlink)*2;
    ctx.save();
    ctx.shadowBlur = 20 + Math.sin(foodBlink)*6;
    ctx.shadowColor = '#ff4466';
    // Outer ring
    ctx.strokeStyle = '#ff446688';
    ctx.lineWidth = 1.5;
    ctx.beginPath(); ctx.arc(fx, fy, r+5, 0, Math.PI*2); ctx.stroke();
    // Core
    const fg = ctx.createRadialGradient(fx-2,fy-2,1, fx,fy,r);
    fg.addColorStop(0, '#ffaacc');
    fg.addColorStop(0.5,'#ff4466');
    fg.addColorStop(1, '#cc0033');
    ctx.fillStyle = fg;
    ctx.beginPath(); ctx.arc(fx, fy, r, 0, Math.PI*2); ctx.fill();
    // Shine
    ctx.fillStyle = '#ffffff55';
    ctx.beginPath(); ctx.arc(fx-r*0.3, fy-r*0.3, r*0.25, 0, Math.PI*2); ctx.fill();
    ctx.restore();
  }

  // ── SNAKE ──
  snake.forEach(([sx, sy], i) =&gt; {
    const x = sx*CELL, y = sy*CELL;
    const isHead = i === 0;
    const t = i / snake.length;
    const hue = 160; // green-cyan
    const lightness = isHead ? 65 : 45 + (1-t)*20;
    const col = `hsl(${hue},100%,${lightness}%)`;

    ctx.save();
    if (shieldActive) {
      ctx.shadowBlur = isHead ? 20 : 10;
      ctx.shadowColor = '#44aaff';
    } else {
      ctx.shadowBlur = isHead ? 16 : 6;
      ctx.shadowColor = '#00ffaa';
    }

    // Body segment
    const pad = isHead ? 1 : 2.5;
    const rad = isHead ? 5 : 4;
    ctx.fillStyle = col;
    ctx.beginPath();
    ctx.roundRect(x+pad, y+pad, CELL-pad*2, CELL-pad*2, rad);
    ctx.fill();

    // Segment separator line
    if (!isHead) {
      ctx.strokeStyle = '#050810';
      ctx.lineWidth = 1;
      ctx.stroke();
    }

    // Shield glow overlay
    if (shieldActive) {
      ctx.fillStyle = '#44aaff22';
      ctx.beginPath();
      ctx.roundRect(x+pad, y+pad, CELL-pad*2, CELL-pad*2, rad);
      ctx.fill();
    }

    // Head details — eyes
    if (isHead) {
      ctx.fillStyle = '#050810';
      const eyeOff = 4;
      let ex1, ey1, ex2, ey2;
      if (dir[0] === 1)       { ex1=x+CELL-7; ey1=y+eyeOff+1; ex2=x+CELL-7; ey2=y+CELL-eyeOff-3; }
      else if (dir[0] === -1) { ex1=x+5;      ey1=y+eyeOff+1; ex2=x+5;      ey2=y+CELL-eyeOff-3; }
      else if (dir[1] === -1) { ex1=x+eyeOff; ey1=y+5;        ex2=x+CELL-eyeOff-2; ey2=y+5; }
      else                    { ex1=x+eyeOff; ey1=y+CELL-7;   ex2=x+CELL-eyeOff-2; ey2=y+CELL-7; }
      ctx.shadowBlur = 0;
      ctx.beginPath(); ctx.arc(ex1, ey1, 2.5, 0, Math.PI*2); ctx.fill();
      ctx.beginPath(); ctx.arc(ex2, ey2, 2.5, 0, Math.PI*2); ctx.fill();
      // Eye shine
      ctx.fillStyle = '#ffffffaa';
      ctx.beginPath(); ctx.arc(ex1-0.5, ey1-0.5, 1, 0, Math.PI*2); ctx.fill();
      ctx.beginPath(); ctx.arc(ex2-0.5, ey2-0.5, 1, 0, Math.PI*2); ctx.fill();

      // Tongue
      const tx1 = sx*CELL+CELL/2, ty1 = sy*CELL+CELL/2;
      const tLen = 7, tSplit = 4;
      ctx.strokeStyle = '#ff4488'; ctx.lineWidth = 1.5;
      ctx.shadowBlur = 6; ctx.shadowColor = '#ff4488';
      ctx.beginPath();
      let tdx = dir[0]*CELL*0.55, tdy = dir[1]*CELL*0.55;
      ctx.moveTo(tx1, ty1);
      ctx.lineTo(tx1 + tdx, ty1 + tdy);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(tx1+tdx, ty1+tdy);
      ctx.lineTo(tx1+tdx + dir[0]*tLen/2 - dir[1]*tSplit, ty1+tdy + dir[1]*tLen/2 + dir[0]*tSplit);
      ctx.moveTo(tx1+tdx, ty1+tdy);
      ctx.lineTo(tx1+tdx + dir[0]*tLen/2 + dir[1]*tSplit, ty1+tdy + dir[1]*tLen/2 - dir[0]*tSplit);
      ctx.stroke();

      // Shield aura around head
      if (shieldActive) {
        ctx.shadowBlur = 20;
        ctx.shadowColor = '#44aaff';
        ctx.strokeStyle = '#44aaff88';
        ctx.lineWidth = 2.5;
        ctx.beginPath();
        ctx.arc(sx*CELL+CELL/2, sy*CELL+CELL/2, CELL*0.72, 0, Math.PI*2);
        ctx.stroke();
      }
    }

    ctx.restore();
  });

  // ── PARTICLES ──
  particles.forEach(p =&gt; {
    ctx.save();
    ctx.globalAlpha = Math.max(0, p.life);
    if (p.text) {
      ctx.font = `bold ${p.size}px 'Silkscreen', monospace`;
      ctx.fillStyle = p.color;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.shadowBlur = 10; ctx.shadowColor = p.color;
      ctx.fillText(p.text, p.x, p.y);
    } else {
      ctx.fillStyle = p.color;
      ctx.shadowBlur = 10; ctx.shadowColor = p.color;
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI*2);
      ctx.fill();
    }
    ctx.restore();
  });

  // ── STATUS INDICATORS ──
  let indicX = 8;
  if (shieldActive) {
    const pct = Math.min(1, shieldTimer / 8000);
    ctx.save();
    ctx.fillStyle = '#44aaff22';
    ctx.strokeStyle = '#44aaff';
    ctx.lineWidth = 1.5;
    ctx.shadowBlur = 8; ctx.shadowColor = '#44aaff';
    ctx.fillRect(indicX, H-16, 60*pct, 6);
    ctx.strokeRect(indicX, H-16, 60, 6);
    ctx.fillStyle = '#44aaff';
    ctx.font = &quot;9px 'Silkscreen', monospace&quot;;
    ctx.fillText('SHIELD', indicX, H-20);
    ctx.restore();
    indicX += 70;
  }
  if (slowActive) {
    const pct = Math.min(1, slowTimer / 5000);
    ctx.save();
    ctx.fillStyle = '#44aaff22';
    ctx.strokeStyle = '#aaddff';
    ctx.lineWidth = 1.5;
    ctx.shadowBlur = 8; ctx.shadowColor = '#aaddff';
    ctx.fillRect(indicX, H-16, 60*pct, 6);
    ctx.strokeRect(indicX, H-16, 60, 6);
    ctx.fillStyle = '#aaddff';
    ctx.font = &quot;9px 'Silkscreen', monospace&quot;;
    ctx.fillText('SLOW', indicX, H-20);
    ctx.restore();
    indicX += 70;
  }
  if (combo &gt; 1) {
    const pct = Math.min(1, comboTimer / 3000);
    ctx.save();
    ctx.fillStyle = '#ff88cc22';
    ctx.strokeStyle = '#ff88cc';
    ctx.lineWidth = 1.5;
    ctx.shadowBlur = 8; ctx.shadowColor = '#ff88cc';
    ctx.fillRect(indicX, H-16, 60*pct, 6);
    ctx.strokeRect(indicX, H-16, 60, 6);
    ctx.fillStyle = '#ff88cc';
    ctx.font = &quot;9px 'Silkscreen', monospace&quot;;
    ctx.fillText(`x${combo} COMBO`, indicX, H-20);
    ctx.restore();
  }

  // ── FLASH EFFECT ──
  if (flashEffect &amp;&amp; flashEffect.alpha &gt; 0) {
    ctx.save();
    ctx.globalAlpha = Math.max(0, flashEffect.alpha);
    ctx.fillStyle = flashEffect.color;
    ctx.fillRect(0, 0, W, H);
    ctx.restore();
  }

  // ── VIGNETTE ──
  const vig = ctx.createRadialGradient(W/2, H/2, H*0.25, W/2, H/2, H*0.75);
  vig.addColorStop(0, 'transparent');
  vig.addColorStop(1, 'rgba(0,0,0,0.55)');
  ctx.fillStyle = vig;
  ctx.fillRect(0, 0, W, H);
}

// ── INITIAL SPLASH ──
ctx.fillStyle = '#050810';
ctx.fillRect(0, 0, W, H);
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</description>
      <category>게임</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/67</guid>
      <comments>https://bbibbabbbi.tistory.com/67#entry67comment</comments>
      <pubDate>Thu, 19 Mar 2026 01:12:58 +0900</pubDate>
    </item>
    <item>
      <title>터보 대시</title>
      <link>https://bbibbabbbi.tistory.com/66</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;title&gt;터보 대시&lt;/title&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&amp;family=Chakra+Petch:wght@400;600;700&amp;display=swap');

  :root {
    --sun: #ff6b00;
    --sun2: #ffcc00;
    --road: #1a1a2e;
    --stripe: #e8e800;
    --sky1: #0d0221;
    --sky2: #1a0533;
    --accent: #ff2d78;
    --green: #00ff99;
    --ui-bg: #0a0015;
  }

  * { margin: 0; padding: 0; box-sizing: border-box; }

  body {
    background: var(--ui-bg);
    display: flex;
    justify-content: center;
    align-items: flex-start;
    min-height: 100vh;
    padding: 20px 0 40px;
    font-family: 'Chakra Petch', sans-serif;
    overflow-y: auto;
    overflow-x: hidden;
  }

  #gameWrapper {
    position: relative;
    width: 480px;
  }

  /* ── HEADER ── */
  #header {
    text-align: center;
    padding: 10px 0 7px;
    background: linear-gradient(180deg, #100020 0%, var(--ui-bg) 100%);
    border: 1px solid #ff2d7844;
    border-bottom: none;
    position: relative;
    overflow: hidden;
  }
  #header::before {
    content: '';
    position: absolute;
    inset: 0;
    background: repeating-linear-gradient(
      90deg,
      transparent 0px, transparent 38px,
      #ff2d7808 38px, #ff2d7808 40px
    );
  }
  #header h1 {
    font-family: 'Press Start 2P', monospace;
    font-size: 18px;
    letter-spacing: 4px;
    background: linear-gradient(180deg, var(--sun2) 0%, var(--sun) 60%, var(--accent) 100%);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    text-shadow: none;
    filter: drop-shadow(0 0 8px #ff6b0088);
    position: relative;
  }

  /* ── HUD ── */
  #hud {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 7px 14px;
    background: #0c0018;
    border-left: 1px solid #ff2d7833;
    border-right: 1px solid #ff2d7833;
    font-size: 11px;
    letter-spacing: 2px;
  }
  #hud .hud-item { display: flex; flex-direction: column; align-items: center; gap: 2px; }
  #hud .hud-label { color: #664455; font-size: 8px; letter-spacing: 3px; font-family: 'Press Start 2P', monospace; }
  #hud .hud-val { font-family: 'Press Start 2P', monospace; font-size: 11px; }
  #hud #scoreVal { color: var(--sun2); text-shadow: 0 0 8px #ffcc0066; }
  #hud #speedVal { color: var(--green); text-shadow: 0 0 8px #00ff9966; }
  #hud #livesVal { color: var(--accent); text-shadow: 0 0 8px #ff2d7866; font-size: 13px; }

  /* ── CANVAS ── */
  canvas {
    display: block;
    border: 1px solid #ff2d7833;
    border-top: none;
    cursor: default;
  }

  /* ── OVERLAY ── */
  #overlay {
    position: absolute;
    top: 38px; left: 0; right: 0; bottom: 50px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    background: rgba(5, 0, 20, 0.92);
    z-index: 10;
    gap: 18px;
    border-left: 1px solid #ff2d7833;
    border-right: 1px solid #ff2d7833;
  }
  #overlay.hidden { display: none; }

  #overlay h2 {
    font-family: 'Press Start 2P', monospace;
    font-size: 22px;
    line-height: 1.6;
    text-align: center;
    background: linear-gradient(180deg, var(--sun2), var(--accent));
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    filter: drop-shadow(0 0 12px #ff6b0066);
  }
  #overlay .subtitle {
    font-size: 12px;
    color: #997788;
    letter-spacing: 2px;
    text-align: center;
    line-height: 2;
  }
  #overlay .final-score {
    font-family: 'Press Start 2P', monospace;
    font-size: 13px;
    color: var(--sun2);
    text-shadow: 0 0 10px #ffcc0066;
    letter-spacing: 2px;
    text-align: center;
    line-height: 2;
  }
  #overlay .final-score.hidden { display: none; }

  .btn {
    font-family: 'Press Start 2P', monospace;
    font-size: 11px;
    letter-spacing: 3px;
    padding: 13px 30px;
    background: transparent;
    border: 2px solid var(--accent);
    color: var(--accent);
    cursor: pointer;
    position: relative;
    overflow: hidden;
    transition: color 0.18s;
  }
  .btn::before {
    content: '';
    position: absolute;
    inset: 0;
    background: var(--accent);
    transform: scaleX(0);
    transform-origin: left;
    transition: transform 0.18s;
    z-index: -1;
  }
  .btn:hover::before { transform: scaleX(1); }
  .btn:hover { color: #fff; }

  /* ── CONTROLS BAR ── */
  #controls {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px 16px;
    padding: 10px 14px;
    background: #080012;
    border: 1px solid #ff2d7822;
    border-top: 1px solid #ff2d7818;
    font-size: 10px;
    color: #cc7788;
    letter-spacing: 1.5px;
  }
  #controls .ctrl-item { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
  #controls kbd {
    display: inline-block;
    background: #1a0025;
    border: 1px solid #ff2d7855;
    border-bottom: 2px solid #ff2d7899;
    border-radius: 3px;
    padding: 1px 7px;
    font-family: 'Chakra Petch', sans-serif;
    font-size: 10px;
    color: #ff6b99;
  }
  #controls .ctrl-sep { color: #ff2d7822; font-size: 16px; }

  /* ── MOBILE CONTROLS ── */
  #mobileControls {
    display: none;
    justify-content: space-between;
    align-items: center;
    padding: 10px 24px;
    background: #080012;
    border: 1px solid #ff2d7822;
    border-top: none;
    gap: 10px;
  }
  .dpad {
    display: grid;
    grid-template-columns: repeat(3, 46px);
    grid-template-rows: repeat(2, 46px);
    gap: 4px;
  }
  .dpad-btn {
    background: #ff2d7811;
    border: 1px solid #ff2d7855;
    color: #ff6b99;
    font-size: 20px;
    border-radius: 4px;
    display: flex; align-items: center; justify-content: center;
    cursor: pointer;
    user-select: none; -webkit-user-select: none;
    touch-action: none;
  }
  .dpad-btn:active { background: #ff2d7833; }
  .accel-btn, .brake-btn {
    width: 70px; height: 70px;
    border-radius: 50%;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    font-family: 'Press Start 2P', monospace;
    font-size: 8px;
    letter-spacing: 1px;
    cursor: pointer;
    user-select: none; -webkit-user-select: none;
    touch-action: none;
    gap: 3px;
  }
  .accel-btn { background: #00ff9911; border: 2px solid #00ff99; color: #00ff99; }
  .accel-btn:active { background: #00ff9933; }
  .brake-btn { background: #ff2d7811; border: 2px solid #ff2d78; color: #ff2d78; }
  .brake-btn:active { background: #ff2d7833; }

  @media (max-width: 500px) {
    #gameWrapper { width: 100vw; }
    canvas { width: 100vw; height: auto; }
    #mobileControls { display: flex; }
  }
  @media (pointer: coarse) {
    #mobileControls { display: flex; }
  }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div id=&quot;gameWrapper&quot;&gt;
  &lt;!-- HEADER --&gt;
  &lt;div id=&quot;header&quot;&gt;&lt;h1&gt;터보 대시&lt;/h1&gt;&lt;/div&gt;

  &lt;!-- HUD --&gt;
  &lt;div id=&quot;hud&quot;&gt;
    &lt;div class=&quot;hud-item&quot;&gt;
      &lt;span class=&quot;hud-label&quot;&gt;SCORE&lt;/span&gt;
      &lt;span class=&quot;hud-val&quot; id=&quot;scoreVal&quot;&gt;0&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;hud-item&quot;&gt;
      &lt;span class=&quot;hud-label&quot;&gt;SPEED&lt;/span&gt;
      &lt;span class=&quot;hud-val&quot; id=&quot;speedVal&quot;&gt;0&lt;span style=&quot;font-size:7px&quot;&gt; km/h&lt;/span&gt;&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;hud-item&quot;&gt;
      &lt;span class=&quot;hud-label&quot;&gt;LIVES&lt;/span&gt;
      &lt;span class=&quot;hud-val&quot; id=&quot;livesVal&quot;&gt;♥♥♥&lt;/span&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;!-- CANVAS --&gt;
  &lt;canvas id=&quot;c&quot; width=&quot;480&quot; height=&quot;560&quot;&gt;&lt;/canvas&gt;

  &lt;!-- OVERLAY --&gt;
  &lt;div id=&quot;overlay&quot;&gt;
    &lt;h2 id=&quot;overlayTitle&quot;&gt;터보&lt;br&gt;대시&lt;/h2&gt;
    &lt;p class=&quot;subtitle&quot; id=&quot;overlaySubtitle&quot;&gt;
      최고 속도로 질주하며&lt;br&gt;
      장애물을 피하고 살아남아라!
    &lt;/p&gt;
    &lt;div class=&quot;final-score hidden&quot; id=&quot;finalScore&quot;&gt;&lt;/div&gt;
    &lt;button class=&quot;btn&quot; id=&quot;startBtn&quot;&gt;START&lt;/button&gt;
  &lt;/div&gt;

  &lt;!-- CONTROLS --&gt;
  &lt;div id=&quot;controls&quot;&gt;
    &lt;span class=&quot;ctrl-item&quot;&gt;&lt;kbd&gt;↑&lt;/kbd&gt; 가속&lt;/span&gt;
    &lt;span class=&quot;ctrl-sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ctrl-item&quot;&gt;&lt;kbd&gt;↓&lt;/kbd&gt; 감속&lt;/span&gt;
    &lt;span class=&quot;ctrl-sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ctrl-item&quot;&gt;&lt;kbd&gt;←&lt;/kbd&gt;&lt;kbd&gt;→&lt;/kbd&gt; 차선변경&lt;/span&gt;
    &lt;span class=&quot;ctrl-sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ctrl-item&quot;&gt;&lt;kbd&gt;P&lt;/kbd&gt; 일시정지&lt;/span&gt;
  &lt;/div&gt;

  &lt;!-- MOBILE CONTROLS --&gt;
  &lt;div id=&quot;mobileControls&quot;&gt;
    &lt;div class=&quot;dpad&quot;&gt;
      &lt;div&gt;&lt;/div&gt;
      &lt;div class=&quot;dpad-btn&quot; id=&quot;btnUp&quot;&gt;▲&lt;/div&gt;
      &lt;div&gt;&lt;/div&gt;
      &lt;div class=&quot;dpad-btn&quot; id=&quot;btnLeft&quot;&gt;◀&lt;/div&gt;
      &lt;div class=&quot;dpad-btn&quot; id=&quot;btnDown&quot;&gt;▼&lt;/div&gt;
      &lt;div class=&quot;dpad-btn&quot; id=&quot;btnRight&quot;&gt;▶&lt;/div&gt;
    &lt;/div&gt;
    &lt;div style=&quot;display:flex;gap:10px;align-items:center;&quot;&gt;
      &lt;div class=&quot;brake-btn&quot; id=&quot;btnBrake&quot;&gt;&lt;span style=&quot;font-size:16px&quot;&gt; &lt;/span&gt;BRAKE&lt;/div&gt;
      &lt;div class=&quot;accel-btn&quot; id=&quot;btnAccel&quot;&gt;&lt;span style=&quot;font-size:16px&quot;&gt; &lt;/span&gt;ACCEL&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
/* ══════════════════════════════════════
   터보 대시 — Car Racing Game
   Top-down infinite road scroller
══════════════════════════════════════ */
const canvas = document.getElementById('c');
const ctx    = canvas.getContext('2d');
const W = 480, H = 560;

// ── ROAD CONFIG ──
const ROAD_LEFT  = 80;
const ROAD_RIGHT = 400;
const ROAD_W     = ROAD_RIGHT - ROAD_LEFT;
const LANE_W     = ROAD_W / 4;  // 4 lanes
const LANES      = [0,1,2,3].map(i =&gt; ROAD_LEFT + LANE_W * i + LANE_W / 2);

// ── STATE ──
let score = 0, lives = 3, gameRunning = false, paused = false, animId;
let roadOffset = 0, baseSpeed = 3, speed = 3, targetSpeed = 3;
let frameCount = 0, invincible = 0;
let particles = [];

// ── INPUT ──
const keys = {};
let mob = { up:false, down:false, left:false, right:false };

document.addEventListener('keydown', e =&gt; {
  keys[e.code] = true;
  if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
  if (e.code === 'KeyP' &amp;&amp; gameRunning) togglePause();
});
document.addEventListener('keyup', e =&gt; { keys[e.code] = false; });

function holdBtn(id, key) {
  const el = document.getElementById(id);
  if (!el) return;
  const on  = () =&gt; mob[key] = true;
  const off = () =&gt; mob[key] = false;
  el.addEventListener('touchstart', e =&gt; { e.preventDefault(); on(); }, { passive: false });
  el.addEventListener('touchend',   e =&gt; { e.preventDefault(); off(); }, { passive: false });
  el.addEventListener('mousedown', on);
  el.addEventListener('mouseup',   off);
  el.addEventListener('mouseleave',off);
}
holdBtn('btnUp','up'); holdBtn('btnDown','down');
holdBtn('btnLeft','left'); holdBtn('btnRight','right');
holdBtn('btnAccel','up'); holdBtn('btnBrake','down');

// scroll lock
let mouseOver = false;
canvas.addEventListener('mouseenter', () =&gt; mouseOver = true);
canvas.addEventListener('mouseleave', () =&gt; mouseOver = false);
window.addEventListener('wheel', e =&gt; {
  if (mouseOver &amp;&amp; gameRunning &amp;&amp; !paused) e.preventDefault();
}, { passive: false });

// ── PLAYER ──
const player = {
  lane: 1,          // current lane index (0-3)
  x: LANES[1],      // actual x (smooth)
  y: H - 100,
  w: 26, h: 46,
  laneTimer: 0,     // cooldown between lane changes
  changingLane: false,
  targetX: LANES[1],
};

// ── TRAFFIC CARS ──
let cars = [];
const CAR_COLORS = ['#ff3355','#33aaff','#ffcc00','#aa44ff','#ff8800','#00ddcc'];
const CAR_DESIGNS = ['sedan','truck','sport','van'];

function spawnCar() {
  const lane = Math.floor(Math.random() * 4);
  const design = CAR_DESIGNS[Math.floor(Math.random() * CAR_DESIGNS.length)];
  const col = CAR_COLORS[Math.floor(Math.random() * CAR_COLORS.length)];
  // Don't spawn too close to player
  cars.push({
    lane, x: LANES[lane], y: -60,
    w: 26, h: design === 'truck' ? 56 : 42,
    color: col, design,
    speed: speed * (0.4 + Math.random() * 0.35), // slower than player
  });
}

// ── ROAD SCENERY ──
const trees = [];
for (let i = 0; i &lt; 12; i++) {
  trees.push({
    x: Math.random() &lt; 0.5 ? 20 + Math.random() * 40 : 420 + Math.random() * 40,
    y: Math.random() * H,
    size: 8 + Math.random() * 10,
    col: `hsl(${100 + Math.random()*40},${50+Math.random()*30}%,${20+Math.random()*20}%)`
  });
}
const buildings = [];
for (let i = 0; i &lt; 8; i++) {
  buildings.push({
    side: i % 2,
    x: i % 2 === 0 ? 2 + Math.random() * 35 : 443 + Math.random() * 35,
    y: Math.random() * H,
    w: 18 + Math.random() * 22,
    h: 30 + Math.random() * 60,
    col: `hsl(${200+Math.random()*60},${20+Math.random()*20}%,${12+Math.random()*12}%)`,
    lit: Math.random() &gt; 0.5
  });
}

// ── PARTICLES ──
function spawnParticles(x, y, col, n = 10) {
  for (let i = 0; i &lt; n; i++) {
    const a = Math.random() * Math.PI * 2;
    const sp = 1 + Math.random() * 4;
    particles.push({ x, y, vx: Math.cos(a)*sp, vy: Math.sin(a)*sp,
      life:1, decay: 0.025 + Math.random()*0.04,
      size: 2 + Math.random()*4, color: col });
  }
}
function spawnExhaust(x, y) {
  particles.push({ x, y, vx:(Math.random()-0.5)*0.6, vy: 1.5 + Math.random(),
    life:0.7, decay:0.06, size:4+Math.random()*4, color:'#ffffff44' });
}

// ── DRAW FUNCTIONS ──

function drawRoad() {
  // Sky / background gradient
  const sky = ctx.createLinearGradient(0, 0, 0, H * 0.35);
  sky.addColorStop(0, '#0d0221');
  sky.addColorStop(1, '#250838');
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, W, H * 0.35);

  // Sun
  const sunY = H * 0.28;
  const sunGrad = ctx.createRadialGradient(W/2, sunY, 0, W/2, sunY, 70);
  sunGrad.addColorStop(0,  '#ffdd55ff');
  sunGrad.addColorStop(0.4,'#ff8800cc');
  sunGrad.addColorStop(0.7,'#ff2d7866');
  sunGrad.addColorStop(1,  '#ff2d7800');
  ctx.fillStyle = sunGrad;
  ctx.fillRect(0, 0, W, H * 0.35);

  // Horizon glow
  const hGrad = ctx.createLinearGradient(0, H*0.3, 0, H*0.4);
  hGrad.addColorStop(0, '#ff6b0033');
  hGrad.addColorStop(1, '#1a0a2800');
  ctx.fillStyle = hGrad;
  ctx.fillRect(0, H*0.3, W, H*0.1);

  // Side margins (dirt/grass)
  const lGrad = ctx.createLinearGradient(0, 0, ROAD_LEFT, 0);
  lGrad.addColorStop(0, '#0f1a0f');
  lGrad.addColorStop(1, '#1a2a1a');
  ctx.fillStyle = lGrad;
  ctx.fillRect(0, H*0.34, ROAD_LEFT, H);

  const rGrad = ctx.createLinearGradient(ROAD_RIGHT, 0, W, 0);
  rGrad.addColorStop(0, '#1a2a1a');
  rGrad.addColorStop(1, '#0f1a0f');
  ctx.fillStyle = rGrad;
  ctx.fillRect(ROAD_RIGHT, H*0.34, W - ROAD_RIGHT, H);

  // Road base
  const roadGrad = ctx.createLinearGradient(ROAD_LEFT, 0, ROAD_RIGHT, 0);
  roadGrad.addColorStop(0, '#111128');
  roadGrad.addColorStop(0.5, '#1a1a35');
  roadGrad.addColorStop(1, '#111128');
  ctx.fillStyle = roadGrad;
  ctx.fillRect(ROAD_LEFT, H*0.34, ROAD_W, H);

  // Road edge lines
  ctx.save();
  ctx.shadowBlur = 8;
  ctx.shadowColor = '#ff8800';
  ctx.strokeStyle = '#ff880088';
  ctx.lineWidth = 3;
  ctx.beginPath(); ctx.moveTo(ROAD_LEFT, H*0.34); ctx.lineTo(ROAD_LEFT, H); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(ROAD_RIGHT, H*0.34); ctx.lineTo(ROAD_RIGHT, H); ctx.stroke();
  ctx.restore();

  // Dashed lane lines
  const dashLen = 35, gapLen = 25;
  const totalLen = dashLen + gapLen;
  const offset = roadOffset % totalLen;

  ctx.save();
  ctx.setLineDash([dashLen, gapLen]);
  ctx.lineDashOffset = -offset;
  ctx.strokeStyle = '#ffee0066';
  ctx.lineWidth = 2;
  ctx.shadowBlur = 4;
  ctx.shadowColor = '#ffee0044';

  for (let i = 1; i &lt; 4; i++) {
    const lx = ROAD_LEFT + LANE_W * i;
    ctx.beginPath();
    ctx.moveTo(lx, H * 0.34);
    ctx.lineTo(lx, H);
    ctx.stroke();
  }
  ctx.restore();
}

function drawScenery(offsetY) {
  // Moving trees
  trees.forEach(t =&gt; {
    const y = ((t.y + offsetY) % (H + 40)) - 20;
    const inSky = y &lt; H * 0.35;
    if (inSky) return;
    ctx.save();
    ctx.shadowBlur = 6;
    ctx.shadowColor = t.col;
    ctx.fillStyle = t.col;
    ctx.beginPath();
    ctx.arc(t.x, y, t.size * 0.7, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#1a2e10';
    ctx.fillRect(t.x - 2, y + t.size * 0.5, 4, t.size * 0.6);
    ctx.restore();
  });

  // Moving buildings (city skyline sides)
  buildings.forEach(b =&gt; {
    const y = ((b.y + offsetY * 0.6) % (H + b.h + 20)) - b.h;
    if (y &lt; H * 0.32) return;
    ctx.save();
    ctx.fillStyle = b.col;
    ctx.fillRect(b.x, y, b.w, b.h);
    // windows
    if (b.lit) {
      ctx.fillStyle = '#ffdd8844';
      for (let wy = y + 5; wy &lt; y + b.h - 8; wy += 10) {
        for (let wx = b.x + 3; wx &lt; b.x + b.w - 5; wx += 8) {
          if (Math.random() &gt; 0.4) ctx.fillRect(wx, wy, 4, 5);
        }
      }
    }
    ctx.restore();
  });
}

function drawCar(c, isPlayer, inv) {
  ctx.save();
  if (isPlayer &amp;&amp; inv &gt; 0 &amp;&amp; Math.floor(inv/4)%2===0) { ctx.restore(); return; }

  ctx.translate(c.x, c.y);

  const w = c.w, h = c.h;
  const col  = isPlayer ? '#00ddff' : c.color;
  const col2 = isPlayer ? '#0088cc' : darken(c.color);

  // Shadow
  ctx.save();
  ctx.globalAlpha = 0.3;
  ctx.fillStyle = '#000';
  ctx.beginPath();
  ctx.ellipse(3, h*0.5 - 2, w*0.45, h*0.15, 0, 0, Math.PI*2);
  ctx.fill();
  ctx.restore();

  // Body
  ctx.shadowBlur = isPlayer ? 16 : 8;
  ctx.shadowColor = col;
  ctx.fillStyle = col2;
  ctx.beginPath();
  ctx.roundRect(-w/2, -h/2, w, h, 4);
  ctx.fill();

  ctx.fillStyle = col;
  ctx.beginPath();
  ctx.roundRect(-w/2+2, -h/2+3, w-4, h-6, 3);
  ctx.fill();

  // Windshield
  ctx.fillStyle = isPlayer ? '#aaeeff88' : '#ffffff33';
  if (!isPlayer) {
    // Front windshield (top of car since traffic goes down)
    ctx.fillRect(-w/2+4, -h/2+5, w-8, h*0.22);
  } else {
    ctx.fillRect(-w/2+4, -h*0.05, w-8, h*0.2);
  }

  // Headlights / taillights
  if (isPlayer) {
    ctx.shadowBlur = 14;
    ctx.shadowColor = '#ffffff';
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(-w/2+2, -h/2-2, 6, 4);
    ctx.fillRect(w/2-8, -h/2-2, 6, 4);
    // taillights
    ctx.shadowColor = '#ff3333';
    ctx.fillStyle = '#ff3333';
    ctx.fillRect(-w/2+2, h/2-2, 6, 4);
    ctx.fillRect(w/2-8, h/2-2, 6, 4);
  } else {
    ctx.shadowBlur = 10;
    ctx.shadowColor = '#ffcc44';
    ctx.fillStyle = '#ffcc44';
    ctx.fillRect(-w/2+2, -h/2-2, 6, 4);
    ctx.fillRect(w/2-8, -h/2-2, 6, 4);
  }

  // Wheels
  ctx.shadowBlur = 0;
  ctx.fillStyle = '#111';
  [[-w/2-2,-h/2+6],[w/2-4,-h/2+6],[-w/2-2,h/2-10],[w/2-4,h/2-10]].forEach(([wx,wy]) =&gt; {
    ctx.fillRect(wx, wy, 6, 9);
    ctx.fillStyle = '#333';
    ctx.fillRect(wx+1, wy+1, 4, 7);
    ctx.fillStyle = '#111';
  });

  ctx.restore();
}

function darken(hex) {
  try {
    let r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
    return `rgb(${Math.max(0,r-40)},${Math.max(0,g-40)},${Math.max(0,b-40)})`;
  } catch(e) { return hex; }
}

function drawSpeedLines() {
  if (speed &lt; 5) return;
  const alpha = Math.min(1, (speed - 5) / 10) * 0.25;
  ctx.save();
  ctx.globalAlpha = alpha;
  ctx.strokeStyle = '#ffffff';
  ctx.lineWidth = 1;
  for (let i = 0; i &lt; 12; i++) {
    const x = ROAD_LEFT + Math.random() * ROAD_W;
    const y = H * 0.4 + Math.random() * H * 0.5;
    const len = 8 + speed * 3;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x, y + len);
    ctx.stroke();
  }
  ctx.restore();
}

function drawHUD_canvas() {
  // Speed-o-meter bar
  const ratio = Math.min(1, (speed - baseSpeed) / 12);
  if (ratio &gt; 0) {
    const bx = ROAD_LEFT + 4, by = H - 14, bw = ROAD_W - 8, bh = 6;
    ctx.save();
    ctx.fillStyle = '#ffffff11';
    ctx.fillRect(bx, by, bw, bh);
    const grad = ctx.createLinearGradient(bx, 0, bx+bw, 0);
    grad.addColorStop(0, '#00ff99');
    grad.addColorStop(0.6,'#ffcc00');
    grad.addColorStop(1,  '#ff3355');
    ctx.fillStyle = grad;
    ctx.shadowBlur = 6;
    ctx.shadowColor = '#00ff9988';
    ctx.fillRect(bx, by, bw * ratio, bh);
    ctx.restore();
  }
}

// ── HUD (DOM) update ──
function updateHUD() {
  document.getElementById('scoreVal').textContent = score;
  document.getElementById('speedVal').innerHTML = `${Math.round(speed*18)}&lt;span style=&quot;font-size:7px&quot;&gt; km/h&lt;/span&gt;`;
  const h = ['♥','♥','♥'].map((v,i) =&gt; `&lt;span style=&quot;opacity:${i&lt;lives?1:0.15}&quot;&gt;${v}&lt;/span&gt;`).join('');
  document.getElementById('livesVal').innerHTML = h;
}

// ── COLLISION CHECK ──
function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
  return Math.abs(ax-bx) &lt; (aw+bw)*0.45 &amp;&amp; Math.abs(ay-by) &lt; (ah+bh)*0.42;
}

// ── DAMAGE ──
function takeDamage() {
  lives--;
  invincible = 120;
  spawnParticles(player.x, player.y, '#00ddff', 14);
  updateHUD();
  if (lives &lt;= 0) endGame();
}

// ── MAIN LOOP ──
let sceneryOffset = 0;

function gameLoop() {
  frameCount++;
  animId = requestAnimationFrame(gameLoop);
  if (paused) return;

  const isUp    = keys['ArrowUp']    || keys['KeyW'] || mob.up;
  const isDown  = keys['ArrowDown']  || keys['KeyS'] || mob.down;
  const isLeft  = keys['ArrowLeft']  || keys['KeyA'] || mob.left;
  const isRight = keys['ArrowRight'] || keys['KeyD'] || mob.right;

  // Speed control
  if (isUp)   targetSpeed = Math.min(20, targetSpeed + 0.15);
  else if (isDown) targetSpeed = Math.max(1, targetSpeed - 0.25);
  else targetSpeed = Math.max(baseSpeed, targetSpeed - 0.04);

  speed += (targetSpeed - speed) * 0.1;
  baseSpeed = Math.min(12, 3 + frameCount / 1800);

  // Lane change
  if (player.laneTimer &gt; 0) player.laneTimer--;
  if (isLeft  &amp;&amp; player.laneTimer === 0 &amp;&amp; player.lane &gt; 0) {
    player.lane--; player.targetX = LANES[player.lane]; player.laneTimer = 18;
  }
  if (isRight &amp;&amp; player.laneTimer === 0 &amp;&amp; player.lane &lt; 3) {
    player.lane++; player.targetX = LANES[player.lane]; player.laneTimer = 18;
  }
  player.x += (player.targetX - player.x) * 0.2;

  // Scroll
  roadOffset += speed;
  sceneryOffset += speed * 0.7;

  // Score
  if (frameCount % 4 === 0) { score += Math.floor(speed); updateHUD(); }

  // Spawn traffic
  const spawnRate = Math.max(40, 90 - frameCount / 80);
  if (frameCount % Math.floor(spawnRate) === 0) spawnCar();

  // Move traffic
  cars = cars.filter(c =&gt; {
    c.y += (speed - c.speed);
    return c.y &lt; H + 80;
  });

  // Collision
  if (invincible &gt; 0) invincible--;
  if (invincible === 0) {
    cars.forEach(c =&gt; {
      if (rectsOverlap(player.x, player.y, player.w, player.h, c.x, c.y, c.w, c.h)) {
        spawnParticles(player.x, player.y, '#ff3355', 20);
        spawnParticles(c.x, c.y, c.color, 12);
        cars = cars.filter(x =&gt; x !== c);
        takeDamage();
      }
    });
  }

  // Exhaust
  if (frameCount % 3 === 0) {
    spawnExhaust(player.x - 6, player.y + player.h/2);
    spawnExhaust(player.x + 6, player.y + player.h/2);
  }

  // Particles
  particles = particles.filter(p =&gt; {
    p.x += p.vx; p.y += p.vy; p.vy -= 0.04;
    p.life -= p.decay;
    return p.life &gt; 0;
  });

  // ── RENDER ──
  ctx.clearRect(0, 0, W, H);

  drawRoad();
  drawScenery(sceneryOffset);

  // Draw traffic
  cars.forEach(c =&gt; drawCar(c, false, 0));

  // Speed lines
  drawSpeedLines();

  // Particles
  particles.forEach(p =&gt; {
    ctx.save();
    ctx.globalAlpha = p.life;
    ctx.fillStyle = p.color;
    ctx.shadowBlur = 8; ctx.shadowColor = p.color;
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI*2);
    ctx.fill();
    ctx.restore();
  });

  // Player
  drawCar(player, true, invincible);

  // Canvas HUD
  drawHUD_canvas();
}

// ── GAME CONTROL ──
function startGame() {
  score = 0; lives = 3; frameCount = 0;
  speed = 3; targetSpeed = 3; baseSpeed = 3;
  invincible = 0; paused = false;
  cars = []; particles = [];
  player.lane = 1; player.x = LANES[1]; player.targetX = LANES[1];
  player.y = H - 100; player.laneTimer = 0;
  roadOffset = 0; sceneryOffset = 0;
  gameRunning = true;
  updateHUD();
  document.getElementById('overlay').classList.add('hidden');
  if (animId) cancelAnimationFrame(animId);
  gameLoop();
}

function endGame() {
  gameRunning = false;
  cancelAnimationFrame(animId);
  document.getElementById('overlayTitle').textContent = 'GAME OVER';
  document.getElementById('overlaySubtitle').textContent = '다시 도전하시겠습니까?';
  const fs = document.getElementById('finalScore');
  fs.innerHTML = `SCORE&lt;br&gt;${score}&lt;br&gt;&lt;span style=&quot;font-size:9px;color:#cc8866&quot;&gt;MAX SPEED: ${Math.round(speed*18)} km/h&lt;/span&gt;`;
  fs.classList.remove('hidden');
  document.getElementById('startBtn').textContent = 'RETRY';
  document.getElementById('overlay').classList.remove('hidden');
}

function togglePause() {
  paused = !paused;
  if (paused) {
    document.getElementById('overlayTitle').textContent = 'PAUSED';
    document.getElementById('overlaySubtitle').textContent = 'P키로 재개';
    document.getElementById('finalScore').classList.add('hidden');
    document.getElementById('startBtn').textContent = 'RESUME';
    document.getElementById('overlay').classList.remove('hidden');
  } else {
    document.getElementById('overlay').classList.add('hidden');
  }
}

document.getElementById('startBtn').addEventListener('click', () =&gt; {
  if (paused) togglePause();
  else startGame();
});

// ── INITIAL DRAW ──
(function initDraw() {
  const sky = ctx.createLinearGradient(0, 0, 0, H);
  sky.addColorStop(0, '#0d0221');
  sky.addColorStop(1, '#250838');
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, W, H);
  drawRoad();
})();
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</description>
      <category>게임</category>
      <category>ai제작</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/66</guid>
      <comments>https://bbibbabbbi.tistory.com/66#entry66comment</comments>
      <pubDate>Tue, 17 Mar 2026 23:59:00 +0900</pubDate>
    </item>
    <item>
      <title>갤럭시 어썰트</title>
      <link>https://bbibbabbbi.tistory.com/65</link>
      <description>&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;ko&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;UTF-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;title&gt;갤럭시 어썰트&lt;/title&gt;
&lt;style&gt;
  @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&amp;family=Share+Tech+Mono&amp;display=swap');

  * { margin: 0; padding: 0; box-sizing: border-box; }

  body {
    background: #00000f;
    display: flex;
    justify-content: center;
    align-items: flex-start;
    min-height: 100vh;
    padding: 20px 0 40px;
    font-family: 'Share Tech Mono', monospace;
    overflow-y: auto;
    overflow-x: hidden;
  }

  #gameWrapper {
    position: relative;
    width: 480px;
  }

  #header {
    text-align: center;
    padding: 10px 0 6px;
    background: linear-gradient(180deg, #000014 0%, #00000f 100%);
    border: 1px solid #00ffcc33;
    border-bottom: none;
  }

  #header h1 {
    font-family: 'Orbitron', sans-serif;
    font-size: 22px;
    font-weight: 900;
    letter-spacing: 6px;
    background: linear-gradient(90deg, #00ffcc, #ff00aa, #00ffcc);
    background-size: 200% auto;
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    animation: shimmer 3s linear infinite;
  }

  @keyframes shimmer {
    0% { background-position: 0% center; }
    100% { background-position: 200% center; }
  }

  #hud {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 6px 14px;
    background: #000014;
    border-left: 1px solid #00ffcc33;
    border-right: 1px solid #00ffcc33;
    font-size: 12px;
    color: #00ffcc;
    letter-spacing: 2px;
  }

  #lives-display span {
    color: #ff4466;
    font-size: 14px;
    margin-right: 1px;
  }

  #score-display { color: #ffee00; }

  #level-display { color: #aa88ff; }

  canvas {
    display: block;
    border: 1px solid #00ffcc44;
    border-top: none;
    image-rendering: pixelated;
    cursor: crosshair;
  }

  #overlay {
    position: absolute;
    top: 0; left: 0; right: 0; bottom: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    background: rgba(0, 0, 15, 0.88);
    z-index: 10;
    gap: 20px;
    border: 1px solid #00ffcc33;
  }

  #overlay.hidden { display: none; }

  #overlay h2 {
    font-family: 'Orbitron', sans-serif;
    font-size: 32px;
    font-weight: 900;
    color: #00ffcc;
    text-shadow: 0 0 20px #00ffcc, 0 0 40px #00ffcc55;
    letter-spacing: 4px;
  }

  #overlay .subtitle {
    font-size: 13px;
    color: #8899aa;
    letter-spacing: 3px;
    text-align: center;
    line-height: 1.8;
  }

  #overlay .final-score {
    font-size: 20px;
    color: #ffee00;
    text-shadow: 0 0 10px #ffee0088;
    letter-spacing: 3px;
  }

  .btn {
    font-family: 'Orbitron', sans-serif;
    font-size: 14px;
    letter-spacing: 4px;
    padding: 12px 36px;
    background: transparent;
    border: 2px solid #00ffcc;
    color: #00ffcc;
    cursor: pointer;
    text-transform: uppercase;
    position: relative;
    overflow: hidden;
    transition: color 0.2s;
  }

  .btn::before {
    content: '';
    position: absolute;
    inset: 0;
    background: #00ffcc;
    transform: scaleX(0);
    transform-origin: left;
    transition: transform 0.2s;
    z-index: -1;
  }

  .btn:hover::before { transform: scaleX(1); }
  .btn:hover { color: #000014; }

  #controls {
    display: flex;
    justify-content: center;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px 20px;
    padding: 10px 14px;
    background: #000a18;
    border: 1px solid #00ffcc33;
    border-top: 1px solid #00ffcc22;
    font-size: 11px;
    color: #00ccaa;
    letter-spacing: 1.5px;
  }

  #controls .ctrl-item {
    display: flex;
    align-items: center;
    gap: 6px;
    white-space: nowrap;
  }

  #controls kbd {
    display: inline-block;
    background: #001a14;
    border: 1px solid #00ffcc55;
    border-bottom: 2px solid #00ffcc88;
    border-radius: 3px;
    padding: 1px 6px;
    font-family: 'Share Tech Mono', monospace;
    font-size: 11px;
    color: #00ffcc;
    box-shadow: 0 0 6px #00ffcc22;
  }

  #controls .ctrl-sep {
    color: #00ffcc22;
    font-size: 16px;
    line-height: 1;
  }

  /* Mobile Controls */
  #mobileControls {
    display: none;
    justify-content: space-between;
    align-items: center;
    padding: 10px 20px;
    background: #000014;
    border: 1px solid #00ffcc22;
    border-top: none;
    gap: 10px;
  }

  .dpad {
    display: grid;
    grid-template-columns: repeat(3, 44px);
    grid-template-rows: repeat(3, 44px);
    gap: 3px;
  }

  .dpad-btn {
    background: #00ffcc11;
    border: 1px solid #00ffcc44;
    color: #00ffcc;
    font-size: 18px;
    border-radius: 4px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
    touch-action: none;
  }

  .dpad-btn:active { background: #00ffcc33; }
  .dpad-center { grid-column: 2; grid-row: 2; }

  .fire-btn {
    width: 80px;
    height: 80px;
    border-radius: 50%;
    background: #ff004422;
    border: 2px solid #ff4466;
    color: #ff4466;
    font-family: 'Orbitron', sans-serif;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 1px;
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
    touch-action: none;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .fire-btn:active { background: #ff004466; }

  @media (max-width: 500px) {
    #gameWrapper { width: 100vw; }
    canvas { width: 100vw; height: auto; }
    #mobileControls { display: flex; }
  }

  @media (pointer: coarse) {
    #mobileControls { display: flex; }
    #controls { display: none; }
  }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div id=&quot;gameWrapper&quot;&gt;
  &lt;div id=&quot;header&quot;&gt;&lt;h1&gt;갤럭시 어썰트&lt;/h1&gt;&lt;/div&gt;
  &lt;div id=&quot;hud&quot;&gt;
    &lt;div id=&quot;lives-display&quot;&gt;LIFE: &lt;span id=&quot;life1&quot;&gt;♥&lt;/span&gt;&lt;span id=&quot;life2&quot;&gt;♥&lt;/span&gt;&lt;span id=&quot;life3&quot;&gt;♥&lt;/span&gt;&lt;/div&gt;
    &lt;div id=&quot;score-display&quot;&gt;SCORE: &lt;span id=&quot;scoreVal&quot;&gt;0&lt;/span&gt;&lt;/div&gt;
    &lt;div id=&quot;level-display&quot;&gt;LV.&lt;span id=&quot;levelVal&quot;&gt;1&lt;/span&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;canvas id=&quot;c&quot; width=&quot;480&quot; height=&quot;560&quot;&gt;&lt;/canvas&gt;
  &lt;div id=&quot;controls&quot;&gt;
    &lt;span class=&quot;ctrl-item&quot;&gt;&lt;kbd&gt;←&lt;/kbd&gt;&lt;kbd&gt;→&lt;/kbd&gt; 좌우 이동&lt;/span&gt;
    &lt;span class=&quot;ctrl-sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ctrl-item&quot;&gt;&lt;kbd&gt;↑&lt;/kbd&gt;&lt;kbd&gt;↓&lt;/kbd&gt; 상하 이동&lt;/span&gt;
    &lt;span class=&quot;ctrl-sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ctrl-item&quot;&gt;&lt;kbd&gt;SPACE&lt;/kbd&gt; 발사&lt;/span&gt;
    &lt;span class=&quot;ctrl-sep&quot;&gt;|&lt;/span&gt;
    &lt;span class=&quot;ctrl-item&quot;&gt;&lt;kbd&gt;P&lt;/kbd&gt; 일시정지&lt;/span&gt;
  &lt;/div&gt;
  &lt;div id=&quot;mobileControls&quot;&gt;
    &lt;div class=&quot;dpad&quot;&gt;
      &lt;div&gt;&lt;/div&gt;
      &lt;div class=&quot;dpad-btn&quot; id=&quot;btnUp&quot;&gt;▲&lt;/div&gt;
      &lt;div&gt;&lt;/div&gt;
      &lt;div class=&quot;dpad-btn&quot; id=&quot;btnLeft&quot;&gt;◀&lt;/div&gt;
      &lt;div class=&quot;dpad-center&quot;&gt;&lt;/div&gt;
      &lt;div class=&quot;dpad-btn&quot; id=&quot;btnRight&quot;&gt;▶&lt;/div&gt;
      &lt;div&gt;&lt;/div&gt;
      &lt;div class=&quot;dpad-btn&quot; id=&quot;btnDown&quot;&gt;▼&lt;/div&gt;
      &lt;div&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;button class=&quot;fire-btn&quot; id=&quot;btnFire&quot;&gt;FIRE&lt;/button&gt;
  &lt;/div&gt;

  &lt;div id=&quot;overlay&quot;&gt;
    &lt;h2 id=&quot;overlayTitle&quot;&gt;갤럭시 어썰트&lt;/h2&gt;
    &lt;p class=&quot;subtitle&quot; id=&quot;overlaySubtitle&quot;&gt;
      은하계를 침략한 네온 함대를&lt;br&gt;
      격파하라! 지구의 운명이 걸렸다.
    &lt;/p&gt;
    &lt;div class=&quot;final-score hidden&quot; id=&quot;finalScore&quot;&gt;&lt;/div&gt;
    &lt;button class=&quot;btn&quot; id=&quot;startBtn&quot;&gt;START GAME&lt;/button&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = 480, H = 560;

// --- State ---
let score = 0, lives = 3, level = 1;
let gameRunning = false, paused = false;
let animId;

// --- Input ---
const keys = {};
let mobileLeft = false, mobileRight = false, mobileUp = false, mobileDown = false, mobileFire = false;

document.addEventListener('keydown', e =&gt; {
  keys[e.code] = true;
  if (e.code === 'Space') e.preventDefault();
  if (gameRunning &amp;&amp; !paused &amp;&amp; ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code)) e.preventDefault();
  if (e.code === 'KeyP' &amp;&amp; gameRunning) togglePause();
});
document.addEventListener('keyup', e =&gt; keys[e.code] = false);

function holdBtn(el, setTrue) {
  el.addEventListener('touchstart', e =&gt; { e.preventDefault(); setTrue(true); }, { passive: false });
  el.addEventListener('touchend', e =&gt; { e.preventDefault(); setTrue(false); }, { passive: false });
  el.addEventListener('mousedown', () =&gt; setTrue(true));
  el.addEventListener('mouseup', () =&gt; setTrue(false));
  el.addEventListener('mouseleave', () =&gt; setTrue(false));
}

holdBtn(document.getElementById('btnLeft'), v =&gt; mobileLeft = v);
holdBtn(document.getElementById('btnRight'), v =&gt; mobileRight = v);
holdBtn(document.getElementById('btnUp'), v =&gt; mobileUp = v);
holdBtn(document.getElementById('btnDown'), v =&gt; mobileDown = v);
holdBtn(document.getElementById('btnFire'), v =&gt; mobileFire = v);

// --- Particles ---
let particles = [];
function spawnParticles(x, y, color, n = 12) {
  for (let i = 0; i &lt; n; i++) {
    const angle = Math.random() * Math.PI * 2;
    const speed = 1 + Math.random() * 3;
    particles.push({
      x, y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 1,
      decay: 0.03 + Math.random() * 0.04,
      size: 2 + Math.random() * 3,
      color
    });
  }
}

// --- Stars ---
const stars = Array.from({ length: 120 }, () =&gt; ({
  x: Math.random() * W,
  y: Math.random() * H,
  size: Math.random() * 1.5,
  speed: 0.3 + Math.random() * 1.2,
  brightness: 0.3 + Math.random() * 0.7
}));

// --- Player ---
const player = {
  x: W / 2, y: H - 70,
  w: 36, h: 40,
  speed: 4.5,
  shootCooldown: 0,
  shootRate: 14,
  invincible: 0,
  trail: []
};

// --- Bullets ---
let bullets = [];
let enemyBullets = [];

// --- Enemies ---
let enemies = [];
let enemySpawnTimer = 0;
let enemySpawnRate = 80;

// --- Boss ---
let boss = null;
let bossSpawned = false;

// --- HUD update ---
function updateHUD() {
  document.getElementById('scoreVal').textContent = score;
  document.getElementById('levelVal').textContent = level;
  const hearts = ['life1', 'life2', 'life3'];
  hearts.forEach((id, i) =&gt; {
    const el = document.getElementById(id);
    el.style.opacity = i &lt; lives ? '1' : '0.15';
  });
}

// --- Draw helpers ---
function drawNeonRect(x, y, w, h, color, glow = 10) {
  ctx.save();
  ctx.shadowBlur = glow;
  ctx.shadowColor = color;
  ctx.strokeStyle = color;
  ctx.lineWidth = 2;
  ctx.strokeRect(x, y, w, h);
  ctx.restore();
}

function drawPlayer(p) {
  // Trail
  p.trail.forEach((t, i) =&gt; {
    const a = (i / p.trail.length) * 0.3;
    ctx.save();
    ctx.globalAlpha = a;
    ctx.shadowBlur = 8;
    ctx.shadowColor = '#00ffcc';
    ctx.fillStyle = '#00ffcc44';
    ctx.fillRect(t.x - p.w/2, t.y - p.h/2, p.w, p.h);
    ctx.restore();
  });

  if (p.invincible &gt; 0 &amp;&amp; Math.floor(p.invincible / 4) % 2 === 0) return;

  ctx.save();
  ctx.translate(p.x, p.y);
  ctx.shadowBlur = 20;
  ctx.shadowColor = '#00ffcc';

  // Body
  ctx.fillStyle = '#001a14';
  ctx.strokeStyle = '#00ffcc';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(0, -20); ctx.lineTo(18, 18); ctx.lineTo(0, 10); ctx.lineTo(-18, 18); ctx.closePath();
  ctx.fill(); ctx.stroke();

  // Core
  ctx.fillStyle = '#00ffcc';
  ctx.shadowBlur = 12;
  ctx.beginPath();
  ctx.arc(0, 4, 4, 0, Math.PI * 2);
  ctx.fill();

  // Wing accents
  ctx.strokeStyle = '#00ffcc88';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(-14, 14); ctx.lineTo(-8, 2);
  ctx.moveTo(14, 14); ctx.lineTo(8, 2);
  ctx.stroke();

  ctx.restore();
}

function drawBullet(b) {
  ctx.save();
  ctx.shadowBlur = 12;
  ctx.shadowColor = b.color || '#ffee00';
  ctx.fillStyle = b.color || '#ffee00';
  ctx.fillRect(b.x - 2, b.y - 7, 4, 14);
  ctx.restore();
}

const ENEMY_TYPES = [
  { color: '#ff4466', hp: 1, pts: 10, size: 20, speed: 1.2, shootRate: 0 },
  { color: '#ff8800', hp: 2, pts: 20, size: 24, speed: 0.9, shootRate: 0.004 },
  { color: '#aa44ff', hp: 3, pts: 40, size: 28, speed: 0.7, shootRate: 0.008 },
];

function drawEnemy(e) {
  ctx.save();
  ctx.translate(e.x, e.y);
  ctx.shadowBlur = 16;
  ctx.shadowColor = e.color;
  ctx.strokeStyle = e.color;
  ctx.fillStyle = e.color + '22';
  ctx.lineWidth = 2;

  const s = e.size;
  if (e.type === 0) {
    ctx.beginPath();
    ctx.moveTo(0, s); ctx.lineTo(-s, -s * 0.5); ctx.lineTo(0, -s * 0.2);
    ctx.lineTo(s, -s * 0.5); ctx.closePath();
  } else if (e.type === 1) {
    ctx.beginPath();
    ctx.moveTo(0, s); ctx.lineTo(-s, 0); ctx.lineTo(-s * 0.5, -s);
    ctx.lineTo(s * 0.5, -s); ctx.lineTo(s, 0); ctx.closePath();
  } else {
    ctx.beginPath();
    for (let i = 0; i &lt; 6; i++) {
      const a = (i / 6) * Math.PI * 2 - Math.PI / 2;
      const r = i % 2 === 0 ? s : s * 0.5;
      i === 0 ? ctx.moveTo(Math.cos(a) * r, Math.sin(a) * r)
               : ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
    }
    ctx.closePath();
  }
  ctx.fill();
  ctx.stroke();

  // HP bar
  if (e.maxHp &gt; 1) {
    const bw = s * 1.6;
    ctx.fillStyle = '#ffffff22';
    ctx.fillRect(-bw / 2, -s - 8, bw, 4);
    ctx.fillStyle = e.color;
    ctx.fillRect(-bw / 2, -s - 8, bw * (e.hp / e.maxHp), 4);
  }

  ctx.restore();
}

function drawBoss(b) {
  ctx.save();
  ctx.translate(b.x, b.y);

  // Glow pulse
  const pulse = 0.6 + 0.4 * Math.sin(Date.now() * 0.005);

  ctx.shadowBlur = 30 * pulse;
  ctx.shadowColor = '#ff0088';
  ctx.strokeStyle = '#ff0088';
  ctx.fillStyle = '#1a0010';
  ctx.lineWidth = 3;

  // Main body
  ctx.beginPath();
  ctx.rect(-50, -30, 100, 60);
  ctx.fill();
  ctx.stroke();

  // Wings
  ctx.beginPath();
  ctx.moveTo(-50, -10); ctx.lineTo(-80, -30); ctx.lineTo(-80, 20); ctx.lineTo(-50, 20);
  ctx.moveTo(50, -10); ctx.lineTo(80, -30); ctx.lineTo(80, 20); ctx.lineTo(50, 20);
  ctx.fill();
  ctx.stroke();

  // Eye
  ctx.shadowBlur = 20;
  ctx.shadowColor = '#ffee00';
  ctx.fillStyle = '#ffee00';
  ctx.beginPath();
  ctx.arc(0, 0, 12, 0, Math.PI * 2);
  ctx.fill();

  ctx.fillStyle = '#000';
  ctx.beginPath();
  ctx.arc(0, 0, 5, 0, Math.PI * 2);
  ctx.fill();

  // HP bar
  ctx.shadowBlur = 0;
  const bw = 180;
  ctx.fillStyle = '#330011';
  ctx.fillRect(-bw / 2, 42, bw, 8);
  const hpRatio = b.hp / b.maxHp;
  const hpColor = hpRatio &gt; 0.5 ? '#ff0088' : hpRatio &gt; 0.25 ? '#ff8800' : '#ff4400';
  ctx.fillStyle = hpColor;
  ctx.shadowColor = hpColor;
  ctx.shadowBlur = 8;
  ctx.fillRect(-bw / 2, 42, bw * hpRatio, 8);

  ctx.restore();
}

function spawnEnemy() {
  const typeIdx = Math.min(2, Math.floor(Math.random() * (1 + level * 0.5)));
  const type = ENEMY_TYPES[typeIdx];
  enemies.push({
    x: 30 + Math.random() * (W - 60),
    y: -30,
    type: typeIdx,
    color: type.color,
    hp: type.hp + Math.floor(level / 3),
    maxHp: type.hp + Math.floor(level / 3),
    pts: type.pts * level,
    size: type.size,
    speed: type.speed + level * 0.08,
    shootRate: type.shootRate,
    vx: (Math.random() - 0.5) * 0.8,
    angle: 0
  });
}

function spawnBoss() {
  boss = {
    x: W / 2, y: 100,
    hp: 40 + level * 15,
    maxHp: 40 + level * 15,
    vx: 1.5,
    phase: 0,
    shootTimer: 0,
    shootRate: 35
  };
}

function shootPlayerBullet() {
  bullets.push({ x: player.x - 6, y: player.y - 10, vy: -10, color: '#ffee00' });
  bullets.push({ x: player.x + 6, y: player.y - 10, vy: -10, color: '#ffee00' });
}

// --- Main game loop ---
let frameCount = 0;
let lastLevel = 0;

function gameLoop() {
  frameCount++;
  animId = requestAnimationFrame(gameLoop);
  if (paused) return;

  // Scroll stars
  stars.forEach(s =&gt; {
    s.y += s.speed;
    if (s.y &gt; H) { s.y = 0; s.x = Math.random() * W; }
  });

  // Player move
  const isLeft = keys['ArrowLeft'] || keys['KeyA'] || mobileLeft;
  const isRight = keys['ArrowRight'] || keys['KeyD'] || mobileRight;
  const isUp = keys['ArrowUp'] || keys['KeyW'] || mobileUp;
  const isDown = keys['ArrowDown'] || keys['KeyS'] || mobileDown;
  const isFire = keys['Space'] || mobileFire;

  player.trail.unshift({ x: player.x, y: player.y });
  if (player.trail.length &gt; 8) player.trail.pop();

  if (isLeft) player.x = Math.max(player.w / 2, player.x - player.speed);
  if (isRight) player.x = Math.min(W - player.w / 2, player.x + player.speed);
  if (isUp) player.y = Math.max(player.h / 2 + 50, player.y - player.speed);
  if (isDown) player.y = Math.min(H - player.h / 2, player.y + player.speed);

  // Shoot
  if (player.shootCooldown &gt; 0) player.shootCooldown--;
  if (isFire &amp;&amp; player.shootCooldown === 0) {
    shootPlayerBullet();
    player.shootCooldown = Math.max(8, player.shootRate - level);
  }

  if (player.invincible &gt; 0) player.invincible--;

  // Level up
  const newLevel = 1 + Math.floor(score / 500);
  if (newLevel !== level) { level = newLevel; updateHUD(); }

  // Boss spawn
  if (level &gt; lastLevel &amp;&amp; level % 3 === 0 &amp;&amp; !bossSpawned &amp;&amp; !boss) {
    bossSpawned = true;
    enemies = [];
    spawnBoss();
    lastLevel = level;
  }

  // Enemy spawn
  enemySpawnRate = Math.max(30, 80 - level * 5);
  if (!boss &amp;&amp; frameCount % enemySpawnRate === 0) spawnEnemy();

  // Update enemies
  enemies = enemies.filter(e =&gt; {
    e.y += e.speed;
    e.x += e.vx;
    if (e.x &lt; e.size || e.x &gt; W - e.size) e.vx *= -1;
    e.angle += 0.02;

    // Enemy shoot
    if (e.shootRate &gt; 0 &amp;&amp; Math.random() &lt; e.shootRate * (1 + level * 0.2)) {
      const dx = player.x - e.x, dy = player.y - e.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      enemyBullets.push({
        x: e.x, y: e.y,
        vx: (dx / dist) * 3.5,
        vy: (dy / dist) * 3.5,
        color: e.color
      });
    }

    // Reached bottom
    if (e.y &gt; H + 40) {
      takeDamage();
      return false;
    }
    return e.hp &gt; 0;
  });

  // Update boss
  if (boss) {
    boss.x += boss.vx;
    if (boss.x &lt; 90 || boss.x &gt; W - 90) boss.vx *= -1;

    boss.shootTimer++;
    if (boss.shootTimer &gt;= boss.shootRate) {
      boss.shootTimer = 0;
      for (let i = 0; i &lt; 5; i++) {
        const angle = -Math.PI / 2 + (i - 2) * 0.3;
        enemyBullets.push({
          x: boss.x, y: boss.y + 30,
          vx: Math.cos(angle) * 3.5,
          vy: Math.sin(angle) * 3.5,
          color: '#ff00aa'
        });
      }
      // Spread pattern at low HP
      if (boss.hp &lt; boss.maxHp / 2) {
        for (let i = 0; i &lt; 8; i++) {
          const angle = (i / 8) * Math.PI * 2;
          enemyBullets.push({
            x: boss.x, y: boss.y,
            vx: Math.cos(angle) * 2,
            vy: Math.sin(angle) * 2,
            color: '#ff8800'
          });
        }
      }
    }

    if (boss.hp &lt;= 0) {
      spawnParticles(boss.x, boss.y, '#ff00aa', 40);
      spawnParticles(boss.x, boss.y, '#ffee00', 30);
      score += 500 * level;
      boss = null;
      bossSpawned = false;
      updateHUD();
    }
  }

  // Update player bullets
  bullets = bullets.filter(b =&gt; {
    b.y += b.vy;

    // Hit enemies
    let hit = false;
    enemies.forEach(e =&gt; {
      if (!hit &amp;&amp; Math.abs(b.x - e.x) &lt; e.size &amp;&amp; Math.abs(b.y - e.y) &lt; e.size) {
        e.hp--;
        spawnParticles(b.x, b.y, e.color, 5);
        if (e.hp &lt;= 0) {
          score += e.pts;
          spawnParticles(e.x, e.y, e.color, 16);
          updateHUD();
        }
        hit = true;
      }
    });

    // Hit boss
    if (!hit &amp;&amp; boss) {
      if (Math.abs(b.x - boss.x) &lt; 85 &amp;&amp; Math.abs(b.y - boss.y) &lt; 40) {
        boss.hp--;
        spawnParticles(b.x, b.y, '#ff0088', 4);
        hit = true;
      }
    }

    return !hit &amp;&amp; b.y &gt; -10;
  });

  // Update enemy bullets
  enemyBullets = enemyBullets.filter(b =&gt; {
    b.x += b.vx; b.y += b.vy;
    if (player.invincible === 0) {
      if (Math.abs(b.x - player.x) &lt; 16 &amp;&amp; Math.abs(b.y - player.y) &lt; 20) {
        takeDamage();
        return false;
      }
    }
    return b.x &gt; 0 &amp;&amp; b.x &lt; W &amp;&amp; b.y &gt; 0 &amp;&amp; b.y &lt; H;
  });

  // Player-enemy collision
  if (player.invincible === 0) {
    enemies.forEach(e =&gt; {
      if (Math.abs(player.x - e.x) &lt; (16 + e.size * 0.6) &amp;&amp;
          Math.abs(player.y - e.y) &lt; (20 + e.size * 0.6)) {
        e.hp = 0;
        spawnParticles(e.x, e.y, e.color, 12);
        takeDamage();
      }
    });
  }

  // Update particles
  particles = particles.filter(p =&gt; {
    p.x += p.vx; p.y += p.vy;
    p.vy += 0.05;
    p.life -= p.decay;
    return p.life &gt; 0;
  });

  // --- Draw ---
  ctx.fillStyle = '#00000f';
  ctx.fillRect(0, 0, W, H);

  // Grid BG
  ctx.save();
  ctx.strokeStyle = '#00ffcc08';
  ctx.lineWidth = 1;
  for (let x = 0; x &lt; W; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
  for (let y = 0; y &lt; H; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
  ctx.restore();

  // Stars
  stars.forEach(s =&gt; {
    ctx.save();
    ctx.globalAlpha = s.brightness;
    ctx.fillStyle = '#ffffff';
    ctx.shadowBlur = 4;
    ctx.shadowColor = '#88bbff';
    ctx.fillRect(s.x, s.y, s.size, s.size);
    ctx.restore();
  });

  // Particles
  particles.forEach(p =&gt; {
    ctx.save();
    ctx.globalAlpha = p.life;
    ctx.fillStyle = p.color;
    ctx.shadowBlur = 8;
    ctx.shadowColor = p.color;
    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  });

  // Enemies
  enemies.forEach(drawEnemy);

  // Boss
  if (boss) drawBoss(boss);

  // Bullets
  bullets.forEach(drawBullet);
  enemyBullets.forEach(b =&gt; {
    ctx.save();
    ctx.shadowBlur = 10;
    ctx.shadowColor = b.color;
    ctx.fillStyle = b.color;
    ctx.beginPath();
    ctx.arc(b.x, b.y, 3, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  });

  // Player
  drawPlayer(player);

  // Boss warning
  if (bossSpawned &amp;&amp; boss &amp;&amp; boss.hp === boss.maxHp) {
    ctx.save();
    ctx.font = 'bold 20px Orbitron, monospace';
    ctx.textAlign = 'center';
    ctx.fillStyle = `rgba(255,0,136,${0.5 + 0.5 * Math.sin(Date.now() * 0.01)})`;
    ctx.shadowBlur = 20;
    ctx.shadowColor = '#ff0088';
    ctx.fillText('⚠ BOSS INCOMING ⚠', W / 2, H / 2);
    ctx.restore();
  }
}

function takeDamage() {
  lives--;
  player.invincible = 90;
  spawnParticles(player.x, player.y, '#00ffcc', 10);
  updateHUD();
  if (lives &lt;= 0) endGame();
}

function togglePause() {
  paused = !paused;
  if (paused) {
    const ov = document.getElementById('overlay');
    document.getElementById('overlayTitle').textContent = 'PAUSED';
    document.getElementById('overlaySubtitle').textContent = 'P키 또는 버튼으로 재개';
    document.getElementById('finalScore').classList.add('hidden');
    document.getElementById('startBtn').textContent = 'RESUME';
    ov.classList.remove('hidden');
  } else {
    document.getElementById('overlay').classList.add('hidden');
  }
}

function startGame() {
  score = 0; lives = 3; level = 1; frameCount = 0; lastLevel = 0;
  bossSpawned = false; boss = null;
  enemies = []; bullets = []; enemyBullets = []; particles = [];
  player.x = W / 2; player.y = H - 70;
  player.trail = []; player.invincible = 0; player.shootCooldown = 0;
  paused = false;
  gameRunning = true;
  updateHUD();
  document.getElementById('overlay').classList.add('hidden');
  document.getElementById('startBtn').textContent = 'START GAME';
  if (animId) cancelAnimationFrame(animId);
  gameLoop();
}

function endGame() {
  gameRunning = false;
  cancelAnimationFrame(animId);
  const ov = document.getElementById('overlay');
  document.getElementById('overlayTitle').textContent = 'GAME OVER';
  document.getElementById('overlaySubtitle').textContent = '지구는 함락되었다...';
  const fs = document.getElementById('finalScore');
  fs.textContent = `FINAL SCORE: ${score}`;
  fs.classList.remove('hidden');
  document.getElementById('startBtn').textContent = 'PLAY AGAIN';
  ov.classList.remove('hidden');
}

// Prevent page scroll with wheel only when hovering canvas during active game
let mouseOverCanvas = false;
canvas.addEventListener('mouseenter', () =&gt; mouseOverCanvas = true);
canvas.addEventListener('mouseleave', () =&gt; mouseOverCanvas = false);

window.addEventListener('wheel', e =&gt; {
  if (mouseOverCanvas &amp;&amp; gameRunning &amp;&amp; !paused) {
    e.preventDefault();
  }
}, { passive: false });

document.getElementById('startBtn').addEventListener('click', () =&gt; {
  if (paused) togglePause();
  else startGame();
});

// Initial draw
ctx.fillStyle = '#00000f';
ctx.fillRect(0, 0, W, H);
stars.forEach(s =&gt; {
  ctx.globalAlpha = s.brightness;
  ctx.fillStyle = '#fff';
  ctx.fillRect(s.x, s.y, s.size, s.size);
});
ctx.globalAlpha = 1;
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;</description>
      <category>게임</category>
      <category>ai 제작</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/65</guid>
      <comments>https://bbibbabbbi.tistory.com/65#entry65comment</comments>
      <pubDate>Sat, 21 Feb 2026 07:20:19 +0900</pubDate>
    </item>
    <item>
      <title>가라데카 Karateka (Apple II, 1984) 리뷰 &amp;ndash; 시네마틱 연출의 원조 액션</title>
      <link>https://bbibbabbbi.tistory.com/63</link>
      <description>&lt;h1&gt;가라데카 Karateka (Apple II, 1984) 리뷰 &amp;ndash; 시네마틱 연출의 원조 액션&lt;/h1&gt;
&lt;!-- 상단 이미지: Apple II 인게임(아쿠마 전투 장면) --&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  이미지&lt;/h2&gt;
&lt;figure style=&quot;text-align: center; margin: 20px 0;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #ddd; border-radius: 8px;&quot; src=&quot;https://upload.wikimedia.org/wikipedia/en/thumb/2/24/Karateka_Fighting_Akuma.png/250px-Karateka_Fighting_Akuma.png&quot; alt=&quot;Karateka (Apple II)에서 아쿠마와의 대결 장면&quot; /&gt;
&lt;figcaption style=&quot;font-size: 0.9em; color: #666; margin-top: 6px;&quot;&gt;Apple II 인게임 스크린샷 &amp;mdash; 출처:&lt;a href=&quot;https://en.wikipedia.org/wiki/Karateka_(video_game)&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Wikipedia&lt;/a&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  게임 개요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개발/디자인:&lt;/b&gt; Jordan Mechner &amp;nbsp;|&amp;nbsp; &lt;b&gt;배급:&lt;/b&gt; Br&amp;oslash;derbund (NA), Ariolasoft (EU) &amp;nbsp;|&amp;nbsp; &lt;b&gt;출시:&lt;/b&gt; 1984 (Apple II) &amp;nbsp;|&amp;nbsp; &lt;b&gt;장르:&lt;/b&gt; 액션/격투 &amp;nbsp;|&amp;nbsp; &lt;b&gt;모드:&lt;/b&gt; 1인용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;Karateka&lt;/i&gt;는 좌&amp;rarr;우로 전진하며 적과 일대일로 맞서는 진행과 컷신 중심의 시네마틱 연출, &lt;b&gt;로토스코핑&lt;/b&gt;으로 구현된 부드러운 애니메이션으로 유명합니다. 주인공은 성채를 돌파해 &lt;i&gt;마리코&lt;/i&gt;를 구출해야 하며, 마지막 방에서 &lt;b&gt;전투 자세로 접근하면 마리코의 발차기에 즉사&lt;/b&gt;하는 연출로도 잘 알려져 있죠.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  게임플레이 특징&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;좌&amp;rarr;우 진행형 스테이지 + 적과의 일대일 대치(펀치/킥/가드/거리 조절)&lt;/li&gt;
&lt;li&gt;플레이어&amp;middot;적 체력 바가 화면 하단에서 서서히 회복되는 시스템&lt;/li&gt;
&lt;li&gt;맵 곳곳의 낙사&amp;middot;쇠창문 등 즉사 함정, 매복 매&amp;middot;수리검 같은 변칙 요소&lt;/li&gt;
&lt;li&gt;컷신으로 이어지는 &amp;lsquo;영화 같은&amp;rsquo; 흐름 &amp;mdash; 당시 Apple II 한계에서 돋보인 사운드&amp;middot;연출&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  장단점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;장점:&lt;/b&gt; 훌륭한 애니메이션과 시네마틱 구성, 직관적인 일대일 전투, 상징적인 연출(마지막 방 연출 등)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단점:&lt;/b&gt; 반복되는 적 패턴과 짧은 러닝타임, 즉사 함정으로 인한 체감 난이도 상승&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  플레이 영상&lt;/h2&gt;
&lt;p&gt;&lt;iframe src=&quot;https://www.youtube.com/embed/NUvJPaQiipY&quot; width=&quot;560&quot; height=&quot;315&quot; frameborder=&quot;0&quot; allowfullscreen=&quot;&quot;&gt;
  &lt;/iframe&gt;&lt;/p&gt;
&lt;p style=&quot;font-size: 0.9em; color: #666;&quot; data-ke-size=&quot;size16&quot;&gt;출처: &lt;a href=&quot;https://www.youtube.com/watch?v=NUvJPaQiipY&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt; YouTube &amp;ndash; [hirudov ]Apple II Longplay: Karateka &lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  한줄 평&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&amp;ldquo;애니메이션과 연출로 &amp;lsquo;게임은 영화처럼&amp;rsquo;이라는 상상을 현실로 만든 초창기 명작.&amp;rdquo;&lt;/blockquote&gt;</description>
      <category>게임리뷰</category>
      <category>appleii</category>
      <category>Broderbund</category>
      <category>JordanMechner</category>
      <category>KARATEKA</category>
      <category>가라데카</category>
      <category>고전게임</category>
      <category>레트로게임</category>
      <category>액션게임</category>
      <author>bbibbabbbi</author>
      <guid isPermaLink="true">https://bbibbabbbi.tistory.com/63</guid>
      <comments>https://bbibbabbbi.tistory.com/63#entry63comment</comments>
      <pubDate>Mon, 10 Nov 2025 12:52:43 +0900</pubDate>
    </item>
  </channel>
</rss>