絵文字で絵を描く「Emoji Map」
【更新履歴】
・2026/2/17バージョン1.0公開。
・2026/2/17バージョン1.1公開。
・ダウンロードされる方はこちら。↓
🎨 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.1</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; /* iOS Safari */
}
/* --- メニューバー (ドロップダウン) --- */
#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: 200px;
flex-direction: column;
}
.menu-group:hover .dropdown { display: flex; }
.menu-item { padding: 8px 15px; cursor: pointer; display: flex; justify-content: space-between; }
.menu-item:hover { background: #eee; }
.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;
}
.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);
}
#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-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 20px 20px;
background-color: #fff;
}
canvas {
margin: auto;
background: white;
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; }
}
</style>
</head>
<body>
<input type="file" id="file-input" accept=".txt,.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.showResizeDialog()">サイズ変更...</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>
</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">
<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 Pixel Studio Pro v3
*/
const SPACE = ' ';
const PALETTE_KEY = 'palette.json';
class EmojiApp {
constructor() {
this.grid = [];
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; // File System Access API用
// パレット初期データ
this.categories = {
"基本": ["⬛","⬜","🟥","🟦","🟧","🟨","🟩","🟪","🟫"],
"顔": ["😀","😂","😊","🥰","😎","🤔","😭","😡"],
"自然": ["🌲","🌷","🌻","🔥","💧","⭐","🌙"],
"記号": ["❤","💔","💯","💢","💤","💨","💥"],
"RPG": ["⚔️","🛡️","🏰","👾","🐉","🧙♂️","🧚"]
};
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();
this.initGrid(this.width, this.height);
this.renderCategorySelect();
this.renderPalette();
this.updateCanvasSize();
this.saveHistory();
this.setupEvents();
this.setupLegacyFileIO();
window.addEventListener('beforeunload', () => {
this.savePalette();
});
// キーボードショートカット
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 & Data ---
initGrid(w, h, fill = SPACE) {
this.width = w;
this.height = h;
this.grid = [];
for(let y=0; y<h; y++) {
this.grid.push(new Array(w).fill(fill));
}
this.fileHandle = null; // リセット
}
// --- Palette System ---
loadPalette() {
const saved = localStorage.getItem(PALETTE_KEY);
if (saved) {
try { this.categories = JSON.parse(saved); } catch(e){}
}
}
savePalette() {
localStorage.setItem(PALETTE_KEY, JSON.stringify(this.categories));
}
resetPalette() {
if(confirm("パレットを初期設定に戻しますか?")) {
localStorage.removeItem(PALETTE_KEY);
location.reload();
}
}
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.savePalette();
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.savePalette();
// ★修正: 編集した文字を即座にカレントに設定する
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) {
// --- クリック処理 (選択) ---
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 {
// --- ドラッグ終了処理 (並べ替え) ---
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.savePalette();
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.savePalette();
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');
}
handlePointerDown(e) {
if(e.cancelable) e.preventDefault();
const pos = this.getGridPos(e);
if(!pos) 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') {
const char = this.grid[pos.y][pos.x];
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.savePalette();
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;
if (this.tool === 'pen') {
this.grid[y][x] = this.currentEmoji;
} else if (this.tool === 'eraser') {
this.grid[y][x] = SPACE;
} else if (this.tool === 'fill') {
this.floodFill(x, y, this.grid[y][x], this.currentEmoji);
}
}
floodFill(x, y, target, replace) {
if(target === replace) return;
if(this.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(this.grid[cy][cx] === target) {
this.grid[cy][cx] = replace;
stack.push([cx+1, cy]); stack.push([cx-1, cy]);
stack.push([cx, cy+1]); stack.push([cx, cy-1]);
}
}
}
// --- History ---
saveHistory() {
if (this.historyIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.historyIndex + 1);
}
const snapshot = {
grid: JSON.parse(JSON.stringify(this.grid)),
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.grid = JSON.parse(JSON.stringify(state.grid));
this.width = state.w;
this.height = state.h;
this.selection = null;
this.updateCanvasSize();
this.draw();
}
// --- Selection ---
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 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(this.grid[gy][gx]);
this.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 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) {
this.grid[gy][gx] = buf[iy][ix];
}
}
}
}
this.selection = null;
this.saveHistory();
this.draw();
}
// --- Editing ---
editAction(type) {
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 buf = this.selection.buffer || this.extractBufferFromGrid(this.selection);
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) {
const buf = [];
for(let y=0; y<sel.h; y++) {
let row = [];
for(let x=0; x<sel.w; x++) {
row.push(this.grid[sel.y+y][sel.x+x]);
}
buf.push(row);
}
return buf;
}
// --- File I/O (File System Access API & Legacy) ---
setupLegacyFileIO() {
const fileInput = document.getElementById('file-input');
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;
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;
if (h === 0 || w === 0) throw new Error("無効なデータです");
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.grid = newGrid;
this.width = maxW;
this.height = h;
this.updateCanvasSize();
this.saveHistory();
} catch(err) {
alert("読み込みエラー: " + err.message);
}
};
reader.readAsText(file);
}
newFile() {
if(confirm("新規作成しますか?")) {
this.initGrid(16, 16);
this.saveHistory();
this.updateCanvasSize();
}
}
// 開く (API対応ならハンドル取得)
async openFileAction() {
if (window.showOpenFilePicker) {
try {
const [handle] = await window.showOpenFilePicker({
types: [{ description: 'Text Files', accept: {'text/plain': ['.txt']} }]
});
const file = await handle.getFile();
await this.readFileContent(file);
this.fileHandle = handle; // 保存用にハンドル保持
} catch (err) {
// キャンセル時は何もしない
}
} else {
document.getElementById('file-input').click();
}
}
// 上書き保存 (ハンドルがあれば上書き、なければ別名保存へ)
async saveFileAction() {
if (this.fileHandle) {
try {
const writable = await this.fileHandle.createWritable();
const text = this.grid.map(row => row.join("")).join("\n");
await writable.write(text);
await writable.close();
alert("保存しました");
} catch (err) {
alert("保存失敗: " + err.message);
}
} else {
await this.saveAsFileAction();
}
}
// 別名で保存 (ダイアログ表示)
async saveAsFileAction() {
const text = this.grid.map(row => row.join("")).join("\n");
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: `pixel_art.txt`,
types: [{ description: 'Text Files', accept: {'text/plain': ['.txt']} }]
});
const writable = await handle.createWritable();
await writable.write(text);
await writable.close();
this.fileHandle = handle; // 新しいハンドルを保持
} catch (err) {
// キャンセル
}
} else {
// レガシーダウンロード
const blob = new Blob([text], {type: "text/plain"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pixel_art_${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
// --- Resize ---
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("サイズが小さすぎます");
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] = this.grid[y][x];
}
}
}
this.grid = newGrid;
this.width = newW;
this.height = newH;
this.updateCanvasSize();
this.saveHistory();
this.closeDialog();
}
// --- Rendering ---
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.fillStyle = '#fff';
this.ctx.fillRect(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';
for(let y=0; y<this.height; y++) {
for(let x=0; x<this.width; x++) {
if(this.selection && this.selection.buffer && this.isInSelection(x, y)) {
// buffer move (skip)
} else {
const char = this.grid[y][x];
if(char !== SPACE) {
this.ctx.fillText(char, x*this.cellSize + this.cellSize/2, y*this.cellSize + this.cellSize/2 + 2);
}
}
}
}
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();
}
}
const app = new EmojiApp();
</script>
</body>
</html>・「絵文字ローグ」のソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Emoji Rogue 1.0</title>
<style>
body {
background-color: #050505; /* 背景はほぼ黒 */
color: #eee;
font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", monospace;
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
margin: 0;
overflow: hidden;
user-select: none;
}
#game-container {
position: relative;
border: 4px solid #333;
margin-top: 10px;
box-shadow: 0 0 20px rgba(0,0,0,0.8);
background: #000;
}
canvas {
display: block;
}
/* UIレイヤー */
#ui-header {
width: 100%;
max-width: 800px;
display: flex;
justify-content: space-around;
padding: 10px;
background: #222;
border-bottom: 2px solid #444;
font-size: 18px;
font-weight: bold;
}
.stat-box { display: flex; align-items: center; gap: 5px; }
/* ログウィンドウ */
#log {
width: 100%;
max-width: 800px;
height: 120px;
background: rgba(0,0,0,0.9);
border: 1px solid #444;
overflow-y: hidden; /* スクロールバー隠す */
font-size: 14px;
padding: 8px;
box-sizing: border-box;
color: #ccc;
display: flex;
flex-direction: column-reverse; /* 新しいログが下に出るように */
}
.log-line { margin-bottom: 2px; border-bottom: 1px dashed #222; }
.log-line.new { color: #fff; text-shadow: 0 0 5px orange; }
/* オーバーレイ(スタート/ゲームオーバー) */
#overlay {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
z-index: 100;
}
button {
background: #d35400;
color: white;
border: none;
padding: 15px 30px;
font-size: 20px;
cursor: pointer;
border-radius: 8px;
font-family: inherit;
margin-top: 20px;
transition: transform 0.1s;
}
button:active { transform: scale(0.95); }
h1 { font-size: 48px; margin: 0; text-shadow: 0 0 10px #d35400; }
.subtitle { color: #aaa; margin-top: 10px; }
</style>
</head>
<body>
<div id="ui-header">
<div class="stat-box">🪜 <span id="floor-val">1</span>F</div>
<div class="stat-box">❤️ <span id="hp-val">30/30</span></div>
<div class="stat-box">⚔️ <span id="lv-val">1</span></div>
<div class="stat-box">💰 <span id="gold-val">0</span></div>
</div>
<div id="game-container">
<canvas id="gameCanvas" width="800" height="500"></canvas>
<div id="overlay">
<h1>🏰 Emoji Rogue</h1>
<div class="subtitle">Auto Dungeon Generator</div>
<div id="msg-area" style="margin-top:20px; font-size:18px;"></div>
<button id="start-btn" onclick="game.init()">冒険を始める</button>
</div>
</div>
<div id="log"></div>
<script>
/**
* Emoji Rogue: Auto Dungeon
* User Provided Assets Integrated
*/
// --- 定数・アセット ---
const TILE_SIZE = 32; // 少し大きめに見やすく
const VIEW_W = 25; // 画面に表示する横タイル数
const VIEW_H = 15; // 画面に表示する縦タイル数
const MAP_W = 50; // フロア全体の横幅
const MAP_H = 40; // フロア全体の縦幅
// 提供された絵文字リストからのマッピング
const ASSETS = {
// マップチップ
WALL_TOP: '⬛', // 壁の中/天井
WALL_FACE: '🟫', // 壁の正面
FLOOR: '🟧', // 床 (明るめの茶色/オレンジ)
PASSAGE: '🟧', // 通路も床と同じ
STAIRS: '🚪', // 階段 (扉を使用)
// キャラクター
PLAYER: '🧙', // 魔法使い風
// 敵 (階層ごとの強さ順)
ENEMIES: [
['🐀', '🐜', '🕷️'], // 1-3F: 小動物・虫
['🐍', '🐺', '🦇'], // 4-6F: 動物・コウモリ
['🧟', '💀', '👻'], // 7-9F: アンデッド
['👺', '👹', '🦂'], // 10-12F: 鬼・サソリ
['🐉', '🐲', '👿'] // 13F+: ドラゴン・悪魔
],
// アイテム
POTION: '🧪', // 回復
FOOD: '🍖', // 食料(回復)
GOLD: '💰', // お金
GEM: '💎', // 高額アイテム
WEAPON: '⚔️', // 攻撃力アップ
ARMOR: '🛡️', // 防御的な何か(今回はHP最大値アップに使用)
// エフェクト
HIT: '💥',
DEAD: '⚰️'
};
// サウンド管理 (Web Audio API)
const Sound = {
ctx: null,
init: () => {
if (!Sound.ctx) Sound.ctx = new (window.AudioContext || window.webkitAudioContext)();
},
play: (type) => {
if (!Sound.ctx) return;
if (Sound.ctx.state === 'suspended') Sound.ctx.resume();
const osc = Sound.ctx.createOscillator();
const gain = Sound.ctx.createGain();
osc.connect(gain);
gain.connect(Sound.ctx.destination);
const now = Sound.ctx.currentTime;
if (type === 'move') { // コツッ
osc.type = 'triangle';
osc.frequency.setValueAtTime(100, now);
osc.frequency.exponentialRampToValueAtTime(50, now + 0.05);
gain.gain.setValueAtTime(0.05, now);
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.05);
osc.start(now);
osc.stop(now + 0.05);
}
else if (type === 'attack') { // ザシュッ
osc.type = 'sawtooth'; // ノイズっぽい波形がないのでノコギリ波で代用
osc.frequency.setValueAtTime(150, now);
osc.frequency.linearRampToValueAtTime(50, now + 0.1);
gain.gain.setValueAtTime(0.1, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.1);
osc.start(now);
osc.stop(now + 0.1);
}
else if (type === 'item') { // キラッ
osc.type = 'sine';
osc.frequency.setValueAtTime(1000, now);
osc.frequency.exponentialRampToValueAtTime(2000, now + 0.1);
gain.gain.setValueAtTime(0.1, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.2);
osc.start(now);
osc.stop(now + 0.2);
}
else if (type === 'stairs') { // ギギー
osc.type = 'square';
osc.frequency.setValueAtTime(100, now);
osc.frequency.linearRampToValueAtTime(50, now + 0.5);
gain.gain.setValueAtTime(0.1, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.5);
osc.start(now);
osc.stop(now + 0.5);
}
else if (type === 'hit') { // プレイヤー被弾
osc.type = 'square';
osc.frequency.setValueAtTime(80, now);
osc.frequency.linearRampToValueAtTime(40, now + 0.2);
gain.gain.setValueAtTime(0.2, now);
gain.gain.linearRampToValueAtTime(0.01, now + 0.2);
osc.start(now);
osc.stop(now + 0.2);
}
}
};
class Game {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.ctx = this.canvas.getContext('2d');
// ゲームステート
this.map = []; // 2次元配列: 0=壁, 1=床
this.entities = []; // 敵、アイテム
this.player = { x: 0, y: 0, hp: 30, maxHp: 30, lv: 1, exp: 0, gold: 0, atk: 3 };
this.floor = 1;
this.stairs = { x: 0, y: 0 };
this.camera = { x: 0, y: 0 };
this.messageLog = [];
this.isGameOver = false;
// キー操作
window.addEventListener('keydown', e => this.handleInput(e));
}
// 初期化・リスタート
init() {
Sound.init();
document.getElementById('overlay').style.display = 'none';
this.floor = 1;
this.player = { x: 0, y: 0, hp: 30, maxHp: 30, lv: 1, exp: 0, gold: 0, atk: 3 };
this.isGameOver = false;
this.log("ダンジョンに入った!");
this.generateFloor();
}
// --- ダンジョン生成ロジック (BSPライクな分割生成) ---
generateFloor() {
// 1. 全体を壁(0)で埋める
this.map = [];
for(let y=0; y<MAP_H; y++) {
this.map.push(new Array(MAP_W).fill(0));
}
this.entities = [];
// 2. 部屋の生成
const rooms = [];
const minRoomSize = 5;
const maxRooms = 8;
// シンプルにランダム配置して重なりをチェックする方法
for(let i=0; i<20; i++) { // 試行回数
const w = Math.floor(Math.random() * 6) + 5; // 幅 5-10
const h = Math.floor(Math.random() * 6) + 5; // 高 5-10
const x = Math.floor(Math.random() * (MAP_W - w - 2)) + 1;
const y = Math.floor(Math.random() * (MAP_H - h - 2)) + 1;
const newRoom = {x, y, w, h, cx: Math.floor(x+w/2), cy: Math.floor(y+h/2)};
// 重なりチェック
let overlap = false;
for(let r of rooms) {
if(x < r.x + r.w + 1 && x + w + 1 > r.x &&
y < r.y + r.h + 1 && y + h + 1 > r.y) {
overlap = true;
break;
}
}
if(!overlap) {
rooms.push(newRoom);
// マップに書き込み (床=1)
for(let ry=y; ry<y+h; ry++) {
for(let rx=x; rx<x+w; rx++) {
this.map[ry][rx] = 1;
}
}
if(rooms.length >= maxRooms) break;
}
}
// 3. 通路で繋ぐ
// 部屋の中心同士を繋ぐ
for(let i=1; i<rooms.length; i++) {
const r1 = rooms[i-1];
const r2 = rooms[i];
this.createTunnel(r1.cx, r1.cy, r2.cx, r2.cy);
}
// 4. 配置
// プレイヤー: 最初の部屋の中心
this.player.x = rooms[0].cx;
this.player.y = rooms[0].cy;
// 階段: 最後の部屋の中心
const lastRoom = rooms[rooms.length-1];
this.stairs = { x: lastRoom.cx, y: lastRoom.cy };
// 敵・アイテム生成
rooms.forEach((r, idx) => {
if(idx === 0) return; // 最初の部屋は安全に
// 敵の数
const enemyCount = Math.floor(Math.random() * 2);
for(let j=0; j<enemyCount; j++) {
this.spawnEntity('enemy', r);
}
// アイテム
if(Math.random() < 0.6) {
this.spawnEntity('item', r);
}
});
this.updateCamera();
this.draw();
this.updateUI();
}
// 通路作成 (L字型)
createTunnel(x1, y1, x2, y2) {
const minX = Math.min(x1, x2);
const maxX = Math.max(x1, x2);
const minY = Math.min(y1, y2);
const maxY = Math.max(y1, y2);
// 横→縦 または 縦→横 をランダムに
if(Math.random() < 0.5) {
// 横移動
for(let x=minX; x<=maxX; x++) this.map[y1][x] = 1;
// 縦移動
for(let y=minY; y<=maxY; y++) this.map[y][x2] = 1;
} else {
// 縦移動
for(let y=minY; y<=maxY; y++) this.map[y][x1] = 1;
// 横移動
for(let x=minX; x<=maxX; x++) this.map[y2][x] = 1;
}
}
spawnEntity(type, room) {
let ex, ey;
// 空いている場所を探す
let limit = 10;
do {
ex = Math.floor(Math.random() * room.w) + room.x;
ey = Math.floor(Math.random() * room.h) + room.y;
limit--;
} while(limit > 0 && (this.isOccupied(ex, ey) || (ex===this.stairs.x && ey===this.stairs.y)));
if(limit <= 0) return;
if(type === 'enemy') {
// 階層に応じた敵を選出
const tier = Math.min(this.floor, 13);
const enemySet = ASSETS.ENEMIES[Math.min(Math.floor((tier-1)/3), ASSETS.ENEMIES.length-1)];
const char = enemySet[Math.floor(Math.random()*enemySet.length)];
this.entities.push({
type: 'enemy', x: ex, y: ey, char: char,
hp: this.floor * 3 + 5, maxHp: this.floor * 3 + 5,
atk: Math.floor(this.floor/2) + 1
});
} else if (type === 'item') {
const rand = Math.random();
let itemType = 'GOLD';
let char = ASSETS.GOLD;
let val = 0;
if(rand < 0.4) {
itemType = 'GOLD'; char = ASSETS.GOLD; val = 10 + Math.floor(Math.random()*20)*this.floor;
} else if (rand < 0.6) {
itemType = 'POTION'; char = ASSETS.POTION; val = 15;
} else if (rand < 0.75) {
itemType = 'FOOD'; char = ASSETS.FOOD; val = 10;
} else if (rand < 0.85) {
itemType = 'WEAPON'; char = ASSETS.WEAPON; val = 1;
} else if (rand < 0.95) {
itemType = 'ARMOR'; char = ASSETS.ARMOR; val = 2; // 最大HPアップ
} else {
itemType = 'GEM'; char = ASSETS.GEM; val = 100 * this.floor;
}
this.entities.push({ type: 'item', itemType: itemType, x: ex, y: ey, char: char, value: val });
}
}
isOccupied(x, y) {
if(this.player.x === x && this.player.y === y) return true;
if(this.entities.some(e => e.x === x && e.y === y)) return true;
return false;
}
// --- ゲーム進行 ---
handleInput(e) {
if(this.isGameOver) return;
let dx = 0, dy = 0;
if(e.key === 'ArrowUp') dy = -1;
else if(e.key === 'ArrowDown') dy = 1;
else if(e.key === 'ArrowLeft') dx = -1;
else if(e.key === 'ArrowRight') dx = 1;
else return;
e.preventDefault();
this.turn(dx, dy);
}
turn(dx, dy) {
const tx = this.player.x + dx;
const ty = this.player.y + dy;
// 移動可能判定
if(this.map[ty][tx] === 0) return; // 壁
// 敵判定
const target = this.entities.find(e => e.type === 'enemy' && e.x === tx && e.y === ty);
if(target) {
// 攻撃
Sound.play('attack');
const dmg = Math.max(1, this.player.atk + Math.floor(Math.random()*2));
target.hp -= dmg;
this.log(`${target.char}に${dmg}ダメージ!`);
// 撃破
if(target.hp <= 0) {
this.log(`${target.char}を倒した!`);
this.player.exp += 1;
this.checkLevelUp();
// 敵消滅
this.entities = this.entities.filter(e => e !== target);
}
} else {
// 移動
this.player.x = tx;
this.player.y = ty;
Sound.play('move');
// アイテム取得
const itemIdx = this.entities.findIndex(e => e.type === 'item' && e.x === tx && e.y === ty);
if(itemIdx !== -1) {
this.pickupItem(this.entities[itemIdx]);
this.entities.splice(itemIdx, 1);
}
// 階段
if(tx === this.stairs.x && ty === this.stairs.y) {
Sound.play('stairs');
this.log(`${this.floor}階クリア! 休憩してHP回復。`);
this.player.hp = Math.min(this.player.hp + 5, this.player.maxHp);
this.floor++;
this.generateFloor();
return;
}
}
// 敵のターン
this.updateEnemies();
this.updateCamera();
this.draw();
this.updateUI();
}
updateEnemies() {
this.entities.forEach(e => {
if(e.type !== 'enemy') return;
const dist = Math.abs(this.player.x - e.x) + Math.abs(this.player.y - e.y);
if(dist <= 1) {
// 攻撃してくる
Sound.play('hit');
const dmg = Math.max(1, Math.floor(this.floor/2));
this.player.hp -= dmg;
this.log(`${e.char}の攻撃!${dmg}ダメージ!`);
if(this.player.hp <= 0) this.gameOver();
} else if(dist < 6) {
// 近づく
let ex = e.x, ey = e.y;
if(this.player.x > e.x) ex++;
else if(this.player.x < e.x) ex--;
else if(this.player.y > e.y) ey++;
else if(this.player.y < e.y) ey--;
// 移動先が床で、かつ誰もいないなら移動
if(this.map[ey][ex] === 1 && !this.isOccupied(ex, ey)) {
e.x = ex; e.y = ey;
}
}
});
}
pickupItem(item) {
Sound.play('item');
let msg = "";
switch(item.itemType) {
case 'GOLD':
this.player.gold += item.value;
msg = `${item.value}Gを拾った`; break;
case 'GEM':
this.player.gold += item.value;
msg = `宝石(${item.value}G)を拾った!`; break;
case 'POTION':
this.player.hp = Math.min(this.player.hp + item.value, this.player.maxHp);
msg = `ポーションで回復した`; break;
case 'FOOD':
this.player.hp = Math.min(this.player.hp + item.value, this.player.maxHp);
msg = `肉を食べて回復した`; break;
case 'WEAPON':
this.player.atk += item.value;
msg = `武器を拾い攻撃力が上がった`; break;
case 'ARMOR':
this.player.maxHp += item.value;
this.player.hp += item.value;
msg = `盾を拾い最大HPが増えた`; break;
}
this.log(msg);
}
checkLevelUp() {
if(this.player.exp >= this.player.lv * 3) {
this.player.lv++;
this.player.maxHp += 5;
this.player.hp = this.player.maxHp;
this.player.atk += 1;
this.player.exp = 0;
this.log(`✨レベルアップ!(Lv${this.player.lv}) HPと攻撃力上昇!`);
Sound.play('item');
}
}
gameOver() {
this.isGameOver = true;
document.getElementById('overlay').style.display = 'flex';
document.querySelector('#overlay h1').innerText = "💀 GAME OVER";
document.querySelector('#overlay .subtitle').innerText = "力尽きてしまった...";
document.getElementById('msg-area').innerText = `到達: ${this.floor}階 / 所持金: ${this.player.gold}G`;
document.getElementById('start-btn').innerText = "もう一度挑戦";
}
// --- 描画 ---
updateCamera() {
this.camera.x = Math.max(0, Math.min(MAP_W - VIEW_W, this.player.x - Math.floor(VIEW_W/2)));
this.camera.y = Math.max(0, Math.min(MAP_H - VIEW_H, this.player.y - Math.floor(VIEW_H/2)));
}
draw() {
// 画面クリア
this.ctx.fillStyle = '#000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.font = `${Math.floor(TILE_SIZE * 0.9)}px sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
for(let y=0; y<VIEW_H; y++) {
for(let x=0; x<VIEW_W; x++) {
const mx = this.camera.x + x;
const my = this.camera.y + y;
const px = x * TILE_SIZE + TILE_SIZE/2;
const py = y * TILE_SIZE + TILE_SIZE/2;
// タイル描画ロジック
if(this.map[my][mx] === 1) {
// 床
this.ctx.fillText(ASSETS.FLOOR, px, py);
} else {
// 壁の判定 (下のマスが床なら「正面」、それ以外は「天井(黒)」)
// マップ範囲外チェック
const isBottomFloor = (my < MAP_H - 1) && (this.map[my+1][mx] === 1);
if(isBottomFloor) {
this.ctx.fillText(ASSETS.WALL_FACE, px, py);
} else {
this.ctx.fillStyle = '#111'; // 黒より少し明るくして視認性確保
this.ctx.fillText(ASSETS.WALL_TOP, px, py);
}
}
// 階段
if(mx === this.stairs.x && my === this.stairs.y) {
this.ctx.fillText(ASSETS.STAIRS, px, py);
}
// エンティティ
const ent = this.entities.find(e => e.x === mx && e.y === my);
if(ent) {
this.ctx.fillText(ent.char, px, py);
// 敵ならHPバーを小さく表示
if(ent.type === 'enemy') {
this.ctx.fillStyle = 'red';
this.ctx.fillRect(px - 10, py + 12, 20 * (ent.hp / ent.maxHp), 3);
}
}
// プレイヤー
if(mx === this.player.x && my === this.player.y) {
this.ctx.fillText(ASSETS.PLAYER, px, py);
}
}
}
}
log(msg) {
const el = document.getElementById('log');
const line = document.createElement('div');
line.className = 'log-line new';
line.innerText = msg;
el.prepend(line);
this.messageLog.push(msg);
if(el.children.length > 5) el.lastChild.remove();
// アニメーション用にクラス削除
setTimeout(() => line.classList.remove('new'), 200);
}
updateUI() {
document.getElementById('floor-val').innerText = this.floor;
document.getElementById('hp-val').innerText = `${this.player.hp}/${this.player.maxHp}`;
document.getElementById('lv-val').innerText = this.player.lv;
document.getElementById('gold-val').innerText = this.player.gold;
}
}
const game = new Game();
</script>
</body>
</html>・「Rogue」は元々キャラクターコードのゲームでしたから、
これと言って新規性はないんですが、楽しそうなので付けました。


コメント