絵文字で絵を描く「Emoji Map」
【更新履歴】
・2026/2/17 バージョン1.0公開。
・2026/2/17 バージョン1.1公開。
・2026/2/17 バージョン1.2公開。
・2026/2/23 バージョン1.3公開。
・ダウンロードされる方はこちら。↓
🎨 Emoji Map 説明書
・このツールでは、絵文字を使って
絵を書くことができます。
・最近のAIでは、
画像を添付して、AIに分析してもらうこともできますが、
テキストで画面のレイアウト図を書いて送信する方が
わかりやすくていい場合もあるということで作りました。
・作成したデータはテキストファイルとして保存され、
SNSや他のドキュメントに貼り付けて使用できます。
基本操作(ツールバー)
・画面上部のアイコンを選択して操作を切り替えます。
• ✏️ ペン:
キャンバスをクリックまたはドラッグして、
現在選択中の絵文字を描画します。
• 🧽 消しゴム:
絵文字を消去し、空白(全角スペース)に戻します。
• 🎨 塗りつぶし:
クリックした場所と繋がっている同色の領域を、
現在の絵文字で塗りつぶします。
• 🧪 スポイト:
キャンバス上の絵文字をクリックして、
その絵文字を「現在のペン」として取得します。
• ⛶ 選択:
四角い範囲を選択します。
範囲の移動:
選択範囲の内側をドラッグすると、
絵を切り取って移動できます。
確定:
選択範囲の外側をクリックするか、
ツールを切り替えると移動が確定されます。
コピー/カット:
範囲選択中にメニューの「編集」
またはショートカットキーで行えます。
• ➕ / ➖ 拡大縮小:
キャンバスの表示倍率を変更します。
パレット操作(左パネル)
• 絵文字を選ぶ:
クリックするとペンに設定されます。
• 絵文字の変更:
パレット上の絵文字をダブルクリックすると、
入力ダイアログが開きます。
好きな絵文字(1文字)を入力して変更できます。
変更後はすぐにペンに反映されます。
• 並べ替え:
絵文字をドラッグ&ドロップすると、
順番を入れ替えることができます。
• カテゴリ切替:
上部のプルダウンからカテゴリを切り替えます。
「➕ 新規カテゴリ...」で独自のカテゴリを作成できます。
• 追加 / 初期化:
下部のボタンで、現在のカテゴリに新しい絵文字を追加したり、
パレット全体を初期状態に戻したりできます。
• 自動保存:
パレットの状態はブラウザに自動保存され、
次回起動時にも維持されます。
ファイルの保存と読み込み
・メニューバーの「ファイル」から行います。
• 新規作成:
キャンバスを初期化します。
• 開く (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.3</title>
<style>
:root {
--bg-color: #050505;
--ui-bg: rgba(20, 20, 30, 0.97);
--border-color: #444;
--accent-color: #9b59b6;
--text-color: #eee;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", monospace;
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
margin: 0;
overflow: hidden;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
#game-wrapper {
position: relative;
margin-top: 10px;
box-shadow: 0 0 30px rgba(0,0,0,0.8);
border: 2px solid #333;
}
canvas { display: block; background: #000; }
#btn-menu {
position: absolute;
bottom: 20px;
right: 20px;
background: var(--accent-color);
color: white;
border: 2px solid #fff;
border-radius: 50%;
width: 70px;
height: 70px;
font-size: 30px;
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
cursor: pointer;
z-index: 50;
display: flex;
justify-content: center;
align-items: center;
transition: transform 0.1s;
}
#btn-menu:active { transform: scale(0.9); background: #8e44ad; }
#btn-menu::after {
content: "[A]";
position: absolute;
bottom: 10px;
font-size: 10px;
font-weight: bold;
}
#ui-header {
width: 800px;
display: grid;
grid-template-columns: repeat(5, 1fr);
padding: 8px 15px;
background: #222;
border-bottom: 2px solid #444;
font-size: 16px;
font-weight: bold;
box-sizing: border-box;
}
.stat-box { display: flex; align-items: center; justify-content: center; gap: 6px; }
#log-container {
width: 800px;
height: 120px;
background: #111;
border: 1px solid #333;
overflow-y: auto;
font-size: 14px;
padding: 10px;
box-sizing: border-box;
display: flex;
flex-direction: column-reverse;
}
.log-line { border-bottom: 1px solid #222; padding: 2px 0; color: #aaa; }
.log-line.new { color: #fff; animation: flash 0.5s; }
.log-line.danger { color: #ff6b6b; }
.log-line.good { color: #51cf66; }
.log-line.lucky { color: #ffd700; text-shadow: 0 0 5px gold; }
.log-line.magic { color: #e056fd; text-shadow: 0 0 5px #e056fd; }
@keyframes flash { from { text-shadow: 0 0 10px yellow; } to { text-shadow: none; } }
.modal-overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.75);
display: none; justify-content: center; align-items: center; z-index: 100;
}
.modal-window {
background: var(--ui-bg);
border: 2px solid var(--accent-color);
padding: 20px;
border-radius: 8px;
min-width: 440px;
max-width: 90%;
color: #fff;
box-shadow: 0 10px 40px rgba(0,0,0,1);
}
.modal-title {
font-size: 22px; margin: 0 0 12px 0;
border-bottom: 1px solid #555; padding-bottom: 5px;
display: flex; justify-content: space-between; align-items: center;
}
.item-list { list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto; }
.item-list li {
padding: 9px 10px; border-bottom: 1px solid #333; cursor: pointer;
display: flex; justify-content: space-between; align-items: center; font-size: 17px;
}
.item-list li:hover, .item-list li.selected { background: rgba(155, 89, 182, 0.4); }
.item-list li.equipped { border-left: 3px solid #ffd700; }
.key-hint { font-size: 12px; color: #888; margin-top: 12px; text-align: right; line-height: 1.6; }
/* コンテキストメニュー */
#ctx-menu {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background: var(--ui-bg);
border: 2px solid var(--accent-color);
border-radius: 6px;
padding: 4px 0;
min-width: 260px;
z-index: 300;
display: none;
box-shadow: 0 6px 30px rgba(0,0,0,0.95);
}
#ctx-menu .ctx-title {
padding: 8px 18px 6px;
font-size: 13px;
color: #aaa;
border-bottom: 1px solid #444;
margin-bottom: 2px;
}
#ctx-menu div.ctx-item {
padding: 12px 18px;
cursor: pointer;
font-size: 17px;
}
#ctx-menu div.ctx-item:hover,
#ctx-menu div.ctx-item.selected { background: rgba(155,89,182,0.55); }
#start-screen {
position: absolute; top:0; left:0; width:100%; height:100%;
background: rgba(0,0,0,0.9);
display: flex; flex-direction: column;
justify-content: center; align-items: center;
z-index: 200;
}
h1 { font-size: 48px; margin: 0; color: var(--accent-color); text-shadow: 2px 2px 0 #fff; }
button.big-btn {
background: var(--accent-color); color: white; border: none;
padding: 15px 40px; font-size: 20px; cursor: pointer;
margin-top: 30px; border-radius: 4px; font-weight: bold; font-family: inherit;
}
button.big-btn:hover { background: #8e44ad; }
#map-load-area {
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
#map-load-area label {
cursor: pointer;
background: #333;
color: #ccc;
border: 1px dashed #666;
border-radius: 4px;
padding: 8px 20px;
font-size: 14px;
transition: background 0.2s;
}
#map-load-area label:hover { background: #444; color: #fff; }
#map-file-status {
font-size: 12px;
color: #888;
max-width: 300px;
text-align: center;
word-break: break-all;
}
#map-file-status.loaded { color: #51cf66; }
#map-file-status.cleared { color: #888; }
#btn-clear-map {
background: transparent;
color: #888;
border: 1px solid #555;
border-radius: 4px;
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
display: none;
}
#btn-clear-map:hover { background: #333; color: #fff; }
</style>
</head>
<body>
<div id="ui-header">
<div class="stat-box">🪜 <span id="val-floor">1</span></div>
<div class="stat-box">❤️ <span id="val-hp">50/50</span></div>
<div class="stat-box">🍞 <span id="val-food">100%</span></div>
<div class="stat-box">⚔️ Lv<span id="val-lv">1</span></div>
<div class="stat-box">💰 <span id="val-gold">0</span></div>
</div>
<div id="game-wrapper">
<canvas id="gameCanvas" width="800" height="512"></canvas>
<div id="btn-menu" onclick="game.toggleInventory()">🎒</div>
<div id="start-screen">
<h1>🏰 Emoji Rogue</h1>
<p style="color:#aaa;">Rogue-like Adventure</p>
<div id="result-msg" style="margin-top:10px; font-size:18px; color:#f55;"></div>
<button class="big-btn" onclick="game.init()">NEW GAME</button>
<div id="map-load-area">
<label for="map-file-input">📂 マップファイルを読み込む (.json)</label>
<input type="file" id="map-file-input" accept=".json" style="display:none;">
<div id="map-file-status">マップなし(完全自動生成)</div>
<button id="btn-clear-map" onclick="game.clearMapData()">✖ マップをクリア</button>
</div>
<div style="margin-top:20px; font-size:13px; color:#666; line-height:1.8;">
[矢印] 移動/攻撃 | [A] メニュー開閉<br>
[Z] 確定/アクション | [X] キャンセル / ダッシュ(マップ)<br>
武器・防具は自動装備(強い方を保持)<br>
🪓斧・ガラクタ → Zキー → 投げる でも投擲可能
</div>
</div>
<div id="modal-inventory" class="modal-overlay">
<div class="modal-window" style="position:relative;">
<div class="modal-title">🎒 持ち物 <span style="font-size:13px;color:#888;">[Z]アクション [X/A]閉じる</span></div>
<ul class="item-list" id="inventory-list"></ul>
<div class="key-hint">
↑↓ 選択 | Z/クリック: 使う・装備・投げる | X/A: 閉じる<br>
装備中: [E] 表示
</div>
<!-- コンテキストメニュー (アクション選択) -->
<div id="ctx-menu">
<div class="ctx-title">アクションを選んでください</div>
<div id="ctx-use" class="ctx-item">✅ 使う / 装備する</div>
<div id="ctx-throw" class="ctx-item">🪃 投げる</div>
<div id="ctx-drop" class="ctx-item">📦 その場に置く</div>
<div id="ctx-cancel" class="ctx-item">✖ キャンセル</div>
</div>
</div>
</div>
<div id="modal-shop" class="modal-overlay">
<div class="modal-window">
<div class="modal-title">🛒 Merchant <span id="shop-gold" style="font-size:16px;color:gold;"></span></div>
<ul class="item-list" id="shop-list"></ul>
<div class="key-hint">↑↓ 選択 | Z/クリック: 購入 | X/A: 閉じる</div>
</div>
</div>
</div>
<div id="log-container"></div>
<script>
/**
* Emoji Rogue 2.0 - Full Feature Update
*/
const ASSETS = {
BG: { WALL: '⬛', FLOOR: '🟧', WATER: '🟦' },
FG: {
STAIRS: '🚪', FLOWERS: ['🌼', '🌷', '🌻', '🌹'],
ROCK: '🪨', TREE: '🌲', FOUNTAIN: '⛲', STATUE: '🗿', TRAP: '⚡', CHEST: '🎁'
},
PLAYER: '🧙', MERCHANT: '👳',
ENEMIES: [
['🐀', '🦇', '🐍'], ['🐺', '🐗', '🕷️'],
['💀', '🧟', '👻'], ['👺', '👹', '👁️'], ['🐉', '🐲', '👿']
],
// ガラクタ一覧
JUNK: [
{ char: '🍾', name: '空きビン' },
{ char: '🧱', name: 'レンガのかけら' },
{ char: '🔩', name: '古いネジ' },
{ char: '🥫', name: '空き缶' },
{ char: '🪣', name: '壊れたバケツ' },
{ char: '🧲', name: '錆びた磁石' },
{ char: '📦', name: '壊れた箱' },
{ char: '🪵', name: '木の棒' },
],
ITEMS: {
POTION: { char: '🧪', name: '回復薬', type: 'consumable', value: 50, effect: 'heal', power: 50 },
BIG_POTION: { char: '🏺', name: '特効薬', type: 'consumable', value: 120, effect: 'heal', power: 999 },
MEAT: { char: '🍖', name: 'マンガ肉', type: 'food', value: 40, effect: 'food', power: 100 },
BREAD: { char: '🍞', name: 'パン', type: 'food', value: 20, effect: 'food', power: 50 },
SWORD: { char: '⚔️', name: '鉄の剣', type: 'weapon', value: 200, power: 2, throwable: false },
AXE: { char: '🪓', name: '戦斧', type: 'weapon', value: 500, power: 5, throwable: true },
CLUB: { char: '🪃', name: '棍棒', type: 'weapon', value: 150, power: 3, throwable: true },
DAGGER: { char: '🗡️', name: '短剣', type: 'weapon', value: 180, power: 2, throwable: true },
SHIELD: { char: '🛡️', name: '木の盾', type: 'armor', value: 150, power: 2 },
ARMOR: { char: '🥋', name: '革鎧', type: 'armor', value: 300, power: 4 },
GOLD_S: { char: '💰', name: '金貨', type: 'gold', value: 0 },
GOLD_L: { char: '💎', name: '宝石', type: 'gold', value: 0 },
SCROLL_FIRE: { char: '📜', name: '炎の巻物', type: 'magic', value: 150, magicChar: '🔥', power: 15, range: 4 },
SCROLL_THUNDER:{ char:'📜', name: '雷の巻物', type: 'magic', value: 200, magicChar: '⚡', power: 25, range: 5 },
SCROLL_ICE: { char: '📜', name: '氷の巻物', type: 'magic', value: 200, magicChar: '❄️', power: 20, range: 4 }
}
};
// 武器・防具の強化値テキスト
function enhanceText(item) {
if(item.enhance && item.enhance > 0) return ` +${item.enhance}`;
if(item.enhance && item.enhance < 0) return ` ${item.enhance}`;
return '';
}
// アイテムの実際のpower(強化込み)
function itemPower(item) {
return (item.power || 0) + (item.enhance || 0);
}
const Sound = {
ctx: null,
init: () => { if(!Sound.ctx) Sound.ctx = new (window.AudioContext||window.webkitAudioContext)(); },
play: (type) => {
if(!Sound.ctx) return;
if(Sound.ctx.state === 'suspended') Sound.ctx.resume();
const t = Sound.ctx.currentTime;
const osc = Sound.ctx.createOscillator();
const gain = Sound.ctx.createGain();
osc.connect(gain); gain.connect(Sound.ctx.destination);
switch(type) {
case 'move':
osc.type = 'triangle'; osc.frequency.setValueAtTime(100, t); osc.frequency.linearRampToValueAtTime(0, t+0.05);
gain.gain.setValueAtTime(0.04, t); osc.start(t); osc.stop(t+0.05); break;
case 'pickup':
osc.type = 'sine'; osc.frequency.setValueAtTime(600, t); osc.frequency.linearRampToValueAtTime(1000, t+0.1);
gain.gain.setValueAtTime(0.1, t); gain.gain.linearRampToValueAtTime(0, t+0.1); osc.start(t); osc.stop(t+0.1); break;
case 'attack':
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(150, t); osc.frequency.linearRampToValueAtTime(30, t+0.12);
gain.gain.setValueAtTime(0.15, t); osc.start(t); osc.stop(t+0.12); break;
case 'throw':
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(400, t); osc.frequency.linearRampToValueAtTime(100, t+0.2);
gain.gain.setValueAtTime(0.12, t); osc.start(t); osc.stop(t+0.2); break;
case 'hit':
osc.type = 'square'; osc.frequency.setValueAtTime(150, t); osc.frequency.linearRampToValueAtTime(50, t+0.2);
gain.gain.setValueAtTime(0.2, t); osc.start(t); osc.stop(t+0.2); break;
case 'coin':
osc.type = 'sine'; osc.frequency.setValueAtTime(1500, t); osc.frequency.linearRampToValueAtTime(2000, t+0.2);
gain.gain.setValueAtTime(0.1, t); osc.start(t); osc.stop(t+0.2); break;
case 'powerup':
osc.type = 'triangle'; osc.frequency.setValueAtTime(300, t); osc.frequency.linearRampToValueAtTime(600, t+0.3);
gain.gain.setValueAtTime(0.2, t); osc.start(t); osc.stop(t+0.3); break;
case 'levelup':
osc.type = 'sine'; osc.frequency.setValueAtTime(400, t);
osc.frequency.linearRampToValueAtTime(800, t+0.1);
osc.frequency.linearRampToValueAtTime(1200, t+0.2);
gain.gain.setValueAtTime(0.25, t); osc.start(t); osc.stop(t+0.3); break;
case 'magic_cast':
osc.type = 'sine'; osc.frequency.setValueAtTime(800, t); osc.frequency.exponentialRampToValueAtTime(200, t+0.5);
gain.gain.setValueAtTime(0.2, t); osc.start(t); osc.stop(t+0.5); break;
case 'magic_hit':
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(50, t); osc.frequency.linearRampToValueAtTime(20, t+0.2);
gain.gain.setValueAtTime(0.3, t); osc.start(t); osc.stop(t+0.2); break;
case 'miss':
osc.type = 'triangle'; osc.frequency.setValueAtTime(300, t); osc.frequency.linearRampToValueAtTime(200, t+0.1);
gain.gain.setValueAtTime(0.08, t); osc.start(t); osc.stop(t+0.1); break;
case 'drop':
osc.type = 'triangle'; osc.frequency.setValueAtTime(200, t); osc.frequency.linearRampToValueAtTime(100, t+0.1);
gain.gain.setValueAtTime(0.08, t); osc.start(t); osc.stop(t+0.1); break;
case 'equip':
osc.type = 'sine'; osc.frequency.setValueAtTime(500, t); osc.frequency.linearRampToValueAtTime(700, t+0.15);
gain.gain.setValueAtTime(0.1, t); osc.start(t); osc.stop(t+0.15); break;
case 'hunger':
osc.type = 'triangle'; osc.frequency.setValueAtTime(120, t); osc.frequency.linearRampToValueAtTime(80, t+0.3);
gain.gain.setValueAtTime(0.12, t); osc.start(t); osc.stop(t+0.3); break;
}
}
};
class Game {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
this.TILE_SIZE = 32;
this.VIEW_W = 25;
this.VIEW_H = 16;
this.mode = 'TITLE';
this.floor = 1;
this.map = { w: 50, h: 40, layers: [[], []] };
this.entities = [];
this.camera = { x:0, y:0 };
this.player = {
x: 0, y: 0, hp: 50, maxHp: 50,
food: 100, maxFood: 100,
lv: 1, exp: 0, nextExp: 10,
atk: 4, def: 0, gold: 0,
inventory: [],
weapon: null, armor: null,
lastDx: 0, lastDy: -1 // 最後の進行方向
};
// アニメーション
this.projectiles = []; // { x, y, char, dx, dy, steps, maxSteps, item, fromJunk }
this.attackEffect = null; // { weaponChar, tx, ty, timer }
this.isAnimating = false;
this.menuSelection = 0;
this.ctxMenuSelection = 0;
this.ctxMenuItemIdx = -1;
this.currentShopItems = [];
// マップファイル由来のデータプール(nullなら自動生成)
// mapDataPool: { width, height, layers: [bg_data, char_data] }[] ランダム選択用
this.mapDataPool = [];
window.addEventListener('keydown', e => this.handleInput(e));
this.setupCtxMenu();
this.setupMapFileIO();
}
setupCtxMenu() {
document.getElementById('ctx-use').onclick = () => this.ctxAction('use');
document.getElementById('ctx-throw').onclick = () => this.ctxAction('throw');
document.getElementById('ctx-drop').onclick = () => this.ctxAction('drop');
document.getElementById('ctx-cancel').onclick = () => this.closeCtxMenu();
}
// ===== マップファイルIO =====
setupMapFileIO() {
const input = document.getElementById('map-file-input');
input.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
const pending = [];
let loaded = 0;
files.forEach(file => {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
// 必須フィールド検証
if (
typeof data.width === 'number' &&
typeof data.height === 'number' &&
Array.isArray(data.layers) &&
data.layers.length >= 2
) {
pending.push(data);
}
} catch (err) {
console.warn('マップJSON解析エラー:', file.name, err);
}
loaded++;
if (loaded === files.length) {
if (pending.length > 0) {
this.mapDataPool = pending;
const statusEl = document.getElementById('map-file-status');
statusEl.textContent = `✅ ${pending.length}件のマップデータを読み込みました`;
statusEl.className = 'loaded';
document.getElementById('btn-clear-map').style.display = 'inline-block';
} else {
alert('有効なマップデータが見つかりませんでした。\nEmojiMap で作成した .json ファイルを指定してください。');
}
}
};
reader.readAsText(file);
});
// inputをリセット(同じファイル再選択を許可)
e.target.value = '';
});
}
clearMapData() {
this.mapDataPool = [];
const statusEl = document.getElementById('map-file-status');
statusEl.textContent = 'マップなし(完全自動生成)';
statusEl.className = 'cleared';
document.getElementById('btn-clear-map').style.display = 'none';
}
init() {
Sound.init();
document.getElementById('start-screen').style.display = 'none';
this.floor = 1;
this.player = {
x: 0, y: 0, hp: 50, maxHp: 50,
food: 100, maxFood: 100,
lv: 1, exp: 0, nextExp: 10,
atk: 4, def: 0, gold: 0,
inventory: [
{ ...ASSETS.ITEMS.BREAD, id: Date.now() },
{ ...ASSETS.ITEMS.SCROLL_FIRE, id: Date.now()+1 }
],
weapon: null, armor: null,
lastDx: 0, lastDy: -1
};
this.mode = 'PLAY';
this.log('🏰 不思議のダンジョンへようこそ!', 'lucky');
this.log('[A]メニュー [Z]確定 [X]キャンセル/投げる', '');
this.generateFloor();
this.updateUI();
}
generateFloor() {
// マップデータプールがあればランダムに選択して使用、なければ自動生成
if (this.mapDataPool.length > 0) {
const mapData = this.mapDataPool[Math.floor(Math.random() * this.mapDataPool.length)];
this.generateFloorFromMapData(mapData);
} else {
this.generateFloorAuto();
}
}
// ===== マップデータからフロアを生成 =====
generateFloorFromMapData(mapData) {
const W = mapData.width;
const H = mapData.height;
// マップサイズを更新
this.map.w = W;
this.map.h = H;
const bgSrc = mapData.layers[0]; // 背景レイヤー
const charSrc = mapData.layers[1]; // キャラクターレイヤー
// 背景レイヤー構築
// エディタのSPACE(全角スペース)は壁として扱う
const EDITOR_SPACE = ' ';
const bgLayer = [];
for (let y = 0; y < H; y++) {
bgLayer.push([]);
for (let x = 0; x < W; x++) {
const cell = (bgSrc[y] && bgSrc[y][x]) ? bgSrc[y][x] : EDITOR_SPACE;
if (cell === EDITOR_SPACE) {
bgLayer[y].push(ASSETS.BG.WALL);
} else if (cell === ASSETS.BG.WATER) {
bgLayer[y].push(ASSETS.BG.WATER);
} else if (cell === ASSETS.BG.WALL) {
bgLayer[y].push(ASSETS.BG.WALL);
} else {
// その他の絵文字は床として扱う(エディタで描いた床タイル)
bgLayer[y].push(cell);
}
}
}
// フォアグラウンドレイヤー(マップのギミック: 階段・岩・木・泉etc)
const fgLayer = Array(H).fill(null).map(() => Array(W).fill(null));
this.entities = [];
this.projectiles = [];
this.attackEffect = null;
// プレイヤー開始位置候補(最初の床タイル)
let playerStartX = -1, playerStartY = -1;
let stairsPlaced = false;
// キャラクターレイヤーを解析してエンティティ・ギミックに変換
// 全ての絵文字をリストアップして、ランダムな解釈を適用
const charPositions = []; // { x, y, char }
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const cell = (charSrc[y] && charSrc[y][x]) ? charSrc[y][x] : EDITOR_SPACE;
if (cell !== EDITOR_SPACE && bgLayer[y][x] !== ASSETS.BG.WALL) {
charPositions.push({ x, y, char: cell });
}
}
}
// プレイヤー開始位置: 最初の床セルを探す(キャラクターデータ無関係)
outer:
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (bgLayer[y][x] !== ASSETS.BG.WALL) {
playerStartX = x;
playerStartY = y;
break outer;
}
}
}
// マップが全壁(空マップ)の場合はフォールバック
if (playerStartX === -1) {
this.log('⚠ マップデータに床が見つかりません。自動生成にフォールバックします。', 'danger');
this.generateFloorAuto();
return;
}
// キャラクター絵文字をシャッフルして配置場所を確定
const shuffled = charPositions.slice().sort(() => Math.random() - 0.5);
shuffled.forEach(({ x, y, char }) => {
// === 絵文字の種別判定 ===
// 1. FGギミック(階段・罠・泉・岩・木・宝箱・像)
if (char === ASSETS.FG.STAIRS) {
fgLayer[y][x] = ASSETS.FG.STAIRS;
stairsPlaced = true;
return;
}
if (char === ASSETS.FG.TRAP) { fgLayer[y][x] = ASSETS.FG.TRAP; return; }
if (char === ASSETS.FG.FOUNTAIN) { fgLayer[y][x] = ASSETS.FG.FOUNTAIN; return; }
if (char === ASSETS.FG.ROCK) { fgLayer[y][x] = ASSETS.FG.ROCK; return; }
if (char === ASSETS.FG.TREE) { fgLayer[y][x] = ASSETS.FG.TREE; return; }
if (char === ASSETS.FG.STATUE) { fgLayer[y][x] = ASSETS.FG.STATUE; return; }
if (char === ASSETS.FG.CHEST) { fgLayer[y][x] = ASSETS.FG.CHEST; return; }
if (ASSETS.FG.FLOWERS.includes(char)) { fgLayer[y][x] = char; return; }
// 2. プレイヤー開始位置
if (char === ASSETS.PLAYER) {
playerStartX = x;
playerStartY = y;
return;
}
// 3. 商人
if (char === ASSETS.MERCHANT) {
this.entities.push({ type: 'MERCHANT', char: ASSETS.MERCHANT, x, y, hp: 999 });
return;
}
// 4. 敵キャラクター(ASSETSのENEMIESリストに含まれるか判定)
const isEnemy = ASSETS.ENEMIES.some(group => group.includes(char));
if (isEnemy) {
// 敵の強さはフロアに応じて設定
this.entities.push({
type: 'ENEMY', char, x, y,
hp: this.floor * 3 + 5, maxHp: this.floor * 3 + 5,
atk: Math.floor(this.floor / 2) + 2, exp: this.floor * 3
});
return;
}
// 5. アイテム(ASSETSのITEMSのcharに一致するか判定)
const itemKey = Object.keys(ASSETS.ITEMS).find(k => ASSETS.ITEMS[k].char === char);
if (itemKey) {
const itemData = { ...ASSETS.ITEMS[itemKey], id: Date.now() + Math.random() };
if (itemData.type === 'gold') {
itemData.value = (itemData.char === '💎')
? 300 * this.floor
: 50 + Math.floor(Math.random() * 50) * this.floor;
}
if (itemData.type === 'weapon' || itemData.type === 'armor') {
const r = Math.random();
if (r < 0.15) itemData.enhance = 2;
else if (r < 0.4) itemData.enhance = 1;
else if (r < 0.5) itemData.enhance = -1;
else itemData.enhance = 0;
itemData.displayName = itemData.name + enhanceText(itemData);
}
this.entities.push({ type: 'ITEM', item: itemData, char: itemData.char, x, y });
return;
}
// 6. ガラクタ(ASSETSのJUNKのcharに一致するか判定)
const junkDef = ASSETS.JUNK.find(j => j.char === char);
if (junkDef) {
const junkItem = {
...junkDef,
type: 'junk',
value: 0,
throwDmg: Math.floor(Math.random() * 3) + 1,
id: Date.now() + Math.random()
};
this.entities.push({ type: 'ITEM', item: junkItem, char: junkDef.char, x, y });
return;
}
// 7. 上記に該当しない絵文字は無視(エディタ特有の装飾など)
});
// 階段が未配置の場合、床タイルの端付近に強制配置
if (!stairsPlaced) {
// 右下から探索
let placed = false;
for (let y = H - 1; y >= 0 && !placed; y--) {
for (let x = W - 1; x >= 0 && !placed; x--) {
if (bgLayer[y][x] !== ASSETS.BG.WALL && fgLayer[y][x] === null &&
!this.entities.some(e => e.x === x && e.y === y) &&
!(x === playerStartX && y === playerStartY)) {
fgLayer[y][x] = ASSETS.FG.STAIRS;
placed = true;
}
}
}
if (!placed) {
// 最後の手段:プレイヤー位置に置く(ゲームは成立しないが壊れない)
fgLayer[playerStartY][playerStartX] = ASSETS.FG.STAIRS;
}
}
this.map.layers[0] = bgLayer;
this.map.layers[1] = fgLayer;
this.player.x = playerStartX;
this.player.y = playerStartY;
this.updateCamera();
this.draw();
}
// ===== 従来の自動生成フロア =====
generateFloorAuto() {
// 自動生成時はマップサイズをデフォルトに戻す
this.map.w = 50;
this.map.h = 40;
const W = this.map.w, H = this.map.h;
// 部屋が最低2つ生成されるまでリトライ
let rooms = [];
while(rooms.length < 2) {
this.map.layers[0] = Array(H).fill().map(() => Array(W).fill(ASSETS.BG.WALL));
this.map.layers[1] = Array(H).fill().map(() => Array(W).fill(null));
rooms = [];
for(let i=0; i<12; i++) {
const w = Math.floor(Math.random()*6) + 4;
const h = Math.floor(Math.random()*6) + 4;
const x = Math.floor(Math.random()*(W-w-2)) + 1;
const y = Math.floor(Math.random()*(H-h-2)) + 1;
if(rooms.some(r => x < r.x+r.w+1 && x+w+1 > r.x && y < r.y+r.h+1 && y+h+1 > r.y)) continue;
rooms.push({ x, y, w, h, cx: Math.floor(x+w/2), cy: Math.floor(y+h/2) });
for(let ry=y; ry<y+h; ry++) {
for(let rx=x; rx<x+w; rx++) {
this.map.layers[0][ry][rx] = ASSETS.BG.FLOOR;
if(Math.random() < 0.15) {
const fl = ASSETS.FG.FLOWERS[Math.floor(Math.random()*ASSETS.FG.FLOWERS.length)];
this.map.layers[1][ry][rx] = fl;
}
}
}
}
}
this.entities = [];
this.projectiles = [];
this.attackEffect = null;
for(let i=1; i<rooms.length; i++) this.connectRooms(rooms[i-1], rooms[i]);
this.player.x = rooms[0].cx;
this.player.y = rooms[0].cy;
// 階段は最終部屋の中心に確実配置(フォアグラウンドを上書き)
const lastRoom = rooms[rooms.length-1];
this.map.layers[1][lastRoom.cy][lastRoom.cx] = ASSETS.FG.STAIRS;
rooms.forEach((r, idx) => {
if(idx === 0) return;
if(Math.random() < 0.2) this.spawnEntity('MERCHANT', r.cx, r.cy);
const enemyCnt = 1 + Math.floor(Math.random()*3);
for(let k=0; k<enemyCnt; k++) this.spawnEntity('ENEMY', this.rand(r).x, this.rand(r).y);
const itemCnt = 1 + Math.floor(Math.random()*2);
for(let k=0; k<itemCnt; k++) this.spawnEntity('ITEM', this.rand(r).x, this.rand(r).y);
// ガラクタをランダムに配置
if(Math.random() < 0.5) {
const p = this.rand(r);
this.spawnEntity('JUNK', p.x, p.y);
}
if(Math.random() < 0.4) {
const p = this.rand(r);
const dice = Math.random();
if(dice < 0.3) this.map.layers[1][p.y][p.x] = ASSETS.FG.FOUNTAIN;
else if(dice < 0.5) this.map.layers[1][p.y][p.x] = ASSETS.FG.STATUE;
else if(dice < 0.7) this.map.layers[1][p.y][p.x] = ASSETS.FG.CHEST;
else this.map.layers[1][p.y][p.x] = ASSETS.FG.TRAP;
}
});
this.updateCamera();
this.draw();
}
connectRooms(r1, r2) {
let x = r1.cx, y = r1.cy;
while(x !== r2.cx || y !== r2.cy) {
if(Math.random() < 0.5) { if(x < r2.cx) x++; else if(x > r2.cx) x--; }
else { if(y < r2.cy) y++; else if(y > r2.cy) y--; }
this.map.layers[0][y][x] = ASSETS.BG.FLOOR;
}
}
rand(room) {
return {
x: room.x + Math.floor(Math.random() * room.w),
y: room.y + Math.floor(Math.random() * room.h)
};
}
spawnEntity(type, x, y) {
if(x < 0 || x >= this.map.w || y < 0 || y >= this.map.h) return;
if(this.map.layers[0][y][x] === ASSETS.BG.WALL) return;
if(type === 'ENEMY') {
const tier = Math.min(Math.floor((this.floor-1)/3), ASSETS.ENEMIES.length-1);
const group = ASSETS.ENEMIES[tier];
const char = group[Math.floor(Math.random()*group.length)];
this.entities.push({
type: 'ENEMY', char, x, y,
hp: this.floor * 3 + 5, maxHp: this.floor * 3 + 5,
atk: Math.floor(this.floor/2) + 2, exp: this.floor * 3
});
} else if(type === 'MERCHANT') {
this.entities.push({ type: 'MERCHANT', char: ASSETS.MERCHANT, x, y, hp: 999 });
} else if(type === 'ITEM') {
const keys = Object.keys(ASSETS.ITEMS);
const k = keys[Math.floor(Math.random() * keys.length)];
const itemData = { ...ASSETS.ITEMS[k], id: Date.now() + Math.random() };
if(itemData.type === 'gold') {
itemData.value = (itemData.char === '💎') ? 300 * this.floor : 50 + Math.floor(Math.random()*50)*this.floor;
}
// 武器・防具は確率で強化値を付与
if(itemData.type === 'weapon' || itemData.type === 'armor') {
const r = Math.random();
if(r < 0.15) itemData.enhance = 2;
else if(r < 0.4) itemData.enhance = 1;
else if(r < 0.5) itemData.enhance = -1;
else itemData.enhance = 0;
// nameに強化値を反映
if(itemData.enhance !== 0) {
itemData.displayName = itemData.name + enhanceText(itemData);
} else {
itemData.displayName = itemData.name;
}
}
this.entities.push({ type: 'ITEM', item: itemData, char: itemData.char, x, y });
} else if(type === 'JUNK') {
if(this.entities.some(e => e.x === x && e.y === y)) return;
const junkDef = ASSETS.JUNK[Math.floor(Math.random() * ASSETS.JUNK.length)];
const junkItem = {
...junkDef,
type: 'junk',
value: 0,
throwDmg: Math.floor(Math.random()*3) + 1,
id: Date.now() + Math.random()
};
this.entities.push({ type: 'ITEM', item: junkItem, char: junkDef.char, x, y });
}
}
// アイテムの表示名
displayName(item) {
if(item.displayName) return item.displayName;
if((item.type === 'weapon' || item.type === 'armor') && item.enhance !== undefined) {
return item.name + enhanceText(item);
}
return item.name;
}
handleInput(e) {
if(this.mode === 'TITLE' || this.mode === 'GAMEOVER') return;
// コンテキストメニューが開いているとき
if(document.getElementById('ctx-menu').style.display === 'block') {
e.preventDefault();
if(e.key === 'ArrowUp' || e.key === 'ArrowLeft') this.moveCtxMenu(-1);
else if(e.key === 'ArrowDown' || e.key === 'ArrowRight') this.moveCtxMenu(1);
else if(e.key === 'z' || e.key === 'Z' || e.key === 'Enter') this.ctxConfirm();
else if(e.key === 'x' || e.key === 'X' || e.key === 'Escape') this.closeCtxMenu();
return;
}
if(this.isAnimating) return;
if(this.mode === 'PLAY') {
let dx=0, dy=0;
if(e.key === 'ArrowUp') dy=-1;
else if(e.key === 'ArrowDown') dy=1;
else if(e.key === 'ArrowLeft') dx=-1;
else if(e.key === 'ArrowRight') dx=1;
else if(e.key === 'Enter' || e.key === 'z' || e.key === 'Z') { this.interact(); return; }
else if(e.key === 'a' || e.key === 'A') { this.toggleInventory(); return; }
else if(e.key === 'x' || e.key === 'X') {
// Xキー: 進行方向にダッシュ(最大10歩、壁・敵・アイテムで止まる)
this.dash(this.player.lastDx, this.player.lastDy);
return;
}
if(dx!==0 || dy!==0) { e.preventDefault(); this.turn(dx, dy); }
}
else if(this.mode === 'INVENTORY') {
e.preventDefault();
if(e.key === 'ArrowUp') this.moveMenuSelection(-1);
else if(e.key === 'ArrowDown') this.moveMenuSelection(1);
else if(e.key === 'z' || e.key === 'Z' || e.key === 'Enter') this.menuActionOpenCtx();
else if(e.key === 'x' || e.key === 'X' || e.key === 'a' || e.key === 'A' || e.key === 'Escape') this.closeModal();
}
else if(this.mode === 'SHOP') {
e.preventDefault();
if(e.key === 'ArrowUp') this.moveMenuSelection(-1);
else if(e.key === 'ArrowDown') this.moveMenuSelection(1);
else if(e.key === 'z' || e.key === 'Z' || e.key === 'Enter') this.menuAction();
else if(e.key === 'a' || e.key === 'A' || e.key === 'x' || e.key === 'X' || e.key === 'Escape') this.closeModal();
}
}
turn(dx, dy) {
const tx = this.player.x + dx;
const ty = this.player.y + dy;
// 進行方向を記録
if(dx !== 0 || dy !== 0) {
this.player.lastDx = dx;
this.player.lastDy = dy;
}
if(!this.isWalkable(tx, ty)) return;
const target = this.entities.find(e => e.x === tx && e.y === ty);
let blocked = false;
if(target) {
if(target.type === 'ENEMY') {
this.attack(target, dx, dy);
blocked = true;
} else if(target.type === 'MERCHANT') {
this.openShop();
blocked = true;
}
}
if(!blocked) {
this.player.x = tx;
this.player.y = ty;
Sound.play('move');
const itemIdx = this.entities.findIndex(e => e.type === 'ITEM' && e.x === tx && e.y === ty);
if(itemIdx !== -1) {
this.pickup(this.entities[itemIdx], itemIdx);
}
this.checkGimmick(tx, ty);
}
// 食料消費(攻撃でも消費)
this.player.food -= 0.3;
if(this.player.food < 0) this.player.food = 0;
if(this.player.food <= 0) {
this.player.food = 0;
this.player.hp = Math.max(1, this.player.hp - 1); // 即死しない(HP1止まり)
Sound.play('hunger');
this.log('お腹が空いてフラフラだ...(HP-1)', 'danger');
}
this.updateEnemies();
this.updateCamera();
this.draw();
this.updateUI();
}
isWalkable(x, y) {
if(x<0 || x>=this.map.w || y<0 || y>=this.map.h) return false;
if(this.map.layers[0][y][x] === ASSETS.BG.WALL) return false;
const fg = this.map.layers[1][y][x];
if(fg === ASSETS.FG.ROCK || fg === ASSETS.FG.TREE) return false;
return true;
}
pickup(ent, idx) {
const item = ent.item;
if(item.type === 'gold') {
Sound.play('coin');
this.player.gold += item.value;
this.log(`${item.value}G を拾った!`, 'lucky');
this.entities.splice(idx, 1);
return;
}
// 武器を拾ったとき:現在の装備より強ければ自動装備、弱ければ捨てる
if(item.type === 'weapon') {
const newPower = itemPower(item);
if(this.player.weapon) {
const curPower = itemPower(this.player.weapon);
if(newPower > curPower) {
// 現在の武器を地面に置く
const oldWeapon = this.player.weapon;
this.dropItemAt(oldWeapon, this.player.x, this.player.y);
this.log(`弱い${this.displayName(oldWeapon)}を置いた`, '');
// 新しい武器を装備
this.player.atk -= curPower;
this.player.weapon = item;
this.player.atk += newPower;
this.log(`🗡️ ${this.displayName(item)}を拾って装備!(攻撃力+${newPower})`, 'good');
Sound.play('equip');
} else if(newPower === curPower) {
// 同じ強さならインベントリに
if(this.player.inventory.length < 15) {
this.player.inventory.push(item);
this.log(`${this.displayName(item)}を拾った`, 'good');
Sound.play('pickup');
} else {
this.log('持ち物がいっぱいで拾えない!');
return;
}
} else {
// 弱い→その場に残す(通過するだけ)
this.log(`${this.displayName(item)}は今の武器より弱いので無視した`, '');
return; // entityはそのままにする(spliceしない)
}
} else {
// 武器を持っていない→装備
this.player.weapon = item;
this.player.atk += newPower;
this.log(`🗡️ ${this.displayName(item)}を拾って装備!(攻撃力+${newPower})`, 'good');
Sound.play('equip');
}
this.entities.splice(idx, 1);
return;
}
// 防具を拾ったとき
if(item.type === 'armor') {
const newPower = itemPower(item);
if(this.player.armor) {
const curPower = itemPower(this.player.armor);
if(newPower > curPower) {
const oldArmor = this.player.armor;
this.dropItemAt(oldArmor, this.player.x, this.player.y);
this.log(`弱い${this.displayName(oldArmor)}を置いた`, '');
this.player.def -= curPower;
this.player.armor = item;
this.player.def += newPower;
this.log(`🛡️ ${this.displayName(item)}を拾って装備!(防御力+${newPower})`, 'good');
Sound.play('equip');
} else if(newPower <= curPower) {
this.log(`${this.displayName(item)}は今の防具より弱いので無視した`, '');
return; // その場に残す
}
} else {
this.player.armor = item;
this.player.def += newPower;
this.log(`🛡️ ${this.displayName(item)}を拾って装備!(防御力+${newPower})`, 'good');
Sound.play('equip');
}
this.entities.splice(idx, 1);
return;
}
// ガラクタ・その他
if(this.player.inventory.length >= 15) {
this.log('持ち物がいっぱいで拾えない!');
return;
}
Sound.play('pickup');
this.player.inventory.push(item);
this.log(`${item.name}を拾った`, 'good');
this.entities.splice(idx, 1);
}
// アイテムをその場に置く(地面にEntityとして追加)
dropItemAt(item, x, y) {
// 同じ場所に既にアイテムがあれば少しずらす
let px = x, py = y;
const offsets = [[0,0],[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1],[1,-1],[-1,1]];
for(const [ox, oy] of offsets) {
const nx = x+ox, ny = y+oy;
if(this.isWalkable(nx, ny) && !this.entities.some(e => e.type==='ITEM' && e.x===nx && e.y===ny)) {
px = nx; py = ny; break;
}
}
this.entities.push({ type: 'ITEM', item, char: item.char, x: px, y: py });
}
checkGimmick(x, y) {
const fg = this.map.layers[1][y][x];
if(!fg) return;
if(fg === ASSETS.FG.TRAP) {
Sound.play('hit');
const dmg = Math.floor(this.player.maxHp * 0.1) + 1;
this.player.hp -= dmg;
if(this.player.hp < 1) this.player.hp = 1; // 罠でも即死しない
this.log(`⚡ 罠にかかった!${dmg}ダメージ!`, 'danger');
this.map.layers[1][y][x] = null;
} else if(fg === ASSETS.FG.FOUNTAIN) {
const effect = Math.random();
if(effect < 0.4) {
this.player.hp = this.player.maxHp;
this.log('⛲ 泉の水でHP全回復!', 'lucky');
Sound.play('powerup');
} else if(effect < 0.7) {
this.player.food = this.player.maxFood;
this.log('⛲ 美味しい水!お腹が満たされた!', 'lucky');
Sound.play('powerup');
} else {
this.log('⛲ ただの水だった。');
}
this.map.layers[1][y][x] = null;
} else if(fg === ASSETS.FG.CHEST) {
Sound.play('coin');
const gold = 100 + Math.floor(Math.random()*200) * this.floor;
this.player.gold += gold;
this.log(`🎁 宝箱!${gold}G 入っていた!`, 'lucky');
this.map.layers[1][y][x] = null;
}
}
interact() {
if(this.map.layers[1][this.player.y][this.player.x] === ASSETS.FG.STAIRS) {
Sound.play('powerup');
this.floor++;
this.log(`✨ ${this.floor}階へ進んだ!`, 'good');
this.generateFloor();
}
}
// ===== 攻撃 =====
attack(target, dx, dy) {
Sound.play('attack');
const dmg = Math.max(1, this.player.atk);
// 武器エフェクト:敵の上に武器を一瞬表示
if(this.player.weapon) {
this.attackEffect = {
weaponChar: this.player.weapon.char,
tx: target.x,
ty: target.y,
timer: 8 // フレーム数
};
}
target.hp -= dmg;
this.log(`${target.char}に${dmg}ダメージ!`);
if(target.hp <= 0) {
this.log(`${target.char}を倒した!`, 'good');
this.gainExp(target.exp);
this.entities = this.entities.filter(e => e !== target);
if(Math.random() < 0.4) this.spawnEntity('ITEM', target.x, target.y);
}
}
gainExp(amount) {
this.player.exp += amount;
if(this.player.exp >= this.player.nextExp) {
this.player.lv++;
this.player.exp -= this.player.nextExp;
this.player.nextExp = Math.floor(this.player.nextExp * 1.5);
this.player.maxHp += 5;
this.player.hp = this.player.maxHp;
this.player.atk += 1;
this.log(`🎉 レベルアップ!Lv${this.player.lv}!HP全回復!`, 'lucky');
Sound.play('levelup');
}
}
// ===== 投擲 =====
// item: すでにインベントリ・装備から除去済みのアイテムオブジェクト, dx/dy: 方向
// callerRemovedFromInventory: 呼び出し元でインベントリ削除済みならtrue
throwItem(item, dx, dy, callerRemovedFromInventory = false) {
if(dx === 0 && dy === 0) {
this.log('投げる方向が分からない(まず移動してから投げよう)');
return false;
}
// まだインベントリにあれば除去(マップ上Xキー直接投擲など)
if(!callerRemovedFromInventory) {
const invIdx = this.player.inventory.indexOf(item);
if(invIdx !== -1) this.player.inventory.splice(invIdx, 1);
}
Sound.play('throw');
// 投射物追加
this.projectiles.push({
x: this.player.x,
y: this.player.y,
char: item.char,
dx, dy,
item,
steps: 0,
maxSteps: (item.type === 'weapon' || item.type === 'junk') ? 8 : 5
});
const name = this.displayName(item);
this.log(`${item.char} ${name}を投げた!`, '');
// モーダルを閉じてからアニメ開始
this.closeModal();
this.isAnimating = true;
this.animateProjectiles();
return true;
}
animateProjectiles() {
if(this.projectiles.length === 0) {
this.isAnimating = false;
this.updateEnemies();
this.updateCamera();
this.draw();
this.updateUI();
return;
}
const proj = this.projectiles[0];
// まず1歩進める
proj.x += proj.dx;
proj.y += proj.dy;
proj.steps++;
const hitWall = !this.isWalkable(proj.x, proj.y);
const hitEnemy = this.entities.find(e => e.type === 'ENEMY' && e.x === proj.x && e.y === proj.y);
if(hitWall || hitEnemy || proj.steps >= proj.maxSteps) {
// 着弾処理
if(hitEnemy) {
let dmg = 0;
if(proj.item.type === 'weapon') {
dmg = Math.max(1, itemPower(proj.item) + Math.floor(this.player.atk / 2));
} else if(proj.item.type === 'junk') {
dmg = proj.item.throwDmg || 1;
} else {
dmg = Math.max(1, Math.floor(this.player.atk / 2));
}
hitEnemy.hp -= dmg;
Sound.play('hit');
this.log(`${hitEnemy.char}に${proj.item.char}がヒット!${dmg}ダメージ!`, 'good');
if(hitEnemy.hp <= 0) {
this.log(`${hitEnemy.char}を倒した!`, 'good');
this.gainExp(hitEnemy.exp);
this.entities = this.entities.filter(e => e !== hitEnemy);
if(Math.random() < 0.4) this.spawnEntity('ITEM', hitEnemy.x, hitEnemy.y);
}
} else if(!hitWall) {
Sound.play('miss');
}
// アイテムを着弾地点に落とす(壁に当たった場合は1歩手前)
let landX = hitWall ? proj.x - proj.dx : proj.x;
let landY = hitWall ? proj.y - proj.dy : proj.y;
landX = Math.max(0, Math.min(this.map.w-1, landX));
landY = Math.max(0, Math.min(this.map.h-1, landY));
if(this.isWalkable(landX, landY)) {
this.dropItemAt(proj.item, landX, landY);
}
this.projectiles.shift();
this.draw();
setTimeout(() => this.animateProjectiles(), 60);
} else {
// 飛翔中:描画
this.draw();
const screenX = (proj.x - this.camera.x) * this.TILE_SIZE + this.TILE_SIZE/2;
const screenY = (proj.y - this.camera.y) * this.TILE_SIZE + this.TILE_SIZE/2;
this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.9)}px sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(proj.char, screenX, screenY);
setTimeout(() => this.animateProjectiles(), 60);
}
}
// ===== メニュー =====
toggleInventory() {
if(this.mode === 'INVENTORY') {
this.closeModal();
} else if(this.mode === 'PLAY') {
this.openInventory();
}
}
openInventory() {
this.mode = 'INVENTORY';
this.renderInventory();
this.menuSelection = 0;
document.getElementById('modal-inventory').style.display = 'flex';
this.highlightMenuItem('inventory-list', 0);
}
renderInventory() {
const list = document.getElementById('inventory-list');
list.innerHTML = '';
if(this.player.inventory.length === 0) {
list.innerHTML = '<li style="color:#666;">持ち物がありません</li>';
return;
}
this.player.inventory.forEach((item, idx) => {
const li = document.createElement('li');
let prefix = '';
if(this.player.weapon === item) { prefix = '⚔️[E] '; li.classList.add('equipped'); }
else if(this.player.armor === item) { prefix = '🛡️[E] '; li.classList.add('equipped'); }
const name = this.displayName(item);
let typeTag = '';
if(item.type==='junk') typeTag = '<span style="color:#888;font-size:12px;"> ガラクタ</span>';
else if(item.type==='weapon') typeTag = `<span style="font-size:12px;color:#fa8;"> ATK+${itemPower(item)}</span>`;
else if(item.type==='armor') typeTag = `<span style="font-size:12px;color:#8af;"> DEF+${itemPower(item)}</span>`;
li.innerHTML = `<span>${prefix}${item.char} ${name}${typeTag}</span>`;
li.onclick = () => { this.menuSelection = idx; this.menuActionOpenCtx(); };
if(idx === 0) li.classList.add('selected');
list.appendChild(li);
});
}
highlightMenuItem(listId, idx) {
const items = document.getElementById(listId).getElementsByTagName('li');
for(let i=0; i<items.length; i++) items[i].classList.remove('selected');
if(items[idx]) { items[idx].classList.add('selected'); items[idx].scrollIntoView({block:'nearest'}); }
}
openShop() {
this.mode = 'SHOP';
this.menuSelection = 0;
this.currentShopItems = [];
const cnt = 4 + Math.floor(Math.random()*3);
const keys = Object.keys(ASSETS.ITEMS).filter(k => ASSETS.ITEMS[k].type !== 'gold');
for(let i=0; i<cnt; i++) {
const k = keys[Math.floor(Math.random() * keys.length)];
const base = ASSETS.ITEMS[k];
const price = Math.max(10, Math.floor(base.value * (0.5 + Math.random()*0.5)));
const shopItem = { ...base, price, id: Date.now()+i };
// 武器・防具には強化値
if(shopItem.type === 'weapon' || shopItem.type === 'armor') {
const r = Math.random();
shopItem.enhance = r < 0.2 ? 1 : r < 0.3 ? 2 : 0;
shopItem.displayName = shopItem.name + enhanceText(shopItem);
if(shopItem.enhance > 0) shopItem.price = Math.floor(shopItem.price * (1 + shopItem.enhance * 0.5));
}
this.currentShopItems.push(shopItem);
}
this.renderShop();
document.getElementById('modal-shop').style.display = 'flex';
}
renderShop() {
const list = document.getElementById('shop-list');
list.innerHTML = '';
if(this.currentShopItems.length === 0) {
list.innerHTML = '<li>売り切れ</li>';
} else {
this.currentShopItems.forEach((item, idx) => {
const li = document.createElement('li');
const name = this.displayName(item);
li.innerHTML = `<span>${item.char} ${name}</span> <span style="color:gold;">${item.price} G</span>`;
li.onclick = () => { this.menuSelection = idx; this.menuAction(); };
if(idx === this.menuSelection) li.classList.add('selected');
list.appendChild(li);
});
}
document.getElementById('shop-gold').innerText = `所持金: ${this.player.gold} G`;
}
// インベントリでZ/クリック:アクションメニューを開く
menuActionOpenCtx() {
if(this.mode !== 'INVENTORY') return;
if(this.player.inventory.length === 0) return;
const item = this.player.inventory[this.menuSelection];
this.ctxMenuItemIdx = this.menuSelection;
this.showCtxMenu(item);
}
menuAction() {
if(this.mode === 'INVENTORY') {
if(this.player.inventory.length === 0) return;
const item = this.player.inventory[this.menuSelection];
this.useItem(item, this.menuSelection);
} else if(this.mode === 'SHOP') {
if(this.currentShopItems.length === 0) return;
const item = this.currentShopItems[this.menuSelection];
if(this.player.gold >= item.price) {
Sound.play('coin');
this.player.gold -= item.price;
// 武器・防具は自動装備チェック
if(item.type === 'weapon' || item.type === 'armor') {
this.player.inventory.push(item);
this.log(`${this.displayName(item)}を購入!`, 'good');
// その後自動で装備判定(PickupとほぼDry runなので手動)
this.tryAutoEquipFromInventory(item);
} else {
this.player.inventory.push(item);
this.log(`${this.displayName(item)}を買った!`, 'good');
}
this.currentShopItems.splice(this.menuSelection, 1);
if(this.menuSelection >= this.currentShopItems.length) this.menuSelection = Math.max(0, this.currentShopItems.length-1);
this.renderShop();
this.updateUI();
} else {
this.log('お金が足りない...', 'danger');
}
}
}
tryAutoEquipFromInventory(item) {
if(item.type === 'weapon') {
const np = itemPower(item);
if(!this.player.weapon) {
this.player.weapon = item;
this.player.atk += np;
this.log(`${this.displayName(item)}を装備!`, 'good');
Sound.play('equip');
} else if(np > itemPower(this.player.weapon)) {
const old = this.player.weapon;
this.player.atk -= itemPower(old);
this.player.weapon = item;
this.player.atk += np;
this.log(`${this.displayName(item)}に持ち替えた!`, 'good');
Sound.play('equip');
}
} else if(item.type === 'armor') {
const np = itemPower(item);
if(!this.player.armor) {
this.player.armor = item;
this.player.def += np;
this.log(`${this.displayName(item)}を装備!`, 'good');
Sound.play('equip');
} else if(np > itemPower(this.player.armor)) {
const old = this.player.armor;
this.player.def -= itemPower(old);
this.player.armor = item;
this.player.def += np;
this.log(`${this.displayName(item)}に着替えた!`, 'good');
Sound.play('equip');
}
}
}
useItem(item, idx) {
let used = false;
let shouldClose = true;
if(item.type === 'consumable') {
const heal = Math.min(item.power, this.player.maxHp - this.player.hp);
this.player.hp += heal;
this.log(`${item.name}でHP+${heal}回復!`, 'good');
Sound.play('powerup');
used = true;
} else if(item.type === 'food') {
const gain = Math.min(item.power, this.player.maxFood - this.player.food);
this.player.food += gain;
this.log(`${item.name}を食べた!満腹度UP`, 'good');
Sound.play('powerup');
used = true;
} else if(item.type === 'magic') {
const targets = this.entities.filter(e => e.type === 'ENEMY' &&
Math.abs(e.x - this.player.x) + Math.abs(e.y - this.player.y) <= item.range);
if(targets.length === 0) {
this.log('近くに敵がいない!', '');
return;
}
this.closeModal();
shouldClose = false;
used = true;
this.log(`${item.name}を唱えた!`, 'magic');
this.castMagicSequence(targets, item);
} else if(item.type === 'weapon') {
// 武器:装備切り替え
if(this.player.weapon === item) {
this.player.atk -= itemPower(item);
this.player.weapon = null;
this.log(`${this.displayName(item)}を外した`);
} else {
if(this.player.weapon) this.player.atk -= itemPower(this.player.weapon);
this.player.weapon = item;
this.player.atk += itemPower(item);
this.log(`${this.displayName(item)}を装備!`, 'good');
Sound.play('equip');
}
} else if(item.type === 'armor') {
if(this.player.armor === item) {
this.player.def -= itemPower(item);
this.player.armor = null;
this.log(`${this.displayName(item)}を外した`);
} else {
if(this.player.armor) this.player.def -= itemPower(this.player.armor);
this.player.armor = item;
this.player.def += itemPower(item);
this.log(`${this.displayName(item)}を装備!`, 'good');
Sound.play('equip');
}
} else if(item.type === 'junk') {
this.log(`${item.name}...使い道がなさそうだ。「投げる」で投げてみよう`);
return;
}
if(used) {
this.player.inventory.splice(idx, 1);
if(this.menuSelection >= this.player.inventory.length) this.menuSelection = Math.max(0, this.player.inventory.length-1);
}
if(shouldClose) {
this.closeModal();
this.turn(0, 0);
} else {
this.renderInventory();
this.highlightMenuItem('inventory-list', this.menuSelection);
}
this.updateUI();
}
// インベントリでXキー:投げる or 捨てる
menuActionThrow() {
if(this.mode !== 'INVENTORY') return;
if(this.player.inventory.length === 0) return;
const item = this.player.inventory[this.menuSelection];
this.ctxMenuItemIdx = this.menuSelection;
this.showCtxMenu(item);
}
showCtxMenu(item) {
const name = this.displayName(item);
// 「使う/装備する」ラベルをアイテム種別に合わせて変更
let useLabel = '✅ 使う';
if(item.type === 'weapon' || item.type === 'armor') {
const isEquipped = (this.player.weapon === item || this.player.armor === item);
useLabel = isEquipped ? '✅ 外す' : '✅ 装備する';
} else if(item.type === 'food' || item.type === 'consumable') {
useLabel = '✅ 使う(食べる/飲む)';
} else if(item.type === 'magic') {
useLabel = '✅ 唱える';
} else if(item.type === 'junk') {
useLabel = '✅ 使う(使い道なし)';
}
document.getElementById('ctx-use').textContent = `${useLabel}(${item.char} ${name})`;
document.getElementById('ctx-throw').textContent = `🪃 投げる(${item.char} ${name})`;
document.getElementById('ctx-drop').textContent = `📦 その場に置く(${item.char} ${name})`;
document.getElementById('ctx-cancel').textContent = `✖ キャンセル`;
const menu = document.getElementById('ctx-menu');
menu.style.display = 'block';
this.ctxMenuSelection = 0;
this.updateCtxMenuHighlight();
}
moveCtxMenu(dir) {
const items = document.getElementById('ctx-menu').querySelectorAll('.ctx-item');
this.ctxMenuSelection = (this.ctxMenuSelection + dir + items.length) % items.length;
this.updateCtxMenuHighlight();
}
updateCtxMenuHighlight() {
const items = document.getElementById('ctx-menu').querySelectorAll('.ctx-item');
items.forEach((el, i) => {
el.classList.toggle('selected', i === this.ctxMenuSelection);
});
}
ctxConfirm() {
const labels = ['use', 'throw', 'drop', 'cancel'];
this.ctxAction(labels[this.ctxMenuSelection] || 'cancel');
}
ctxAction(action) {
// closeCtxMenuの前にインデックスを保存(closeCtxMenuがctxMenuItemIdxを-1にリセットするため)
const savedIdx = this.ctxMenuItemIdx;
this.closeCtxMenu();
if(action === 'cancel') return;
if(savedIdx < 0 || savedIdx >= this.player.inventory.length) return;
const item = this.player.inventory[savedIdx];
// 装備解除(武器・防具どちらか)
const unequip = () => {
if(this.player.weapon === item) {
this.player.atk -= itemPower(item);
this.player.weapon = null;
} else if(this.player.armor === item) {
this.player.def -= itemPower(item);
this.player.armor = null;
}
};
if(action === 'use') {
// 元のuseItem処理を呼ぶ
this.menuSelection = savedIdx;
this.useItem(item, savedIdx);
} else if(action === 'throw') {
unequip();
// インベントリから削除
this.player.inventory.splice(savedIdx, 1);
const dx = this.player.lastDx;
const dy = this.player.lastDy;
// throwItemにはすでに削除済みフラグを渡す
this.throwItem(item, dx, dy, true);
} else if(action === 'drop') {
unequip();
this.player.inventory.splice(savedIdx, 1);
this.dropItemAt(item, this.player.x, this.player.y);
Sound.play('drop');
this.log(`${item.char} ${this.displayName(item)}を置いた`);
this.closeModal();
this.updateUI();
this.draw();
}
}
closeCtxMenu() {
document.getElementById('ctx-menu').style.display = 'none';
this.ctxMenuItemIdx = -1;
}
moveMenuSelection(dir) {
const listId = (this.mode === 'INVENTORY') ? 'inventory-list' : 'shop-list';
const items = document.getElementById(listId).getElementsByTagName('li');
if(items.length === 0) return;
items[this.menuSelection].classList.remove('selected');
this.menuSelection += dir;
if(this.menuSelection < 0) this.menuSelection = items.length - 1;
if(this.menuSelection >= items.length) this.menuSelection = 0;
items[this.menuSelection].classList.add('selected');
items[this.menuSelection].scrollIntoView({ block: 'nearest' });
}
closeModal() {
this.mode = 'PLAY';
document.querySelectorAll('.modal-overlay').forEach(el => el.style.display = 'none');
this.closeCtxMenu();
}
// ===== ダッシュ(Xキー)=====
// 進行方向に最大10歩連続移動。壁・エネミー・アイテム・ギミックで停止。
// 各1歩ごとに敵も1回行動する。
dash(dx, dy) {
if(dx === 0 && dy === 0) {
this.log('まず移動して方向を決めてからダッシュしよう');
return;
}
const MAX_DASH = 10;
let moved = 0;
for(let i = 0; i < MAX_DASH; i++) {
const tx = this.player.x + dx;
const ty = this.player.y + dy;
// 壁なら停止
if(!this.isWalkable(tx, ty)) break;
// 敵がいたら停止(攻撃はしない)
const enemy = this.entities.find(e => e.type === 'ENEMY' && e.x === tx && e.y === ty);
if(enemy) break;
// 商人がいたら停止
const merchant = this.entities.find(e => e.type === 'MERCHANT' && e.x === tx && e.y === ty);
if(merchant) break;
// 1歩進む
this.player.x = tx;
this.player.y = ty;
moved++;
// 食料消費
this.player.food -= 0.3;
if(this.player.food < 0) this.player.food = 0;
if(this.player.food <= 0) {
this.player.food = 0;
this.player.hp = Math.max(1, this.player.hp - 1);
Sound.play('hunger');
this.log('お腹が空いてフラフラだ...(HP-1)', 'danger');
}
// 敵行動(1歩ごと)
this.updateEnemies();
if(this.mode === 'GAMEOVER') return;
// アイテム・ギミックチェック
const itemIdx = this.entities.findIndex(e => e.type === 'ITEM' && e.x === tx && e.y === ty);
if(itemIdx !== -1) {
this.pickup(this.entities[itemIdx], itemIdx);
// アイテムを踏んだら停止
break;
}
const hasTrap = this.map.layers[1][ty][tx] === ASSETS.FG.TRAP;
this.checkGimmick(tx, ty);
if(hasTrap) break; // 罠を踏んだら停止
// 階段があれば停止
if(this.map.layers[1][ty][tx] === ASSETS.FG.STAIRS) break;
}
if(moved > 0) Sound.play('move');
this.updateCamera();
this.draw();
this.updateUI();
}
// ===== 敵の行動 =====
updateEnemies() {
this.entities.forEach(e => {
if(e.type !== 'ENEMY') return;
const dist = Math.abs(this.player.x - e.x) + Math.abs(this.player.y - e.y);
if(dist <= 1) {
Sound.play('hit');
const dmg = Math.max(1, e.atk - this.player.def);
this.player.hp -= dmg;
this.log(`${e.char}の攻撃!${dmg}ダメージ`, 'danger');
if(this.player.hp <= 0) { this.gameOver(`${e.char}にやられた`); }
} else if(dist < 8) {
let mx = e.x, my = e.y;
if(this.player.x < e.x) mx--;
else if(this.player.x > e.x) mx++;
else if(this.player.y < e.y) my--;
else if(this.player.y > e.y) my++;
if(this.isWalkable(mx, my) &&
!this.entities.some(ent => ent.x === mx && ent.y === my) &&
!(mx === this.player.x && my === this.player.y)) {
e.x = mx; e.y = my;
}
}
});
}
updateCamera() {
this.camera.x = Math.max(0, Math.min(this.map.w - this.VIEW_W, this.player.x - Math.floor(this.VIEW_W/2)));
this.camera.y = Math.max(0, Math.min(this.map.h - this.VIEW_H, this.player.y - Math.floor(this.VIEW_H/2)));
}
// ===== 描画 =====
draw() {
this.ctx.fillStyle = '#000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.9)}px sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
for(let y=0; y<this.VIEW_H; y++) {
for(let x=0; x<this.VIEW_W; x++) {
const mx = this.camera.x + x;
const my = this.camera.y + y;
const px = x * this.TILE_SIZE + this.TILE_SIZE/2;
const py = y * this.TILE_SIZE + this.TILE_SIZE/2;
// 背景
const bg = this.map.layers[0][my][mx];
if(bg !== ASSETS.BG.WALL) {
this.ctx.fillText(bg, px, py);
} else {
this.ctx.fillStyle = (my < this.map.h-1 && this.map.layers[0][my+1][mx] !== ASSETS.BG.WALL) ? '#666' : '#222';
this.ctx.fillText(ASSETS.BG.WALL, px, py);
this.ctx.fillStyle = '#fff';
}
// フォアグラウンド
const fg = this.map.layers[1][my][mx];
if(fg) this.ctx.fillText(fg, px, py);
// エンティティ
const ent = this.entities.find(e => e.x === mx && e.y === my);
if(ent) {
this.ctx.fillText(ent.char, px, py);
if(ent.type === 'ENEMY') {
this.ctx.fillStyle = 'red';
this.ctx.fillRect(px-12, py+12, 24*(ent.hp/ent.maxHp), 3);
this.ctx.fillStyle = '#fff';
// 攻撃エフェクト:武器を敵の上に表示
if(this.attackEffect && this.attackEffect.tx === mx && this.attackEffect.ty === my && this.attackEffect.timer > 0) {
const bigFont = Math.floor(this.TILE_SIZE * 1.3);
this.ctx.font = `${bigFont}px sans-serif`;
this.ctx.globalAlpha = this.attackEffect.timer / 8;
this.ctx.fillText(this.attackEffect.weaponChar, px, py - this.TILE_SIZE * 0.3);
this.ctx.globalAlpha = 1.0;
this.ctx.font = `${Math.floor(this.TILE_SIZE * 0.9)}px sans-serif`;
this.attackEffect.timer--;
if(this.attackEffect.timer <= 0) this.attackEffect = null;
}
}
}
// プレイヤー
if(mx === this.player.x && my === this.player.y) {
this.ctx.fillText(ASSETS.PLAYER, px, py);
}
}
}
}
updateUI() {
document.getElementById('val-floor').innerText = this.floor;
document.getElementById('val-hp').innerText = `${this.player.hp}/${this.player.maxHp}`;
const foodEl = document.getElementById('val-food');
foodEl.innerText = `${Math.floor(this.player.food)}%`;
foodEl.style.color = this.player.food === 0 ? '#f00' : this.player.food < 30 ? '#fa0' : '#fff';
document.getElementById('val-lv').innerText = this.player.lv;
document.getElementById('val-gold').innerText = this.player.gold;
}
log(msg, type='') {
const el = document.getElementById('log-container');
const line = document.createElement('div');
line.className = `log-line new ${type}`;
line.innerText = msg;
el.prepend(line);
if(el.children.length > 30) el.lastChild.remove();
setTimeout(() => line.classList.remove('new'), 500);
}
// ===== 魔法 =====
async castMagicSequence(targets, item) {
this.isAnimating = true;
Sound.play('magic_cast');
for(const target of targets) {
await this.animateFallingEmoji(target.x, target.y, item.magicChar);
if(Math.random() < 0.5) {
Sound.play('magic_hit');
const dmg = item.power;
target.hp -= dmg;
this.log(`${target.char}に${item.magicChar}が命中!${dmg}ダメージ!`, 'magic');
if(target.hp <= 0) {
this.log(`${target.char}を倒した!`, 'good');
this.gainExp(target.exp);
this.entities = this.entities.filter(e => e !== target);
}
} else {
Sound.play('miss');
this.log(`${target.char}には当たらなかった...`);
}
this.draw();
await new Promise(r => setTimeout(r, 300));
}
this.isAnimating = false;
this.turn(0, 0);
}
animateFallingEmoji(tx, ty, char) {
return new Promise(resolve => {
const startY = (ty - 5) * this.TILE_SIZE;
const endY = ty * this.TILE_SIZE;
const pixelX = tx * this.TILE_SIZE;
let currentPixelY = startY;
const animate = () => {
currentPixelY += 15;
if(currentPixelY >= endY) { resolve(); }
else {
this.drawWithEffect(pixelX, currentPixelY, char);
requestAnimationFrame(animate);
}
};
animate();
});
}
drawWithEffect(ex, ey, echar) {
this.draw();
const cx = ex - this.camera.x * this.TILE_SIZE;
const cy = ey - this.camera.y * this.TILE_SIZE;
if(cx >= 0 && cy >= 0 && cx < this.canvas.width && cy < this.canvas.height) {
this.ctx.font = `${Math.floor(this.TILE_SIZE * 1.2)}px sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(echar, cx + this.TILE_SIZE/2, cy + this.TILE_SIZE/2);
}
}
gameOver(reason) {
this.mode = 'GAMEOVER';
document.getElementById('start-screen').style.display = 'flex';
document.querySelector('#start-screen h1').innerText = '💀 GAME OVER';
document.getElementById('result-msg').innerHTML = `死因: ${reason}<br>到達: ${this.floor}F / Lv: ${this.player.lv} / 所持金: ${this.player.gold}G`;
document.querySelector('button.big-btn').innerText = 'Try Again';
}
}
const game = new Game();
</script>
</body>
</html>・「Rogue」は元々キャラクターコードのゲームでしたから、
これと言って新規性はないんですが、楽しそうなので付けました。

