絵文字で絵を描く「Emoji Map」

【更新履歴】

 ・2026/2/17 バージョン1.0公開。
 ・2026/2/17 バージョン1.1公開。
 
・2026/2/17 バージョン1.2公開。
 ・2026/2/23 バージョン1.3公開。

画像
絵文字マップの画面


画像
絵文字ローグの画面

・ダウンロードされる方はこちら。↓

🎨 Emoji Map 説明書

・このツールでは、絵文字を使って
 絵を書くことができます。

・最近のAIでは、
 画像を添付して、AIに分析してもらうこともできますが、
 テキストで画面のレイアウト図を書いて送信する方が
 わかりやすくていい場合もあるということで作りました。
 
・作成したデータはテキストファイルとして保存され、
 SNSや他のドキュメントに貼り付けて使用できます。

  1. 基本操作(ツールバー)

 ・画面上部のアイコンを選択して操作を切り替えます。

  • ✏️ ペン:
   キャンバスをクリックまたはドラッグして、
   現在選択中の絵文字を描画します。
 
  • 🧽 消しゴム:
   絵文字を消去し、空白(全角スペース)に戻します。
 
  • 🎨 塗りつぶし:
   クリックした場所と繋がっている同色の領域を、
   現在の絵文字で塗りつぶします。
 
  • 🧪 スポイト:
   キャンバス上の絵文字をクリックして、
   その絵文字を「現在のペン」として取得します。
 
  • ⛶ 選択:
   四角い範囲を選択します。

   範囲の移動:
    選択範囲の内側をドラッグすると、
    絵を切り取って移動できます。
   
   確定:
    選択範囲の外側をクリックするか、
    ツールを切り替えると移動が確定されます。
   
   コピー/カット:
    範囲選択中にメニューの「編集」
    またはショートカットキーで行えます。

 • ➕ / ➖ 拡大縮小:
  キャンバスの表示倍率を変更します。

  1. パレット操作(左パネル)

  • 絵文字を選ぶ:
   クリックするとペンに設定されます。
  
  • 絵文字の変更:
   パレット上の絵文字をダブルクリックすると、
   入力ダイアログが開きます。

   好きな絵文字(1文字)を入力して変更できます。
   変更後はすぐにペンに反映されます。
  
  • 並べ替え:
   絵文字をドラッグ&ドロップすると、
   順番を入れ替えることができます。
  
  • カテゴリ切替:
   上部のプルダウンからカテゴリを切り替えます。
   「➕ 新規カテゴリ...」で独自のカテゴリを作成できます。
  
  • 追加 / 初期化:
   下部のボタンで、現在のカテゴリに新しい絵文字を追加したり、
   パレット全体を初期状態に戻したりできます。
  
  • 自動保存:
   パレットの状態はブラウザに自動保存され、
   次回起動時にも維持されます。

  1. ファイルの保存と読み込み

 ・メニューバーの「ファイル」から行います。
 
  • 新規作成:
   キャンバスを初期化します。

  • 開く (Ctrl+O):
   パソコン内のテキストファイル(.txt)を選択して読み込みます。

  • 上書き保存 (Ctrl+S):
   
   ○ 一度「名前を付けて保存」したファイル、
    または「開く」で開いたファイルに対しては、
    ダイアログを出さずに上書き保存します(Chrome/Edge等)。
   
   ○ 保存先が未定の場合は、保存ダイアログが開きます。

  • 名前を付けて保存 (Ctrl+Shift+S):
   常に保存場所とファイル名を指定して保存します。

  • サイズ変更:
   キャンバスの上下左右にマスを追加、
   または削除(トリミング)します。

  1. 便利なショートカットキー

 キー操作   機能
 Ctrl + Z   元に戻す (Undo)
 Ctrl + Y   やり直す (Redo)
 Ctrl + S   上書き保存
 Ctrl + Shift + S 名前を付けて保存
 Ctrl + O   ファイルを開く

  1. 推奨環境・注意点

  • ブラウザ:
   Google Chrome, Microsoft Edge (PC版) を推奨します。
   これらのブラウザでは「上書き保存」機能が快適に動作します。
   (File System Access API対応のため)

  • その他:
   FirefoxやSafari、スマホブラウザでは、
   セキュリティの制限により「上書き保存」を押しても
   毎回「ダウンロード(別名保存)」のような挙動になる
   場合があります。

  • スマホ操作:
   タッチ操作に対応しています。
   ピンチイン・アウトでの拡大縮小は
   ブラウザの機能に依存するため、
   +-ボタンを使用してください。

・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Emoji Map 1.2</title>
<style>
  :root {
    --bg-app: #e0e0e0;
    --bg-panel: #f5f5f5;
    --border: #ccc;
    --accent: #4a90e2;
    --accent-hover: #357abd;
    --text: #333;
    --menu-bg: #333;
    --menu-text: #fff;
    --menu-hover: #555;
  }
  body {
    margin: 0;
    padding: 0;
    font-family: "Segoe UI", sans-serif;
    background: var(--bg-app);
    height: 100vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
    user-select: none;
    -webkit-user-select: none;
  }

  /* --- メニューバー (v1.1の記述を参考に修正) --- */
  #menubar {
    background: var(--menu-bg);
    color: var(--menu-text);
    display: flex;
    height: 30px;
    align-items: center;
    font-size: 13px;
    position: relative;
    z-index: 100;
  }
  
  .menu-group { 
    position: relative; 
  }
  
  .menu-title { padding: 0 15px; line-height: 30px; cursor: pointer; }
  .menu-title:hover { background: var(--menu-hover); }
  
  .dropdown {
    display: none;
    position: absolute;
    top: 30px; left: 0;
    background: #fff; color: #000;
    border: 1px solid #ccc;
    box-shadow: 2px 2px 5px rgba(0,0,0,0.2);
    min-width: 220px;
    flex-direction: column;
    text-align: left;
  }
  .menu-group:hover .dropdown { display: flex; }
  
  .menu-item { padding: 8px 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
  .menu-item:hover { background: #eee; }
  .menu-check { width: 12px; margin-right: 4px; color: var(--accent); font-weight: bold; flex-shrink: 0; }
  .menu-item .menu-text { flex: 1; }

  .shortcut { color: #888; font-size: 0.9em; margin-left: 10px; }
  .menu-sep { height: 1px; background: #ddd; margin: 2px 0; }

  /* --- ツールバー --- */
  #toolbar {
    background: var(--bg-panel);
    border-bottom: 1px solid var(--border);
    padding: 5px;
    display: flex;
    gap: 5px;
    align-items: center;
    flex-wrap: wrap;
    min-height: 44px;
    justify-content: flex-start;
  }
  .tool-group {
    display: flex; gap: 2px; align-items: center;
    border-right: 1px solid #ccc; padding-right: 5px; margin-right: 5px;
  }
  .tool-group:last-child { border: none; }

  .tool-btn {
    width: 40px; height: 40px;
    border: 1px solid #aaa; background: white; border-radius: 4px;
    cursor: pointer; display: flex; flex-direction: column;
    justify-content: center; align-items: center;
    font-size: 18px; position: relative; transition: background 0.1s;
  }
  .tool-btn span { font-size: 9px; margin-top: -2px; }
  .tool-btn:active { transform: translateY(1px); }
  .tool-btn.active {
    background: #d0e8ff; border-color: var(--accent);
    box-shadow: inset 0 0 3px rgba(0,0,0,0.3);
  }
  
  /* レイヤー切り替え用ラジオ風ボタン */
  .layer-switch {
    display: flex; flex-direction: column; gap: 2px;
  }
  .layer-btn {
    font-size: 11px; padding: 2px 8px; border: 1px solid #999;
    background: #fff; cursor: pointer; border-radius: 3px; text-align: left;
    width: 80px; display: flex; align-items: center; justify-content: space-between;
  }
  .layer-btn.active { background: var(--accent); color: white; border-color: #2a5a8e; }
  .layer-btn:hover:not(.active) { background: #eee; }

  #current-icon-wrapper {
    display: flex; align-items: center; gap: 5px; padding: 0 10px;
    background: #fff; border: 1px solid #ccc; border-radius: 4px; height: 40px;
  }
  #current-emoji-display { font-size: 32px; line-height: 1; width: 40px; text-align: center; }

  /* --- メインレイアウト --- */
  #main-area { display: flex; flex: 1; overflow: hidden; }

  /* --- 左パネル(パレット) --- */
  #left-panel {
    width: 260px; background: var(--bg-panel);
    border-right: 1px solid var(--border);
    display: flex; flex-direction: column; z-index: 5;
  }
  #palette-controls {
    padding: 8px; background: #eee; border-bottom: 1px solid #ccc;
    display: flex; flex-direction: column; gap: 5px;
  }
  select#category-select {
    width: 100%; padding: 5px; font-size: 14px;
    border-radius: 4px; border: 1px solid #999;
  }

  #palette-grid {
    flex: 1; padding: 8px; overflow-y: auto;
    display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
    gap: 4px; align-content: start;
  }
  .emoji-cell {
    width: 40px; height: 40px; font-size: 28px;
    background: white; border: 1px solid #ddd; border-radius: 4px;
    display: flex; justify-content: center; align-items: center;
    cursor: pointer; user-select: none; touch-action: none;
  }
  .emoji-cell:hover { background: #f0f0f0; }
  .emoji-cell.selected { border: 2px solid var(--accent); background: #eef6ff; }
  .emoji-cell.dragging { opacity: 0.5; border: 2px dashed #333; }

  /* --- キャンバスエリア --- */
  #canvas-wrapper {
    flex: 1; position: relative; background: #888; overflow: hidden;
  }
  #scroll-container {
    width: 100%; height: 100%; overflow: auto;
    display: flex;
    background-color: #ccc; 
  }
  canvas {
    margin: auto;
    background: #ffffff;
    box-shadow: 0 0 10px rgba(0,0,0,0.5);
    cursor: crosshair;
    image-rendering: pixelated;
    display: block;
  }

  /* --- モーダル --- */
  #overlay {
    position: fixed; top:0; left:0; right:0; bottom:0;
    background: rgba(0,0,0,0.5);
    display: none;
    z-index: 2000; justify-content: center; align-items: center;
  }
  .dialog {
    background: white; padding: 20px; border-radius: 6px;
    width: 320px; box-shadow: 0 10px 25px rgba(0,0,0,0.5);
    display: flex; flex-direction: column; gap: 15px;
  }
  .dialog h3 { margin: 0 0 5px 0; border-bottom: 1px solid #eee; padding-bottom: 5px;}
  .dialog-buttons { text-align: right; margin-top: 10px; }
  .dialog-buttons button { padding: 6px 12px; cursor: pointer; }
  
  .resize-grid {
    display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: center;
  }
  .resize-inputs label { display: block; margin-top: 5px; }
  .resize-inputs input { width: 50px; padding: 4px; }

  @media (max-width: 600px) {
    #left-panel { width: 80px; }
    #palette-grid { grid-template-columns: 1fr; }
    .emoji-cell { width: 100%; height: 50px; font-size: 30px; }
    .tool-btn { width: 44px; height: 44px; }
    #menubar { font-size: 11px; }
    .menu-title { padding: 0 8px; }
    #current-icon-wrapper { display: none; }
    .layer-btn { width: 40px; font-size: 9px; justify-content: center; }
  }
</style>
</head>
<body>

<input type="file" id="file-input-project" accept=".json,.txt" style="display:none;">
<input type="file" id="file-input-palette" accept=".json" style="display:none;">

<div id="menubar">
  <div class="menu-group">
    <div class="menu-title">ファイル</div>
    <div class="dropdown">
      <div class="menu-item" onclick="app.newFile()">新規作成</div>
      <div class="menu-item" onclick="app.openFileAction()">開く... <span class="shortcut">Ctrl+O</span></div>
      <div class="menu-item" onclick="app.saveFileAction()">上書き保存 <span class="shortcut">Ctrl+S</span></div>
      <div class="menu-item" onclick="app.saveAsFileAction()">名前を付けて保存... <span class="shortcut">Ctrl+Shift+S</span></div>
      <div class="menu-sep"></div>
      <div class="menu-item" onclick="app.loadPaletteAction()">パレットの読み込み...</div>
      <div class="menu-item" onclick="app.savePaletteAction()">パレットの保存...</div>
      <div class="menu-sep"></div>
      <div class="menu-item" onclick="app.showResizeDialog()">サイズ変更...</div>
      <div class="menu-sep"></div>
      <div class="menu-item" onclick="app.exportTextAction()">テキスト出力 (Clip)</div>
      <div class="menu-item" onclick="app.exportImageAction()">画像出力 (File)</div>
    </div>
  </div>
  <div class="menu-group">
    <div class="menu-title">編集</div>
    <div class="dropdown">
      <div class="menu-item" onclick="app.undo()">元に戻す <span class="shortcut">Ctrl+Z</span></div>
      <div class="menu-item" onclick="app.redo()">やり直す <span class="shortcut">Ctrl+Y</span></div>
      <div class="menu-sep"></div>
      <div class="menu-item" onclick="app.editAction('cut')">カット</div>
      <div class="menu-item" onclick="app.editAction('copy')">コピー</div>
      <div class="menu-item" onclick="app.editAction('paste')">ペースト</div>
      <div class="menu-sep"></div>
      <div class="menu-item" onclick="app.editAction('selectAll')">すべて選択</div>
      <div class="menu-item" onclick="app.editAction('deselect')">選択解除</div>
    </div>
  </div>
  <div class="menu-group">
    <div class="menu-title">表示</div>
    <div class="dropdown">
      <div class="menu-item" onclick="app.toggleGrid()">グリッド表示切替</div>
      <div class="menu-sep"></div>
      <div class="menu-item" id="menu-view-char" onclick="app.toggleLayerVisible(1)"><span class="menu-check" id="check-view-char"></span><span class="menu-text">キャラクターを表示</span></div>
      <div class="menu-item" id="menu-view-bg" onclick="app.toggleLayerVisible(0)"><span class="menu-check" id="check-view-bg"></span><span class="menu-text">背景を表示</span></div>
    </div>
  </div>
</div>

<div id="toolbar">
  <div class="tool-group">
    <button class="tool-btn active" id="btn-pen" onclick="app.setTool('pen')">✏️<span>ペン</span></button>
    <button class="tool-btn" id="btn-eraser" onclick="app.setTool('eraser')">🧽<span>消しゴム</span></button>
    <button class="tool-btn" id="btn-fill" onclick="app.setTool('fill')">🎨<span>塗り</span></button>
    <button class="tool-btn" id="btn-dropper" onclick="app.setTool('dropper')">🧪<span>スポイト</span></button>
  </div>
  
  <div class="tool-group">
    <button class="tool-btn" id="btn-select" onclick="app.setTool('select')">⛶<span>選択</span></button>
  </div>

  <div class="tool-group">
    <div class="layer-switch">
      <div class="layer-btn" id="btn-layer-1" onclick="app.setActiveLayer(1)">
        <span>👤 キャラ</span> <span id="ind-layer-1">●</span>
      </div>
      <div class="layer-btn" id="btn-layer-0" onclick="app.setActiveLayer(0)">
        <span>🏞️ 背景</span> <span id="ind-layer-0">●</span>
      </div>
    </div>
  </div>

  <div class="tool-group">
    <button class="tool-btn" onclick="app.zoomIn()">➕<span>拡大</span></button>
    <button class="tool-btn" onclick="app.zoomOut()">➖<span>縮小</span></button>
  </div>

  <div id="current-icon-wrapper">
    <div id="current-emoji-display">⬛</div>
    <div style="font-size: 10px; color: #666; display:flex; flex-direction:column; line-height:1.2;">
      <span>Current</span>
      <span>Color</span>
    </div>
  </div>
</div>

<div id="main-area">
  <div id="left-panel">
    <div id="palette-controls">
      <select id="category-select" onchange="app.changeCategory(this.value)"></select>
      <div style="display:flex; justify-content:space-between;">
        <button onclick="app.addEmojiToPalette()" style="font-size:11px;">+追加</button>
        <button onclick="app.resetPalette()" style="font-size:11px;">リセット</button>
      </div>
    </div>
    <div id="palette-grid"></div>
  </div>

  <div id="canvas-wrapper">
    <div id="scroll-container">
      <canvas id="editor-canvas"></canvas>
    </div>
  </div>
</div>

<div id="overlay">
  <div id="resize-dialog" class="dialog" style="display:none;">
    <h3>サイズ変更</h3>
    <div class="resize-grid">
      <div class="resize-inputs">
        <label>上に追加: <input type="number" id="rs-top" value="0"></label>
        <label>下に追加: <input type="number" id="rs-bottom" value="0"></label>
      </div>
      <div class="resize-inputs">
        <label>左に追加: <input type="number" id="rs-left" value="0"></label>
        <label>右に追加: <input type="number" id="rs-right" value="0"></label>
      </div>
    </div>
    <p style="font-size:12px; color:#666;">※マイナス値でトリミング</p>
    <div class="dialog-buttons">
      <button onclick="app.closeDialog()">キャンセル</button>
      <button onclick="app.applyResize()">適用</button>
    </div>
  </div>
</div>

<script>
/**
 * Emoji Map Studio v5.0 (Final Fix)
 */
const SPACE = ' ';
// 古いキャッシュの影響を完全に避けるためキーを更新
const PALETTE_KEY = 'emoji_palette_v5_final.json'; 

class EmojiApp {
  constructor() {
    this.width = 16;
    this.height = 16;
    this.zoom = 1.0;
    this.tool = 'pen';
    this.currentEmoji = '⬛';
    this.showGrid = true;
    this.isDrawing = false;
    this.history = [];
    this.historyIndex = -1;
    this.selection = null;
    this.fileHandle = null; 
    
    // Layers: 0=Background, 1=Character
    this.layers = []; 
    this.activeLayerIndex = 1; // Default to Character

    this.categories = {
      "枠線": [
        "─", "│", "┌", "┐", "└", "┘", "├", "┤", "┬", "┴", "┼",
        "━", "┃", "┏", "┓", "┗", "┛", "┣", "┫", "┳", "┻", "╋",
        "═", "║", "╔", "╗", "╚", "╝", "╠", "╣", "╦", "╩", "╬",
        "╒", "╓", "╕", "╖", "╘", "╙", "╛", "╜", "╞", "╟", "╡", "╢", "╤", "╥", "╧", "╨", "╪", "╫",
        "╭", "╮", "╯", "╰", "╱", "╲", "╳", "╴", "╵", "╶", "╷",
        "┄", "┅", "┆", "┇", "┈", "┉", "┊", "┋"
      ],
      "笑顔・ポジティブ": [
        "😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩"
      ],
      "舌を出した顔・手つき": [
        "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤔", "🤫"
      ],
      "中立・懐疑的・眠気": [
        "😐", "🤐", "🤨", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😪", "😴"
      ],
      "体調不良・心配・悲しみ": [
        "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "🤯", "😕", "😟", "🙁", "☹", "😮", "😯", "😲", "😳", "🥺", "😢", "😭", "😱"
      ],
      "怒り・悪魔・コスチューム": [
        "😤", "😡", "😠", "🤬", "😈", "👿", "💀", "💩", "🤡", "👹", "👺", "👻", "👽", "🤖"
      ],
      "ハート・感情表現": [
        "💋", "💌", "💘", "💝", "💖", "💗", "💓", "💞", "💕", "💔", "❤️", "🧡", "💛", "💚", "💙", "💜", "🤎", "🖤", "🤍", "💯", "💢", "💥", "💦", "💨", "💤"
      ],
      "手・指": [
        "👋", "🤚", "🖐", "✋", "👌", "✌", "🤞", "🤟", "🤘", "🤙", "👈", "👉", "👆", "👇", "👍", "👎", "✊", "👊", "👏", "🙌", "👐", "🤲", "🤝", "🙏"
      ],
      "身体パーツ": [
        "💪", "🦵", "🦶", "👂", "👃", "🧠", "👀", "👁", "👅", "👄"
      ],
      "人物": [
        "🧑", "👶", "🧒", "👦", "👧", "👨", "👩", "🧓", "👴", "👵"
      ],
      "職業・役割": [
        "👮‍♂️", "👮‍♀️", "🧑‍⚕️", "🧑‍🎓", "🧑‍🏫", "🧑‍⚖️", "🧑‍🌾", "🧑‍🍳", "🧑‍🔧", "🧑‍💻", "🧑‍🎤", "🧑‍🎨", "🧑‍✈️", "🧑‍🚀", "🧑‍🚒"
      ],
      "家族・カップル": [
        "👪", "👫", "💏", "💑"
      ],
      "動物 (哺乳類)": [
        "🐵", "🦍", "🐶", "🐕", "🐺", "🦊", "🐱", "🐈", "🦁", "🐯", "🐴", "🦄", "🦓", "🐮", "🐷", "🐭", "🐰", "🐻", "🐨", "🐼", "🐘"
      ],
      "鳥・水生生物・虫": [
        "🐔", "🐦", "🐧", "🦅", "🦆", "🦉", "🐸", "🐢", "🐍", "🐳", "🐬", "🐟", "🐙", "🦋", "🐝", "🕷"
      ],
      "植物・花": [
        "💐", "🌸", "🌹", "🌻", "🌷", "🌱", "🌲", "🌳", "🌴", "🌵", "🍀", "🍁"
      ],
      "果物・野菜": [
        "🍇", "🍈", "🍉", "🍊", "🍌", "🍍", "🍎", "🍑", "🍒", "🍓", "🍅", "🥑", "🍆", "🥔", "🥕", "🌽", "🍄"
      ],
      "食事・料理": [
        "🍞", "🥐", "🧀", "🍖", "🍔", "🍟", "🍕", "🌭", "🥪", "🌮", "🥚", "🍳", "🍱", "🍙", "🍚", "🍛", "🍜", "🍝", "🍣"
      ],
      "スイーツ・飲み物": [
        "🍦", "🍩", "🍪", "🎂", "🍫", "🍬", "☕", "🍵", "🍶", "🍷", "🍺", "🍻"
      ],
      "イベント・祝日": [
        "🎃", "🎄", "🎆", "✨", "🎈", "🎉", "🎍", "🎎", "🎁"
      ],
      "スポーツ": [
        "⚽", "⚾", "🏀", "🏐", "🏈", "🎾", "🎳", "⛳", "🎣", "🎿", "🏆", "🏅"
      ],
      "ゲーム・遊び": [
        "🎯", "🎱", "🎮", "🎰", "🎲", "🧩", "♟", "🎨"
      ],
      "地図・自然の場所": [
        "🌍", "🗺", "🗻", "🏕", "🏖", "🏝"
      ],
      "建物": [
        "🏠", "🏢", "🏣", "🏥", "🏦", "🏪", "🏫", "🏯", "🏰", "🗼", "🗽", "⛩", "♨", "🎡"
      ],
      "乗り物": [
        "🚂", "🚅", "🚆", "🚇", "Bus", "🚑", "🚒", "🚓", "🚕", "🚗", "🚚", "🚲", "✈", "🚀", "🚢"
      ],
      "天気・空": [
        "🌑", "🌕", "🌙", "☀", "⭐", "☁", "🌧", "❄", "🔥", "🌊"
      ],
      "衣類": [
        "👓", "👔", "👕", "👖", "👗", "👘", "👙", "🎒", "👟", "👠", "👑", "💍"
      ],
      "音・音楽": [
        "📢", "🔔", "🎵", "🎤", "🎧", "🎸", "🎹"
      ],
      "テクノロジー・機器": [
        "📱", "📞", "🔋", "💻", "🖥", "📷", "📺", "💡"
      ],
      "文具・オフィス": [
        "📖", "📚", "💰", "💴", "💳", "✉", "📦", "✏", "✒", "📝", "💼", "📁", "📅", "📎", "✂", "🗑", "🔒", "🔑"
      ],
      "ツール・科学・医療・生活": [
        "🔨", "🔧", "🔫", "🔬", "🔭", "💉", "💊", "🚪", "🚽", "🛁", "🚬", "🗿"
      ],
      "標識・警告・矢印": [
        "🏧", "🚮", "♿", "🚹", "🚺", "⚠", "⛔", "🚫", "🔞", "⬆", "⬇", "⬅", "➡", "↩"
      ],
      "宗教・星座・AV": [
        "⚛", "✝", "♈", "♉", "▶", "⏸", "📶", "♀", "♂"
      ],
      "算数・句読点・通貨": [
        "✖", "➕", "➖", "➗", "❗", "❓", "💲", "©", "®", "™"
      ],
      "その他記号・日本語": [
        "✅", "✔", "❌", "#️⃣", "1️⃣", "🆗", "🆕", "🉐", "🈹", "🈲", "㊗", "🔴", "🔵"
      ],
      "主要国・よく使う国旗": [
        "🇯🇵", "🇺🇸", "🇨🇳", "🇰🇷", "🇬🇧", "🇫🇷", "🇩🇪", "🇮🇹", "🇨🇦", "🇦🇺", "🇹🇼", "🇮🇳", "🇧🇷", "🇷🇺", "🇪🇸", "🇺🇦"
      ],
      "アジア": [
        "🇯🇵", "🇨🇳", "🇰🇷", "🇰🇵", "🇹🇼", "🇭🇰", "🇲🇴", "🇲🇳", "🇮🇳", "🇮🇩", "🇹🇭", "🇻🇳", "🇵🇭", "🇲🇾", "🇸🇬", "🇰🇭", "🇱🇦", "🇲🇲", "🇧🇳", "🇹🇱", "🇧🇩", "🇵🇰", "🇱🇰", "🇳🇵", "🇧🇹", "🇲🇻", "🇦🇫", "🇰🇿", "🇺🇿", "🇰🇬", "🇹🇯", "🇹🇲"
      ],
      "中東": [
        "🇹🇷", "🇸🇦", "🇦🇪", "🇮🇱", "🇮🇷", "🇮🇶", "🇶🇦", "🇰🇼", "🇧🇭", "🇴🇲", "🇾🇪", "🇯🇴", "🇱🇧", "🇸🇾", "🇵🇸", "🇨🇾", "🇦🇿", "🇦🇲", "🇬🇪"
      ],
      "ヨーロッパ": [
        "🇬🇧", "🇫🇷", "🇩🇪", "🇮🇹", "🇪🇸", "🇷🇺", "🇺🇦", "🇳🇱", "🇧🇪", "🇨🇭", "🇦🇹", "🇵🇹", "🇬🇷", "🇸🇪", "🇳🇴", "🇩🇰", "🇫🇮", "🇮🇪", "🇵🇱", "🇨🇿", "🇭🇺", "🇷🇴", "🇧🇬", "🇭🇷", "🇷🇸", "🇸🇰", "🇸🇮", "🇧🇾", "🇮🇸", "🇱🇺", "🇲🇨", "🇻🇦", "🇲🇹", "🇦🇱", "🇧🇦", "🇽🇰", "🇲🇰", "🇲🇪", "🇪🇪", "🇱🇻", "🇱🇹", "🇲🇩", "🇦🇩", "🇸🇲", "🇱🇮", "🏴󠁧󠁢󠁥󠁮󠁧󠁿", "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "🏴󠁧󠁢󠁷󠁬󠁳󠁿"
      ],
      "北米・中米": [
        "🇺🇸", "🇨🇦", "🇲🇽", "🇨🇺", "🇯🇲", "🇵🇦", "🇨🇷", "🇩🇴", "🇬🇹", "🇭🇳", "🇳🇮", "🇸🇻", "🇧🇿", "🇧🇸", "🇭🇹", "🇧🇧", "🇹🇹", "🇵🇷", "🇦🇬", "🇩🇲", "🇱🇨", "🇬🇩", "🇰🇳", "🇻🇨"
      ],
      "南米": [
        "🇧🇷", "🇦🇷", "🇨🇱", "🇨🇴", "🇵🇪", "🇻🇪", "🇺🇾", "🇵🇾", "🇪🇨", "🇧🇴", "🇬🇾", "🇸🇷"
      ],
      "オセアニア": [
        "🇦🇺", "🇳🇿", "🇵🇬", "🇫🇯", "🇼🇸", "🇹🇴", "🇻🇺", "🇸🇧", "🇫🇲", "🇵🇼", "🇹🇻", "🇰🇮", "🇲🇭", "🇳🇷", "🇬🇺", "🇳🇨", "🇵🇫", "🇦🇸"
      ],
      "アフリカ": [
        "🇿🇦", "🇪🇬", "🇳🇳", "🇰🇪", "🇲🇦", "🇪🇹", "🇬🇭", "🇩🇿", "🇹🇳", "🇸🇳", "🇨🇲", "🇨🇮", "🇦🇴", "🇹🇿", "🇺🇬", "🇿🇲", "🇿🇼", "🇲🇬", "🇲🇺", "🇸🇨", "🇸🇩", "🇸🇸", "🇱🇾", "🇸🇴", "🇩🇯", "🇷🇼", "🇧🇼", "🇳🇦", "🇲🇿", "🇲🇱", "🇧🇫", "🇬🇳", "🇸🇱", "🇱🇷", "🇬🇲", "🇹🇬", "🇧🇯", "🇳🇪", "🇹🇩", "🇨🇫", "🇬🇦", "🇨🇬", "🇨🇩", "🇧🇮", "🇲🇼", "🇱🇸", "🇸🇿", "🇪🇷", "🇨🇻", "🇰🇲", "🇸🇹", "🇬🇶", "🇬🇼", "🇲🇷"
      ],
      "その他・地域・国際機関": [
        "🇺🇳", "🇪🇺", "🏳️‍🌈", "🏳️‍⚧️", "🏴‍☠️", "🏁", "🚩", "🇦🇶", "🇬🇱", "🇫🇴", "🇬🇮", "🇮🇲", "🇯🇪", "🇬🇬", "🇦🇽", "🇷🇪", "🇬🇫", "🇲🇶", "🇬🇵", "🇾🇹", "🇧🇲", "🇰🇾", "🇻🇬", "🇻🇮", "🇲🇸", "🇦🇮", "🇹🇨", "🇵🇲", "🇦🇼", "🇨🇼", "🇸🇽", "🇧🇶", "🇧🇱", "🇲🇫", "🇼🇫", "🇨🇰", "🇳🇺", "🇹🇰", "🇵🇳", "🇸🇭", "🇦🇨", "🇹🇦", "🇫🇰", "🇬🇸", "🇳🇫", "🇨🇽", "🇨🇨", "🇮🇴", "🇹🇫", "🇭🇲", "🇧🇻", "🇺🇲", "🇸🇯", "🇪🇦", "🇮🇨", "🇩🇬", "🇨🇵"
      ],

    };
    this.currentCat = "枠線";

    this.canvas = document.getElementById('editor-canvas');
    this.ctx = this.canvas.getContext('2d');
    this.paletteGrid = document.getElementById('palette-grid');
    this.overlay = document.getElementById('overlay');
    this.cellSize = 32;

    this.init();
  }

  init() {
    this.loadPalette(); // Try loading from storage
    this.initLayers(this.width, this.height);
    this.renderCategorySelect();
    this.renderPalette();
    this.updateCanvasSize();
    this.saveHistory();
    this.updateUI();

    this.setupEvents();
    this.setupLegacyFileIO();
    
    window.addEventListener('beforeunload', () => {
      // 終了時にパレットを保存(自動)
      localStorage.setItem(PALETTE_KEY, JSON.stringify(this.categories));
    });

    // キーボードショートカット
    window.addEventListener('keydown', (e) => {
      if ((e.ctrlKey || e.metaKey)) {
        if(e.key === 'z') { e.preventDefault(); this.undo(); }
        if(e.key === 'y') { e.preventDefault(); this.redo(); }
        if(e.key === 's') { 
          e.preventDefault(); 
          if(e.shiftKey) this.saveAsFileAction();
          else this.saveFileAction();
        }
        if(e.key === 'o') { e.preventDefault(); this.openFileAction(); }
      }
    });
  }

  setupEvents() {
    const startHandler = (e) => this.handlePointerDown(e);
    const moveHandler = (e) => this.handlePointerMove(e);
    const endHandler = (e) => this.handlePointerUp(e);

    this.canvas.addEventListener('mousedown', startHandler);
    window.addEventListener('mousemove', moveHandler);
    window.addEventListener('mouseup', endHandler);

    this.canvas.addEventListener('touchstart', startHandler, {passive: false});
    window.addEventListener('touchmove', moveHandler, {passive: false});
    window.addEventListener('touchend', endHandler);
    
    this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
  }
  
  // --- Grid & Layers ---
  initLayers(w, h) {
    this.width = w;
    this.height = h;
    this.layers = [
      { id: 0, name: "Background", visible: true, data: this.createEmptyGrid(w, h) },
      { id: 1, name: "Character", visible: true, data: this.createEmptyGrid(w, h) }
    ];
    this.fileHandle = null; 
  }

  createEmptyGrid(w, h) {
    const grid = [];
    for(let y=0; y<h; y++) grid.push(new Array(w).fill(SPACE));
    return grid;
  }

  // --- Layer Control ---
  setActiveLayer(idx) {
    this.activeLayerIndex = idx;
    if(this.tool === 'select') this.commitSelection();
    this.updateUI();
  }

  toggleLayerVisible(idx) {
    this.layers[idx].visible = !this.layers[idx].visible;
    this.draw();
    this.updateUI();
  }

  updateUI() {
    // ツールバーのレイヤーボタン
    document.getElementById('btn-layer-0').classList.toggle('active', this.activeLayerIndex === 0);
    document.getElementById('btn-layer-1').classList.toggle('active', this.activeLayerIndex === 1);
    
    document.getElementById('ind-layer-0').style.color = this.layers[0].visible ? (this.activeLayerIndex===0?'#fff':'#333') : 'transparent';
    document.getElementById('ind-layer-1').style.color = this.layers[1].visible ? (this.activeLayerIndex===1?'#fff':'#333') : 'transparent';

    // メニューのチェックマーク
    document.getElementById('check-view-bg').textContent = this.layers[0].visible ? '✔' : '';
    document.getElementById('check-view-char').textContent = this.layers[1].visible ? '✔' : '';
  }

  // --- Palette System ---
  loadPalette() {
    const saved = localStorage.getItem(PALETTE_KEY);
    if (saved) {
      try { 
        this.categories = JSON.parse(saved); 
      } catch(e){}
    }
  }

  resetPalette() {
    if(confirm("パレットを初期設定に戻しますか?")) {
      localStorage.removeItem(PALETTE_KEY);
      location.reload();
    }
  }

  // パレットデータ構造の正規化
  normalizePaletteData(json) {
    if (json.emoji_palette && Array.isArray(json.emoji_palette)) {
      const converted = {};
      json.emoji_palette.forEach(section => {
        if (section.groups && Array.isArray(section.groups)) {
          section.groups.forEach(group => {
            if (group.label && Array.isArray(group.items)) {
              const chars = group.items
                .map(item => item.char)
                .filter(c => c); 
              if (chars.length > 0) {
                if (converted[group.label]) {
                  converted[group.label] = converted[group.label].concat(chars);
                } else {
                  converted[group.label] = chars;
                }
              }
            }
          });
        }
      });
      return Object.keys(converted).length > 0 ? converted : null;
    }
    else if (typeof json === 'object' && !Array.isArray(json) && !json.emoji_palette) {
      return json;
    }
    return null;
  }

  loadPaletteAction() {
    document.getElementById('file-input-palette').click();
    document.getElementById('file-input-palette').onchange = (e) => {
        const file = e.target.files[0];
        if(!file) return;
        const reader = new FileReader();
        reader.onload = (ev) => {
            try {
                const rawJson = JSON.parse(ev.target.result);
                const normalized = this.normalizePaletteData(rawJson);

                if(normalized) {
                    this.categories = normalized;
                    this.currentCat = Object.keys(this.categories)[0] || "基本";
                    this.renderCategorySelect();
                    this.renderPalette();
                    alert("パレットを読み込みました");
                } else {
                    throw new Error("対応していないパレット形式です");
                }
            } catch(err) {
                console.error(err);
                alert("パレットファイルの読み込みに失敗しました。\n" + err.message);
            }
        };
        reader.readAsText(file);
        e.target.value = '';
    };
  }

  savePaletteAction() {
    const json = JSON.stringify(this.categories, null, 2);
    const blob = new Blob([json], {type: "application/json"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = "emoji_palette.json";
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  }

  renderCategorySelect() {
    const sel = document.getElementById('category-select');
    sel.innerHTML = '';
    for(let cat in this.categories) {
      let op = document.createElement('option');
      op.value = cat;
      op.innerText = cat;
      if(cat === this.currentCat) op.selected = true;
      sel.appendChild(op);
    }
    let addOp = document.createElement('option');
    addOp.value = "__ADD__";
    addOp.innerText = "➕ 新規カテゴリ...";
    sel.appendChild(addOp);
  }

  changeCategory(val) {
    if(val === "__ADD__") {
      const name = prompt("新規カテゴリ名:");
      if(name) {
        if(!this.categories[name]) this.categories[name] = [];
        this.currentCat = name;
        this.renderCategorySelect();
      } else {
        document.getElementById('category-select').value = this.currentCat;
      }
    } else {
      this.currentCat = val;
    }
    this.renderPalette();
  }

  renderPalette() {
    this.paletteGrid.innerHTML = '';
    const emojis = this.categories[this.currentCat] || [];
    
    emojis.forEach((emoji, index) => {
      const el = document.createElement('div');
      el.className = 'emoji-cell';
      if(emoji === this.currentEmoji) el.classList.add('selected');
      el.innerText = emoji;
      
      el.ondblclick = () => {
        let newE = prompt("絵文字を入力 (1文字のみ):", emoji);
        if(newE && newE.length > 0) {
          newE = Array.from(newE)[0];
          this.categories[this.currentCat][index] = newE;
          this.setCurrentEmoji(newE);
          this.renderPalette();
        }
      };

      this.setupPaletteInteraction(el, index);
      this.paletteGrid.appendChild(el);
    });
  }

  setCurrentEmoji(emoji) {
    this.currentEmoji = emoji;
    document.getElementById('current-emoji-display').innerText = emoji;
    this.setTool('pen');
  }

  setupPaletteInteraction(el, index) {
    let startX, startY;
    let isDragStarted = false;
    const DRAG_THRESHOLD = 5;

    const down = (e) => {
      if(e.button !== 0 && e.type === 'mousedown') return;
      startX = e.touches ? e.touches[0].clientX : e.clientX;
      startY = e.touches ? e.touches[0].clientY : e.clientY;
      isDragStarted = false;
    };

    const move = (e) => {
      if(startX === undefined) return;
      const cx = e.touches ? e.touches[0].clientX : e.clientX;
      const cy = e.touches ? e.touches[0].clientY : e.clientY;
      
      if (!isDragStarted && (Math.abs(cx - startX) > DRAG_THRESHOLD || Math.abs(cy - startY) > DRAG_THRESHOLD)) {
        isDragStarted = true;
        el.classList.add('dragging');
      }
    };

    const up = (e) => {
      if(startX === undefined) return;
      el.classList.remove('dragging');

      if (!isDragStarted) {
        // Click
        const selectedEmoji = this.categories[this.currentCat][index];
        this.setCurrentEmoji(selectedEmoji);
        
        document.querySelectorAll('.emoji-cell').forEach(c => c.classList.remove('selected'));
        el.classList.add('selected');
      } else {
        // Drag End
        const touch = e.changedTouches ? e.changedTouches[0] : e;
        const target = document.elementFromPoint(touch.clientX, touch.clientY);
        const targetCell = target ? target.closest('.emoji-cell') : null;
        
        if (targetCell) {
           const children = Array.from(this.paletteGrid.children);
           const targetIndex = children.indexOf(targetCell);
           
           if (targetIndex !== -1 && targetIndex !== index) {
             const list = this.categories[this.currentCat];
             const item = list.splice(index, 1)[0];
             list.splice(targetIndex, 0, item);
             this.renderPalette();
           }
        }
      }
      startX = undefined;
    };

    el.addEventListener('mousedown', down);
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
    el.addEventListener('touchstart', down, {passive:false});
    window.addEventListener('touchmove', move, {passive:false});
    window.addEventListener('touchend', up);
  }

  addEmojiToPalette() {
    let val = prompt("追加する絵文字:");
    if(val && val.length > 0) {
      val = Array.from(val)[0];
      this.categories[this.currentCat].push(val);
      this.renderPalette();
    }
  }

  // --- Drawing Logic ---
  setTool(name) {
    if(this.tool === 'select' && name !== 'select') this.commitSelection();
    this.tool = name;
    
    document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
    document.getElementById('btn-'+name).classList.add('active');
  }

  getActiveGrid() {
    return this.layers[this.activeLayerIndex].data;
  }

  handlePointerDown(e) {
    if(e.cancelable) e.preventDefault();
    const pos = this.getGridPos(e);
    if(!pos) return;

    if(!this.layers[this.activeLayerIndex].visible) {
        alert("非表示のレイヤーには描画できません");
        return;
    }

    this.isDrawing = true;
    this.drawStartPos = pos;

    if (this.tool === 'select') {
      if (this.selection && this.isInSelection(pos.x, pos.y)) {
        this.isDraggingSelection = true;
        this.selectionStart = { x: pos.x, y: pos.y, selX: this.selection.x, selY: this.selection.y };
        if(!this.selection.buffer) this.floatSelection();
      } else {
        this.commitSelection();
        this.selection = {x: pos.x, y: pos.y, w:1, h:1, buffer: null};
        this.draw();
      }
      return;
    }

    if (this.tool === 'dropper') {
        let char = SPACE;
        const fg = this.layers[1].data[pos.y][pos.x];
        const bg = this.layers[0].data[pos.y][pos.x];
        
        if(this.layers[1].visible && fg !== SPACE) char = fg;
        else if(this.layers[0].visible) char = bg;

        if (char !== SPACE) this.setCurrentEmoji(char);
        return;
    }

    this.useTool(pos.x, pos.y);
    this.draw();
  }

  handlePointerMove(e) {
    if(!this.isDrawing) return;
    if(e.cancelable) e.preventDefault();
    
    if (this.tool === 'dropper') return;

    const pos = this.getGridPos(e);
    
    if (this.tool === 'select') {
      if (this.isDraggingSelection && pos) {
        const dx = pos.x - this.selectionStart.x;
        const dy = pos.y - this.selectionStart.y;
        this.selection.x = this.selectionStart.selX + dx;
        this.selection.y = this.selectionStart.selY + dy;
        this.draw();
      } else if (pos) {
        const sx = this.drawStartPos.x;
        const sy = this.drawStartPos.y;
        this.selection.x = Math.min(sx, pos.x);
        this.selection.y = Math.min(sy, pos.y);
        this.selection.w = Math.abs(pos.x - sx) + 1;
        this.selection.h = Math.abs(pos.y - sy) + 1;
        this.draw();
      }
      return;
    }

    if(pos) {
      this.useTool(pos.x, pos.y);
      this.draw();
    }
  }

  handlePointerUp(e) {
    if(!this.isDrawing) return;
    this.isDrawing = false;
    this.isDraggingSelection = false;

    if (this.tool === 'dropper') {
        const touch = e.changedTouches ? e.changedTouches[0] : e;
        const target = document.elementFromPoint(touch.clientX, touch.clientY);
        const cell = target ? target.closest('.emoji-cell') : null;
        if (cell) {
            const children = Array.from(this.paletteGrid.children);
            const index = children.indexOf(cell);
            if(index !== -1) {
                this.categories[this.currentCat][index] = this.currentEmoji;
                this.renderPalette();
            }
        }
        return; 
    }

    if(this.tool !== 'select') {
      this.saveHistory();
    }
  }

  getGridPos(e) {
    const rect = this.canvas.getBoundingClientRect();
    const clientX = e.touches ? e.touches[0].clientX : e.clientX;
    const clientY = e.touches ? e.touches[0].clientY : e.clientY;
    
    const x = Math.floor((clientX - rect.left) / (this.cellSize * this.zoom));
    const y = Math.floor((clientY - rect.top) / (this.cellSize * this.zoom));
    
    if (this.isDraggingSelection) return {x, y};
    if(x < 0 || x >= this.width || y < 0 || y >= this.height) return null;
    return {x, y};
  }

  useTool(x, y) {
    if(x < 0 || x >= this.width || y < 0 || y >= this.height) return;
    
    const grid = this.getActiveGrid();

    if (this.tool === 'pen') {
      grid[y][x] = this.currentEmoji;
    } else if (this.tool === 'eraser') {
      grid[y][x] = SPACE;
    } else if (this.tool === 'fill') {
      this.floodFill(x, y, grid[y][x], this.currentEmoji, grid);
    }
  }

  floodFill(x, y, target, replace, grid) {
    if(target === replace) return;
    if(grid[y][x] !== target) return;
    
    const stack = [[x,y]];
    while(stack.length) {
      const [cx, cy] = stack.pop();
      if(cx < 0 || cx >= this.width || cy < 0 || cy >= this.height) continue;
      if(grid[cy][cx] === target) {
        grid[cy][cx] = replace;
        stack.push([cx+1, cy]); stack.push([cx-1, cy]);
        stack.push([cx, cy+1]); stack.push([cx, cy-1]);
      }
    }
  }

  saveHistory() {
    if (this.historyIndex < this.history.length - 1) {
      this.history = this.history.slice(0, this.historyIndex + 1);
    }
    const layerSnapshot = this.layers.map(l => ({
        ...l,
        data: JSON.parse(JSON.stringify(l.data))
    }));

    const snapshot = {
      layers: layerSnapshot,
      w: this.width,
      h: this.height
    };
    this.history.push(snapshot);
    if(this.history.length > 30) this.history.shift();
    else this.historyIndex++;
  }
  undo() {
    if (this.historyIndex > 0) {
      this.historyIndex--;
      this.restoreHistory();
    }
  }
  redo() {
    if (this.historyIndex < this.history.length - 1) {
      this.historyIndex++;
      this.restoreHistory();
    }
  }
  restoreHistory() {
    const state = this.history[this.historyIndex];
    this.width = state.w;
    this.height = state.h;
    
    this.layers = state.layers.map(l => ({
        ...l,
        data: JSON.parse(JSON.stringify(l.data))
    }));

    this.selection = null;
    this.updateCanvasSize();
    this.updateUI();
    this.draw();
  }

  isInSelection(x, y) {
    if(!this.selection) return false;
    return x >= this.selection.x && x < this.selection.x + this.selection.w &&
           y >= this.selection.y && y < this.selection.y + this.selection.h;
  }
  floatSelection() {
    if(!this.selection) return;
    const grid = this.getActiveGrid();
    const buf = [];
    for(let iy=0; iy<this.selection.h; iy++) {
      let row = [];
      for(let ix=0; ix<this.selection.w; ix++) {
        const gx = this.selection.x + ix;
        const gy = this.selection.y + iy;
        if(gx>=0 && gx<this.width && gy>=0 && gy<this.height) {
          row.push(grid[gy][gx]);
          grid[gy][gx] = SPACE;
        } else {
          row.push(SPACE);
        }
      }
      buf.push(row);
    }
    this.selection.buffer = buf;
    this.draw();
  }
  commitSelection() {
    if(!this.selection || !this.selection.buffer) {
      this.selection = null; return;
    }
    const grid = this.getActiveGrid();
    const buf = this.selection.buffer;
    for(let iy=0; iy<this.selection.h; iy++) {
      for(let ix=0; ix<this.selection.w; ix++) {
        const gx = this.selection.x + ix;
        const gy = this.selection.y + iy;
        if(gx>=0 && gx<this.width && gy>=0 && gy<this.height) {
          if(buf[iy][ix] !== SPACE) {
             grid[gy][gx] = buf[iy][ix];
          }
        }
      }
    }
    this.selection = null;
    this.saveHistory();
    this.draw();
  }

  editAction(type) {
    if(!this.layers[this.activeLayerIndex].visible) return;

    if(type === 'selectAll') {
      this.commitSelection();
      this.selection = {x:0, y:0, w:this.width, h:this.height, buffer: null};
      this.setTool('select');
      this.floatSelection();
    } else if (type === 'deselect') {
      this.commitSelection();
    } else if (type === 'cut') {
      if(this.selection) {
        if(!this.selection.buffer) this.floatSelection();
        this.clipboard = JSON.parse(JSON.stringify(this.selection.buffer));
        this.selection = null;
        this.saveHistory();
        this.draw();
      }
    } else if (type === 'copy') {
      if(this.selection) {
        const grid = this.getActiveGrid();
        const buf = this.selection.buffer || this.extractBufferFromGrid(this.selection, grid);
        this.clipboard = JSON.parse(JSON.stringify(buf));
        alert("コピーしました");
      }
    } else if (type === 'paste') {
      if(this.clipboard) {
        this.commitSelection();
        const h = this.clipboard.length;
        const w = this.clipboard[0].length;
        this.selection = {x:0, y:0, w:w, h:h, buffer: JSON.parse(JSON.stringify(this.clipboard))};
        this.setTool('select');
        this.draw();
      }
    }
  }
  extractBufferFromGrid(sel, grid) {
    const buf = [];
    for(let y=0; y<sel.h; y++) {
        let row = [];
        for(let x=0; x<sel.w; x++) {
            row.push(grid[sel.y+y][sel.x+x]);
        }
        buf.push(row);
    }
    return buf;
  }

  setupLegacyFileIO() {
    const fileInput = document.getElementById('file-input-project');
    fileInput.addEventListener('change', (e) => {
      const file = e.target.files[0];
      if (!file) return;
      this.readFileContent(file);
      e.target.value = '';
      this.fileHandle = null;
    });
  }

  async readFileContent(file) {
    const reader = new FileReader();
    reader.onload = (ev) => {
      try {
        const text = ev.target.result;
        if(text.trim().startsWith('{')) {
          const data = JSON.parse(text);
          if(data.layers && Array.isArray(data.layers)) {
            this.width = data.width;
            this.height = data.height;
            this.layers = data.layers;
            if(!this.layers[0]) this.layers[0] = {id:0, name:"BG", visible:true, data:this.createEmptyGrid(this.width, this.height)};
            if(!this.layers[1]) this.layers[1] = {id:1, name:"FG", visible:true, data:this.createEmptyGrid(this.width, this.height)};
            
            this.activeLayerIndex = 1;
          } else {
             throw new Error("Old JSON format");
          }
        } else {
          const lines = text.replace(/\r\n/g, '\n').split('\n').filter(line => line.length > 0);
          const newGrid = lines.map(line => Array.from(line));
          const h = newGrid.length;
          const w = newGrid[0] ? newGrid[0].length : 0;
          
          const maxW = Math.max(...newGrid.map(row => row.length));
          for(let y=0; y<h; y++) {
            while(newGrid[y].length < maxW) newGrid[y].push(SPACE);
          }
          
          this.width = maxW;
          this.height = h;
          this.layers[0].data = newGrid;
          this.layers[1].data = this.createEmptyGrid(maxW, h); // Clear FG
          this.layers[0].visible = true;
          this.layers[1].visible = true;
        }

        this.updateCanvasSize();
        this.saveHistory();
        this.updateUI();
      } catch(err) {
        alert("読み込みエラー: " + err.message);
      }
    };
    reader.readAsText(file);
  }

  newFile() {
    if(confirm("新規作成しますか?")) {
      this.initLayers(16, 16);
      this.saveHistory();
      this.updateCanvasSize();
      this.updateUI();
    }
  }

  async openFileAction() {
    if (window.showOpenFilePicker) {
      try {
        const [handle] = await window.showOpenFilePicker({
          types: [
              { description: 'Emoji Map Project', accept: {'application/json': ['.json']} },
              { description: 'Text File (Legacy)', accept: {'text/plain': ['.txt']} }
          ]
        });
        const file = await handle.getFile();
        await this.readFileContent(file);
        this.fileHandle = handle;
      } catch (err) {
      }
    } else {
      document.getElementById('file-input-project').click();
    }
  }

  async saveFileAction() {
    if (this.fileHandle) {
      try {
        const writable = await this.fileHandle.createWritable();
        const data = {
            width: this.width,
            height: this.height,
            layers: this.layers
        };
        await writable.write(JSON.stringify(data, null, 2));
        await writable.close();
        alert("保存しました");
      } catch (err) {
        alert("保存失敗: " + err.message);
      }
    } else {
      await this.saveAsFileAction();
    }
  }

  async saveAsFileAction() {
    const data = {
        width: this.width,
        height: this.height,
        layers: this.layers
    };
    const blobText = JSON.stringify(data, null, 2);
    
    if (window.showSaveFilePicker) {
      try {
        const handle = await window.showSaveFilePicker({
          suggestedName: `emoji_map.json`,
          types: [{ description: 'Emoji Map Project', accept: {'application/json': ['.json']} }]
        });
        const writable = await handle.createWritable();
        await writable.write(blobText);
        await writable.close();
        this.fileHandle = handle;
      } catch (err) {
      }
    } else {
      const blob = new Blob([blobText], {type: "application/json"});
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = `emoji_map_${Date.now()}.json`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    }
  }

  exportTextAction() {
    let text = "";
    const bg = this.layers[0].data;
    const fg = this.layers[1].data;
    
    for(let y=0; y<this.height; y++) {
        let line = "";
        for(let x=0; x<this.width; x++) {
            let char = SPACE;
            if(this.layers[1].visible && fg[y][x] !== SPACE) {
                char = fg[y][x];
            } else if (this.layers[0].visible) {
                char = bg[y][x];
            }
            line += char;
        }
        text += line + "\n";
    }

    navigator.clipboard.writeText(text).then(() => {
        alert("現在の表示状態をクリップボードにコピーしました");
    });
  }

  exportImageAction() {
    const wasSelection = this.selection;
    this.selection = null;
    this.draw(); 
    
    this.canvas.toBlob((blob) => {
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `emoji_map_${Date.now()}.png`;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);

        this.selection = wasSelection;
        this.draw();
    });
  }

  showResizeDialog() {
    this.overlay.style.display = 'flex';
    document.getElementById('resize-dialog').style.display = 'block';
  }
  closeDialog() {
    this.overlay.style.display = 'none';
  }
  applyResize() {
    const t = parseInt(document.getElementById('rs-top').value) || 0;
    const b = parseInt(document.getElementById('rs-bottom').value) || 0;
    const l = parseInt(document.getElementById('rs-left').value) || 0;
    const r = parseInt(document.getElementById('rs-right').value) || 0;
    
    const newW = this.width + l + r;
    const newH = this.height + t + b;
    if(newW < 1 || newH < 1) return alert("サイズが小さすぎます");

    this.layers.forEach(layer => {
        const newGrid = [];
        for(let y=0; y<newH; y++) newGrid.push(new Array(newW).fill(SPACE));

        for(let y=0; y<this.height; y++) {
            for(let x=0; x<this.width; x++) {
                const ny = y + t;
                const nx = x + l;
                if(ny >= 0 && ny < newH && nx >= 0 && nx < newW) {
                    newGrid[ny][nx] = layer.data[y][x];
                }
            }
        }
        layer.data = newGrid;
    });
    
    this.width = newW;
    this.height = newH;
    this.updateCanvasSize();
    this.saveHistory();
    this.closeDialog();
  }

  updateCanvasSize() {
    this.canvas.width = this.width * this.cellSize * this.zoom;
    this.canvas.height = this.height * this.cellSize * this.zoom;
    this.canvas.style.width = `${this.width * this.cellSize * this.zoom}px`;
    this.canvas.style.height = `${this.height * this.cellSize * this.zoom}px`;
    this.draw();
  }
  
  zoomIn() { this.zoom = Math.min(3, this.zoom + 0.2); this.updateCanvasSize(); }
  zoomOut() { this.zoom = Math.max(0.5, this.zoom - 0.2); this.updateCanvasSize(); }
  toggleGrid() { this.showGrid = !this.showGrid; this.draw(); }

  draw() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    
    this.ctx.save();
    this.ctx.scale(this.zoom, this.zoom);
    
    this.ctx.font = `${Math.floor(this.cellSize * 0.8)}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`;
    this.ctx.textAlign = 'center';
    this.ctx.textBaseline = 'middle';
    
    if(this.layers[0].visible) {
        this.drawLayer(this.layers[0].data);
    }
    
    if(this.layers[1].visible) {
        this.drawLayer(this.layers[1].data);
    }

    if(this.selection && this.selection.buffer) {
      const buf = this.selection.buffer;
      this.ctx.strokeStyle = '#007bff';
      this.ctx.lineWidth = 2;
      this.ctx.setLineDash([4, 2]);
      this.ctx.strokeRect(this.selection.x * this.cellSize, this.selection.y * this.cellSize, this.selection.w * this.cellSize, this.selection.h * this.cellSize);
      this.ctx.setLineDash([]);
      
      for(let iy=0; iy<this.selection.h; iy++) {
        for(let ix=0; ix<this.selection.w; ix++) {
          const char = buf[iy][ix];
          if(char !== SPACE) {
            const dx = (this.selection.x + ix) * this.cellSize;
            const dy = (this.selection.y + iy) * this.cellSize;
            this.ctx.fillText(char, dx + this.cellSize/2, dy + this.cellSize/2 + 2);
          }
        }
      }
    }

    if(this.showGrid) {
      this.ctx.strokeStyle = '#e0e0e0';
      this.ctx.lineWidth = 1;
      this.ctx.beginPath();
      for(let x=0; x<=this.width; x++) this.ctx.moveTo(x*this.cellSize, 0), this.ctx.lineTo(x*this.cellSize, this.height*this.cellSize);
      for(let y=0; y<=this.height; y++) this.ctx.moveTo(0, y*this.cellSize), this.ctx.lineTo(this.width*this.cellSize, y*this.cellSize);
      this.ctx.stroke();
    }

    this.ctx.restore();
  }

  drawLayer(gridData) {
      for(let y=0; y<this.height; y++) {
        for(let x=0; x<this.width; x++) {
           const char = gridData[y][x];
           if(char !== SPACE) {
             this.ctx.fillText(char, x*this.cellSize + this.cellSize/2, y*this.cellSize + this.cellSize/2 + 2);
           }
        }
      }
  }
}

const app = new EmojiApp();

</script>
</body>
</html>

・「絵文字ローグ」のソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Emoji Rogue 1.3</title>
<style>
  :root {
    --bg-color: #050505;
    --ui-bg: rgba(20, 20, 30, 0.97);
    --border-color: #444;
    --accent-color: #9b59b6;
    --text-color: #eee;
  }
  body {
    background-color: var(--bg-color);
    color: var(--text-color);
    font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", monospace;
    display: flex;
    flex-direction: column;
    align-items: center;
    height: 100vh;
    margin: 0;
    overflow: hidden;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
  }

  #game-wrapper {
    position: relative;
    margin-top: 10px;
    box-shadow: 0 0 30px rgba(0,0,0,0.8);
    border: 2px solid #333;
  }
  canvas { display: block; background: #000; }

  #btn-menu {
    position: absolute;
    bottom: 20px;
    right: 20px;
    background: var(--accent-color);
    color: white;
    border: 2px solid #fff;
    border-radius: 50%;
    width: 70px;
    height: 70px;
    font-size: 30px;
    box-shadow: 0 4px 10px rgba(0,0,0,0.5);
    cursor: pointer;
    z-index: 50;
    display: flex;
    justify-content: center;
    align-items: center;
    transition: transform 0.1s;
  }
  #btn-menu:active { transform: scale(0.9); background: #8e44ad; }
  #btn-menu::after {
    content: "[A]";
    position: absolute;
    bottom: 10px;
    font-size: 10px;
    font-weight: bold;
  }

  #ui-header {
    width: 800px;
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    padding: 8px 15px;
    background: #222;
    border-bottom: 2px solid #444;
    font-size: 16px;
    font-weight: bold;
    box-sizing: border-box;
  }
  .stat-box { display: flex; align-items: center; justify-content: center; gap: 6px; }

  #log-container {
    width: 800px;
    height: 120px;
    background: #111;
    border: 1px solid #333;
    overflow-y: auto;
    font-size: 14px;
    padding: 10px;
    box-sizing: border-box;
    display: flex;
    flex-direction: column-reverse;
  }
  .log-line { border-bottom: 1px solid #222; padding: 2px 0; color: #aaa; }
  .log-line.new { color: #fff; animation: flash 0.5s; }
  .log-line.danger { color: #ff6b6b; }
  .log-line.good { color: #51cf66; }
  .log-line.lucky { color: #ffd700; text-shadow: 0 0 5px gold; }
  .log-line.magic { color: #e056fd; text-shadow: 0 0 5px #e056fd; }
  @keyframes flash { from { text-shadow: 0 0 10px yellow; } to { text-shadow: none; } }

  .modal-overlay {
    position: absolute; top: 0; left: 0; width: 100%; height: 100%;
    background: rgba(0,0,0,0.75);
    display: none; justify-content: center; align-items: center; z-index: 100;
  }
  .modal-window {
    background: var(--ui-bg);
    border: 2px solid var(--accent-color);
    padding: 20px;
    border-radius: 8px;
    min-width: 440px;
    max-width: 90%;
    color: #fff;
    box-shadow: 0 10px 40px rgba(0,0,0,1);
  }
  .modal-title {
    font-size: 22px; margin: 0 0 12px 0;
    border-bottom: 1px solid #555; padding-bottom: 5px;
    display: flex; justify-content: space-between; align-items: center;
  }
  .item-list { list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto; }
  .item-list li {
    padding: 9px 10px; border-bottom: 1px solid #333; cursor: pointer;
    display: flex; justify-content: space-between; align-items: center; font-size: 17px;
  }
  .item-list li:hover, .item-list li.selected { background: rgba(155, 89, 182, 0.4); }
  .item-list li.equipped { border-left: 3px solid #ffd700; }
  .key-hint { font-size: 12px; color: #888; margin-top: 12px; text-align: right; line-height: 1.6; }

  /* コンテキストメニュー */
  #ctx-menu {
    position: absolute;
    top: 50%; left: 50%;
    transform: translate(-50%, -50%);
    background: var(--ui-bg);
    border: 2px solid var(--accent-color);
    border-radius: 6px;
    padding: 4px 0;
    min-width: 260px;
    z-index: 300;
    display: none;
    box-shadow: 0 6px 30px rgba(0,0,0,0.95);
  }
  #ctx-menu .ctx-title {
    padding: 8px 18px 6px;
    font-size: 13px;
    color: #aaa;
    border-bottom: 1px solid #444;
    margin-bottom: 2px;
  }
  #ctx-menu div.ctx-item {
    padding: 12px 18px;
    cursor: pointer;
    font-size: 17px;
  }
  #ctx-menu div.ctx-item:hover,
  #ctx-menu div.ctx-item.selected { background: rgba(155,89,182,0.55); }

  #start-screen {
    position: absolute; top:0; left:0; width:100%; height:100%;
    background: rgba(0,0,0,0.9);
    display: flex; flex-direction: column;
    justify-content: center; align-items: center;
    z-index: 200;
  }
  h1 { font-size: 48px; margin: 0; color: var(--accent-color); text-shadow: 2px 2px 0 #fff; }
  button.big-btn {
    background: var(--accent-color); color: white; border: none;
    padding: 15px 40px; font-size: 20px; cursor: pointer;
    margin-top: 30px; border-radius: 4px; font-weight: bold; font-family: inherit;
  }
  button.big-btn:hover { background: #8e44ad; }

  #map-load-area {
    margin-top: 20px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
  }
  #map-load-area label {
    cursor: pointer;
    background: #333;
    color: #ccc;
    border: 1px dashed #666;
    border-radius: 4px;
    padding: 8px 20px;
    font-size: 14px;
    transition: background 0.2s;
  }
  #map-load-area label:hover { background: #444; color: #fff; }
  #map-file-status {
    font-size: 12px;
    color: #888;
    max-width: 300px;
    text-align: center;
    word-break: break-all;
  }
  #map-file-status.loaded { color: #51cf66; }
  #map-file-status.cleared { color: #888; }
  #btn-clear-map {
    background: transparent;
    color: #888;
    border: 1px solid #555;
    border-radius: 4px;
    padding: 4px 12px;
    font-size: 12px;
    cursor: pointer;
    display: none;
  }
  #btn-clear-map:hover { background: #333; color: #fff; }
</style>
</head>
<body>

<div id="ui-header">
  <div class="stat-box">🪜 <span id="val-floor">1</span></div>
  <div class="stat-box">❤️ <span id="val-hp">50/50</span></div>
  <div class="stat-box">🍞 <span id="val-food">100%</span></div>
  <div class="stat-box">⚔️ Lv<span id="val-lv">1</span></div>
  <div class="stat-box">💰 <span id="val-gold">0</span></div>
</div>

<div id="game-wrapper">
  <canvas id="gameCanvas" width="800" height="512"></canvas>

  <div id="btn-menu" onclick="game.toggleInventory()">🎒</div>

  <div id="start-screen">
    <h1>🏰 Emoji Rogue</h1>
    <p style="color:#aaa;">Rogue-like Adventure</p>
    <div id="result-msg" style="margin-top:10px; font-size:18px; color:#f55;"></div>
    <button class="big-btn" onclick="game.init()">NEW GAME</button>
    <div id="map-load-area">
      <label for="map-file-input">📂 マップファイルを読み込む (.json)</label>
      <input type="file" id="map-file-input" accept=".json" style="display:none;">
      <div id="map-file-status">マップなし(完全自動生成)</div>
      <button id="btn-clear-map" onclick="game.clearMapData()">✖ マップをクリア</button>
    </div>
    <div style="margin-top:20px; font-size:13px; color:#666; line-height:1.8;">
      [矢印] 移動/攻撃 | [A] メニュー開閉<br>
      [Z] 確定/アクション | [X] キャンセル / ダッシュ(マップ)<br>
      武器・防具は自動装備(強い方を保持)<br>
      🪓斧・ガラクタ → Zキー → 投げる でも投擲可能
    </div>
  </div>

  <div id="modal-inventory" class="modal-overlay">
    <div class="modal-window" style="position:relative;">
      <div class="modal-title">🎒 持ち物 <span style="font-size:13px;color:#888;">[Z]アクション [X/A]閉じる</span></div>
      <ul class="item-list" id="inventory-list"></ul>
      <div class="key-hint">
        ↑↓ 選択 | Z/クリック: 使う・装備・投げる | X/A: 閉じる<br>
        装備中: [E] 表示
      </div>
      <!-- コンテキストメニュー (アクション選択) -->
      <div id="ctx-menu">
        <div class="ctx-title">アクションを選んでください</div>
        <div id="ctx-use" class="ctx-item">✅ 使う / 装備する</div>
        <div id="ctx-throw" class="ctx-item">🪃 投げる</div>
        <div id="ctx-drop" class="ctx-item">📦 その場に置く</div>
        <div id="ctx-cancel" class="ctx-item">✖ キャンセル</div>
      </div>
    </div>
  </div>

  <div id="modal-shop" class="modal-overlay">
    <div class="modal-window">
      <div class="modal-title">🛒 Merchant <span id="shop-gold" style="font-size:16px;color:gold;"></span></div>
      <ul class="item-list" id="shop-list"></ul>
      <div class="key-hint">↑↓ 選択 | Z/クリック: 購入 | X/A: 閉じる</div>
    </div>
  </div>


</div>

<div id="log-container"></div>

<script>
/**
 * Emoji Rogue 2.0 - Full Feature Update
 */

const ASSETS = {
  BG: { WALL: '⬛', FLOOR: '🟧', WATER: '🟦' },
  FG: {
    STAIRS: '🚪', FLOWERS: ['🌼', '🌷', '🌻', '🌹'],
    ROCK: '🪨', TREE: '🌲', FOUNTAIN: '⛲', STATUE: '🗿', TRAP: '⚡', CHEST: '🎁'
  },
  PLAYER: '🧙', MERCHANT: '👳',
  ENEMIES: [
    ['🐀', '🦇', '🐍'], ['🐺', '🐗', '🕷️'],
    ['💀', '🧟', '👻'], ['👺', '👹', '👁️'], ['🐉', '🐲', '👿']
  ],
  // ガラクタ一覧
  JUNK: [
    { char: '🍾', name: '空きビン' },
    { char: '🧱', name: 'レンガのかけら' },
    { char: '🔩', name: '古いネジ' },
    { char: '🥫', name: '空き缶' },
    { char: '🪣', name: '壊れたバケツ' },
    { char: '🧲', name: '錆びた磁石' },
    { char: '📦', name: '壊れた箱' },
    { char: '🪵', name: '木の棒' },
  ],
  ITEMS: {
    POTION:       { char: '🧪', name: '回復薬',    type: 'consumable', value: 50,  effect: 'heal', power: 50 },
    BIG_POTION:   { char: '🏺', name: '特効薬',    type: 'consumable', value: 120, effect: 'heal', power: 999 },
    MEAT:         { char: '🍖', name: 'マンガ肉',  type: 'food',       value: 40,  effect: 'food', power: 100 },
    BREAD:        { char: '🍞', name: 'パン',      type: 'food',       value: 20,  effect: 'food', power: 50 },
    SWORD:        { char: '⚔️', name: '鉄の剣',    type: 'weapon',     value: 200, power: 2, throwable: false },
    AXE:          { char: '🪓', name: '戦斧',      type: 'weapon',     value: 500, power: 5, throwable: true },
    CLUB:         { char: '🪃', name: '棍棒',      type: 'weapon',     value: 150, power: 3, throwable: true },
    DAGGER:       { char: '🗡️', name: '短剣',      type: 'weapon',     value: 180, power: 2, throwable: true },
    SHIELD:       { char: '🛡️', name: '木の盾',    type: 'armor',      value: 150, power: 2 },
    ARMOR:        { char: '🥋', name: '革鎧',      type: 'armor',      value: 300, power: 4 },
    GOLD_S:       { char: '💰', name: '金貨',      type: 'gold',       value: 0 },
    GOLD_L:       { char: '💎', name: '宝石',      type: 'gold',       value: 0 },
    SCROLL_FIRE:  { char: '📜', name: '炎の巻物',  type: 'magic',      value: 150, magicChar: '🔥', power: 15, range: 4 },
    SCROLL_THUNDER:{ char:'📜', name: '雷の巻物',  type: 'magic',      value: 200, magicChar: '⚡', power: 25, range: 5 },
    SCROLL_ICE:   { char: '📜', name: '氷の巻物',  type: 'magic',      value: 200, magicChar: '❄️', power: 20, range: 4 }
  }
};

// 武器・防具の強化値テキスト
function enhanceText(item) {
  if(item.enhance && item.enhance > 0) return ` +${item.enhance}`;
  if(item.enhance && item.enhance < 0) return ` ${item.enhance}`;
  return '';
}
// アイテムの実際のpower(強化込み)
function itemPower(item) {
  return (item.power || 0) + (item.enhance || 0);
}

const Sound = {
  ctx: null,
  init: () => { if(!Sound.ctx) Sound.ctx = new (window.AudioContext||window.webkitAudioContext)(); },
  play: (type) => {
    if(!Sound.ctx) return;
    if(Sound.ctx.state === 'suspended') Sound.ctx.resume();
    const t = Sound.ctx.currentTime;
    const osc = Sound.ctx.createOscillator();
    const gain = Sound.ctx.createGain();
    osc.connect(gain); gain.connect(Sound.ctx.destination);
    switch(type) {
      case 'move':
        osc.type = 'triangle'; osc.frequency.setValueAtTime(100, t); osc.frequency.linearRampToValueAtTime(0, t+0.05);
        gain.gain.setValueAtTime(0.04, t); osc.start(t); osc.stop(t+0.05); break;
      case 'pickup':
        osc.type = 'sine'; osc.frequency.setValueAtTime(600, t); osc.frequency.linearRampToValueAtTime(1000, t+0.1);
        gain.gain.setValueAtTime(0.1, t); gain.gain.linearRampToValueAtTime(0, t+0.1); osc.start(t); osc.stop(t+0.1); break;
      case 'attack':
        osc.type = 'sawtooth'; osc.frequency.setValueAtTime(150, t); osc.frequency.linearRampToValueAtTime(30, t+0.12);
        gain.gain.setValueAtTime(0.15, t); osc.start(t); osc.stop(t+0.12); break;
      case 'throw':
        osc.type = 'sawtooth'; osc.frequency.setValueAtTime(400, t); osc.frequency.linearRampToValueAtTime(100, t+0.2);
        gain.gain.setValueAtTime(0.12, t); osc.start(t); osc.stop(t+0.2); break;
      case 'hit':
        osc.type = 'square'; osc.frequency.setValueAtTime(150, t); osc.frequency.linearRampToValueAtTime(50, t+0.2);
        gain.gain.setValueAtTime(0.2, t); osc.start(t); osc.stop(t+0.2); break;
      case 'coin':
        osc.type = 'sine'; osc.frequency.setValueAtTime(1500, t); osc.frequency.linearRampToValueAtTime(2000, t+0.2);
        gain.gain.setValueAtTime(0.1, t); osc.start(t); osc.stop(t+0.2); break;
      case 'powerup':
        osc.type = 'triangle'; osc.frequency.setValueAtTime(300, t); osc.frequency.linearRampToValueAtTime(600, t+0.3);
        gain.gain.setValueAtTime(0.2, t); osc.start(t); osc.stop(t+0.3); break;
      case 'levelup':
        osc.type = 'sine'; osc.frequency.setValueAtTime(400, t);
        osc.frequency.linearRampToValueAtTime(800, t+0.1);
        osc.frequency.linearRampToValueAtTime(1200, t+0.2);
        gain.gain.setValueAtTime(0.25, t); osc.start(t); osc.stop(t+0.3); break;
      case 'magic_cast':
        osc.type = 'sine'; osc.frequency.setValueAtTime(800, t); osc.frequency.exponentialRampToValueAtTime(200, t+0.5);
        gain.gain.setValueAtTime(0.2, t); osc.start(t); osc.stop(t+0.5); break;
      case 'magic_hit':
        osc.type = 'sawtooth'; osc.frequency.setValueAtTime(50, t); osc.frequency.linearRampToValueAtTime(20, t+0.2);
        gain.gain.setValueAtTime(0.3, t); osc.start(t); osc.stop(t+0.2); break;
      case 'miss':
        osc.type = 'triangle'; osc.frequency.setValueAtTime(300, t); osc.frequency.linearRampToValueAtTime(200, t+0.1);
        gain.gain.setValueAtTime(0.08, t); osc.start(t); osc.stop(t+0.1); break;
      case 'drop':
        osc.type = 'triangle'; osc.frequency.setValueAtTime(200, t); osc.frequency.linearRampToValueAtTime(100, t+0.1);
        gain.gain.setValueAtTime(0.08, t); osc.start(t); osc.stop(t+0.1); break;
      case 'equip':
        osc.type = 'sine'; osc.frequency.setValueAtTime(500, t); osc.frequency.linearRampToValueAtTime(700, t+0.15);
        gain.gain.setValueAtTime(0.1, t); osc.start(t); osc.stop(t+0.15); break;
      case 'hunger':
        osc.type = 'triangle'; osc.frequency.setValueAtTime(120, t); osc.frequency.linearRampToValueAtTime(80, t+0.3);
        gain.gain.setValueAtTime(0.12, t); osc.start(t); osc.stop(t+0.3); break;
    }
  }
};

class Game {
  constructor() {
    this.canvas = document.getElementById('gameCanvas');
    this.ctx = this.canvas.getContext('2d');
    this.TILE_SIZE = 32;
    this.VIEW_W = 25;
    this.VIEW_H = 16;

    this.mode = 'TITLE';
    this.floor = 1;
    this.map = { w: 50, h: 40, layers: [[], []] };
    this.entities = [];
    this.camera = { x:0, y:0 };

    this.player = {
      x: 0, y: 0, hp: 50, maxHp: 50,
      food: 100, maxFood: 100,
      lv: 1, exp: 0, nextExp: 10,
      atk: 4, def: 0, gold: 0,
      inventory: [],
      weapon: null, armor: null,
      lastDx: 0, lastDy: -1  // 最後の進行方向
    };

    // アニメーション
    this.projectiles = []; // { x, y, char, dx, dy, steps, maxSteps, item, fromJunk }
    this.attackEffect = null; // { weaponChar, tx, ty, timer }
    this.isAnimating = false;

    this.menuSelection = 0;
    this.ctxMenuSelection = 0;
    this.ctxMenuItemIdx = -1;
    this.currentShopItems = [];

    // マップファイル由来のデータプール(nullなら自動生成)
    // mapDataPool: { width, height, layers: [bg_data, char_data] }[]  ランダム選択用
    this.mapDataPool = [];

    window.addEventListener('keydown', e => this.handleInput(e));
    this.setupCtxMenu();
    this.setupMapFileIO();
  }

  setupCtxMenu() {
    document.getElementById('ctx-use').onclick = () => this.ctxAction('use');
    document.getElementById('ctx-throw').onclick = () => this.ctxAction('throw');
    document.getElementById('ctx-drop').onclick = () => this.ctxAction('drop');
    document.getElementById('ctx-cancel').onclick = () => this.closeCtxMenu();
  }

  // ===== マップファイルIO =====
  setupMapFileIO() {
    const input = document.getElementById('map-file-input');
    input.addEventListener('change', (e) => {
      const files = Array.from(e.target.files);
      if (files.length === 0) return;
      const pending = [];
      let loaded = 0;
      files.forEach(file => {
        const reader = new FileReader();
        reader.onload = (ev) => {
          try {
            const data = JSON.parse(ev.target.result);
            // 必須フィールド検証
            if (
              typeof data.width === 'number' &&
              typeof data.height === 'number' &&
              Array.isArray(data.layers) &&
              data.layers.length >= 2
            ) {
              pending.push(data);
            }
          } catch (err) {
            console.warn('マップJSON解析エラー:', file.name, err);
          }
          loaded++;
          if (loaded === files.length) {
            if (pending.length > 0) {
              this.mapDataPool = pending;
              const statusEl = document.getElementById('map-file-status');
              statusEl.textContent = `✅ ${pending.length}件のマップデータを読み込みました`;
              statusEl.className = 'loaded';
              document.getElementById('btn-clear-map').style.display = 'inline-block';
            } else {
              alert('有効なマップデータが見つかりませんでした。\nEmojiMap で作成した .json ファイルを指定してください。');
            }
          }
        };
        reader.readAsText(file);
      });
      // inputをリセット(同じファイル再選択を許可)
      e.target.value = '';
    });
  }

  clearMapData() {
    this.mapDataPool = [];
    const statusEl = document.getElementById('map-file-status');
    statusEl.textContent = 'マップなし(完全自動生成)';
    statusEl.className = 'cleared';
    document.getElementById('btn-clear-map').style.display = 'none';
  }

  init() {
    Sound.init();
    document.getElementById('start-screen').style.display = 'none';
    this.floor = 1;
    this.player = {
      x: 0, y: 0, hp: 50, maxHp: 50,
      food: 100, maxFood: 100,
      lv: 1, exp: 0, nextExp: 10,
      atk: 4, def: 0, gold: 0,
      inventory: [
        { ...ASSETS.ITEMS.BREAD, id: Date.now() },
        { ...ASSETS.ITEMS.SCROLL_FIRE, id: Date.now()+1 }
      ],
      weapon: null, armor: null,
      lastDx: 0, lastDy: -1
    };
    this.mode = 'PLAY';
    this.log('🏰 不思議のダンジョンへようこそ!', 'lucky');
    this.log('[A]メニュー [Z]確定 [X]キャンセル/投げる', '');
    this.generateFloor();
    this.updateUI();
  }

  generateFloor() {
    // マップデータプールがあればランダムに選択して使用、なければ自動生成
    if (this.mapDataPool.length > 0) {
      const mapData = this.mapDataPool[Math.floor(Math.random() * this.mapDataPool.length)];
      this.generateFloorFromMapData(mapData);
    } else {
      this.generateFloorAuto();
    }
  }

  // ===== マップデータからフロアを生成 =====
  generateFloorFromMapData(mapData) {
    const W = mapData.width;
    const H = mapData.height;

    // マップサイズを更新
    this.map.w = W;
    this.map.h = H;

    const bgSrc = mapData.layers[0];   // 背景レイヤー
    const charSrc = mapData.layers[1]; // キャラクターレイヤー

    // 背景レイヤー構築
    // エディタのSPACE(全角スペース)は壁として扱う
    const EDITOR_SPACE = ' ';
    const bgLayer = [];
    for (let y = 0; y < H; y++) {
      bgLayer.push([]);
      for (let x = 0; x < W; x++) {
        const cell = (bgSrc[y] && bgSrc[y][x]) ? bgSrc[y][x] : EDITOR_SPACE;
        if (cell === EDITOR_SPACE) {
          bgLayer[y].push(ASSETS.BG.WALL);
        } else if (cell === ASSETS.BG.WATER) {
          bgLayer[y].push(ASSETS.BG.WATER);
        } else if (cell === ASSETS.BG.WALL) {
          bgLayer[y].push(ASSETS.BG.WALL);
        } else {
          // その他の絵文字は床として扱う(エディタで描いた床タイル)
          bgLayer[y].push(cell);
        }
      }
    }

    // フォアグラウンドレイヤー(マップのギミック: 階段・岩・木・泉etc)
    const fgLayer = Array(H).fill(null).map(() => Array(W).fill(null));

    this.entities = [];
    this.projectiles = [];
    this.attackEffect = null;

    // プレイヤー開始位置候補(最初の床タイル)
    let playerStartX = -1, playerStartY = -1;
    let stairsPlaced = false;

    // キャラクターレイヤーを解析してエンティティ・ギミックに変換
    // 全ての絵文字をリストアップして、ランダムな解釈を適用
    const charPositions = []; // { x, y, char }
    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        const cell = (charSrc[y] && charSrc[y][x]) ? charSrc[y][x] : EDITOR_SPACE;
        if (cell !== EDITOR_SPACE && bgLayer[y][x] !== ASSETS.BG.WALL) {
          charPositions.push({ x, y, char: cell });
        }
      }
    }

    // プレイヤー開始位置: 最初の床セルを探す(キャラクターデータ無関係)
    outer:
    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        if (bgLayer[y][x] !== ASSETS.BG.WALL) {
          playerStartX = x;
          playerStartY = y;
          break outer;
        }
      }
    }

    // マップが全壁(空マップ)の場合はフォールバック
    if (playerStartX === -1) {
      this.log('⚠ マップデータに床が見つかりません。自動生成にフォールバックします。', 'danger');
      this.generateFloorAuto();
      return;
    }

    // キャラクター絵文字をシャッフルして配置場所を確定
    const shuffled = charPositions.slice().sort(() => Math.random() - 0.5);

    shuffled.forEach(({ x, y, char }) => {
      // === 絵文字の種別判定 ===

      // 1. FGギミック(階段・罠・泉・岩・木・宝箱・像)
      if (char === ASSETS.FG.STAIRS) {
        fgLayer[y][x] = ASSETS.FG.STAIRS;
        stairsPlaced = true;
        return;
      }
      if (char === ASSETS.FG.TRAP) { fgLayer[y][x] = ASSETS.FG.TRAP; return; }
      if (char === ASSETS.FG.FOUNTAIN) { fgLayer[y][x] = ASSETS.FG.FOUNTAIN; return; }
      if (char === ASSETS.FG.ROCK) { fgLayer[y][x] = ASSETS.FG.ROCK; return; }
      if (char === ASSETS.FG.TREE) { fgLayer[y][x] = ASSETS.FG.TREE; return; }
      if (char === ASSETS.FG.STATUE) { fgLayer[y][x] = ASSETS.FG.STATUE; return; }
      if (char === ASSETS.FG.CHEST) { fgLayer[y][x] = ASSETS.FG.CHEST; return; }
      if (ASSETS.FG.FLOWERS.includes(char)) { fgLayer[y][x] = char; return; }

      // 2. プレイヤー開始位置
      if (char === ASSETS.PLAYER) {
        playerStartX = x;
        playerStartY = y;
        return;
      }

      // 3. 商人
      if (char === ASSETS.MERCHANT) {
        this.entities.push({ type: 'MERCHANT', char: ASSETS.MERCHANT, x, y, hp: 999 });
        return;
      }

      // 4. 敵キャラクター(ASSETSのENEMIESリストに含まれるか判定)
      const isEnemy = ASSETS.ENEMIES.some(group => group.includes(char));
      if (isEnemy) {
        // 敵の強さはフロアに応じて設定
        this.entities.push({
          type: 'ENEMY', char, x, y,
          hp: this.floor * 3 + 5, maxHp: this.floor * 3 + 5,
          atk: Math.floor(this.floor / 2) + 2, exp: this.floor * 3
        });
        return;
      }

      // 5. アイテム(ASSETSのITEMSのcharに一致するか判定)
      const itemKey = Object.keys(ASSETS.ITEMS).find(k => ASSETS.ITEMS[k].char === char);
      if (itemKey) {
        const itemData = { ...ASSETS.ITEMS[itemKey], id: Date.now() + Math.random() };
        if (itemData.type === 'gold') {
          itemData.value = (itemData.char === '💎')
            ? 300 * this.floor
            : 50 + Math.floor(Math.random() * 50) * this.floor;
        }
        if (itemData.type === 'weapon' || itemData.type === 'armor') {
          const r = Math.random();
          if (r < 0.15) itemData.enhance = 2;
          else if (r < 0.4) itemData.enhance = 1;
          else if (r < 0.5) itemData.enhance = -1;
          else itemData.enhance = 0;
          itemData.displayName = itemData.name + enhanceText(itemData);
        }
        this.entities.push({ type: 'ITEM', item: itemData, char: itemData.char, x, y });
        return;
      }

      // 6. ガラクタ(ASSETSのJUNKのcharに一致するか判定)
      const junkDef = ASSETS.JUNK.find(j => j.char === char);
      if (junkDef) {
        const junkItem = {
          ...junkDef,
          type: 'junk',
          value: 0,
          throwDmg: Math.floor(Math.random() * 3) + 1,
          id: Date.now() + Math.random()
        };
        this.entities.push({ type: 'ITEM', item: junkItem, char: junkDef.char, x, y });
        return;
      }

      // 7. 上記に該当しない絵文字は無視(エディタ特有の装飾など)
    });

    // 階段が未配置の場合、床タイルの端付近に強制配置
    if (!stairsPlaced) {
      // 右下から探索
      let placed = false;
      for (let y = H - 1; y >= 0 && !placed; y--) {
        for (let x = W - 1; x >= 0 && !placed; x--) {
          if (bgLayer[y][x] !== ASSETS.BG.WALL && fgLayer[y][x] === null &&
              !this.entities.some(e => e.x === x && e.y === y) &&
              !(x === playerStartX && y === playerStartY)) {
            fgLayer[y][x] = ASSETS.FG.STAIRS;
            placed = true;
          }
        }
      }
      if (!placed) {
        // 最後の手段:プレイヤー位置に置く(ゲームは成立しないが壊れない)
        fgLayer[playerStartY][playerStartX] = ASSETS.FG.STAIRS;
      }
    }

    this.map.layers[0] = bgLayer;
    this.map.layers[1] = fgLayer;
    this.player.x = playerStartX;
    this.player.y = playerStartY;

    this.updateCamera();
    this.draw();
  }

  // ===== 従来の自動生成フロア =====
  generateFloorAuto() {
    // 自動生成時はマップサイズをデフォルトに戻す
    this.map.w = 50;
    this.map.h = 40;
    const W = this.map.w, H = this.map.h;

    // 部屋が最低2つ生成されるまでリトライ
    let rooms = [];
    while(rooms.length < 2) {
      this.map.layers[0] = Array(H).fill().map(() => Array(W).fill(ASSETS.BG.WALL));
      this.map.layers[1] = Array(H).fill().map(() => Array(W).fill(null));
      rooms = [];
      for(let i=0; i<12; i++) {
        const w = Math.floor(Math.random()*6) + 4;
        const h = Math.floor(Math.random()*6) + 4;
        const x = Math.floor(Math.random()*(W-w-2)) + 1;
        const y = Math.floor(Math.random()*(H-h-2)) + 1;
        if(rooms.some(r => x < r.x+r.w+1 && x+w+1 > r.x && y < r.y+r.h+1 && y+h+1 > r.y)) continue;
        rooms.push({ x, y, w, h, cx: Math.floor(x+w/2), cy: Math.floor(y+h/2) });
        for(let ry=y; ry<y+h; ry++) {
          for(let rx=x; rx<x+w; rx++) {
            this.map.layers[0][ry][rx] = ASSETS.BG.FLOOR;
            if(Math.random() < 0.15) {
              const fl = ASSETS.FG.FLOWERS[Math.floor(Math.random()*ASSETS.FG.FLOWERS.length)];
              this.map.layers[1][ry][rx] = fl;
            }
          }
        }
      }
    }

    this.entities = [];
    this.projectiles = [];
    this.attackEffect = null;

    for(let i=1; i<rooms.length; i++) this.connectRooms(rooms[i-1], rooms[i]);

    this.player.x = rooms[0].cx;
    this.player.y = rooms[0].cy;

    // 階段は最終部屋の中心に確実配置(フォアグラウンドを上書き)
    const lastRoom = rooms[rooms.length-1];
    this.map.layers[1][lastRoom.cy][lastRoom.cx] = ASSETS.FG.STAIRS;

    rooms.forEach((r, idx) => {
      if(idx === 0) return;

      if(Math.random() < 0.2) this.spawnEntity('MERCHANT', r.cx, r.cy);

      const enemyCnt = 1 + Math.floor(Math.random()*3);
      for(let k=0; k<enemyCnt; k++) this.spawnEntity('ENEMY', this.rand(r).x, this.rand(r).y);

      const itemCnt = 1 + Math.floor(Math.random()*2);
      for(let k=0; k<itemCnt; k++) this.spawnEntity('ITEM', this.rand(r).x, this.rand(r).y);

      // ガラクタをランダムに配置
      if(Math.random() < 0.5) {
        const p = this.rand(r);
        this.spawnEntity('JUNK', p.x, p.y);
      }

      if(Math.random() < 0.4) {
        const p = this.rand(r);
        const dice = Math.random();
        if(dice < 0.3) this.map.layers[1][p.y][p.x] = ASSETS.FG.FOUNTAIN;
        else if(dice < 0.5) this.map.layers[1][p.y][p.x] = ASSETS.FG.STATUE;
        else if(dice < 0.7) this.map.layers[1][p.y][p.x] = ASSETS.FG.CHEST;
        else this.map.layers[1][p.y][p.x] = ASSETS.FG.TRAP;
      }
    });

    this.updateCamera();
    this.draw();
  }

  connectRooms(r1, r2) {
    let x = r1.cx, y = r1.cy;
    while(x !== r2.cx || y !== r2.cy) {
      if(Math.random() < 0.5) { if(x < r2.cx) x++; else if(x > r2.cx) x--; }
      else { if(y < r2.cy) y++; else if(y > r2.cy) y--; }
      this.map.layers[0][y][x] = ASSETS.BG.FLOOR;
    }
  }

  rand(room) {
    return {
      x: room.x + Math.floor(Math.random() * room.w),
      y: room.y + Math.floor(Math.random() * room.h)
    };
  }

  spawnEntity(type, x, y) {
    if(x < 0 || x >= this.map.w || y < 0 || y >= this.map.h) return;
    if(this.map.layers[0][y][x] === ASSETS.BG.WALL) return;

    if(type === 'ENEMY') {
      const tier = Math.min(Math.floor((this.floor-1)/3), ASSETS.ENEMIES.length-1);
      const group = ASSETS.ENEMIES[tier];
      const char = group[Math.floor(Math.random()*group.length)];
      this.entities.push({
        type: 'ENEMY', char, x, y,
        hp: this.floor * 3 + 5, maxHp: this.floor * 3 + 5,
        atk: Math.floor(this.floor/2) + 2, exp: this.floor * 3
      });
    } else if(type === 'MERCHANT') {
      this.entities.push({ type: 'MERCHANT', char: ASSETS.MERCHANT, x, y, hp: 999 });
    } else if(type === 'ITEM') {
      const keys = Object.keys(ASSETS.ITEMS);
      const k = keys[Math.floor(Math.random() * keys.length)];
      const itemData = { ...ASSETS.ITEMS[k], id: Date.now() + Math.random() };
      if(itemData.type === 'gold') {
        itemData.value = (itemData.char === '💎') ? 300 * this.floor : 50 + Math.floor(Math.random()*50)*this.floor;
      }
      // 武器・防具は確率で強化値を付与
      if(itemData.type === 'weapon' || itemData.type === 'armor') {
        const r = Math.random();
        if(r < 0.15) itemData.enhance = 2;
        else if(r < 0.4) itemData.enhance = 1;
        else if(r < 0.5) itemData.enhance = -1;
        else itemData.enhance = 0;
        // nameに強化値を反映
        if(itemData.enhance !== 0) {
          itemData.displayName = itemData.name + enhanceText(itemData);
        } else {
          itemData.displayName = itemData.name;
        }
      }
      this.entities.push({ type: 'ITEM', item: itemData, char: itemData.char, x, y });
    } else if(type === 'JUNK') {
      if(this.entities.some(e => e.x === x && e.y === y)) return;
      const junkDef = ASSETS.JUNK[Math.floor(Math.random() * ASSETS.JUNK.length)];
      const junkItem = {
        ...junkDef,
        type: 'junk',
        value: 0,
        throwDmg: Math.floor(Math.random()*3) + 1,
        id: Date.now() + Math.random()
      };
      this.entities.push({ type: 'ITEM', item: junkItem, char: junkDef.char, x, y });
    }
  }

  // アイテムの表示名
  displayName(item) {
    if(item.displayName) return item.displayName;
    if((item.type === 'weapon' || item.type === 'armor') && item.enhance !== undefined) {
      return item.name + enhanceText(item);
    }
    return item.name;
  }

  handleInput(e) {
    if(this.mode === 'TITLE' || this.mode === 'GAMEOVER') return;

    // コンテキストメニューが開いているとき
    if(document.getElementById('ctx-menu').style.display === 'block') {
      e.preventDefault();
      if(e.key === 'ArrowUp' || e.key === 'ArrowLeft') this.moveCtxMenu(-1);
      else if(e.key === 'ArrowDown' || e.key === 'ArrowRight') this.moveCtxMenu(1);
      else if(e.key === 'z' || e.key === 'Z' || e.key === 'Enter') this.ctxConfirm();
      else if(e.key === 'x' || e.key === 'X' || e.key === 'Escape') this.closeCtxMenu();
      return;
    }

    if(this.isAnimating) return;

    if(this.mode === 'PLAY') {
      let dx=0, dy=0;
      if(e.key === 'ArrowUp') dy=-1;
      else if(e.key === 'ArrowDown') dy=1;
      else if(e.key === 'ArrowLeft') dx=-1;
      else if(e.key === 'ArrowRight') dx=1;
      else if(e.key === 'Enter' || e.key === 'z' || e.key === 'Z') { this.interact(); return; }
      else if(e.key === 'a' || e.key === 'A') { this.toggleInventory(); return; }
      else if(e.key === 'x' || e.key === 'X') {
        // Xキー: 進行方向にダッシュ(最大10歩、壁・敵・アイテムで止まる)
        this.dash(this.player.lastDx, this.player.lastDy);
        return;
      }
      if(dx!==0 || dy!==0) { e.preventDefault(); this.turn(dx, dy); }
    }
    else if(this.mode === 'INVENTORY') {
      e.preventDefault();
      if(e.key === 'ArrowUp') this.moveMenuSelection(-1);
      else if(e.key === 'ArrowDown') this.moveMenuSelection(1);
      else if(e.key === 'z' || e.key === 'Z' || e.key === 'Enter') this.menuActionOpenCtx();
      else if(e.key === 'x' || e.key === 'X' || e.key === 'a' || e.key === 'A' || e.key === 'Escape') this.closeModal();
    }
    else if(this.mode === 'SHOP') {
      e.preventDefault();
      if(e.key === 'ArrowUp') this.moveMenuSelection(-1);
      else if(e.key === 'ArrowDown') this.moveMenuSelection(1);
      else if(e.key === 'z' || e.key === 'Z' || e.key === 'Enter') this.menuAction();
      else if(e.key === 'a' || e.key === 'A' || e.key === 'x' || e.key === 'X' || e.key === 'Escape') this.closeModal();
    }
  }

  turn(dx, dy) {
    const tx = this.player.x + dx;
    const ty = this.player.y + dy;

    // 進行方向を記録
    if(dx !== 0 || dy !== 0) {
      this.player.lastDx = dx;
      this.player.lastDy = dy;
    }

    if(!this.isWalkable(tx, ty)) return;

    const target = this.entities.find(e => e.x === tx && e.y === ty);
    let blocked = false;

    if(target) {
      if(target.type === 'ENEMY') {
        this.attack(target, dx, dy);
        blocked = true;
      } else if(target.type === 'MERCHANT') {
        this.openShop();
        blocked = true;
      }
    }

    if(!blocked) {
      this.player.x = tx;
      this.player.y = ty;
      Sound.play('move');

      const itemIdx = this.entities.findIndex(e => e.type === 'ITEM' && e.x === tx && e.y === ty);
      if(itemIdx !== -1) {
        this.pickup(this.entities[itemIdx], itemIdx);
      }
      this.checkGimmick(tx, ty);
    }

    // 食料消費(攻撃でも消費)
    this.player.food -= 0.3;
    if(this.player.food < 0) this.player.food = 0;
    if(this.player.food <= 0) {
      this.player.food = 0;
      this.player.hp = Math.max(1, this.player.hp - 1); // 即死しない(HP1止まり)
      Sound.play('hunger');
      this.log('お腹が空いてフラフラだ...(HP-1)', 'danger');
    }

    this.updateEnemies();
    this.updateCamera();
    this.draw();
    this.updateUI();
  }

  isWalkable(x, y) {
    if(x<0 || x>=this.map.w || y<0 || y>=this.map.h) return false;
    if(this.map.layers[0][y][x] === ASSETS.BG.WALL) return false;
    const fg = this.map.layers[1][y][x];
    if(fg === ASSETS.FG.ROCK || fg === ASSETS.FG.TREE) return false;
    return true;
  }

  pickup(ent, idx) {
    const item = ent.item;

    if(item.type === 'gold') {
      Sound.play('coin');
      this.player.gold += item.value;
      this.log(`${item.value}G を拾った!`, 'lucky');
      this.entities.splice(idx, 1);
      return;
    }

    // 武器を拾ったとき:現在の装備より強ければ自動装備、弱ければ捨てる
    if(item.type === 'weapon') {
      const newPower = itemPower(item);
      if(this.player.weapon) {
        const curPower = itemPower(this.player.weapon);
        if(newPower > curPower) {
          // 現在の武器を地面に置く
          const oldWeapon = this.player.weapon;
          this.dropItemAt(oldWeapon, this.player.x, this.player.y);
          this.log(`弱い${this.displayName(oldWeapon)}を置いた`, '');
          // 新しい武器を装備
          this.player.atk -= curPower;
          this.player.weapon = item;
          this.player.atk += newPower;
          this.log(`🗡️ ${this.displayName(item)}を拾って装備!(攻撃力+${newPower})`, 'good');
          Sound.play('equip');
        } else if(newPower === curPower) {
          // 同じ強さならインベントリに
          if(this.player.inventory.length < 15) {
            this.player.inventory.push(item);
            this.log(`${this.displayName(item)}を拾った`, 'good');
            Sound.play('pickup');
          } else {
            this.log('持ち物がいっぱいで拾えない!');
            return;
          }
        } else {
          // 弱い→その場に残す(通過するだけ)
          this.log(`${this.displayName(item)}は今の武器より弱いので無視した`, '');
          return; // entityはそのままにする(spliceしない)
        }
      } else {
        // 武器を持っていない→装備
        this.player.weapon = item;
        this.player.atk += newPower;
        this.log(`🗡️ ${this.displayName(item)}を拾って装備!(攻撃力+${newPower})`, 'good');
        Sound.play('equip');
      }
      this.entities.splice(idx, 1);
      return;
    }

    // 防具を拾ったとき
    if(item.type === 'armor') {
      const newPower = itemPower(item);
      if(this.player.armor) {
        const curPower = itemPower(this.player.armor);
        if(newPower > curPower) {
          const oldArmor = this.player.armor;
          this.dropItemAt(oldArmor, this.player.x, this.player.y);
          this.log(`弱い${this.displayName(oldArmor)}を置いた`, '');
          this.player.def -= curPower;
          this.player.armor = item;
          this.player.def += newPower;
          this.log(`🛡️ ${this.displayName(item)}を拾って装備!(防御力+${newPower})`, 'good');
          Sound.play('equip');
        } else if(newPower <= curPower) {
          this.log(`${this.displayName(item)}は今の防具より弱いので無視した`, '');
          return; // その場に残す
        }
      } else {
        this.player.armor = item;
        this.player.def += newPower;
        this.log(`🛡️ ${this.displayName(item)}を拾って装備!(防御力+${newPower})`, 'good');
        Sound.play('equip');
      }
      this.entities.splice(idx, 1);
      return;
    }

    // ガラクタ・その他
    if(this.player.inventory.length >= 15) {
      this.log('持ち物がいっぱいで拾えない!');
      return;
    }
    Sound.play('pickup');
    this.player.inventory.push(item);
    this.log(`${item.name}を拾った`, 'good');
    this.entities.splice(idx, 1);
  }

  // アイテムをその場に置く(地面にEntityとして追加)
  dropItemAt(item, x, y) {
    // 同じ場所に既にアイテムがあれば少しずらす
    let px = x, py = y;
    const offsets = [[0,0],[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1],[1,-1],[-1,1]];
    for(const [ox, oy] of offsets) {
      const nx = x+ox, ny = y+oy;
      if(this.isWalkable(nx, ny) && !this.entities.some(e => e.type==='ITEM' && e.x===nx && e.y===ny)) {
        px = nx; py = ny; break;
      }
    }
    this.entities.push({ type: 'ITEM', item, char: item.char, x: px, y: py });
  }

  checkGimmick(x, y) {
    const fg = this.map.layers[1][y][x];
    if(!fg) return;

    if(fg === ASSETS.FG.TRAP) {
      Sound.play('hit');
      const dmg = Math.floor(this.player.maxHp * 0.1) + 1;
      this.player.hp -= dmg;
      if(this.player.hp < 1) this.player.hp = 1; // 罠でも即死しない
      this.log(`⚡ 罠にかかった!${dmg}ダメージ!`, 'danger');
      this.map.layers[1][y][x] = null;
    } else if(fg === ASSETS.FG.FOUNTAIN) {
      const effect = Math.random();
      if(effect < 0.4) {
        this.player.hp = this.player.maxHp;
        this.log('⛲ 泉の水でHP全回復!', 'lucky');
        Sound.play('powerup');
      } else if(effect < 0.7) {
        this.player.food = this.player.maxFood;
        this.log('⛲ 美味しい水!お腹が満たされた!', 'lucky');
        Sound.play('powerup');
      } else {
        this.log('⛲ ただの水だった。');
      }
      this.map.layers[1][y][x] = null;
    } else if(fg === ASSETS.FG.CHEST) {
      Sound.play('coin');
      const gold = 100 + Math.floor(Math.random()*200) * this.floor;
      this.player.gold += gold;
      this.log(`🎁 宝箱!${gold}G 入っていた!`, 'lucky');
      this.map.layers[1][y][x] = null;
    }
  }

  interact() {
    if(this.map.layers[1][this.player.y][this.player.x] === ASSETS.FG.STAIRS) {
      Sound.play('powerup');
      this.floor++;
      this.log(`✨ ${this.floor}階へ進んだ!`, 'good');
      this.generateFloor();
    }
  }

  // ===== 攻撃 =====
  attack(target, dx, dy) {
    Sound.play('attack');
    const dmg = Math.max(1, this.player.atk);

    // 武器エフェクト:敵の上に武器を一瞬表示
    if(this.player.weapon) {
      this.attackEffect = {
        weaponChar: this.player.weapon.char,
        tx: target.x,
        ty: target.y,
        timer: 8  // フレーム数
      };
    }

    target.hp -= dmg;
    this.log(`${target.char}に${dmg}ダメージ!`);

    if(target.hp <= 0) {
      this.log(`${target.char}を倒した!`, 'good');
      this.gainExp(target.exp);
      this.entities = this.entities.filter(e => e !== target);
      if(Math.random() < 0.4) this.spawnEntity('ITEM', target.x, target.y);
    }
  }

  gainExp(amount) {
    this.player.exp += amount;
    if(this.player.exp >= this.player.nextExp) {
      this.player.lv++;
      this.player.exp -= this.player.nextExp;
      this.player.nextExp = Math.floor(this.player.nextExp * 1.5);
      this.player.maxHp += 5;
      this.player.hp = this.player.maxHp;
      this.player.atk += 1;
      this.log(`🎉 レベルアップ!Lv${this.player.lv}!HP全回復!`, 'lucky');
      Sound.play('levelup');
    }
  }

  // ===== 投擲 =====
  // item: すでにインベントリ・装備から除去済みのアイテムオブジェクト, dx/dy: 方向
  // callerRemovedFromInventory: 呼び出し元でインベントリ削除済みならtrue
  throwItem(item, dx, dy, callerRemovedFromInventory = false) {
    if(dx === 0 && dy === 0) {
      this.log('投げる方向が分からない(まず移動してから投げよう)');
      return false;
    }

    // まだインベントリにあれば除去(マップ上Xキー直接投擲など)
    if(!callerRemovedFromInventory) {
      const invIdx = this.player.inventory.indexOf(item);
      if(invIdx !== -1) this.player.inventory.splice(invIdx, 1);
    }

    Sound.play('throw');

    // 投射物追加
    this.projectiles.push({
      x: this.player.x,
      y: this.player.y,
      char: item.char,
      dx, dy,
      item,
      steps: 0,
      maxSteps: (item.type === 'weapon' || item.type === 'junk') ? 8 : 5
    });

    const name = this.displayName(item);
    this.log(`${item.char} ${name}を投げた!`, '');

    // モーダルを閉じてからアニメ開始
    this.closeModal();
    this.isAnimating = true;
    this.animateProjectiles();
    return true;
  }

  animateProjectiles() {
    if(this.projectiles.length === 0) {
      this.isAnimating = false;
      this.updateEnemies();
      this.updateCamera();
      this.draw();
      this.updateUI();
      return;
    }

    const proj = this.projectiles[0];

    // まず1歩進める
    proj.x += proj.dx;
    proj.y += proj.dy;
    proj.steps++;

    const hitWall = !this.isWalkable(proj.x, proj.y);
    const hitEnemy = this.entities.find(e => e.type === 'ENEMY' && e.x === proj.x && e.y === proj.y);

    if(hitWall || hitEnemy || proj.steps >= proj.maxSteps) {
      // 着弾処理
      if(hitEnemy) {
        let dmg = 0;
        if(proj.item.type === 'weapon') {
          dmg = Math.max(1, itemPower(proj.item) + Math.floor(this.player.atk / 2));
        } else if(proj.item.type === 'junk') {
          dmg = proj.item.throwDmg || 1;
        } else {
          dmg = Math.max(1, Math.floor(this.player.atk / 2));
        }
        hitEnemy.hp -= dmg;
        Sound.play('hit');
        this.log(`${hitEnemy.char}に${proj.item.char}がヒット!${dmg}ダメージ!`, 'good');
        if(hitEnemy.hp <= 0) {
          this.log(`${hitEnemy.char}を倒した!`, 'good');
          this.gainExp(hitEnemy.exp);
          this.entities = this.entities.filter(e => e !== hitEnemy);
          if(Math.random() < 0.4) this.spawnEntity('ITEM', hitEnemy.x, hitEnemy.y);
        }
      } else if(!hitWall) {
        Sound.play('miss');
      }

      // アイテムを着弾地点に落とす(壁に当たった場合は1歩手前)
      let landX = hitWall ? proj.x - proj.dx : proj.x;
      let landY = hitWall ? proj.y - proj.dy : proj.y;
      landX = Math.max(0, Math.min(this.map.w-1, landX));
      landY = Math.max(0, Math.min(this.map.h-1, landY));
      if(this.isWalkable(landX, landY)) {
        this.dropItemAt(proj.item, landX, landY);
      }

      this.projectiles.shift();
      this.draw();
      setTimeout(() => this.animateProjectiles(), 60);
    } else {
      // 飛翔中:描画
      this.draw();
      const screenX = (proj.x - this.camera.x) * this.TILE_SIZE + this.TILE_SIZE/2;
      const screenY = (proj.y - this.camera.y) * this.TILE_SIZE + this.TILE_SIZE/2;
      this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.9)}px sans-serif`;
      this.ctx.textAlign = 'center';
      this.ctx.textBaseline = 'middle';
      this.ctx.fillText(proj.char, screenX, screenY);
      setTimeout(() => this.animateProjectiles(), 60);
    }
  }

  // ===== メニュー =====
  toggleInventory() {
    if(this.mode === 'INVENTORY') {
      this.closeModal();
    } else if(this.mode === 'PLAY') {
      this.openInventory();
    }
  }

  openInventory() {
    this.mode = 'INVENTORY';
    this.renderInventory();
    this.menuSelection = 0;
    document.getElementById('modal-inventory').style.display = 'flex';
    this.highlightMenuItem('inventory-list', 0);
  }

  renderInventory() {
    const list = document.getElementById('inventory-list');
    list.innerHTML = '';
    if(this.player.inventory.length === 0) {
      list.innerHTML = '<li style="color:#666;">持ち物がありません</li>';
      return;
    }
    this.player.inventory.forEach((item, idx) => {
      const li = document.createElement('li');
      let prefix = '';
      if(this.player.weapon === item) { prefix = '⚔️[E] '; li.classList.add('equipped'); }
      else if(this.player.armor === item) { prefix = '🛡️[E] '; li.classList.add('equipped'); }
      const name = this.displayName(item);
      let typeTag = '';
      if(item.type==='junk') typeTag = '<span style="color:#888;font-size:12px;"> ガラクタ</span>';
      else if(item.type==='weapon') typeTag = `<span style="font-size:12px;color:#fa8;"> ATK+${itemPower(item)}</span>`;
      else if(item.type==='armor') typeTag = `<span style="font-size:12px;color:#8af;"> DEF+${itemPower(item)}</span>`;
      li.innerHTML = `<span>${prefix}${item.char} ${name}${typeTag}</span>`;
      li.onclick = () => { this.menuSelection = idx; this.menuActionOpenCtx(); };
      if(idx === 0) li.classList.add('selected');
      list.appendChild(li);
    });
  }

  highlightMenuItem(listId, idx) {
    const items = document.getElementById(listId).getElementsByTagName('li');
    for(let i=0; i<items.length; i++) items[i].classList.remove('selected');
    if(items[idx]) { items[idx].classList.add('selected'); items[idx].scrollIntoView({block:'nearest'}); }
  }

  openShop() {
    this.mode = 'SHOP';
    this.menuSelection = 0;
    this.currentShopItems = [];
    const cnt = 4 + Math.floor(Math.random()*3);
    const keys = Object.keys(ASSETS.ITEMS).filter(k => ASSETS.ITEMS[k].type !== 'gold');
    for(let i=0; i<cnt; i++) {
      const k = keys[Math.floor(Math.random() * keys.length)];
      const base = ASSETS.ITEMS[k];
      const price = Math.max(10, Math.floor(base.value * (0.5 + Math.random()*0.5)));
      const shopItem = { ...base, price, id: Date.now()+i };
      // 武器・防具には強化値
      if(shopItem.type === 'weapon' || shopItem.type === 'armor') {
        const r = Math.random();
        shopItem.enhance = r < 0.2 ? 1 : r < 0.3 ? 2 : 0;
        shopItem.displayName = shopItem.name + enhanceText(shopItem);
        if(shopItem.enhance > 0) shopItem.price = Math.floor(shopItem.price * (1 + shopItem.enhance * 0.5));
      }
      this.currentShopItems.push(shopItem);
    }
    this.renderShop();
    document.getElementById('modal-shop').style.display = 'flex';
  }

  renderShop() {
    const list = document.getElementById('shop-list');
    list.innerHTML = '';
    if(this.currentShopItems.length === 0) {
      list.innerHTML = '<li>売り切れ</li>';
    } else {
      this.currentShopItems.forEach((item, idx) => {
        const li = document.createElement('li');
        const name = this.displayName(item);
        li.innerHTML = `<span>${item.char} ${name}</span> <span style="color:gold;">${item.price} G</span>`;
        li.onclick = () => { this.menuSelection = idx; this.menuAction(); };
        if(idx === this.menuSelection) li.classList.add('selected');
        list.appendChild(li);
      });
    }
    document.getElementById('shop-gold').innerText = `所持金: ${this.player.gold} G`;
  }

  // インベントリでZ/クリック:アクションメニューを開く
  menuActionOpenCtx() {
    if(this.mode !== 'INVENTORY') return;
    if(this.player.inventory.length === 0) return;
    const item = this.player.inventory[this.menuSelection];
    this.ctxMenuItemIdx = this.menuSelection;
    this.showCtxMenu(item);
  }

  menuAction() {
    if(this.mode === 'INVENTORY') {
      if(this.player.inventory.length === 0) return;
      const item = this.player.inventory[this.menuSelection];
      this.useItem(item, this.menuSelection);
    } else if(this.mode === 'SHOP') {
      if(this.currentShopItems.length === 0) return;
      const item = this.currentShopItems[this.menuSelection];
      if(this.player.gold >= item.price) {
        Sound.play('coin');
        this.player.gold -= item.price;
        // 武器・防具は自動装備チェック
        if(item.type === 'weapon' || item.type === 'armor') {
          this.player.inventory.push(item);
          this.log(`${this.displayName(item)}を購入!`, 'good');
          // その後自動で装備判定(PickupとほぼDry runなので手動)
          this.tryAutoEquipFromInventory(item);
        } else {
          this.player.inventory.push(item);
          this.log(`${this.displayName(item)}を買った!`, 'good');
        }
        this.currentShopItems.splice(this.menuSelection, 1);
        if(this.menuSelection >= this.currentShopItems.length) this.menuSelection = Math.max(0, this.currentShopItems.length-1);
        this.renderShop();
        this.updateUI();
      } else {
        this.log('お金が足りない...', 'danger');
      }
    }
  }

  tryAutoEquipFromInventory(item) {
    if(item.type === 'weapon') {
      const np = itemPower(item);
      if(!this.player.weapon) {
        this.player.weapon = item;
        this.player.atk += np;
        this.log(`${this.displayName(item)}を装備!`, 'good');
        Sound.play('equip');
      } else if(np > itemPower(this.player.weapon)) {
        const old = this.player.weapon;
        this.player.atk -= itemPower(old);
        this.player.weapon = item;
        this.player.atk += np;
        this.log(`${this.displayName(item)}に持ち替えた!`, 'good');
        Sound.play('equip');
      }
    } else if(item.type === 'armor') {
      const np = itemPower(item);
      if(!this.player.armor) {
        this.player.armor = item;
        this.player.def += np;
        this.log(`${this.displayName(item)}を装備!`, 'good');
        Sound.play('equip');
      } else if(np > itemPower(this.player.armor)) {
        const old = this.player.armor;
        this.player.def -= itemPower(old);
        this.player.armor = item;
        this.player.def += np;
        this.log(`${this.displayName(item)}に着替えた!`, 'good');
        Sound.play('equip');
      }
    }
  }

  useItem(item, idx) {
    let used = false;
    let shouldClose = true;

    if(item.type === 'consumable') {
      const heal = Math.min(item.power, this.player.maxHp - this.player.hp);
      this.player.hp += heal;
      this.log(`${item.name}でHP+${heal}回復!`, 'good');
      Sound.play('powerup');
      used = true;
    } else if(item.type === 'food') {
      const gain = Math.min(item.power, this.player.maxFood - this.player.food);
      this.player.food += gain;
      this.log(`${item.name}を食べた!満腹度UP`, 'good');
      Sound.play('powerup');
      used = true;
    } else if(item.type === 'magic') {
      const targets = this.entities.filter(e => e.type === 'ENEMY' &&
        Math.abs(e.x - this.player.x) + Math.abs(e.y - this.player.y) <= item.range);
      if(targets.length === 0) {
        this.log('近くに敵がいない!', '');
        return;
      }
      this.closeModal();
      shouldClose = false;
      used = true;
      this.log(`${item.name}を唱えた!`, 'magic');
      this.castMagicSequence(targets, item);
    } else if(item.type === 'weapon') {
      // 武器:装備切り替え
      if(this.player.weapon === item) {
        this.player.atk -= itemPower(item);
        this.player.weapon = null;
        this.log(`${this.displayName(item)}を外した`);
      } else {
        if(this.player.weapon) this.player.atk -= itemPower(this.player.weapon);
        this.player.weapon = item;
        this.player.atk += itemPower(item);
        this.log(`${this.displayName(item)}を装備!`, 'good');
        Sound.play('equip');
      }
    } else if(item.type === 'armor') {
      if(this.player.armor === item) {
        this.player.def -= itemPower(item);
        this.player.armor = null;
        this.log(`${this.displayName(item)}を外した`);
      } else {
        if(this.player.armor) this.player.def -= itemPower(this.player.armor);
        this.player.armor = item;
        this.player.def += itemPower(item);
        this.log(`${this.displayName(item)}を装備!`, 'good');
        Sound.play('equip');
      }
    } else if(item.type === 'junk') {
      this.log(`${item.name}...使い道がなさそうだ。「投げる」で投げてみよう`);
      return;
    }

    if(used) {
      this.player.inventory.splice(idx, 1);
      if(this.menuSelection >= this.player.inventory.length) this.menuSelection = Math.max(0, this.player.inventory.length-1);
    }
    if(shouldClose) {
      this.closeModal();
      this.turn(0, 0);
    } else {
      this.renderInventory();
      this.highlightMenuItem('inventory-list', this.menuSelection);
    }
    this.updateUI();
  }

  // インベントリでXキー:投げる or 捨てる
  menuActionThrow() {
    if(this.mode !== 'INVENTORY') return;
    if(this.player.inventory.length === 0) return;
    const item = this.player.inventory[this.menuSelection];
    this.ctxMenuItemIdx = this.menuSelection;
    this.showCtxMenu(item);
  }

  showCtxMenu(item) {
    const name = this.displayName(item);
    // 「使う/装備する」ラベルをアイテム種別に合わせて変更
    let useLabel = '✅ 使う';
    if(item.type === 'weapon' || item.type === 'armor') {
      const isEquipped = (this.player.weapon === item || this.player.armor === item);
      useLabel = isEquipped ? '✅ 外す' : '✅ 装備する';
    } else if(item.type === 'food' || item.type === 'consumable') {
      useLabel = '✅ 使う(食べる/飲む)';
    } else if(item.type === 'magic') {
      useLabel = '✅ 唱える';
    } else if(item.type === 'junk') {
      useLabel = '✅ 使う(使い道なし)';
    }
    document.getElementById('ctx-use').textContent = `${useLabel}(${item.char} ${name})`;
    document.getElementById('ctx-throw').textContent = `🪃 投げる(${item.char} ${name})`;
    document.getElementById('ctx-drop').textContent = `📦 その場に置く(${item.char} ${name})`;
    document.getElementById('ctx-cancel').textContent = `✖ キャンセル`;
    const menu = document.getElementById('ctx-menu');
    menu.style.display = 'block';
    this.ctxMenuSelection = 0;
    this.updateCtxMenuHighlight();
  }

  moveCtxMenu(dir) {
    const items = document.getElementById('ctx-menu').querySelectorAll('.ctx-item');
    this.ctxMenuSelection = (this.ctxMenuSelection + dir + items.length) % items.length;
    this.updateCtxMenuHighlight();
  }

  updateCtxMenuHighlight() {
    const items = document.getElementById('ctx-menu').querySelectorAll('.ctx-item');
    items.forEach((el, i) => {
      el.classList.toggle('selected', i === this.ctxMenuSelection);
    });
  }

  ctxConfirm() {
    const labels = ['use', 'throw', 'drop', 'cancel'];
    this.ctxAction(labels[this.ctxMenuSelection] || 'cancel');
  }

  ctxAction(action) {
    // closeCtxMenuの前にインデックスを保存(closeCtxMenuがctxMenuItemIdxを-1にリセットするため)
    const savedIdx = this.ctxMenuItemIdx;
    this.closeCtxMenu();

    if(action === 'cancel') return;
    if(savedIdx < 0 || savedIdx >= this.player.inventory.length) return;
    const item = this.player.inventory[savedIdx];

    // 装備解除(武器・防具どちらか)
    const unequip = () => {
      if(this.player.weapon === item) {
        this.player.atk -= itemPower(item);
        this.player.weapon = null;
      } else if(this.player.armor === item) {
        this.player.def -= itemPower(item);
        this.player.armor = null;
      }
    };

    if(action === 'use') {
      // 元のuseItem処理を呼ぶ
      this.menuSelection = savedIdx;
      this.useItem(item, savedIdx);
    } else if(action === 'throw') {
      unequip();
      // インベントリから削除
      this.player.inventory.splice(savedIdx, 1);
      const dx = this.player.lastDx;
      const dy = this.player.lastDy;
      // throwItemにはすでに削除済みフラグを渡す
      this.throwItem(item, dx, dy, true);
    } else if(action === 'drop') {
      unequip();
      this.player.inventory.splice(savedIdx, 1);
      this.dropItemAt(item, this.player.x, this.player.y);
      Sound.play('drop');
      this.log(`${item.char} ${this.displayName(item)}を置いた`);
      this.closeModal();
      this.updateUI();
      this.draw();
    }
  }

  closeCtxMenu() {
    document.getElementById('ctx-menu').style.display = 'none';
    this.ctxMenuItemIdx = -1;
  }

  moveMenuSelection(dir) {
    const listId = (this.mode === 'INVENTORY') ? 'inventory-list' : 'shop-list';
    const items = document.getElementById(listId).getElementsByTagName('li');
    if(items.length === 0) return;
    items[this.menuSelection].classList.remove('selected');
    this.menuSelection += dir;
    if(this.menuSelection < 0) this.menuSelection = items.length - 1;
    if(this.menuSelection >= items.length) this.menuSelection = 0;
    items[this.menuSelection].classList.add('selected');
    items[this.menuSelection].scrollIntoView({ block: 'nearest' });
  }

  closeModal() {
    this.mode = 'PLAY';
    document.querySelectorAll('.modal-overlay').forEach(el => el.style.display = 'none');
    this.closeCtxMenu();
  }

  // ===== ダッシュ(Xキー)=====
  // 進行方向に最大10歩連続移動。壁・エネミー・アイテム・ギミックで停止。
  // 各1歩ごとに敵も1回行動する。
  dash(dx, dy) {
    if(dx === 0 && dy === 0) {
      this.log('まず移動して方向を決めてからダッシュしよう');
      return;
    }
    const MAX_DASH = 10;
    let moved = 0;
    for(let i = 0; i < MAX_DASH; i++) {
      const tx = this.player.x + dx;
      const ty = this.player.y + dy;

      // 壁なら停止
      if(!this.isWalkable(tx, ty)) break;

      // 敵がいたら停止(攻撃はしない)
      const enemy = this.entities.find(e => e.type === 'ENEMY' && e.x === tx && e.y === ty);
      if(enemy) break;

      // 商人がいたら停止
      const merchant = this.entities.find(e => e.type === 'MERCHANT' && e.x === tx && e.y === ty);
      if(merchant) break;

      // 1歩進む
      this.player.x = tx;
      this.player.y = ty;
      moved++;

      // 食料消費
      this.player.food -= 0.3;
      if(this.player.food < 0) this.player.food = 0;
      if(this.player.food <= 0) {
        this.player.food = 0;
        this.player.hp = Math.max(1, this.player.hp - 1);
        Sound.play('hunger');
        this.log('お腹が空いてフラフラだ...(HP-1)', 'danger');
      }

      // 敵行動(1歩ごと)
      this.updateEnemies();
      if(this.mode === 'GAMEOVER') return;

      // アイテム・ギミックチェック
      const itemIdx = this.entities.findIndex(e => e.type === 'ITEM' && e.x === tx && e.y === ty);
      if(itemIdx !== -1) {
        this.pickup(this.entities[itemIdx], itemIdx);
        // アイテムを踏んだら停止
        break;
      }
      const hasTrap = this.map.layers[1][ty][tx] === ASSETS.FG.TRAP;
      this.checkGimmick(tx, ty);
      if(hasTrap) break; // 罠を踏んだら停止

      // 階段があれば停止
      if(this.map.layers[1][ty][tx] === ASSETS.FG.STAIRS) break;
    }

    if(moved > 0) Sound.play('move');
    this.updateCamera();
    this.draw();
    this.updateUI();
  }

  // ===== 敵の行動 =====
  updateEnemies() {
    this.entities.forEach(e => {
      if(e.type !== 'ENEMY') return;
      const dist = Math.abs(this.player.x - e.x) + Math.abs(this.player.y - e.y);
      if(dist <= 1) {
        Sound.play('hit');
        const dmg = Math.max(1, e.atk - this.player.def);
        this.player.hp -= dmg;
        this.log(`${e.char}の攻撃!${dmg}ダメージ`, 'danger');
        if(this.player.hp <= 0) { this.gameOver(`${e.char}にやられた`); }
      } else if(dist < 8) {
        let mx = e.x, my = e.y;
        if(this.player.x < e.x) mx--;
        else if(this.player.x > e.x) mx++;
        else if(this.player.y < e.y) my--;
        else if(this.player.y > e.y) my++;
        if(this.isWalkable(mx, my) &&
          !this.entities.some(ent => ent.x === mx && ent.y === my) &&
          !(mx === this.player.x && my === this.player.y)) {
          e.x = mx; e.y = my;
        }
      }
    });
  }

  updateCamera() {
    this.camera.x = Math.max(0, Math.min(this.map.w - this.VIEW_W, this.player.x - Math.floor(this.VIEW_W/2)));
    this.camera.y = Math.max(0, Math.min(this.map.h - this.VIEW_H, this.player.y - Math.floor(this.VIEW_H/2)));
  }

  // ===== 描画 =====
  draw() {
    this.ctx.fillStyle = '#000';
    this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
    this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.9)}px sans-serif`;
    this.ctx.textAlign = 'center';
    this.ctx.textBaseline = 'middle';

    for(let y=0; y<this.VIEW_H; y++) {
      for(let x=0; x<this.VIEW_W; x++) {
        const mx = this.camera.x + x;
        const my = this.camera.y + y;
        const px = x * this.TILE_SIZE + this.TILE_SIZE/2;
        const py = y * this.TILE_SIZE + this.TILE_SIZE/2;

        // 背景
        const bg = this.map.layers[0][my][mx];
        if(bg !== ASSETS.BG.WALL) {
          this.ctx.fillText(bg, px, py);
        } else {
          this.ctx.fillStyle = (my < this.map.h-1 && this.map.layers[0][my+1][mx] !== ASSETS.BG.WALL) ? '#666' : '#222';
          this.ctx.fillText(ASSETS.BG.WALL, px, py);
          this.ctx.fillStyle = '#fff';
        }

        // フォアグラウンド
        const fg = this.map.layers[1][my][mx];
        if(fg) this.ctx.fillText(fg, px, py);

        // エンティティ
        const ent = this.entities.find(e => e.x === mx && e.y === my);
        if(ent) {
          this.ctx.fillText(ent.char, px, py);
          if(ent.type === 'ENEMY') {
            this.ctx.fillStyle = 'red';
            this.ctx.fillRect(px-12, py+12, 24*(ent.hp/ent.maxHp), 3);
            this.ctx.fillStyle = '#fff';

            // 攻撃エフェクト:武器を敵の上に表示
            if(this.attackEffect && this.attackEffect.tx === mx && this.attackEffect.ty === my && this.attackEffect.timer > 0) {
              const bigFont = Math.floor(this.TILE_SIZE * 1.3);
              this.ctx.font = `${bigFont}px sans-serif`;
              this.ctx.globalAlpha = this.attackEffect.timer / 8;
              this.ctx.fillText(this.attackEffect.weaponChar, px, py - this.TILE_SIZE * 0.3);
              this.ctx.globalAlpha = 1.0;
              this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.9)}px sans-serif`;
              this.attackEffect.timer--;
              if(this.attackEffect.timer <= 0) this.attackEffect = null;
            }
          }
        }

        // プレイヤー
        if(mx === this.player.x && my === this.player.y) {
          this.ctx.fillText(ASSETS.PLAYER, px, py);
        }
      }
    }
  }

  updateUI() {
    document.getElementById('val-floor').innerText = this.floor;
    document.getElementById('val-hp').innerText = `${this.player.hp}/${this.player.maxHp}`;
    const foodEl = document.getElementById('val-food');
    foodEl.innerText = `${Math.floor(this.player.food)}%`;
    foodEl.style.color = this.player.food === 0 ? '#f00' : this.player.food < 30 ? '#fa0' : '#fff';
    document.getElementById('val-lv').innerText = this.player.lv;
    document.getElementById('val-gold').innerText = this.player.gold;
  }

  log(msg, type='') {
    const el = document.getElementById('log-container');
    const line = document.createElement('div');
    line.className = `log-line new ${type}`;
    line.innerText = msg;
    el.prepend(line);
    if(el.children.length > 30) el.lastChild.remove();
    setTimeout(() => line.classList.remove('new'), 500);
  }

  // ===== 魔法 =====
  async castMagicSequence(targets, item) {
    this.isAnimating = true;
    Sound.play('magic_cast');
    for(const target of targets) {
      await this.animateFallingEmoji(target.x, target.y, item.magicChar);
      if(Math.random() < 0.5) {
        Sound.play('magic_hit');
        const dmg = item.power;
        target.hp -= dmg;
        this.log(`${target.char}に${item.magicChar}が命中!${dmg}ダメージ!`, 'magic');
        if(target.hp <= 0) {
          this.log(`${target.char}を倒した!`, 'good');
          this.gainExp(target.exp);
          this.entities = this.entities.filter(e => e !== target);
        }
      } else {
        Sound.play('miss');
        this.log(`${target.char}には当たらなかった...`);
      }
      this.draw();
      await new Promise(r => setTimeout(r, 300));
    }
    this.isAnimating = false;
    this.turn(0, 0);
  }

  animateFallingEmoji(tx, ty, char) {
    return new Promise(resolve => {
      const startY = (ty - 5) * this.TILE_SIZE;
      const endY = ty * this.TILE_SIZE;
      const pixelX = tx * this.TILE_SIZE;
      let currentPixelY = startY;
      const animate = () => {
        currentPixelY += 15;
        if(currentPixelY >= endY) { resolve(); }
        else {
          this.drawWithEffect(pixelX, currentPixelY, char);
          requestAnimationFrame(animate);
        }
      };
      animate();
    });
  }

  drawWithEffect(ex, ey, echar) {
    this.draw();
    const cx = ex - this.camera.x * this.TILE_SIZE;
    const cy = ey - this.camera.y * this.TILE_SIZE;
    if(cx >= 0 && cy >= 0 && cx < this.canvas.width && cy < this.canvas.height) {
      this.ctx.font = `${Math.floor(this.TILE_SIZE * 1.2)}px sans-serif`;
      this.ctx.textAlign = 'center';
      this.ctx.textBaseline = 'middle';
      this.ctx.fillText(echar, cx + this.TILE_SIZE/2, cy + this.TILE_SIZE/2);
    }
  }

  gameOver(reason) {
    this.mode = 'GAMEOVER';
    document.getElementById('start-screen').style.display = 'flex';
    document.querySelector('#start-screen h1').innerText = '💀 GAME OVER';
    document.getElementById('result-msg').innerHTML = `死因: ${reason}<br>到達: ${this.floor}F / Lv: ${this.player.lv} / 所持金: ${this.player.gold}G`;
    document.querySelector('button.big-btn').innerText = 'Try Again';
  }
}

const game = new Game();
</script>
</body>
</html>

・「Rogue」は元々キャラクターコードのゲームでしたから、
 これと言って新規性はないんですが、楽しそうなので付けました。


いいなと思ったら応援しよう!

小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
絵文字で絵を描く「Emoji Map」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1