絵文字で絵を描く「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.3</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;
  }

  /* --- メニューバー --- */
  #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;
  }
  
  /* パレットモード切り替えボタン */
  .palette-mode-switch {
    display: flex; gap: 2px; margin-bottom: 2px;
  }
  .mode-btn {
    flex: 1; padding: 6px; font-size: 12px; cursor: pointer;
    background: #fff; border: 1px solid #aaa; border-radius: 3px;
    color: #333; font-weight: bold;
  }
  .mode-btn.active {
    background: var(--accent); color: white; border-color: #2a5a8e;
  }

  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; }
    .palette-mode-switch { flex-direction: column; }
  }
</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">
      <div class="palette-mode-switch">
        <button id="btn-mode-game" class="mode-btn active" onclick="app.setPaletteMode('game')">ゲーム関連</button>
        <button id="btn-mode-other" class="mode-btn" onclick="app.setPaletteMode('other')">その他</button>
      </div>
      <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 v7.0 (Category Tabs)
 */
const SPACE = ' ';
// 古いキャッシュの影響を避けるためキーを更新
const PALETTE_KEY = 'emoji_palette_v7_tabs.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.paletteMode = 'game'; // 'game' or 'other'

    this.categories = {
      // ===== ゲーム用 =====
      "ゲーム:背景": [
        "⬛", "🟦", "🟧", "🟩", "⬜", "🟫", "🟥", "🟨", "🟪"
      ],
      "ゲーム:キャラ": [
        "🧙", "👳"
      ],
      "ゲーム:敵キャラ": [
        "🐀", "🦇", "🐍", "🐺", "🐗", "🕷️", "💀", "🧟", "👻", 
        "👺", "👹", "👁️", "🐉", "🐲", "👿", "🧛", "🎃", "🐒", "🦅", "💩", "🦝", "🦎",
        "🦍", "🦉", "🐢", "🐙", "🦑", "🦋", "🐝", "🐜"
      ],
      "ゲーム:障害物": [
        "🚪", "🌼", "🌷", "🌻", "🌹", "🪨", "🌲", "⛲", "🗿", "⚡", "📦", 
        "⛰️", "🌳", "🌾", "🌿"
      ],
      "ゲーム:装備品": [
        "⚔️", "🪓", "🗡️", "🏹", "🛡️", "🥋"
      ],
      "ゲーム:アイテム": [
        "🧪", "🏺", "💰", "💎", "📜", "🗺️", "🕯️"
      ],
      "ゲーム:食料": [
        "🍖", "🍞", "🍄", "🍌", "🧀", "🥐", "🍎", "🍊", "🍇"
      ],
      "ゲーム:ガラクタ": [
        "🍾", "🧱", "🔩", "🥫", "🧲"
      ],
      // ===== 大きくまとめたカテゴリ(その他) =====
      "顔・人物・身体": [
        "😀","😃","😄","😁","😆","😅","🤣","😂","🙂","🙃","😉","😊","😇",
        "🥰","😍","🤩","😋","😛","😜","🤪","😝","🤑","🤗","🤔","🤫",
        "😐","🤐","🤨","😑","😶","😏","😒","🙄","😬","🤥","😪","😴",
        "😷","🤒","🤕","🤢","🤮","🤧","🥵","🥶","🥴","😵","🤯",
        "😕","😟","🙁","☹","😮","😯","😲","😳","🥺","😢","😭","😱",
        "😤","😡","😠","🤬","😈","👿","🤡","👽","🤖",
        "🧑","👶","🧒","👦","👧","👨","👩","🧓","👴","👵",
        "👮","🕵","💂","👷","🤴","👸","👳","👲","🧕","🧔",
        "🏃","🚶","💃","🕺","🤸","🤼","🤽","🚣",
        "👪","👫","👬","👭","💏","💑",
        "👋","🤚","🖐","✋","👌","✌","🤞","🤟","🤘","🤙",
        "👈","👉","👆","👇","👍","👎","✊","👊","👏","🙌","👐","🤲","🤝","🙏",
        "💪","🦵","🦶","👂","👃","🧠","👀","👁","👅","👄"
      ],
      "動物・植物・自然": [
        "🐵","🦍","🐶","🐕","🦊","🐱","🐈","🦁","🐯","🐴","🦄","🦓","🐮","🐷",
        "🐭","🐰","🐻","🐨","🐼","🐘","🐔","🐦","🐧","🦆","🦉","🐸","🐢",
        "🐳","🐬","🐟","🐙","🦑","🦋","🐝","🐜","🐞","🕷","🦂",
        "💐","🌸","🌹","🌻","🌷","🌱","🌵","🍀","🍁","🍂",
        "🌑","🌕","🌙","⭐","☀","☁","🌧","❄","💧","☔","⚡","🌊"
      ],
      "食べ物・飲み物": [
        "🍇","🍈","🍉","🍊","🍍","🍎","🍑","🍒","🍓","🍅","🥑","🍆","🥔","🥕","🌽",
        "🍞","🥐","🧀","🍔","🌭","🥪","🌮","🥚","🍳","🍱","🍙","🍚","🍛","🍜","🍝","🍣",
        "🍦","🍩","🍪","🎂","🍫","🍬","☕","🍵","🍶","🍷","🍺","🍻"
      ],
      "建物・乗り物・風景": [
        "🏠","🏢","🏣","🏥","🏦","🏪","🏫","🏯","🏰","🗼","🗽","⛩","♨","🎡",
        "🚂","🚅","🚆","🚇","🚑","🚒","🚓","🚕","🚗","🚚","🚲","✈","🚀","🚢",
        "🌍","🗺","🏔","🗻","🏕","🏖","🏝","🏞","🛣"
      ],
      "物・道具・服": [
        "👓","👔","👕","👖","👗","👘","👙","🎒","👟","👠","👑","💍",
        "🔨","🔧","🔫","🔬","🔭","💉","💊","🚽","🛁","🚬",
        "📢","🔔","🎵","🎤","🎧","🎸","🎹",
        "📱","📞","🔋","💻","🖥","📷","📺","💡",
        "📖","📚","💴","💳","✉","✏","✒","📝","💼","📁","📅","📎","✂","🗑","🔒","🔑"
      ],
      "スポーツ・イベント": [
        "⚽","⚾","🏀","🏐","🏈","🎾","🎳","⛳","🎣","🎿","🏆","🏅",
        "🎯","🎱","🎮","🎰","🎲","🧩","♟","🎨",
        "🎄","🎆","🎈","🎉","🎍","🎎","🎏","🎐","🎑"
      ],
      "記号・マーク": [
        "💋","💌","💘","💝","💖","💗","💓","💞","💕","💔","❤️","🧡","💛","💚","💙","💜","🤎","🖤","🤍",
        "💯","💢","💦","💨","💤",
        "🏧","🚮","♿","🚹","🚺","⚠","⛔","🚫","🔞",
        "⬆","⬇","⬅","➡","↩",
        "✅","✔","❌","#️⃣","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.setPaletteMode('game'); // UIの初期化
    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.setPaletteMode('game');
                    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);
  }

  setPaletteMode(mode) {
    this.paletteMode = mode;
    document.getElementById('btn-mode-game').classList.toggle('active', mode === 'game');
    document.getElementById('btn-mode-other').classList.toggle('active', mode === 'other');
    
    // 表示モードの最初のカテゴリを選択状態にする
    const cats = Object.keys(this.categories).filter(c => 
      mode === 'game' ? c.startsWith('ゲーム:') : !c.startsWith('ゲーム:')
    );
    
    if(cats.length > 0) {
       this.currentCat = cats[0];
    } else {
       this.currentCat = "";
    }
    
    this.renderCategorySelect();
    this.renderPalette();
  }

  renderCategorySelect() {
    const sel = document.getElementById('category-select');
    sel.innerHTML = '';
    const isGame = this.paletteMode === 'game';
    
    for(let cat in this.categories) {
      if ((isGame && cat.startsWith('ゲーム:')) || (!isGame && !cat.startsWith('ゲーム:'))) {
        let op = document.createElement('option');
        op.value = cat;
        // 表示上は接頭辞「ゲーム:」を省いてスッキリさせる
        op.innerText = isGame ? cat.replace('ゲーム:', '') : 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) {
        // 現在のモードに合わせてキーを付与
        const catName = (this.paletteMode === 'game' && !name.startsWith('ゲーム:')) ? 'ゲーム:' + name : name;
        if(!this.categories[catName]) this.categories[catName] = [];
        this.currentCat = catName;
        this.renderCategorySelect();
      } else {
        document.getElementById('category-select').value = this.currentCat;
      }
    } else {
      this.currentCat = val;
    }
    this.renderPalette();
  }

  renderPalette() {
    this.paletteGrid.innerHTML = '';
    if (!this.currentCat || !this.categories[this.currentCat]) return;

    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() {
    if (!this.currentCat) return;
    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; touch-action: none; }

  #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">📂 マップフォルダを読み込む</label>
      <input type="file" id="map-file-input" accept=".json" webkitdirectory directory 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;">
      [マウスクリック/タップ] 移動<br>
      [矢印] 移動/攻撃 | [A] メニュー開閉<br>
      [Z] 確定/弓発射/アクション<br>
      <span style="color:#fa0;">※Zキーを押しっぱなしで方向キーを押すと、移動せずに「斜め」などの方向指定ができます</span><br>
      [X] キャンセル / ダッシュ(マップ)<br>
      武器・防具は自動装備(強い方を保持)<br>
    </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: '🟦', GRASS: '🟩' },
  FG: {
    STAIRS: '🚪',       
    FLOWERS: ['🌼', '🌷', '🌻', '🌹'],
    ROCK: '🪨', TREE: '🌲', FOUNTAIN: '⛲', STATUE: '🗿', TRAP: '⚡', CHEST: '📦',
    MOUNTAIN: '⛰️', BIG_TREE: '🌳', GRASS_DECO: ['🌾'] // 🌿を背景から除外
  },
  PLAYER: '🧙', MERCHANT: '👳',
  ENEMIES: [
    ['🐀', '🦇', '🐍', '🐜', '🦋'], 
    ['🐺', '🐗', '🕷️', '🐝'],
    ['💀', '🧟', '👻', '🦉'], 
    ['👺', '👹', '👁️', '🐢'], 
    ['🐉', '🐲', '👿', '🐙'],
    ['🧛', '🎃', '🐒', '🦍'], 
    ['🦅', '🐲', '🐉', '🦑'], 
    ['💩', '🦝', '🦎']
  ],
  JUNK: [
    { 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 },
    CHEESE:          { char: '🧀', name: 'チーズ',      type: 'food',       value: 30,  effect: 'food', power: 40 },
    CROISSANT:       { char: '🥐', name: 'クロワッサン',type: 'food',       value: 25,  effect: 'food', power: 30 },
    APPLE:           { char: '🍎', name: 'りんご',      type: 'food',       value: 15,  effect: 'food', power: 20 },
    ORANGE:          { char: '🍊', name: 'みかん',      type: 'food',       value: 15,  effect: 'food', power: 20 },
    GRAPE:           { char: '🍇', name: 'ぶどう',      type: 'food',       value: 20,  effect: 'food', power: 25 },
    MUSHROOM:        { char: '🍄', name: 'キノコ',      type: 'food_gamble',value: 15,  healPower: 30, dmgPower: 20 },
    BANANA:          { char: '🍌', name: 'バナナ',      type: 'food_gamble',value: 10,  healPower: 25, dmgPower: 15 },
    HERB:            { char: '🌿', name: '薬草',        type: 'consumable', value: 25,  effect: 'heal', power: 25 },
    SWORD:           { char: '⚔️', name: '鉄の剣',      type: 'weapon',     value: 200, power: 2,  throwable: false },
    AXE:             { char: '🪓', name: '戦斧',        type: 'weapon',     value: 500, power: 5,  throwable: true,  isSpecialWeapon: true },
    DAGGER:          { char: '🗡️', name: '短剣',        type: 'weapon',     value: 180, power: 2,  throwable: true },
    BOW:             { char: '🏹', name: '弓矢',        type: 'weapon',     value: 220, power: 3,  throwable: false, isBow: true, isSpecialWeapon: 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 },
    SCROLL_WIND:     { char: '📜', name: '風の巻物',    type: 'magic',      value: 180, magicChar: '🌪️', power: 18, range: 5 },
    SCROLL_METEOR:   { char: '📜', name: '隕石の巻物',  type: 'magic',      value: 250, magicChar: '☄️', power: 30, range: 3 },
    MAP_ITEM:        { char: '🗺️', name: '地図',        type: 'map_reveal', value: 80 },
    CANDLE:          { char: '🕯️', name: '燭台',        type: 'candle',     value: 60 }
  }
};

function enhanceText(item) {
  if(item.enhance && item.enhance > 0) return ` +${item.enhance}`;
  if(item.enhance && item.enhance < 0) return ` ${item.enhance}`;
  return '';
}
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;
      case 'bow_shoot':
        osc.type = 'sawtooth'; osc.frequency.setValueAtTime(600, t); osc.frequency.exponentialRampToValueAtTime(100, t+0.15);
        gain.gain.setValueAtTime(0.08, t); osc.start(t); osc.stop(t+0.15); break;
      case 'heal_sparkle':
        osc.type = 'sine'; osc.frequency.setValueAtTime(1200, t);
        osc.frequency.linearRampToValueAtTime(1800, t+0.15);
        osc.frequency.linearRampToValueAtTime(2400, t+0.3);
        gain.gain.setValueAtTime(0.12, t); gain.gain.linearRampToValueAtTime(0, t+0.3);
        osc.start(t); osc.stop(t+0.3); break;
      case 'poison':
        osc.type = 'square'; osc.frequency.setValueAtTime(80, t); osc.frequency.linearRampToValueAtTime(40, t+0.4);
        gain.gain.setValueAtTime(0.15, t); gain.gain.linearRampToValueAtTime(0, t+0.4);
        osc.start(t); osc.stop(t+0.4); break;
      case 'candle':
        osc.type = 'sine'; osc.frequency.setValueAtTime(600, t); osc.frequency.linearRampToValueAtTime(1200, t+0.1);
        osc.frequency.linearRampToValueAtTime(800, t+0.25);
        gain.gain.setValueAtTime(0.1, t); osc.start(t); osc.stop(t+0.25); break;
      case 'map_reveal':
        osc.type = 'triangle'; osc.frequency.setValueAtTime(400, t); osc.frequency.linearRampToValueAtTime(1600, t+0.4);
        gain.gain.setValueAtTime(0.15, t); gain.gain.linearRampToValueAtTime(0, t+0.4);
        osc.start(t); osc.stop(t+0.4); 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.keys = { up: false, down: false, left: false, right: false };
    this.zPressed = false;
    this.zActionHandled = false;

    this.mouseTarget = null;
    this.moveInterval = null;

    this.projectiles = []; 
    this.attackEffect = null; 
    this.isAnimating = false;

    this.isDarkFloor = false;
    this.darkViewRadius = 4;
    this.mapRevealed = false;
    this.healEffect = null;
    this.damageEffects = [];

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

    // イベントリスナーの登録
    window.addEventListener('keydown', e => this.handleKeyDown(e));
    window.addEventListener('keyup', e => this.handleKeyUp(e));
    
    this.setupCtxMenu();
    this.setupMapFileIO();
    this.setupMouseEvents();
  }

  setupMouseEvents() {
    const handleDown = (e) => {
      if (this.mode !== 'PLAY' || this.isAnimating) return;
      const pos = this.getMouseGridPos(e);
      if (pos) {
        this.mouseTarget = pos;
        this.processMouseMove();
        this.moveInterval = setInterval(() => this.processMouseMove(), 150);
      }
    };
    const handleMove = (e) => {
      if (this.moveInterval) {
        const pos = this.getMouseGridPos(e);
        if (pos) this.mouseTarget = pos;
      }
    };
    const handleUp = () => {
      if (this.moveInterval) {
        clearInterval(this.moveInterval);
        this.moveInterval = null;
        this.mouseTarget = null;
      }
    };

    this.canvas.addEventListener('mousedown', handleDown);
    this.canvas.addEventListener('mousemove', handleMove);
    window.addEventListener('mouseup', handleUp);
    
    this.canvas.addEventListener('touchstart', (e) => {
      if(e.target === this.canvas) e.preventDefault();
      handleDown(e.touches[0]);
    }, {passive: false});
    this.canvas.addEventListener('touchmove', (e) => {
      if(e.target === this.canvas) e.preventDefault();
      handleMove(e.touches[0]);
    }, {passive: false});
    window.addEventListener('touchend', handleUp);
  }

  getMouseGridPos(e) {
    const rect = this.canvas.getBoundingClientRect();
    const scaleX = this.canvas.width / rect.width;
    const scaleY = this.canvas.height / rect.height;
    
    const px = (e.clientX - rect.left) * scaleX;
    const py = (e.clientY - rect.top) * scaleY;
    
    const x = Math.floor(px / this.TILE_SIZE) + this.camera.x;
    const y = Math.floor(py / this.TILE_SIZE) + this.camera.y;
    
    if (x >= 0 && x < this.map.w && y >= 0 && y < this.map.h) return {x, y};
    return null;
  }

  processMouseMove() {
    if (this.mode !== 'PLAY' || this.isAnimating || !this.mouseTarget) return;
    const dx = this.mouseTarget.x - this.player.x;
    const dy = this.mouseTarget.y - this.player.y;
    
    if (dx === 0 && dy === 0) return;
    
    let stepX = 0;
    let stepY = 0;
    
    if (Math.abs(dx) > Math.abs(dy)) stepX = dx > 0 ? 1 : -1;
    else if (Math.abs(dy) > Math.abs(dx)) stepY = dy > 0 ? 1 : -1;
    else {
      if (Math.random() < 0.5) stepX = dx > 0 ? 1 : -1;
      else stepY = dy > 0 ? 1 : -1;
    }
    
    const isPassable = (tx, ty) => {
        if (!this.isWalkable(tx, ty)) return false;
        return true; 
    };

    if (!isPassable(this.player.x + stepX, this.player.y + stepY)) {
      if (stepX !== 0) {
        stepX = 0; stepY = dy > 0 ? 1 : (dy < 0 ? -1 : 0);
      } else {
        stepY = 0; stepX = dx > 0 ? 1 : (dx < 0 ? -1 : 0);
      }
    }
    
    if (stepX !== 0 || stepY !== 0) {
      this.turn(stepX, stepY);
    }
  }

  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();
  }

  setupMapFileIO() {
    const input = document.getElementById('map-file-input');
    input.addEventListener('change', (e) => {
      const files = Array.from(e.target.files).filter(f => f.name.endsWith('.json'));
      if (files.length === 0) {
          alert('選択したフォルダ内に有効なJSONファイルが見つかりませんでした。');
          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) {}
          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('有効なマップデータが見つかりませんでした。');
            }
          }
        };
        reader.readAsText(file);
      });
      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.BOW, id: Date.now()+1 } 
      ],
      weapon: null, armor: null,
      lastDx: 0, lastDy: -1
    };
    this.mode = 'PLAY';
    this.log('🏰 不思議のダンジョンへようこそ!', 'lucky');
    this.log('[Z]長押し+方向キーで狙いを定めて離すと発射!', '');
    this.healEffect = null;
    this.damageEffects = [];
    this.mapRevealed = false;
    this.player.candleActive = false;
    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;
    this.mapRevealed = false;
    this.healEffect = null;
    this.damageEffects = [];
    this.isDarkFloor = false;

    const bgSrc = mapData.layers[0];   
    const charSrc = mapData.layers[1]; 
    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 bgLayer[y].push(cell);
      }
    }

    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 = [];
    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 && bgLayer[y][x] !== ASSETS.BG.WATER) {
          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 && bgLayer[y][x] !== ASSETS.BG.WATER) {
          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 }) => {
      if (char === ASSETS.FG.STAIRS) { fgLayer[y][x] = char; 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 (char === ASSETS.FG.MOUNTAIN) { fgLayer[y][x] = ASSETS.FG.MOUNTAIN; return; }
      if (char === ASSETS.FG.BIG_TREE) { fgLayer[y][x] = ASSETS.FG.BIG_TREE; return; }
      if (ASSETS.FG.GRASS_DECO.includes(char)) { fgLayer[y][x] = char; return; }
      if (ASSETS.FG.FLOWERS.includes(char)) { fgLayer[y][x] = char; return; }

      if (char === ASSETS.PLAYER) { playerStartX = x; playerStartY = y; return; }
      if (char === ASSETS.MERCHANT) { this.entities.push({ type: 'MERCHANT', char: ASSETS.MERCHANT, x, y, hp: 999 }); return; }

      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;
      }

      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;
      }

      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;
      }
    });

    if (!stairsPlaced || (fgLayer[playerStartY] && fgLayer[playerStartY][playerStartX] === ASSETS.FG.STAIRS)) {
      if (stairsPlaced && fgLayer[playerStartY][playerStartX] === ASSETS.FG.STAIRS) fgLayer[playerStartY][playerStartX] = null;
      this.map.layers[0] = bgLayer;
      this.map.layers[1] = fgLayer;
      this.player.x = playerStartX;
      this.player.y = playerStartY;
      this.placeStairsGuaranteed(null, null, ASSETS.FG.STAIRS);
    } else {
      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;

    this.mapRevealed = false;
    this.healEffect = null;
    this.damageEffects = [];
    this.player.candleActive = false;

    const isOutdoor = (this.floor % 3 === 0);
    this.isDarkFloor = !isOutdoor && (this.floor % 5 === 0);

    let rooms = [];
    while(rooms.length < 2) {
      this.map.layers[0] = Array(H).fill().map(() => Array(W).fill(isOutdoor ? ASSETS.BG.WATER : 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] = isOutdoor ? ASSETS.BG.GRASS : 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], isOutdoor);

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

    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.placeStairsGuaranteed(rooms[rooms.length - 1], rooms, ASSETS.FG.STAIRS);
    if (this.isDarkFloor) this.log('🕯️ 暗闇のフロア!燭台を使うと視野が広がる', 'danger');

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

  placeStairsGuaranteed(preferRoom, rooms, stairsChar = ASSETS.FG.STAIRS) {
    const sc = stairsChar;
    const W = this.map.w, H = this.map.h;
    const px = this.player.x, py = this.player.y;

    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        if (this.map.layers[1][y][x] === ASSETS.FG.STAIRS) this.map.layers[1][y][x] = null;
      }
    }

    const isFloor = (x, y) =>
      x >= 0 && x < W && y >= 0 && y < H &&
      this.map.layers[0][y][x] !== ASSETS.BG.WALL &&
      this.map.layers[0][y][x] !== ASSETS.BG.WATER;

    const hasEntity = (x, y) => this.entities.some(e => e.x === x && e.y === y);
    const hasGimmick = (x, y) => this.map.layers[1][y][x] !== null && this.map.layers[1][y][x] !== undefined;
    const isPlayerPos = (x, y) => x === px && y === py;

    const roomCandidates = (room) => {
      const cells = [];
      for (let ry = room.y; ry < room.y + room.h; ry++) {
        for (let rx = room.x; rx < room.x + room.w; rx++) cells.push({ x: rx, y: ry });
      }
      return cells.sort(() => Math.random() - 0.5);
    };

    const shuffledCells = [];
    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) shuffledCells.push({x, y});
    }
    shuffledCells.sort(() => Math.random() - 0.5);

    const playerRoom = (rooms && rooms.length > 0) ? rooms[0] : null;
    const isInPlayerRoom = (x, y) => {
      if (!playerRoom) return false;
      return x >= playerRoom.x && x < playerRoom.x + playerRoom.w &&
             y >= playerRoom.y && y < playerRoom.y + playerRoom.h;
    };

    if (preferRoom) {
      for (const { x, y } of roomCandidates(preferRoom)) {
        if (isFloor(x, y) && !isPlayerPos(x, y) && !hasEntity(x, y) && !hasGimmick(x, y)) {
          this.map.layers[1][y][x] = sc; return;
        }
      }
    }
    for (const {x, y} of shuffledCells) {
      if (isFloor(x, y) && !isPlayerPos(x, y) && !isInPlayerRoom(x, y) && !hasEntity(x, y) && !hasGimmick(x, y)) {
        this.map.layers[1][y][x] = sc; return;
      }
    }
    for (const {x, y} of shuffledCells) {
      if (isFloor(x, y) && !isPlayerPos(x, y) && !isInPlayerRoom(x, y) && !hasEntity(x, y)) {
        this.map.layers[1][y][x] = sc; return;
      }
    }
    for (const {x, y} of shuffledCells) {
      if (isFloor(x, y) && !isPlayerPos(x, y)) {
        this.map.layers[1][y][x] = sc; return;
      }
    }
    for (const {x, y} of shuffledCells) {
      if (isFloor(x, y)) {
        this.map.layers[1][y][x] = sc; return;
      }
    }
  }

  connectRooms(r1, r2, isOutdoor) {
    let x = r1.cx, y = r1.cy;
    const floorTile = isOutdoor ? ASSETS.BG.GRASS : ASSETS.BG.FLOOR;
    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] = floorTile;
    }
  }

  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 || this.map.layers[0][y][x] === ASSETS.BG.WATER) 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;
        itemData.displayName = itemData.name + enhanceText(itemData);
      }
      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;
  }

  // --- キー入力ハンドリング ---
  handleKeyDown(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 === '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();
      return;
    }
    
    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();
      return;
    }

    if(this.mode === 'PLAY') {
      if(e.key === 'ArrowUp') { this.keys.up = true; e.preventDefault(); }
      if(e.key === 'ArrowDown') { this.keys.down = true; e.preventDefault(); }
      if(e.key === 'ArrowLeft') { this.keys.left = true; e.preventDefault(); }
      if(e.key === 'ArrowRight') { this.keys.right = true; e.preventDefault(); }

      if(e.key === 'z' || e.key === 'Z' || e.key === 'Enter') {
        if (!this.zPressed) {
          this.zPressed = true;
          this.zActionHandled = false;
          this.draw(); // エイム描画
        }
        e.preventDefault();
      }
      
      if(e.key === 'a' || e.key === 'A') { this.toggleInventory(); return; }
      if(e.key === 'x' || e.key === 'X') {
        this.dash(this.player.lastDx, this.player.lastDy);
        return;
      }

      if (this.zPressed) {
        // Z押下時のエイム(方向転換)処理
        let dx = (this.keys.right ? 1 : 0) - (this.keys.left ? 1 : 0);
        let dy = (this.keys.down ? 1 : 0) - (this.keys.up ? 1 : 0);
        if (dx !== 0 || dy !== 0) {
          this.player.lastDx = dx;
          this.player.lastDy = dy;
          this.zActionHandled = true; // 方向転換したので発射はキャンセル
          this.draw();
        }
      } else {
        // 通常移動(キーダウン時のみ発動・ターン消費)
        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;

        if (dx !== 0 || dy !== 0) {
          this.turn(dx, dy);
        }
      }
    }
  }

  handleKeyUp(e) {
    if(e.key === 'ArrowUp') this.keys.up = false;
    if(e.key === 'ArrowDown') this.keys.down = false;
    if(e.key === 'ArrowLeft') this.keys.left = false;
    if(e.key === 'ArrowRight') this.keys.right = false;

    if(e.key === 'z' || e.key === 'Z' || e.key === 'Enter') {
      if (this.mode === 'PLAY' && this.zPressed) {
        this.zPressed = false;
        if (!this.zActionHandled) {
          // 単押しだった場合は発射・アクション実行
          this.executeZAction();
        } else {
          // 方向転換のみの場合はエイムUIを消すだけ
          this.draw();
        }
      }
    }
  }

  executeZAction() {
    const fg = this.map.layers[1][this.player.y][this.player.x];
    if (fg === ASSETS.FG.STAIRS) {
        this.interact(); 
        return;
    }
    if (this.player.weapon && this.player.weapon.isBow) {
        let shootDx = this.player.lastDx;
        let shootDy = this.player.lastDy;
        if (shootDx === 0 && shootDy === 0) shootDy = -1;
        this.shootBow(shootDx, shootDy);
        return;
    }
    this.interact(); 
  }

  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);
      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;
    if(this.map.layers[0][y][x] === ASSETS.BG.WATER) return false;
    const fg = this.map.layers[1][y][x];
    if(fg === ASSETS.FG.ROCK || fg === ASSETS.FG.TREE || fg === ASSETS.FG.STATUE) return false;
    if(fg === ASSETS.FG.MOUNTAIN || fg === ASSETS.FG.BIG_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(item.isSpecialWeapon) {
        const sameKind = (a, b) => {
          if(!a || !b) return false;
          return (a.isBow && b.isBow) || (a.char === '🪓' && b.char === '🪓');
        };
        if(this.player.weapon && sameKind(this.player.weapon, item)) {
          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(`${item.char} ${this.displayName(item)}を拾って装備!(攻撃力+${newPower})`, 'good');
            Sound.play('equip');
          } else if(newPower === curPower) {
            this.log(`${this.displayName(item)}は今の${this.displayName(this.player.weapon)}と同じ強さなので捨てた`, '');
          } else {
            this.log(`${this.displayName(item)}は今の${this.displayName(this.player.weapon)}より弱いので捨てた`, '');
          }
          this.entities.splice(idx, 1);
          return;
        }
        const invSameIdx = this.player.inventory.findIndex(inv => sameKind(inv, item));
        if(invSameIdx !== -1) {
          const invItem = this.player.inventory[invSameIdx];
          const invPower = itemPower(invItem);
          if(newPower > invPower) {
            this.player.inventory.splice(invSameIdx, 1);
            this.log(`弱い${this.displayName(invItem)}を捨てた`, '');
            this.player.inventory.push(item);
            this.log(`${item.char} ${this.displayName(item)}を拾った!`, 'good');
            Sound.play('pickup');
          } else {
            this.log(`${this.displayName(item)}は今の${this.displayName(invItem)}より弱いので捨てた`, '');
          }
          this.entities.splice(idx, 1);
          return;
        }
        if(this.player.inventory.length < 15) {
          this.player.inventory.push(item);
          this.log(`${item.char} ${this.displayName(item)}を拾った!`, 'good');
          Sound.play('pickup');
          this.entities.splice(idx, 1);
        } else {
          this.log('持ち物がいっぱいで拾えない!');
        }
        return;
      }

      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;
        }
      } 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);
  }

  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() {
    const fg = this.map.layers[1][this.player.y][this.player.x];
    if(fg === ASSETS.FG.STAIRS) {
      Sound.play('powerup');
      this.floor++;
      this.log(`✨ ${this.floor}階へ進んだ!`, 'good');
      this.generateFloor();
    }
  }

  shootBow(dx, dy) {
    Sound.play('bow_shoot');
    const bowDmg = Math.max(1, Math.floor(this.player.atk / 2));
    
    let arrowChar = '➡';
    if(dx === 0 && dy === -1) arrowChar = '⬆';
    else if(dx === 0 && dy === 1) arrowChar = '⬇';
    else if(dx === -1 && dy === 0) arrowChar = '⬅';
    else if(dx === 1 && dy === 0) arrowChar = '➡';
    else if(dx === 1 && dy === -1) arrowChar = '↗';
    else if(dx === -1 && dy === -1) arrowChar = '↖';
    else if(dx === 1 && dy === 1) arrowChar = '↘';
    else if(dx === -1 && dy === 1) arrowChar = '↙';

    this.projectiles.push({
      x: this.player.x, y: this.player.y,
      char: arrowChar, 
      dx, dy,
      item: { type: 'bow_arrow', char: arrowChar, name: '矢', throwable: false },
      steps: 0, maxSteps: 10,
      bowDmg: bowDmg,
      isArrow: true
    });
    this.log(`🏹 矢を放った!`);
    
    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');
    }

    this.closeModal();
    this.isAnimating = true;
    this.animateProjectiles();
  }

  attack(target, dx, dy) {
    if (this.player.weapon && this.player.weapon.isBow) {
      this.shootBow(dx, dy);
      return;
    }

    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.damageEffects.push({ x: target.x, y: target.y, endTime: Date.now() + 200 });
    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');
    }
  }

  throwItem(item, dx, dy, callerRemovedFromInventory = false) {
    if(dx === 0 && dy === 0) {
      this.log('投げる方向が分からない(まず移動してから投げよう)');
      return false;
    }

    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];

    proj.x += proj.dx;
    proj.y += proj.dy;
    proj.steps++;

    // 壁判定(WATER=海 は障害物として判定せず通過できる)
    const isWater = (proj.x >= 0 && proj.x < this.map.w && proj.y >= 0 && proj.y < this.map.h) 
                    ? this.map.layers[0][proj.y][proj.x] === ASSETS.BG.WATER : false;
    const hitWall = !this.isWalkable(proj.x, proj.y) && !isWater;
    
    const hitEnemy = this.entities.find(e => e.type === 'ENEMY' && e.x === proj.x && e.y === proj.y);

    if (proj.isArrow) {
      if (hitWall || proj.steps >= proj.maxSteps || hitEnemy) {
        if (hitEnemy) {
          hitEnemy.hp -= proj.bowDmg;
          this.damageEffects.push({ x: hitEnemy.x, y: hitEnemy.y, endTime: Date.now() + 200 });
          Sound.play('hit');
          this.log(`${hitEnemy.char}に矢が命中!${proj.bowDmg}ダメージ!`, '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 {
          Sound.play('miss');
          this.log('矢は外れた...');
        }
        this.projectiles.shift();
        this.draw();
        setTimeout(() => { this.isAnimating = false; this.updateEnemies(); this.updateCamera(); this.draw(); this.updateUI(); }, 60);
        return;
      }
      
      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.beginPath();
      this.ctx.strokeStyle = '#ffffff';
      this.ctx.lineWidth = 3;
      this.ctx.lineCap = 'round';
      let len = Math.sqrt(proj.dx*proj.dx + proj.dy*proj.dy);
      let nx = proj.dx / len;
      let ny = proj.dy / len;
      this.ctx.moveTo(screenX - nx * 12, screenY - ny * 12);
      this.ctx.lineTo(screenX + nx * 12, screenY + ny * 12);
      this.ctx.stroke();

      setTimeout(() => this.animateProjectiles(), 50);
      return;
    }

    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;
        this.damageEffects.push({ x: hitEnemy.x, y: hitEnemy.y, endTime: Date.now() + 200 });
        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');
      }

      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);
      } else {
        if(this.map.layers[0][landY][landX] === ASSETS.BG.WATER) {
           this.log(`${proj.item.name}は水の中にポチャっと落ちて消えた...`);
        }
      }

      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') {
        const bowTag = item.isBow ? ' 🏹弓(半ダメ)' : '';
        typeTag = `<span style="font-size:12px;color:#fa8;"> ATK+${itemPower(item)}${bowTag}</span>`;
      }
      else if(item.type==='armor') typeTag = `<span style="font-size:12px;color:#8af;"> DEF+${itemPower(item)}</span>`;
      else if(item.type==='food_gamble') typeTag = '<span style="font-size:12px;color:#f80;"> ?毒かも</span>';
      else if(item.type==='map_reveal') typeTag = '<span style="font-size:12px;color:#8f8;"> マップ表示</span>';
      else if(item.type==='candle') typeTag = '<span style="font-size:12px;color:#ff8;"> 暗闇視野UP</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`;
  }

  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');
          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');
      this.triggerHealEffect();
      used = true;
    } else if(item.type === 'food_gamble') {
      if (Math.random() < 0.5) {
        const heal = Math.min(item.healPower, this.player.maxHp - this.player.hp);
        this.player.hp += heal;
        this.log(`${item.name}は新鮮だった!HP+${heal}回復!`, 'good');
        Sound.play('powerup');
        this.triggerHealEffect();
      } else {
        const dmg = Math.max(1, item.dmgPower - this.player.def);
        this.player.hp = Math.max(1, this.player.hp - dmg);
        this.log(`${item.name}は腐っていた!${dmg}ダメージ!`, 'danger');
        Sound.play('poison');
        this.damageEffects.push({ x: this.player.x, y: this.player.y, endTime: Date.now() + 200 });
      }
      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 === 'map_reveal') {
      this.mapRevealed = true;
      Sound.play('map_reveal');
      this.log('🗺️ フロアの地図を入手!右上にマップを表示中', 'lucky');
      used = true;
    } else if(item.type === 'candle') {
      if (this.isDarkFloor) {
        this.player.candleActive = true;
        this.log('🕯️ 燭台に火を灯した!視野が広がった!', 'lucky');
        Sound.play('candle');
      } else {
        this.log('🕯️ 燭台を灯した...ここは明るいので意味がなかった。', '');
      }
      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();
  }

  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 === 'food_gamble') {
      useLabel = '✅ 食べる(毒かも?)';
    } else if(item.type === 'magic') {
      useLabel = '✅ 唱える';
    } else if(item.type === 'map_reveal') {
      useLabel = '✅ 使う(地図を広げる)';
    } else if(item.type === 'candle') {
      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) {
    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') {
      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;
      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();
  }

  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;

      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');
      }

      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.damageEffects.push({ x: this.player.x, y: this.player.y, endTime: Date.now() + 200 });
        this.log(`${e.char}の攻撃!${dmg}ダメージ`, 'danger');
        if(this.player.hp <= 0) { this.gameOver(`${e.char}にやられた`); }
      } else if(dist < 12) { // 追跡範囲を拡大(8 -> 12)
        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';

    const viewR = this.isDarkFloor
      ? (this.player.candleActive ? this.darkViewRadius * 2 : this.darkViewRadius)
      : 999;

    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 distToPlayer = Math.abs(mx - this.player.x) + Math.abs(my - this.player.y);
        if (this.isDarkFloor && distToPlayer > viewR) {
          this.ctx.fillStyle = '#111';
          this.ctx.fillRect(x * this.TILE_SIZE, y * this.TILE_SIZE, this.TILE_SIZE, this.TILE_SIZE);
          continue;
        }

        const bg = this.map.layers[0][my][mx];
        if (bg === ASSETS.BG.WALL) {
          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';
        } else if (bg === ASSETS.BG.WATER) {
          this.ctx.fillStyle = '#0055bb';
          this.ctx.fillRect(x * this.TILE_SIZE, y * this.TILE_SIZE, this.TILE_SIZE, this.TILE_SIZE);
          this.ctx.fillStyle = '#fff';
          this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.75)}px sans-serif`;
          this.ctx.fillText('🌊', px, py);
          this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.9)}px sans-serif`;
        } else if (bg === ASSETS.BG.GRASS) {
          this.ctx.fillStyle = '#4a8a00';
          this.ctx.fillRect(x * this.TILE_SIZE, y * this.TILE_SIZE, this.TILE_SIZE, this.TILE_SIZE);
          this.ctx.fillStyle = '#fff';
        } else {
          this.ctx.fillText(bg, px, py);
        }

        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);
        }

        this.damageEffects = this.damageEffects.filter(ef => Date.now() < ef.endTime);
        for (const ef of this.damageEffects) {
          if (ef.x === mx && ef.y === my) {
            const remaining = ef.endTime - Date.now();
            const alpha = Math.max(0, remaining / 200);
            this.ctx.globalAlpha = alpha;
            this.ctx.font = `${Math.floor(this.TILE_SIZE * 1.1)}px sans-serif`;
            this.ctx.fillText('💥', px, py - (1 - alpha) * 8);
            this.ctx.globalAlpha = 1.0;
            this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.9)}px sans-serif`;
          }
        }

        if (this.healEffect && Date.now() < this.healEffect.endTime) {
          if (mx === this.player.x && my === this.player.y) {
            const remaining = this.healEffect.endTime - Date.now();
            for (const sp of this.healEffect.sparks) {
              const spx = px + sp.ox * this.TILE_SIZE * 0.6;
              const spy = py + sp.oy * this.TILE_SIZE * 0.6;
              const alpha = Math.max(0, (remaining / 200) * (0.5 + Math.random() * 0.5));
              this.ctx.globalAlpha = Math.min(1, alpha);
              this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.6)}px sans-serif`;
              this.ctx.fillText('✨', spx, spy);
              this.ctx.globalAlpha = 1.0;
              this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.9)}px sans-serif`;
            }
          }
        } else if (this.healEffect && Date.now() >= this.healEffect.endTime) {
          this.healEffect = null;
        }
      }
    }

    // エイム矢印の描画(Zキー長押し中)
    if (this.zPressed && this.mode === 'PLAY') {
      const px = (this.player.x - this.camera.x) * this.TILE_SIZE + this.TILE_SIZE/2;
      const py = (this.player.y - this.camera.y) * this.TILE_SIZE + this.TILE_SIZE/2;
      let adx = this.player.lastDx;
      let ady = this.player.lastDy;
      if (adx === 0 && ady === 0) ady = -1; // デフォルトは上

      // カーソルの背景を半透明で表示
      this.ctx.globalAlpha = 0.5;
      this.ctx.fillStyle = '#fff';
      this.ctx.fillRect(px - this.TILE_SIZE/2 + adx * this.TILE_SIZE, py - this.TILE_SIZE/2 + ady * this.TILE_SIZE, this.TILE_SIZE, this.TILE_SIZE);
      
      this.ctx.fillStyle = '#000';
      this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.8)}px sans-serif`;
      let arrowChar = '➡';
      if(adx === 0 && ady === -1) arrowChar = '⬆';
      else if(adx === 0 && ady === 1) arrowChar = '⬇';
      else if(adx === -1 && ady === 0) arrowChar = '⬅';
      else if(adx === 1 && ady === 0) arrowChar = '➡';
      else if(adx === 1 && ady === -1) arrowChar = '↗';
      else if(adx === -1 && ady === -1) arrowChar = '↖';
      else if(adx === 1 && ady === 1) arrowChar = '↘';
      else if(adx === -1 && ady === 1) arrowChar = '↙';
      
      this.ctx.fillText(arrowChar, px + adx * this.TILE_SIZE, py + ady * this.TILE_SIZE);
      this.ctx.globalAlpha = 1.0;
    }

    if (this.mapRevealed) {
      this.drawMiniMap();
    }
  }

  drawMiniMap() {
    const W = this.map.w, H = this.map.h;
    const scale = 3;
    const marginX = 5, marginY = 5;
    const mapW = W * scale, mapH = H * scale;
    const startX = this.canvas.width - mapW - marginX;
    const startY = marginY;

    this.ctx.save();
    this.ctx.globalAlpha = 0.85;
    this.ctx.fillStyle = '#000';
    this.ctx.fillRect(startX - 1, startY - 1, mapW + 2, mapH + 2);

    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        const bg = this.map.layers[0][y][x];
        const fg = this.map.layers[1][y][x];
        
        let color = '#111';
        if (bg === ASSETS.BG.WALL) color = '#111';
        else if (bg === ASSETS.BG.WATER) color = '#0055bb';
        else if (bg === ASSETS.BG.GRASS) color = '#4a8a00';
        else color = '#a85'; 
        
        if (fg === ASSETS.FG.STAIRS) color = '#fff';
        else if (fg === ASSETS.FG.MOUNTAIN || fg === ASSETS.FG.BIG_TREE || fg === ASSETS.FG.ROCK || fg === ASSETS.FG.TREE || fg === ASSETS.FG.STATUE) color = '#363';

        this.ctx.fillStyle = color;
        this.ctx.fillRect(startX + x * scale, startY + y * scale, scale, scale);
      }
    }

    this.ctx.fillStyle = '#ff0';
    this.ctx.fillRect(startX + this.player.x * scale - 1, startY + this.player.y * scale - 1, scale + 2, scale + 2);

    for (let y = 0; y < H; y++) {
      for (let x = 0; x < W; x++) {
        if (this.map.layers[1][y][x] === ASSETS.FG.STAIRS) {
          this.ctx.fillStyle = '#fff';
          this.ctx.fillRect(startX + x * scale, startY + y * scale, scale, scale);
        }
      }
    }

    this.ctx.globalAlpha = 1.0;
    this.ctx.restore();
  }

  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.damageEffects.push({ x: target.x, y: target.y, endTime: Date.now() + 200 });
        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);
    }
  }

  triggerHealEffect() {
    Sound.play('heal_sparkle');
    const sparks = [];
    const dirs = [
      {ox:-0.7,oy:-0.7},{ox:0,oy:-1},{ox:0.7,oy:-0.7},
      {ox:-1,oy:0},{ox:1,oy:0},
      {ox:-0.7,oy:0.7},{ox:0,oy:1},{ox:0.7,oy:0.7}
    ];
    for (const d of dirs) {
      sparks.push({ ox: d.ox + (Math.random()-0.5)*0.3, oy: d.oy + (Math.random()-0.5)*0.3 });
    }
    this.healEffect = { x: this.player.x, y: this.player.y, endTime: Date.now() + 200, sparks };
  }

  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