//============================================================================= // RemovePictureWhiteBg.js //============================================================================= /*: * @target MZ * @plugindesc ピクチャの白背景を自動で透過します(保護範囲エディタ付き) * @author * * @param TargetPictures * @text 対象ピクチャ * @desc 処理するピクチャのファイル名(拡張子なし)。「ファイル名:面積」で最小面積を個別指定。 * エディタで保存した画像は自動で対象になるので、ここに書かなくてもOKです。 * @type string[] * @default [] * * @param ProcessAll * @text 全ピクチャに適用 * @desc ONにすると対象リストに関係なく全ピクチャを処理します。 * @type boolean * @default false * * @param WhiteThreshold * @text 白とみなす明るさ * @desc R,G,Bすべてがこの値以上なら「白」候補(0-255)。 * @type number * @min 0 * @max 255 * @default 235 * * @param ColorTolerance * @text 色味の許容差 * @desc RGBの最大値と最小値の差がこれ以下なら無彩色とみなします。 * @type number * @min 0 * @max 255 * @default 20 * * @param MinEnclosedArea * @text 囲まれた白を消す最小面積 * @desc 線で囲まれた白い塊がこのピクセル数以上なら消します。 * 画像ごとに変えたい場合は対象ピクチャに「ファイル名:面積」と書きます。 * @type number * @min 1 * @default 1500 * * @param StrictDefaultArea * @text 厳格範囲の初期面積 * @desc エディタで厳格範囲を作ったときの最小面積の初期値。 * 厳格範囲の中では、この面積以上の白い塊を消します。 * @type number * @min 1 * @default 1 * * @param EditorKey * @text エディタを開くキー * @desc テストプレイ中、マップでこのキーを押すと一番手前のピクチャの保護範囲エディタを開きます。 * @type select * @option F6 * @value 117 * @option F7 * @value 118 * @option 使わない * @value 0 * @default 117 * * @param KeepAreas * @text 保護範囲(四角形・手入力) * @desc 「ファイル名:x,y,幅,高さ」。通常はエディタで指定してください。 * @type string[] * @default [] * * @param KeepMasks * @text 保護範囲(マスク画像) * @desc 「ファイル名:マスク画像名」。マスク画像の塗った部分を保護します。 * @type string[] * @default [] * * @param RemovePoints * @text 強制的に消す位置 * @desc 「ファイル名:x,y」。その座標を含む白い塊を面積に関係なく消します。 * @type string[] * @default [] * * @param KeepPoints * @text 強制的に残す位置 * @desc 「ファイル名:x,y」。その座標を含む白い塊を面積に関係なく残します。 * @type string[] * @default [] * * @param EdgeSmoothing * @text 輪郭の白フチ除去 * @desc 消した領域に接する白っぽい画素を半透明にして白フチを抑えます。 * @type boolean * @default true * * @param EdgeLow * @text 白フチ判定の下限 * @desc 境界画素の最小チャンネル値がこれより明るいほど透明寄りにします。 * @type number * @min 0 * @max 254 * @default 150 * * @param DebugLog * @text デバッグログ * @desc ONにすると、囲まれた白い塊の面積・位置・判定をF8のコンソールに出します。 * @type boolean * @default false * * @command OpenEditor * @text 保護範囲エディタを開く * @desc テストプレイ中のみ。指定ピクチャの保護範囲エディタを開きます。 * * @arg picture * @text ピクチャ * @type file * @dir img/pictures * * @help * ■ 概要 * 指定したピクチャを読み込んだ時点で、白い背景を透明にします。 * 「ピクチャの表示」はそのまま使えます。 * * ■ 判定方法 * 白っぽい画素を、上下左右でつながった塊ごとに調べます。 * 1. 画像の端につながっている塊 → 消す * 2. 線で囲まれた塊でも面積が「最小面積」以上 → 消す * 3. それより小さい塊 → 残す * * エディタで画像ごとに2種類の範囲を指定できます。 * ・厳格範囲(水色)… 範囲に少しでもかかる白い塊は、範囲ごとの小さい * 最小面積で判定します(髪の細い隙間などを消す用) * ・保護範囲(赤) … 範囲内の画素はどの判定でも消えません * * おすすめ: 全体の最小面積は大きめ(初期値1500など)にしてハイライトを守り、 * 消し残った髪の隙間などを厳格範囲で囲みます。 * * ■ 範囲エディタ(テストプレイ専用) * 開き方: * ・マップでピクチャを表示中に F6 → 一番手前のピクチャを編集 * ・プラグインコマンド「保護範囲エディタを開く」 * * 操作: * 1 / 2 … 追加する範囲の種類(1:厳格 2:保護) * 画像上の何もない所をドラッグ … 範囲を追加 * 範囲の中をドラッグ … 移動 * 選択中の範囲の■をドラッグ … サイズ変更 * T … 選択中の範囲の種類を切り替え * 右クリック / Delete … 範囲を削除 * 矢印キー … 1px移動(Shift+矢印でサイズ変更) * Tab … 選択を切り替え * + / - … 選択中の厳格範囲の最小面積を変更 * ※画像全体の最小面積はプラグインパラメータ(最小面積、または * 対象ピクチャの「ファイル名:面積」)で設定します * P … 元画像 / 透過プレビュー 切り替え * B … 背景色を切り替え * S … 保存 * Esc … 戻る * * 保存すると data/WhiteBgKeepAreas.json に書き込まれ、 * テストプレイ中の表示にもすぐ反映されます。 * このファイルは本番(デプロイ後)でもそのまま読み込まれます。 * 保存した画像は「対象ピクチャ」に書かなくても自動で処理対象になります。 * * ■ 保護範囲(マスク画像) * 四角形では合わない形を守りたい場合は、元画像と同じサイズの画像に * 守りたい部分を塗った(それ以外は透明か白の)画像を img/pictures に置き、 * 「保護範囲(マスク画像)」に「ファイル名:マスク画像名」と書きます。 * マスクの範囲はエディタ上に青い線で表示されます。 * * ■ 注意 * ・同じ画像はキャッシュされるため、処理は読み込み時に1回だけ走ります。 * ・JPEGは圧縮ノイズで白が不均一になりやすいので、PNG推奨です。 * ・デプロイ時に「未使用ファイルを除外する」を使う場合、 * マスク画像が除外されないよう注意してください。 */ (() => { "use strict"; const pluginName = "RemovePictureWhiteBg"; const DATA_FILE = "WhiteBgKeepAreas.json"; const params = PluginManager.parameters(pluginName); const WHITE_TH = Number(params.WhiteThreshold || 235); const COLOR_TOL = Number(params.ColorTolerance || 20); const MIN_AREA = Number(params.MinEnclosedArea || 1500); const EDGE_SMOOTH = params.EdgeSmoothing !== "false"; const EDGE_LOW = Number(params.EdgeLow || 150); const PROCESS_ALL = params.ProcessAll === "true"; const DEBUG = params.DebugLog === "true"; const EDITOR_KEY = Number(params.EditorKey || 117); const STRICT_DEFAULT = Number(params.StrictDefaultArea || 1); const IS_TEST = Utils.isOptionValid("test"); //========================================================================= // パラメータ解析 //========================================================================= const parseList = raw => { try { return JSON.parse(raw || "[]").map(s => String(s).trim()).filter(s => s); } catch (e) { console.error(`${pluginName}: パラメータを読み込めません`, e); return []; } }; const pushTo = (map, key, value) => { if (!map.has(key)) map.set(key, []); map.get(key).push(value); }; const targets = new Map(); for (const text of parseList(params.TargetPictures)) { const m = text.match(/^(.+?):(\d+)$/); if (m) targets.set(m[1].trim(), Number(m[2])); else targets.set(text, MIN_AREA); } const parsePoints = raw => { const map = new Map(); for (const text of parseList(raw)) { const m = text.match(/^(.+?):\s*(\d+)\s*,\s*(\d+)$/); if (!m) { console.warn(`${pluginName}: 座標指定の書式が違います -> ${text}`); continue; } pushTo(map, m[1].trim(), { x: Number(m[2]), y: Number(m[3]) }); } return map; }; const removePoints = parsePoints(params.RemovePoints); const keepPoints = parsePoints(params.KeepPoints); const paramAreas = new Map(); for (const text of parseList(params.KeepAreas)) { const m = text.match(/^(.+?):\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)$/); if (!m) { console.warn(`${pluginName}: 保護範囲の書式が違います -> ${text}`); continue; } pushTo(paramAreas, m[1].trim(), { x: Number(m[2]), y: Number(m[3]), w: Number(m[4]), h: Number(m[5]) }); } const keepMaskNames = new Map(); for (const text of parseList(params.KeepMasks)) { const m = text.match(/^(.+?):(.+)$/); if (!m) { console.warn(`${pluginName}: マスク指定の書式が違います -> ${text}`); continue; } keepMaskNames.set(m[1].trim(), m[2].trim()); } const maskFileNames = new Set(keepMaskNames.values()); const maskBitmaps = new Map(); const loadMask = name => { if (!maskBitmaps.has(name)) { maskBitmaps.set(name, ImageManager.loadBitmap("img/pictures/", name)); } return maskBitmaps.get(name); }; //========================================================================= // エディタの保存データ(data/WhiteBgKeepAreas.json) // { "ファイル名": { "minArea": 1500, // "rects": [ {"x":0,"y":0,"w":10,"h":10,"type":"strict","area":1}, // {"x":0,"y":0,"w":10,"h":10,"type":"keep"} ] } } // 旧形式の [x,y,w,h] は保護範囲として読み込みます。 //========================================================================= function toRect(r) { if (Array.isArray(r)) return { x: r[0], y: r[1], w: r[2], h: r[3], type: "keep", area: STRICT_DEFAULT }; const strict = r.type === "strict"; return { x: Number(r.x), y: Number(r.y), w: Number(r.w), h: Number(r.h), type: strict ? "strict" : "keep", area: strict ? Math.max(1, Number(r.area || STRICT_DEFAULT)) : STRICT_DEFAULT }; } function fromRect(r) { const o = { x: r.x, y: r.y, w: r.w, h: r.h, type: r.type }; if (r.type === "strict") o.area = r.area; return o; } let editorData = {}; let editorDataLoaded = false; function loadEditorData() { const xhr = new XMLHttpRequest(); xhr.open("GET", "data/" + DATA_FILE); xhr.overrideMimeType("application/json"); xhr.onload = () => { if (xhr.status < 400 && xhr.responseText) { try { editorData = JSON.parse(xhr.responseText) || {}; } catch (e) { console.error(`${pluginName}: ${DATA_FILE} の読み込みに失敗`, e); } } editorDataLoaded = true; }; xhr.onerror = () => { editorDataLoaded = true; }; // ファイルが無いだけならOK xhr.send(); } function saveEditorData() { const fs = require("fs"); const path = require("path"); const base = path.dirname(process.mainModule.filename); const file = path.join(base, "data", DATA_FILE); fs.writeFileSync(file, JSON.stringify(editorData, null, 2)); return file; } const baseMinArea = filename => targets.has(filename) ? targets.get(filename) : MIN_AREA; const isTarget = filename => { if (!filename || maskFileNames.has(filename)) return false; return PROCESS_ALL || targets.has(filename) || !!editorData[filename]; }; function settingsFor(filename) { const entry = editorData[filename]; const saved = entry && Array.isArray(entry.rects) ? entry.rects.map(toRect) : []; const maskName = keepMaskNames.get(filename); return { // 全体の最小面積は常にプラグインパラメータの値(保存データの minArea は無視) minArea: baseMinArea(filename), rects: (paramAreas.get(filename) || []).concat(saved.filter(r => r.type === "keep")), strictRects: saved.filter(r => r.type === "strict"), mask: maskName ? loadMask(maskName) : null, removePts: removePoints.get(filename) || [], keepPts: keepPoints.get(filename) || [] }; } //========================================================================= // 起動処理: 保存データとマスク画像を先読み //========================================================================= const _Scene_Boot_create = Scene_Boot.prototype.create; Scene_Boot.prototype.create = function() { _Scene_Boot_create.apply(this, arguments); loadEditorData(); }; const _Scene_Boot_isReady = Scene_Boot.prototype.isReady; Scene_Boot.prototype.isReady = function() { return _Scene_Boot_isReady.apply(this, arguments) && editorDataLoaded; }; const _Scene_Boot_loadSystemImages = Scene_Boot.prototype.loadSystemImages; Scene_Boot.prototype.loadSystemImages = function() { _Scene_Boot_loadSystemImages.apply(this, arguments); for (const name of maskFileNames) loadMask(name); }; //========================================================================= // ピクチャ読み込み時に処理を差し込む //========================================================================= // テストプレイ中は、エディタ保存時に再処理できるよう元画像を控えておく const loadedBitmaps = new Map(); // filename -> Set const _ImageManager_loadPicture = ImageManager.loadPicture; ImageManager.loadPicture = function(filename) { const bitmap = _ImageManager_loadPicture.apply(this, arguments); if (bitmap && !bitmap._wbrRequested && isTarget(filename)) { bitmap._wbrRequested = true; bitmap._wbrFile = filename; bitmap.addLoadListener(b => { const mask = settingsFor(filename).mask; const run = () => { try { processBitmap(b, filename); } catch (e) { console.error(`${pluginName}: ${filename} の処理に失敗`, e); } }; if (mask && !mask.isReady()) mask.addLoadListener(run); else run(); }); } return bitmap; }; function processBitmap(bitmap, filename) { const w = bitmap.width; const h = bitmap.height; if (!w || !h) return; const ctx = bitmap.context; let imageData; if (IS_TEST) { if (!bitmap._wbrOriginal) { bitmap._wbrOriginal = ctx.getImageData(0, 0, w, h); pushToSet(filename, bitmap); } imageData = cloneImageData(bitmap._wbrOriginal); } else { imageData = ctx.getImageData(0, 0, w, h); } processImageData(imageData, filename, settingsFor(filename), DEBUG); ctx.putImageData(imageData, 0, 0); touchBitmap(bitmap); } function pushToSet(filename, bitmap) { if (!loadedBitmaps.has(filename)) loadedBitmaps.set(filename, new Set()); loadedBitmaps.get(filename).add(bitmap); } function reprocessLoaded(filename) { const set = loadedBitmaps.get(filename); if (!set) return; for (const bitmap of set) { try { processBitmap(bitmap, filename); } catch (e) { console.error(`${pluginName}: ${filename} の再処理に失敗`, e); } } } function cloneImageData(src) { return new ImageData(new Uint8ClampedArray(src.data), src.width, src.height); } function touchBitmap(bitmap) { if (bitmap._baseTexture) bitmap._baseTexture.update(); } //========================================================================= // 透過処理の本体(ImageDataを直接書き換える) //========================================================================= function buildProtect(w, h, rects, mask) { const protect = new Uint8Array(w * h); for (const r of rects) { const x0 = Math.max(0, r.x), y0 = Math.max(0, r.y); const x1 = Math.min(w, r.x + r.w), y1 = Math.min(h, r.y + r.h); for (let y = y0; y < y1; y++) protect.fill(1, y * w + x0, y * w + x1); } if (mask && mask.isReady() && mask.width > 0) { const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const c = canvas.getContext("2d"); c.drawImage(mask.canvas, 0, 0, mask.width, mask.height, 0, 0, w, h); const md = c.getImageData(0, 0, w, h).data; for (let i = 0, p = 0; i < w * h; i++, p += 4) { if (md[p + 3] < 128) continue; if (md[p] >= 240 && md[p + 1] >= 240 && md[p + 2] >= 240) continue; protect[i] = 1; } } return protect; } function processImageData(imageData, filename, s, log) { const w = imageData.width; const h = imageData.height; const d = imageData.data; const n = w * h; const protect = buildProtect(w, h, s.rects, s.mask); // 厳格範囲: 画素ごとの最小面積(0=範囲外)。重なりは小さい方を採用 let zone = null; if (s.strictRects && s.strictRects.length > 0) { zone = new Uint32Array(n); for (const r of s.strictRects) { const x0 = Math.max(0, r.x), y0 = Math.max(0, r.y); const x1 = Math.min(w, r.x + r.w), y1 = Math.min(h, r.y + r.h); for (let y = y0; y < y1; y++) { for (let i = y * w + x0, e = y * w + x1; i < e; i++) { if (zone[i] === 0 || r.area < zone[i]) zone[i] = r.area; } } } } // 白(または元から透明)の候補画素。保護範囲は候補にしない const candidate = new Uint8Array(n); for (let i = 0, p = 0; i < n; i++, p += 4) { if (protect[i]) continue; if (d[p + 3] === 0) { candidate[i] = 1; continue; } const r = d[p], g = d[p + 1], b = d[p + 2]; const mn = Math.min(r, g, b); const mx = Math.max(r, g, b); if (mn >= WHITE_TH && mx - mn <= COLOR_TOL) candidate[i] = 1; } // 座標指定(1=消す, 2=残す) const forced = new Uint8Array(n); const mark = (points, value) => { for (const pt of points) { const idx = findCandidateNear(candidate, w, h, pt.x, pt.y, 3); if (idx >= 0) forced[idx] = value; else if (log) console.warn(`${pluginName} [${filename}] (${pt.x},${pt.y}) の近くに白い画素がありません`); } }; mark(s.removePts, 1); mark(s.keepPts, 2); // 4方向でつながった塊ごとに判定 const visited = new Uint8Array(n); const removed = new Uint8Array(n); const queue = new Int32Array(n); const maxX = w - 1; const maxY = h - 1; for (let start = 0; start < n; start++) { if (!candidate[start] || visited[start]) continue; let head = 0; let tail = 0; queue[tail++] = start; visited[start] = 1; let touchesEdge = false; let forceRemove = false; let forceKeep = false; let zoneArea = 0; while (head < tail) { const i = queue[head++]; if (zone && zone[i] && (zoneArea === 0 || zone[i] < zoneArea)) zoneArea = zone[i]; if (forced[i] === 1) forceRemove = true; else if (forced[i] === 2) forceKeep = true; const x = i % w; const y = (i - x) / w; if (x === 0 || y === 0 || x === maxX || y === maxY) touchesEdge = true; if (x > 0) { const j = i - 1; if (candidate[j] && !visited[j]) { visited[j] = 1; queue[tail++] = j; } } if (x < maxX) { const j = i + 1; if (candidate[j] && !visited[j]) { visited[j] = 1; queue[tail++] = j; } } if (y > 0) { const j = i - w; if (candidate[j] && !visited[j]) { visited[j] = 1; queue[tail++] = j; } } if (y < maxY) { const j = i + w; if (candidate[j] && !visited[j]) { visited[j] = 1; queue[tail++] = j; } } } const size = tail; let remove; if (forceKeep) remove = false; else if (forceRemove) remove = true; else remove = touchesEdge || size >= (zoneArea || s.minArea); if (log && !touchesEdge && size >= 5) { const sx = start % w; const sy = (start - sx) / w; const z = zoneArea ? ` (厳格範囲: 面積${zoneArea})` : ""; console.log(`${pluginName} [${filename}] enclosed: size=${size} at (${sx},${sy}) -> ${remove ? "REMOVE" : "keep"}${z}`); } if (remove) { for (let k = 0; k < size; k++) { const i = queue[k]; removed[i] = 1; d[i * 4 + 3] = 0; } } } // 白フチ除去 if (EDGE_SMOOTH) { const range = 255 - EDGE_LOW; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; if (removed[i] || protect[i]) continue; if (!touchesRemoved(removed, x, y, w, h)) continue; const p = i * 4; const r = d[p], g = d[p + 1], b = d[p + 2]; const mn = Math.min(r, g, b); if (mn <= EDGE_LOW) continue; const alpha = Math.max(0, Math.min(1, (255 - mn) / range)); if (alpha <= 0.01) { d[p + 3] = 0; continue; } const bg = 255 * (1 - alpha); d[p] = clamp((r - bg) / alpha); d[p + 1] = clamp((g - bg) / alpha); d[p + 2] = clamp((b - bg) / alpha); d[p + 3] = Math.round(d[p + 3] * alpha); } } } return protect; } function findCandidateNear(candidate, w, h, px, py, r) { let best = -1; let bestDist = Infinity; for (let dy = -r; dy <= r; dy++) { const y = py + dy; if (y < 0 || y >= h) continue; for (let dx = -r; dx <= r; dx++) { const x = px + dx; if (x < 0 || x >= w) continue; const i = y * w + x; if (!candidate[i]) continue; const dist = dx * dx + dy * dy; if (dist < bestDist) { bestDist = dist; best = i; } } } return best; } function touchesRemoved(removed, x, y, w, h) { for (let dy = -1; dy <= 1; dy++) { const ny = y + dy; if (ny < 0 || ny >= h) continue; for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; const nx = x + dx; if (nx < 0 || nx >= w) continue; if (removed[ny * w + nx]) return true; } } return false; } function clamp(v) { return v < 0 ? 0 : v > 255 ? 255 : Math.round(v); } //========================================================================= // エディタの呼び出し //========================================================================= function openEditor(filename) { if (!IS_TEST) return; if (!filename) { console.warn(`${pluginName}: 編集するピクチャがありません`); return; } if (!Utils.isNwjs()) { console.warn(`${pluginName}: エディタはテストプレイ(NW.js)でのみ使えます`); return; } SceneManager.push(Scene_WhiteBgEditor); SceneManager.prepareNextScene(filename); } PluginManager.registerCommand(pluginName, "OpenEditor", args => { openEditor(args.picture); }); if (IS_TEST && EDITOR_KEY > 0) { Input.keyMapper[EDITOR_KEY] = "wbrEditor"; const _Scene_Map_update = Scene_Map.prototype.update; Scene_Map.prototype.update = function() { _Scene_Map_update.apply(this, arguments); if (Input.isTriggered("wbrEditor") && !SceneManager.isSceneChanging()) { for (let id = $gameScreen.maxPictures(); id >= 1; id--) { const picture = $gameScreen.picture(id); if (picture && picture.name()) { openEditor(picture.name()); return; } } console.warn(`${pluginName}: 表示中のピクチャがありません`); } }; } //========================================================================= // Scene_WhiteBgEditor: 保護範囲エディタ //========================================================================= const HEADER_H = 84; const HANDLE = 5; // ハンドルの半径(画面px) const AREA_STEPS = [1, 5, 10, 20, 30, 50, 100, 200, 300, 500, 1000, 1500, 2000, 3000, 5000, 10000]; const BG_MODES = [ { name: "チェック", color: null }, { name: "緑", color: "#5cbf4a" }, { name: "黒", color: "#000000" }, { name: "ピンク", color: "#ff66cc" } ]; const CURSORS = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" }; class Scene_WhiteBgEditor extends Scene_Base { prepare(filename) { this._filename = filename; } create() { super.create(); const url = "img/pictures/" + Utils.encodeURI(this._filename) + ".png"; this._raw = Bitmap.load(url); const s = settingsFor(this._filename); this._mask = s.mask; this._minArea = s.minArea; const entry = editorData[this._filename]; this._rects = entry && Array.isArray(entry.rects) ? entry.rects.map(toRect) : []; this._tool = "strict"; this._fixedRects = paramAreas.get(this._filename) || []; this._selected = -1; this._drag = null; this._dirty = false; this._preview = true; this._bgMode = 0; this._message = ""; this._messageTimer = 0; this._escArmed = false; this._ready = false; } isReady() { if (this._raw.isError()) return true; return super.isReady() && this._raw.isReady() && (!this._mask || this._mask.isReady()); } start() { super.start(); if (this._raw.isError()) { console.error(`${pluginName}: img/pictures/${this._filename}.png を読み込めません`); SceneManager.pop(); return; } this.setupLayout(); this.createSprites(); this._onKeyDown = this.onKeyDown.bind(this); document.addEventListener("keydown", this._onKeyDown); this._ready = true; this.updatePreview(); this.refreshAll(); } terminate() { super.terminate(); if (this._onKeyDown) document.removeEventListener("keydown", this._onKeyDown); this.setCursor(""); Input.clear(); TouchInput.clear(); } setupLayout() { const iw = this._raw.width; const ih = this._raw.height; this._iw = iw; this._ih = ih; const availW = Graphics.width - 16; const availH = Graphics.height - HEADER_H - 8; this._scale = Math.min(availW / iw, availH / ih, 2); this._ox = Math.floor((Graphics.width - iw * this._scale) / 2); this._oy = HEADER_H + Math.floor((Graphics.height - HEADER_H - ih * this._scale) / 2); this._original = this._raw.context.getImageData(0, 0, iw, ih); } createSprites() { this._bgSprite = new Sprite(new Bitmap(Graphics.width, Graphics.height)); this.addChild(this._bgSprite); this._previewBitmap = new Bitmap(this._iw, this._ih); this._imageSprite = new Sprite(this._raw); this._imageSprite.x = this._ox; this._imageSprite.y = this._oy; this._imageSprite.scale.set(this._scale, this._scale); this.addChild(this._imageSprite); this._maskOutline = this._mask ? this.buildMaskOutline() : null; this._overlaySprite = new Sprite(new Bitmap(Graphics.width, Graphics.height)); this.addChild(this._overlaySprite); this._headerSprite = new Sprite(new Bitmap(Graphics.width, HEADER_H)); this.addChild(this._headerSprite); } // マスクの輪郭(画像座標の点の配列) buildMaskOutline() { const w = this._iw, h = this._ih; const protect = buildProtect(w, h, [], this._mask); const pts = []; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; if (!protect[i]) continue; if (x === 0 || y === 0 || x === w - 1 || y === h - 1 || !protect[i - 1] || !protect[i + 1] || !protect[i - w] || !protect[i + w]) { pts.push(x, y); } } } return pts; } //--------------------------------------------------------------------- // 座標変換 //--------------------------------------------------------------------- toImage(sx, sy) { return { x: (sx - this._ox) / this._scale, y: (sy - this._oy) / this._scale }; } toScreenX(ix) { return this._ox + ix * this._scale; } toScreenY(iy) { return this._oy + iy * this._scale; } //--------------------------------------------------------------------- // 更新 //--------------------------------------------------------------------- update() { super.update(); if (!this._ready) return; this.updateMouse(); if (this._messageTimer > 0 && --this._messageTimer === 0) { this._message = ""; this.refreshHeader(); } } updateMouse() { const sx = TouchInput.x; const sy = TouchInput.y; const p = this.toImage(sx, sy); if (TouchInput.isTriggered()) { this.onPress(sx, sy, p); } else if (TouchInput.isPressed() && this._drag) { this.onDrag(p); } if (TouchInput.isReleased() && this._drag) { this.onRelease(); } if (TouchInput.isCancelled()) { this.onRightClick(p); } this.updateCursor(sx, sy, p); } hitHandle(sx, sy) { const r = this._rects[this._selected]; if (!r) return null; for (const [name, hx, hy] of this.handlePositions(r)) { if (Math.abs(sx - hx) <= HANDLE + 2 && Math.abs(sy - hy) <= HANDLE + 2) return name; } return null; } handlePositions(r) { const x0 = this.toScreenX(r.x), y0 = this.toScreenY(r.y); const x1 = this.toScreenX(r.x + r.w), y1 = this.toScreenY(r.y + r.h); const mx = (x0 + x1) / 2, my = (y0 + y1) / 2; return [ ["nw", x0, y0], ["n", mx, y0], ["ne", x1, y0], ["w", x0, my], ["e", x1, my], ["sw", x0, y1], ["s", mx, y1], ["se", x1, y1] ]; } hitRect(p) { for (let i = this._rects.length - 1; i >= 0; i--) { const r = this._rects[i]; if (p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h) return i; } return -1; } insideImage(p) { return p.x >= 0 && p.y >= 0 && p.x < this._iw && p.y < this._ih; } onPress(sx, sy, p) { this._escArmed = false; const handle = this.hitHandle(sx, sy); if (handle) { const r = this._rects[this._selected]; this._drag = { type: "resize", handle, orig: { ...r } }; } else { const idx = this.hitRect(p); if (idx >= 0) { this._selected = idx; this._drag = { type: "move", start: p, orig: { ...this._rects[idx] } }; } else if (this.insideImage(p)) { const ax = Math.floor(p.x); const ay = Math.floor(p.y); this._rects.push({ x: ax, y: ay, w: 1, h: 1, type: this._tool, area: STRICT_DEFAULT }); this._selected = this._rects.length - 1; this._drag = { type: "create", ax, ay }; } else { this._selected = -1; } } this.refreshAll(); } onDrag(p) { const d = this._drag; const r = this._rects[this._selected]; if (!r) return; const iw = this._iw, ih = this._ih; const px = Math.round(Math.max(0, Math.min(iw, p.x))); const py = Math.round(Math.max(0, Math.min(ih, p.y))); if (d.type === "move") { const dx = Math.round(p.x - d.start.x); const dy = Math.round(p.y - d.start.y); r.x = Math.max(0, Math.min(iw - r.w, d.orig.x + dx)); r.y = Math.max(0, Math.min(ih - r.h, d.orig.y + dy)); } else if (d.type === "resize") { let left = d.orig.x, top = d.orig.y; let right = d.orig.x + d.orig.w, bottom = d.orig.y + d.orig.h; if (d.handle.includes("w")) left = Math.min(px, right - 1); if (d.handle.includes("e")) right = Math.max(px, left + 1); if (d.handle.includes("n")) top = Math.min(py, bottom - 1); if (d.handle.includes("s")) bottom = Math.max(py, top + 1); r.x = left; r.y = top; r.w = right - left; r.h = bottom - top; } else if (d.type === "create") { r.x = Math.min(d.ax, px); r.y = Math.min(d.ay, py); r.w = Math.max(1, Math.abs(px - d.ax)); r.h = Math.max(1, Math.abs(py - d.ay)); } this._dirty = true; this.refreshOverlay(); this.refreshHeader(); } onRelease() { const d = this._drag; this._drag = null; const r = this._rects[this._selected]; if (d.type === "create" && r && (r.w < 2 || r.h < 2)) { // クリックだけ(ドラッグしていない)なら追加しない this._rects.splice(this._selected, 1); this._selected = -1; } else { this._dirty = true; } this.updatePreview(); this.refreshAll(); } onRightClick(p) { const idx = this.hitRect(p); if (idx >= 0) this.deleteRect(idx); } deleteRect(idx) { this._rects.splice(idx, 1); this._selected = -1; this._dirty = true; this.updatePreview(); this.refreshAll(); } updateCursor(sx, sy, p) { let cursor = "default"; if (this._drag) { cursor = this._drag.type === "move" ? "move" : this._drag.type === "resize" ? CURSORS[this._drag.handle] : "crosshair"; } else { const handle = this.hitHandle(sx, sy); if (handle) cursor = CURSORS[handle]; else if (this.hitRect(p) >= 0) cursor = "move"; else if (this.insideImage(p)) cursor = "crosshair"; } this.setCursor(cursor); } setCursor(cursor) { const canvas = Graphics._canvas; if (canvas && canvas.style.cursor !== cursor) canvas.style.cursor = cursor; } //--------------------------------------------------------------------- // キーボード //--------------------------------------------------------------------- onKeyDown(event) { if (!this._ready || SceneManager._scene !== this) return; const key = event.key; const r = this._rects[this._selected]; if (key !== "Escape") this._escArmed = false; switch (key) { case "Escape": if (this._dirty && !this._escArmed) { this._escArmed = true; this.showMessage("未保存の変更があります。破棄して戻るにはもう一度 Esc(保存は S)"); } else { SceneManager.pop(); } return; case "s": case "S": event.preventDefault(); this.save(); return; case "p": case "P": this._preview = !this._preview; this.updatePreview(); this.refreshAll(); return; case "b": case "B": this._bgMode = (this._bgMode + 1) % BG_MODES.length; this.refreshBackground(); this.refreshHeader(); return; case "+": case "=": case ";": this.stepMinArea(1); return; case "-": this.stepMinArea(-1); return; case "1": this._tool = "strict"; this.refreshHeader(); return; case "2": this._tool = "keep"; this.refreshHeader(); return; case "t": case "T": if (r) { r.type = r.type === "strict" ? "keep" : "strict"; this._dirty = true; this.updatePreview(); this.refreshAll(); } return; case "Tab": event.preventDefault(); if (this._rects.length > 0) { this._selected = (this._selected + 1) % this._rects.length; this.refreshAll(); } return; case "Delete": case "Backspace": if (r) this.deleteRect(this._selected); return; } const arrows = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; if (arrows[key] && r) { event.preventDefault(); const [dx, dy] = arrows[key]; if (event.shiftKey) { r.w = Math.max(1, Math.min(this._iw - r.x, r.w + dx)); r.h = Math.max(1, Math.min(this._ih - r.y, r.h + dy)); } else { r.x = Math.max(0, Math.min(this._iw - r.w, r.x + dx)); r.y = Math.max(0, Math.min(this._ih - r.h, r.y + dy)); } this._dirty = true; this.updatePreview(); this.refreshAll(); } } stepMinArea(dir) { const target = this._rects[this._selected]; if (!target || target.type !== "strict") { this.showMessage("+/- は厳格範囲を選択中に使います(全体の最小面積はプラグインパラメータで設定)"); return; } const cur = target.area; let next; if (dir > 0) next = AREA_STEPS.find(v => v > cur) || cur; else next = [...AREA_STEPS].reverse().find(v => v < cur) || cur; if (next === cur) return; target.area = next; this._dirty = true; this.updatePreview(); this.refreshAll(); } save() { editorData[this._filename] = { rects: this._rects.map(fromRect) }; try { const file = saveEditorData(); reprocessLoaded(this._filename); // 今回初めて対象になった画像も、読み込み済みならここで処理される ImageManager.loadPicture(this._filename); this._dirty = false; this._escArmed = false; this.showMessage("保存しました → data/" + DATA_FILE); console.log(`${pluginName}: 保存しました ${file}`); } catch (e) { console.error(`${pluginName}: 保存に失敗`, e); this.showMessage("保存に失敗しました(F8で詳細を確認)"); } } showMessage(text) { this._message = text; this._messageTimer = 180; this.refreshHeader(); } //--------------------------------------------------------------------- // 描画 //--------------------------------------------------------------------- updatePreview() { if (!this._preview) { this._imageSprite.bitmap = this._raw; return; } const data = cloneImageData(this._original); processImageData(data, this._filename, { minArea: this._minArea, rects: this._fixedRects.concat(this._rects.filter(r => r.type === "keep")), strictRects: this._rects.filter(r => r.type === "strict"), mask: this._mask, removePts: removePoints.get(this._filename) || [], keepPts: keepPoints.get(this._filename) || [] }, false); this._previewBitmap.context.putImageData(data, 0, 0); touchBitmap(this._previewBitmap); this._imageSprite.bitmap = this._previewBitmap; } refreshAll() { this.refreshBackground(); this.refreshOverlay(); this.refreshHeader(); } refreshBackground() { const bmp = this._bgSprite.bitmap; const ctx = bmp.context; const W = Graphics.width, H = Graphics.height; ctx.fillStyle = "#202020"; ctx.fillRect(0, 0, W, H); const x = this._ox, y = this._oy; const w = Math.round(this._iw * this._scale), h = Math.round(this._ih * this._scale); const mode = BG_MODES[this._bgMode]; if (mode.color) { ctx.fillStyle = mode.color; ctx.fillRect(x, y, w, h); } else { const size = 12; for (let yy = 0; yy < h; yy += size) { for (let xx = 0; xx < w; xx += size) { ctx.fillStyle = ((xx + yy) / size) % 2 === 0 ? "#cfcfcf" : "#8f8f8f"; ctx.fillRect(x + xx, y + yy, Math.min(size, w - xx), Math.min(size, h - yy)); } } } touchBitmap(bmp); } refreshOverlay() { const bmp = this._overlaySprite.bitmap; const ctx = bmp.context; ctx.clearRect(0, 0, Graphics.width, Graphics.height); // 画像の外枠 ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.lineWidth = 1; ctx.strokeRect(this._ox - 0.5, this._oy - 0.5, this._iw * this._scale + 1, this._ih * this._scale + 1); // マスクの輪郭(青) if (this._maskOutline) { ctx.fillStyle = "rgba(60,140,255,0.9)"; const pts = this._maskOutline; const s = Math.max(1, this._scale); for (let i = 0; i < pts.length; i += 2) { ctx.fillRect(this.toScreenX(pts[i]), this.toScreenY(pts[i + 1]), s, s); } } // パラメータで指定された範囲(編集不可・青の点線) ctx.setLineDash([4, 3]); ctx.strokeStyle = "rgba(60,140,255,0.95)"; ctx.lineWidth = 2; for (const r of this._fixedRects) { ctx.strokeRect(this.toScreenX(r.x), this.toScreenY(r.y), r.w * this._scale, r.h * this._scale); } ctx.setLineDash([]); // 保護範囲 ctx.font = "bold 13px sans-serif"; this._rects.forEach((r, i) => { const x = this.toScreenX(r.x), y = this.toScreenY(r.y); const w = r.w * this._scale, h = r.h * this._scale; const selected = i === this._selected; const strict = r.type === "strict"; const base = strict ? "#22d3ee" : "#ff3c3c"; ctx.fillStyle = strict ? "rgba(34,211,238,0.15)" : "rgba(255,60,60,0.15)"; ctx.fillRect(x, y, w, h); ctx.strokeStyle = selected ? "#ffdc00" : base; ctx.lineWidth = selected ? 3 : 2; ctx.strokeRect(x, y, w, h); const label = strict ? `${i + 1} 厳格:${r.area}` : `${i + 1} 保護`; ctx.fillStyle = selected ? "#ffdc00" : base; ctx.fillRect(x, y - 16 < this._oy ? y : y - 16, ctx.measureText(label).width + 8, 16); ctx.fillStyle = "#000"; ctx.fillText(label, x + 4, (y - 16 < this._oy ? y : y - 16) + 13); if (selected) { for (const [, hx, hy] of this.handlePositions(r)) { ctx.fillStyle = "#ffffff"; ctx.fillRect(hx - HANDLE, hy - HANDLE, HANDLE * 2, HANDLE * 2); ctx.strokeStyle = "#000000"; ctx.lineWidth = 1; ctx.strokeRect(hx - HANDLE + 0.5, hy - HANDLE + 0.5, HANDLE * 2 - 1, HANDLE * 2 - 1); } } }); touchBitmap(bmp); } refreshHeader() { const bmp = this._headerSprite.bitmap; const ctx = bmp.context; const W = Graphics.width; ctx.clearRect(0, 0, W, HEADER_H); ctx.fillStyle = "rgba(10,20,40,0.92)"; ctx.fillRect(0, 0, W, HEADER_H); ctx.textBaseline = "top"; ctx.font = "bold 16px sans-serif"; ctx.fillStyle = "#ffffff"; const view = this._preview ? "透過プレビュー" : "元画像"; ctx.fillText( `${this._filename} (${this._iw}×${this._ih}) 全体の最小面積: ${this._minArea}(パラメータ) ` + `表示: ${view} 背景: ${BG_MODES[this._bgMode].name}`, 10, 6, W - 110 ); if (this._dirty) { ctx.fillStyle = "#ffb000"; ctx.textAlign = "right"; ctx.fillText("● 未保存", W - 10, 6); ctx.textAlign = "left"; } ctx.font = "14px sans-serif"; const r = this._rects[this._selected]; const tool = this._tool === "strict" ? "厳格(水色)" : "保護(赤)"; if (this._message) { ctx.fillStyle = "#7fffa0"; ctx.fillText(this._message, 10, 28, W - 20); } else if (r) { ctx.fillStyle = "#ffdc00"; const kind = r.type === "strict" ? `厳格 最小面積:${r.area}` : "保護"; ctx.fillText(`選択中 #${this._selected + 1} ${kind} x:${r.x} y:${r.y} 幅:${r.w} 高さ:${r.h}`, 10, 28, W - 20); } else { ctx.fillStyle = "#c0c8d8"; const ns = this._rects.filter(q => q.type === "strict").length; ctx.fillText(`追加する種類: ${tool}  厳格範囲 ${ns}個 / 保護範囲 ${this._rects.length - ns}個`, 10, 28, W - 20); } ctx.font = "13px sans-serif"; ctx.fillStyle = "#a8b4c8"; ctx.fillText("1/2: 追加する種類(厳格/保護) ドラッグ: 追加・移動・■でサイズ T: 種類切替 右クリック/Del: 削除 矢印: 1px(Shift: サイズ)", 10, 48, W - 20); ctx.fillText("+/-: 選択中の厳格範囲の最小面積 Tab: 選択切替 P: プレビュー B: 背景色 S: 保存 Esc: 戻る", 10, 66, W - 20); touchBitmap(bmp); } } window.Scene_WhiteBgEditor = Scene_WhiteBgEditor; })();