絵文字で絵を描く「Emoji Map」
【更新履歴】
・2026/2/17バージョン1.0公開。
・2026/2/17バージョン1.1公開。
・2026/2/17バージョン1.2公開。
・ダウンロードされる方はこちら。↓
🎨 Emoji Map 説明書
・このツールでは、絵文字を使って
絵を書くことができます。
・最近のAIでは、
画像を添付して、AIに分析してもらうこともできますが、
テキストで画面のレイアウト図を書いて送信する方が
わかりやすくていい場合もあるということで作りました。
・作成したデータはテキストファイルとして保存され、
SNSや他のドキュメントに貼り付けて使用できます。
基本操作(ツールバー)
・画面上部のアイコンを選択して操作を切り替えます。
• ✏️ ペン:
キャンバスをクリックまたはドラッグして、
現在選択中の絵文字を描画します。
• 🧽 消しゴム:
絵文字を消去し、空白(全角スペース)に戻します。
• 🎨 塗りつぶし:
クリックした場所と繋がっている同色の領域を、
現在の絵文字で塗りつぶします。
• 🧪 スポイト:
キャンバス上の絵文字をクリックして、
その絵文字を「現在のペン」として取得します。
• ⛶ 選択:
四角い範囲を選択します。
範囲の移動:
選択範囲の内側をドラッグすると、
絵を切り取って移動できます。
確定:
選択範囲の外側をクリックするか、
ツールを切り替えると移動が確定されます。
コピー/カット:
範囲選択中にメニューの「編集」
またはショートカットキーで行えます。
• ➕ / ➖ 拡大縮小:
キャンバスの表示倍率を変更します。
パレット操作(左パネル)
• 絵文字を選ぶ:
クリックするとペンに設定されます。
• 絵文字の変更:
パレット上の絵文字をダブルクリックすると、
入力ダイアログが開きます。
好きな絵文字(1文字)を入力して変更できます。
変更後はすぐにペンに反映されます。
• 並べ替え:
絵文字をドラッグ&ドロップすると、
順番を入れ替えることができます。
• カテゴリ切替:
上部のプルダウンからカテゴリを切り替えます。
「➕ 新規カテゴリ...」で独自のカテゴリを作成できます。
• 追加 / 初期化:
下部のボタンで、現在のカテゴリに新しい絵文字を追加したり、
パレット全体を初期状態に戻したりできます。
• 自動保存:
パレットの状態はブラウザに自動保存され、
次回起動時にも維持されます。
ファイルの保存と読み込み
・メニューバーの「ファイル」から行います。
• 新規作成:
キャンバスを初期化します。
• 開く (Ctrl+O):
パソコン内のテキストファイル(.txt)を選択して読み込みます。
• 上書き保存 (Ctrl+S):
○ 一度「名前を付けて保存」したファイル、
または「開く」で開いたファイルに対しては、
ダイアログを出さずに上書き保存します(Chrome/Edge等)。
○ 保存先が未定の場合は、保存ダイアログが開きます。
• 名前を付けて保存 (Ctrl+Shift+S):
常に保存場所とファイル名を指定して保存します。
• サイズ変更:
キャンバスの上下左右にマスを追加、
または削除(トリミング)します。
便利なショートカットキー
キー操作 機能
Ctrl + Z 元に戻す (Undo)
Ctrl + Y やり直す (Redo)
Ctrl + S 上書き保存
Ctrl + Shift + S 名前を付けて保存
Ctrl + O ファイルを開く
推奨環境・注意点
• ブラウザ:
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」は元々キャラクターコードのゲームでしたから、
これと言って新規性はないんですが、楽しそうなので付けました。


コメント