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

【更新履歴】

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

画像
絵文字マップの画面


画像
絵文字ローグの画面

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

🎨 Emoji Map 説明書

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. 推奨環境・注意点

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    };
    this.currentCat = "枠線";

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

    this.init();
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    this.ctx.restore();
  }

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

const app = new EmojiApp();

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

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

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Emoji Rogue 1.0</title>
<style>
  body {
    background-color: #050505; /* 背景はほぼ黒 */
    color: #eee;
    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;
  }
  #game-container {
    position: relative;
    border: 4px solid #333;
    margin-top: 10px;
    box-shadow: 0 0 20px rgba(0,0,0,0.8);
    background: #000;
  }
  canvas {
    display: block;
  }
  /* UIレイヤー */
  #ui-header {
    width: 100%;
    max-width: 800px;
    display: flex;
    justify-content: space-around;
    padding: 10px;
    background: #222;
    border-bottom: 2px solid #444;
    font-size: 18px;
    font-weight: bold;
  }
  .stat-box { display: flex; align-items: center; gap: 5px; }
  
  /* ログウィンドウ */
  #log {
    width: 100%;
    max-width: 800px;
    height: 120px;
    background: rgba(0,0,0,0.9);
    border: 1px solid #444;
    overflow-y: hidden; /* スクロールバー隠す */
    font-size: 14px;
    padding: 8px;
    box-sizing: border-box;
    color: #ccc;
    display: flex;
    flex-direction: column-reverse; /* 新しいログが下に出るように */
  }
  .log-line { margin-bottom: 2px; border-bottom: 1px dashed #222; }
  .log-line.new { color: #fff; text-shadow: 0 0 5px orange; }
  
  /* オーバーレイ(スタート/ゲームオーバー) */
  #overlay {
    position: absolute;
    top: 0; left: 0; width: 100%; height: 100%;
    background: rgba(0,0,0,0.85);
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    text-align: center;
    z-index: 100;
  }
  button {
    background: #d35400;
    color: white;
    border: none;
    padding: 15px 30px;
    font-size: 20px;
    cursor: pointer;
    border-radius: 8px;
    font-family: inherit;
    margin-top: 20px;
    transition: transform 0.1s;
  }
  button:active { transform: scale(0.95); }
  h1 { font-size: 48px; margin: 0; text-shadow: 0 0 10px #d35400; }
  .subtitle { color: #aaa; margin-top: 10px; }
</style>
</head>
<body>

<div id="ui-header">
  <div class="stat-box">🪜 <span id="floor-val">1</span>F</div>
  <div class="stat-box">❤️ <span id="hp-val">30/30</span></div>
  <div class="stat-box">⚔️ <span id="lv-val">1</span></div>
  <div class="stat-box">💰 <span id="gold-val">0</span></div>
</div>

<div id="game-container">
  <canvas id="gameCanvas" width="800" height="500"></canvas>
  
  <div id="overlay">
    <h1>🏰 Emoji Rogue</h1>
    <div class="subtitle">Auto Dungeon Generator</div>
    <div id="msg-area" style="margin-top:20px; font-size:18px;"></div>
    <button id="start-btn" onclick="game.init()">冒険を始める</button>
  </div>
</div>

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

<script>
/**
 * Emoji Rogue: Auto Dungeon
 * User Provided Assets Integrated
 */

// --- 定数・アセット ---
const TILE_SIZE = 32; // 少し大きめに見やすく
const VIEW_W = 25;    // 画面に表示する横タイル数
const VIEW_H = 15;    // 画面に表示する縦タイル数
const MAP_W = 50;     // フロア全体の横幅
const MAP_H = 40;     // フロア全体の縦幅

// 提供された絵文字リストからのマッピング
const ASSETS = {
  // マップチップ
  WALL_TOP:  '⬛', // 壁の中/天井
  WALL_FACE: '🟫', // 壁の正面
  FLOOR:     '🟧', // 床 (明るめの茶色/オレンジ)
  PASSAGE:   '🟧', // 通路も床と同じ
  STAIRS:    '🚪', // 階段 (扉を使用)

  // キャラクター
  PLAYER:    '🧙', // 魔法使い風
  
  // 敵 (階層ごとの強さ順)
  ENEMIES: [
    ['🐀', '🐜', '🕷️'],       // 1-3F: 小動物・虫
    ['🐍', '🐺', '🦇'],       // 4-6F: 動物・コウモリ
    ['🧟', '💀', '👻'],       // 7-9F: アンデッド
    ['👺', '👹', '🦂'],       // 10-12F: 鬼・サソリ
    ['🐉', '🐲', '👿']        // 13F+: ドラゴン・悪魔
  ],
  
  // アイテム
  POTION: '🧪', // 回復
  FOOD:   '🍖', // 食料(回復)
  GOLD:   '💰', // お金
  GEM:    '💎', // 高額アイテム
  WEAPON: '⚔️', // 攻撃力アップ
  ARMOR:  '🛡️', // 防御的な何か(今回はHP最大値アップに使用)
  
  // エフェクト
  HIT:    '💥',
  DEAD:   '⚰️'
};

// サウンド管理 (Web Audio API)
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 osc = Sound.ctx.createOscillator();
    const gain = Sound.ctx.createGain();
    osc.connect(gain);
    gain.connect(Sound.ctx.destination);
    
    const now = Sound.ctx.currentTime;
    
    if (type === 'move') { // コツッ
      osc.type = 'triangle';
      osc.frequency.setValueAtTime(100, now);
      osc.frequency.exponentialRampToValueAtTime(50, now + 0.05);
      gain.gain.setValueAtTime(0.05, now);
      gain.gain.exponentialRampToValueAtTime(0.01, now + 0.05);
      osc.start(now);
      osc.stop(now + 0.05);
    } 
    else if (type === 'attack') { // ザシュッ
      osc.type = 'sawtooth'; // ノイズっぽい波形がないのでノコギリ波で代用
      osc.frequency.setValueAtTime(150, now);
      osc.frequency.linearRampToValueAtTime(50, now + 0.1);
      gain.gain.setValueAtTime(0.1, now);
      gain.gain.linearRampToValueAtTime(0.01, now + 0.1);
      osc.start(now);
      osc.stop(now + 0.1);
    }
    else if (type === 'item') { // キラッ
      osc.type = 'sine';
      osc.frequency.setValueAtTime(1000, now);
      osc.frequency.exponentialRampToValueAtTime(2000, now + 0.1);
      gain.gain.setValueAtTime(0.1, now);
      gain.gain.linearRampToValueAtTime(0.01, now + 0.2);
      osc.start(now);
      osc.stop(now + 0.2);
    }
    else if (type === 'stairs') { // ギギー
      osc.type = 'square';
      osc.frequency.setValueAtTime(100, now);
      osc.frequency.linearRampToValueAtTime(50, now + 0.5);
      gain.gain.setValueAtTime(0.1, now);
      gain.gain.linearRampToValueAtTime(0.01, now + 0.5);
      osc.start(now);
      osc.stop(now + 0.5);
    }
    else if (type === 'hit') { // プレイヤー被弾
      osc.type = 'square';
      osc.frequency.setValueAtTime(80, now);
      osc.frequency.linearRampToValueAtTime(40, now + 0.2);
      gain.gain.setValueAtTime(0.2, now);
      gain.gain.linearRampToValueAtTime(0.01, now + 0.2);
      osc.start(now);
      osc.stop(now + 0.2);
    }
  }
};

class Game {
  constructor() {
    this.canvas = document.getElementById('gameCanvas');
    this.ctx = this.canvas.getContext('2d');
    
    // ゲームステート
    this.map = []; // 2次元配列: 0=壁, 1=床
    this.entities = []; // 敵、アイテム
    this.player = { x: 0, y: 0, hp: 30, maxHp: 30, lv: 1, exp: 0, gold: 0, atk: 3 };
    this.floor = 1;
    this.stairs = { x: 0, y: 0 };
    this.camera = { x: 0, y: 0 };
    this.messageLog = [];
    this.isGameOver = false;

    // キー操作
    window.addEventListener('keydown', e => this.handleInput(e));
  }

  // 初期化・リスタート
  init() {
    Sound.init();
    document.getElementById('overlay').style.display = 'none';
    this.floor = 1;
    this.player = { x: 0, y: 0, hp: 30, maxHp: 30, lv: 1, exp: 0, gold: 0, atk: 3 };
    this.isGameOver = false;
    this.log("ダンジョンに入った!");
    this.generateFloor();
  }

  // --- ダンジョン生成ロジック (BSPライクな分割生成) ---
  generateFloor() {
    // 1. 全体を壁(0)で埋める
    this.map = [];
    for(let y=0; y<MAP_H; y++) {
      this.map.push(new Array(MAP_W).fill(0));
    }
    this.entities = [];

    // 2. 部屋の生成
    const rooms = [];
    const minRoomSize = 5;
    const maxRooms = 8;
    
    // シンプルにランダム配置して重なりをチェックする方法
    for(let i=0; i<20; i++) { // 試行回数
      const w = Math.floor(Math.random() * 6) + 5; // 幅 5-10
      const h = Math.floor(Math.random() * 6) + 5; // 高 5-10
      const x = Math.floor(Math.random() * (MAP_W - w - 2)) + 1;
      const y = Math.floor(Math.random() * (MAP_H - h - 2)) + 1;
      
      const newRoom = {x, y, w, h, cx: Math.floor(x+w/2), cy: Math.floor(y+h/2)};
      
      // 重なりチェック
      let overlap = false;
      for(let r of rooms) {
        if(x < r.x + r.w + 1 && x + w + 1 > r.x &&
           y < r.y + r.h + 1 && y + h + 1 > r.y) {
          overlap = true;
          break;
        }
      }
      if(!overlap) {
        rooms.push(newRoom);
        // マップに書き込み (床=1)
        for(let ry=y; ry<y+h; ry++) {
          for(let rx=x; rx<x+w; rx++) {
            this.map[ry][rx] = 1;
          }
        }
        if(rooms.length >= maxRooms) break;
      }
    }

    // 3. 通路で繋ぐ
    // 部屋の中心同士を繋ぐ
    for(let i=1; i<rooms.length; i++) {
      const r1 = rooms[i-1];
      const r2 = rooms[i];
      this.createTunnel(r1.cx, r1.cy, r2.cx, r2.cy);
    }

    // 4. 配置
    // プレイヤー: 最初の部屋の中心
    this.player.x = rooms[0].cx;
    this.player.y = rooms[0].cy;
    
    // 階段: 最後の部屋の中心
    const lastRoom = rooms[rooms.length-1];
    this.stairs = { x: lastRoom.cx, y: lastRoom.cy };
    
    // 敵・アイテム生成
    rooms.forEach((r, idx) => {
      if(idx === 0) return; // 最初の部屋は安全に
      
      // 敵の数
      const enemyCount = Math.floor(Math.random() * 2); 
      for(let j=0; j<enemyCount; j++) {
        this.spawnEntity('enemy', r);
      }
      
      // アイテム
      if(Math.random() < 0.6) {
        this.spawnEntity('item', r);
      }
    });

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

  // 通路作成 (L字型)
  createTunnel(x1, y1, x2, y2) {
    const minX = Math.min(x1, x2);
    const maxX = Math.max(x1, x2);
    const minY = Math.min(y1, y2);
    const maxY = Math.max(y1, y2);
    
    // 横→縦 または 縦→横 をランダムに
    if(Math.random() < 0.5) {
      // 横移動
      for(let x=minX; x<=maxX; x++) this.map[y1][x] = 1;
      // 縦移動
      for(let y=minY; y<=maxY; y++) this.map[y][x2] = 1;
    } else {
      // 縦移動
      for(let y=minY; y<=maxY; y++) this.map[y][x1] = 1;
      // 横移動
      for(let x=minX; x<=maxX; x++) this.map[y2][x] = 1;
    }
  }

  spawnEntity(type, room) {
    let ex, ey;
    // 空いている場所を探す
    let limit = 10;
    do {
      ex = Math.floor(Math.random() * room.w) + room.x;
      ey = Math.floor(Math.random() * room.h) + room.y;
      limit--;
    } while(limit > 0 && (this.isOccupied(ex, ey) || (ex===this.stairs.x && ey===this.stairs.y)));

    if(limit <= 0) return;

    if(type === 'enemy') {
      // 階層に応じた敵を選出
      const tier = Math.min(this.floor, 13);
      const enemySet = ASSETS.ENEMIES[Math.min(Math.floor((tier-1)/3), ASSETS.ENEMIES.length-1)];
      const char = enemySet[Math.floor(Math.random()*enemySet.length)];
      
      this.entities.push({
        type: 'enemy', x: ex, y: ey, char: char,
        hp: this.floor * 3 + 5, maxHp: this.floor * 3 + 5,
        atk: Math.floor(this.floor/2) + 1
      });
    } else if (type === 'item') {
      const rand = Math.random();
      let itemType = 'GOLD';
      let char = ASSETS.GOLD;
      let val = 0;
      
      if(rand < 0.4) { 
        itemType = 'GOLD'; char = ASSETS.GOLD; val = 10 + Math.floor(Math.random()*20)*this.floor;
      } else if (rand < 0.6) {
        itemType = 'POTION'; char = ASSETS.POTION; val = 15;
      } else if (rand < 0.75) {
        itemType = 'FOOD'; char = ASSETS.FOOD; val = 10;
      } else if (rand < 0.85) {
        itemType = 'WEAPON'; char = ASSETS.WEAPON; val = 1;
      } else if (rand < 0.95) {
        itemType = 'ARMOR'; char = ASSETS.ARMOR; val = 2; // 最大HPアップ
      } else {
        itemType = 'GEM'; char = ASSETS.GEM; val = 100 * this.floor;
      }

      this.entities.push({ type: 'item', itemType: itemType, x: ex, y: ey, char: char, value: val });
    }
  }

  isOccupied(x, y) {
    if(this.player.x === x && this.player.y === y) return true;
    if(this.entities.some(e => e.x === x && e.y === y)) return true;
    return false;
  }

  // --- ゲーム進行 ---
  handleInput(e) {
    if(this.isGameOver) return;
    
    let dx = 0, dy = 0;
    if(e.key === 'ArrowUp') dy = -1;
    else if(e.key === 'ArrowDown') dy = 1;
    else if(e.key === 'ArrowLeft') dx = -1;
    else if(e.key === 'ArrowRight') dx = 1;
    else return;

    e.preventDefault();
    this.turn(dx, dy);
  }

  turn(dx, dy) {
    const tx = this.player.x + dx;
    const ty = this.player.y + dy;
    
    // 移動可能判定
    if(this.map[ty][tx] === 0) return; // 壁

    // 敵判定
    const target = this.entities.find(e => e.type === 'enemy' && e.x === tx && e.y === ty);
    if(target) {
      // 攻撃
      Sound.play('attack');
      const dmg = Math.max(1, this.player.atk + Math.floor(Math.random()*2));
      target.hp -= dmg;
      this.log(`${target.char}に${dmg}ダメージ!`);
      // 撃破
      if(target.hp <= 0) {
        this.log(`${target.char}を倒した!`);
        this.player.exp += 1;
        this.checkLevelUp();
        // 敵消滅
        this.entities = this.entities.filter(e => e !== target);
      }
    } else {
      // 移動
      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.pickupItem(this.entities[itemIdx]);
        this.entities.splice(itemIdx, 1);
      }
      
      // 階段
      if(tx === this.stairs.x && ty === this.stairs.y) {
        Sound.play('stairs');
        this.log(`${this.floor}階クリア! 休憩してHP回復。`);
        this.player.hp = Math.min(this.player.hp + 5, this.player.maxHp);
        this.floor++;
        this.generateFloor();
        return;
      }
    }

    // 敵のターン
    this.updateEnemies();
    
    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, Math.floor(this.floor/2));
        this.player.hp -= dmg;
        this.log(`${e.char}の攻撃!${dmg}ダメージ!`);
        if(this.player.hp <= 0) this.gameOver();
      } else if(dist < 6) {
        // 近づく
        let ex = e.x, ey = e.y;
        if(this.player.x > e.x) ex++;
        else if(this.player.x < e.x) ex--;
        else if(this.player.y > e.y) ey++;
        else if(this.player.y < e.y) ey--;
        
        // 移動先が床で、かつ誰もいないなら移動
        if(this.map[ey][ex] === 1 && !this.isOccupied(ex, ey)) {
          e.x = ex; e.y = ey;
        }
      }
    });
  }

  pickupItem(item) {
    Sound.play('item');
    let msg = "";
    switch(item.itemType) {
      case 'GOLD': 
        this.player.gold += item.value; 
        msg = `${item.value}Gを拾った`; break;
      case 'GEM': 
        this.player.gold += item.value; 
        msg = `宝石(${item.value}G)を拾った!`; break;
      case 'POTION': 
        this.player.hp = Math.min(this.player.hp + item.value, this.player.maxHp); 
        msg = `ポーションで回復した`; break;
      case 'FOOD': 
        this.player.hp = Math.min(this.player.hp + item.value, this.player.maxHp); 
        msg = `肉を食べて回復した`; break;
      case 'WEAPON': 
        this.player.atk += item.value; 
        msg = `武器を拾い攻撃力が上がった`; break;
      case 'ARMOR': 
        this.player.maxHp += item.value; 
        this.player.hp += item.value;
        msg = `盾を拾い最大HPが増えた`; break;
    }
    this.log(msg);
  }

  checkLevelUp() {
    if(this.player.exp >= this.player.lv * 3) {
      this.player.lv++;
      this.player.maxHp += 5;
      this.player.hp = this.player.maxHp;
      this.player.atk += 1;
      this.player.exp = 0;
      this.log(`✨レベルアップ!(Lv${this.player.lv}) HPと攻撃力上昇!`);
      Sound.play('item');
    }
  }

  gameOver() {
    this.isGameOver = true;
    document.getElementById('overlay').style.display = 'flex';
    document.querySelector('#overlay h1').innerText = "💀 GAME OVER";
    document.querySelector('#overlay .subtitle').innerText = "力尽きてしまった...";
    document.getElementById('msg-area').innerText = `到達: ${this.floor}階 / 所持金: ${this.player.gold}G`;
    document.getElementById('start-btn').innerText = "もう一度挑戦";
  }

  // --- 描画 ---
  updateCamera() {
    this.camera.x = Math.max(0, Math.min(MAP_W - VIEW_W, this.player.x - Math.floor(VIEW_W/2)));
    this.camera.y = Math.max(0, Math.min(MAP_H - VIEW_H, this.player.y - Math.floor(VIEW_H/2)));
  }

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

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

        // タイル描画ロジック
        if(this.map[my][mx] === 1) {
          // 床
          this.ctx.fillText(ASSETS.FLOOR, px, py);
        } else {
          // 壁の判定 (下のマスが床なら「正面」、それ以外は「天井(黒)」)
          // マップ範囲外チェック
          const isBottomFloor = (my < MAP_H - 1) && (this.map[my+1][mx] === 1);
          
          if(isBottomFloor) {
            this.ctx.fillText(ASSETS.WALL_FACE, px, py);
          } else {
            this.ctx.fillStyle = '#111'; // 黒より少し明るくして視認性確保
            this.ctx.fillText(ASSETS.WALL_TOP, px, py);
          }
        }
        
        // 階段
        if(mx === this.stairs.x && my === this.stairs.y) {
           this.ctx.fillText(ASSETS.STAIRS, px, py);
        }

        // エンティティ
        const ent = this.entities.find(e => e.x === mx && e.y === my);
        if(ent) {
          this.ctx.fillText(ent.char, px, py);
          // 敵ならHPバーを小さく表示
          if(ent.type === 'enemy') {
            this.ctx.fillStyle = 'red';
            this.ctx.fillRect(px - 10, py + 12, 20 * (ent.hp / ent.maxHp), 3);
          }
        }

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

  log(msg) {
    const el = document.getElementById('log');
    const line = document.createElement('div');
    line.className = 'log-line new';
    line.innerText = msg;
    el.prepend(line);
    this.messageLog.push(msg);
    if(el.children.length > 5) el.lastChild.remove();
    
    // アニメーション用にクラス削除
    setTimeout(() => line.classList.remove('new'), 200);
  }

  updateUI() {
    document.getElementById('floor-val').innerText = this.floor;
    document.getElementById('hp-val').innerText = `${this.player.hp}/${this.player.maxHp}`;
    document.getElementById('lv-val').innerText = this.player.lv;
    document.getElementById('gold-val').innerText = this.player.gold;
  }
}

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