/*: * @target MZ * @author F_ * @base Fs * @base ApngCore * @orderAfter Fs * * @plugindesc * * 独自のメニュー画面を実装します。 * * @help * * 独自のメニュー画面を実装します。 * * * ■ アクター画像の設定 * * 各アクターに対応する画像は img/pictures/ 以下に配置する必要があります。 * * 配置した画像は「アクター画像設定」により各アクターと対応付けます。 * * * * @param ActorImages * @type struct[] * @default [] * @text アクター画像設定 * @desc アクターに対応する画像の設定。 * * @param HelpWindowConfig * @type struct * @text ヘルプウィンドウ設定 * @desc ヘルプウィンドウに関する設定。 * * @param CommandWindowConfig * @type struct * @text コマンドウィンドウ設定 * @desc コマンドウィンドウに関する設定。 * * @param GoldWindowConfig * @type struct * @text 所持金ウィンドウ設定 * @desc 所持金ウィンドウに関する設定。 * * @param StatusWindowConfig * @type struct * @text ステータスウィンドウ設定 * @desc ステータスウィンドウに関する設定。 */ /*~struct~ActorImage: * @param actorId * @type actor * @default 0 * @text 対象アクター * @desc 画像を設定する対象のアクター。 * * @param path * @type file * @dir img/pictures/ * @text 画像 * @desc アクターに対応する画像。 */ /*~struct~HelpWindowConfig: * @param lines * @type number * @min 1 * @max 9 * @default 1 * @text 行数 * @desc 説明文の表示行数。 * * @param descriptions * @type struct * @text コマンド説明文 * @desc 各コマンドに対応する説明文。 */ /*~struct~CommandDescs: * @param item * @type multiline_string * @text アイテム * @desc 「アイテム」コマンドの説明文。 * * @param skill * @type multiline_string * @text スキル * @desc 「スキル」コマンドの説明文。 * * @param equip * @type multiline_string * @text 装備 * @desc 「装備」コマンドの説明文。 * * @param status * @type multiline_string * @text ステータス * @desc 「ステータス」コマンドの説明文。 * * @param formation * @type multiline_string * @text 並び替え * @desc 「並び替え」コマンドの説明文。 * * @param options * @type multiline_string * @text オプション * @desc 「オプション」コマンドの説明文。 * * @param save * @type multiline_string * @text セーブ * @desc 「セーブ」コマンドの説明文。 * * @param load * @type multiline_string * @text ロード * @desc 「ロード」コマンドの説明文。 * * @param gameEnd * @type multiline_string * @text ゲーム終了 * @desc 「ゲーム終了」コマンドの説明文。 */ /*~struct~CommandWindowConfig: * @param useLoadCommand * @type boolean * @default true * @text ロードコマンドの追加 * @desc 「ロード」コマンドを追加するかどうか。 * * @param loadCommandName * @parent useLoadCommand * @type string * @default ロード * @text ロードコマンド名 * @desc 「ロード」コマンドの表示名。 */ /*~struct~GoldWindowConfig: * @param goldLabel * @type string * @default 所持金 * @text 所持金項目名 * @desc 所持金の左側に表示する項目名。 * * @param playtimeLabel * @type string * @default プレイ時間 * @text プレイ時間項目名 * @desc プレイ時間の左側に表示する項目名。 * * @param lineHeight * @type number * @default 36 * @text 行の高さ * @desc 行の高さ(px)。 * * @param fontSize * @type number * @default 26 * @text フォントサイズ * @desc フォントサイズ(px)。 */ /*~struct~StatusWindowConfig: * @param columns * @type number * @min 1 * @max 8 * @default 4 * @text 列数 * @desc 同時に表示する列数。 * * @param baseX * @type number * @default 0 * @text 基本水平位置 * @desc 基本情報のX方向の位置(px)。 * * @param baseY * @type number * @default 0 * @text 基本垂直位置 * @desc 基本情報のY方向の位置(px)。 * * @param imageY * @type number * @default 0 * @text アクター画像位置 * @desc アクター画像下端のY方向の位置(px)。 * * @param gaugeY * @type number * @default 0 * @text ゲージ位置 * @desc ゲージ群上端のY方向の位置(px)。 * * @param gaugeWidth * @type number * @default 128 * @text ゲージ長 * @desc ゲージのラベルを含めたX方向の長さ(px)。 */ "use strict"; { const PLUGIN_NAME = "CustomMadeMenuScene"; const DEFAULT_COMMAND_DESCS = { item: "", skill: "", equip: "", status: "", formation: "", options: "", save: "", load: "", gameEnd: "", }; const DEFAULT_HELP_WINDOW_CONFIG = { lines: 1, descriptions: DEFAULT_COMMAND_DESCS, }; const DEFAULT_COMMAND_WINDOW_CONFIG = { useLoadCommand: true, loadCommandName: "Load", }; const DEFAULT_GOLD_WINDOW_CONFIG = { goldLabel: "Gold", playtimeLabel: "Play Time", lineHeight: 36, fontSize: 26, }; const DEFAULT_STATUS_WINDOW_CONFIG = { columns: 4, baseX: 0, baseY: 0, imageY: 0, gaugeY: 0, gaugeWidth: 128, }; const { P, Z } = Fs.v0_4; const parseParam = P.begin(PluginManager.parameters(PLUGIN_NAME)); const ACTOR_IMAGE_MAP = parseParam( "ActorImages", P.validate( P.like([ { actorId: P.natural, path: P.string, }, ]), (value, ok, err) => { const map = new Map(); for (const { actorId, path } of value) { if (!map.has(actorId)) { map.set(actorId, path); } else { return err(`duplicate actor ID: ${actorId}`); } } return ok(map); } ) ); const HELP_WINDOW_CONFIG = parseParam( "HelpWindowConfig", P.optional( P.like({ lines: P.natural, descriptions: P.optional( P.like({ item: P.string, skill: P.string, equip: P.string, status: P.string, formation: P.string, options: P.string, save: P.string, load: P.string, gameEnd: P.string, }), DEFAULT_COMMAND_DESCS ), }), DEFAULT_HELP_WINDOW_CONFIG ) ); const COMMAND_WINDOW_CONFIG = parseParam( "CommandWindowConfig", P.optional( P.like({ useLoadCommand: P.boolean, loadCommandName: P.string, }), DEFAULT_COMMAND_WINDOW_CONFIG ) ); const GOLD_WINDOW_CONFIG = parseParam( "GoldWindowConfig", P.optional( P.like({ goldLabel: P.string, playtimeLabel: P.string, lineHeight: P.natural, fontSize: P.natural, }), DEFAULT_GOLD_WINDOW_CONFIG ) ); const STATUS_WINDOW_CONFIG = parseParam( "StatusWindowConfig", P.optional( P.like({ columns: P.natural, baseX: P.natural, baseY: P.natural, imageY: P.natural, gaugeY: P.natural, gaugeWidth: P.natural, }), DEFAULT_STATUS_WINDOW_CONFIG ) ); const LOAD_COMMAND_SYMBOL = "load"; class ApngSprite extends PIXI.Sprite { #apng; #time; #prevTime; #canvas; #prevCanvas; #texture; constructor() { super(null); this.#apng = undefined; this.#time = 0; this.#prevTime = -1; this.#canvas = this.#initCanvas(); this.#prevCanvas = this.#initCanvas(); this.#texture = this.#initTexture(this.#canvas); } get time() { return this.#time; } set time(value) { this.#time = value; } setImage(apng) { if (this.#apng !== apng) { this.#apng = apng; this.#prevTime = -1; if (apng !== undefined) { this.#canvas.width = apng.width; this.#canvas.height = apng.height; this.#prevCanvas.width = apng.width; this.#prevCanvas.height = apng.height; this.texture = this.#texture; } else { this.texture = null; } } } update() { this.#updateCanvas(); } destroy(...args) { const texture = this.#texture; if (texture !== null) { this.texture = this.#texture = null; texture.destroy(true); } super.destroy(...args); } #initCanvas() { return Object.assign(document.createElement("canvas"), { width: 1, height: 1, }); } #initTexture(canvas) { return new PIXI.Texture( new PIXI.BaseTexture(canvas, { mipmap: PIXI.MIPMAP_MODES.OFF, scaleMode: PIXI.SCALE_MODES.LINEAR, }) ); } #updateCanvas() { const apng = this.#apng; const time = this.#time; const prevTime = this.#prevTime; const canvas = this.#canvas; const prevCanvas = this.#prevCanvas; if (apng !== undefined) { const data = this.#makeRenderData(apng, prevTime, time); if (data !== undefined) { this.#renderFrames(canvas, prevCanvas, data); this.#texture.update(); } this.#prevTime = time; } } #makeRenderData(apng, prevTime, time) { if (prevTime !== time) { const prevOrder = this.#frameOrder(apng, prevTime); const order = this.#frameOrder(apng, time); if (prevTime < 0 || prevOrder !== order) { return order !== -1 ? this.#makeRangeRenderData(apng, prevOrder, order) : this.#makeClearRenderData(); } } return undefined; } #frameOrder(apng, time) { const { numPlays, duration, frames } = apng; if (time >= 0) { const maxLoops = numPlays !== 0 ? numPlays : Number.POSITIVE_INFINITY; const loops = duration !== 0 ? Math.min(Math.floor(time / duration), maxLoops) : 0; const frame = this.#frameIndex(frames, time - loops * duration); const order = frame !== -1 ? frame + loops * frames.length : -1; return order; } else { return -1; } } #frameIndex(frames, position) { let rest = position; for (const [index, frame] of frames.entries()) { if (rest <= frame.delay) { return index; } rest -= frame.delay; } return frames.length - 1; } #makeRangeRenderData(apng, prevOrder, order) { const { frames } = apng; const { start, end } = this.#deltaFrameRange(frames, prevOrder, order); const last = start - 1; return { lastFrame: last !== -1 ? frames[last] : undefined, frames: this.#deltaFrames(frames, start, end), }; } #deltaFrameRange(frames, prevOrder, order) { const prevIndex = prevOrder % frames.length; const index = order % frames.length; const start = prevOrder !== -1 && prevOrder < order && prevIndex < index ? prevIndex + 1 : 0; const end = index + 1; return { start, end }; } #deltaFrames(frames, start, end) { const result = []; const filledRects = []; for (const [n, frame] of frames.slice(start, end).reverse().entries()) { const { offsetX, offsetY, width, height, disposeOp } = frame; if (n === 0 || disposeOp !== APNG.DISPOSE_OP_PREVIOUS) { const minX = offsetX; const maxX = offsetX + width; const minY = offsetY; const maxY = offsetY + height; if ( filledRects.every( (r) => minX < r.minX || maxX > r.maxX || minY < r.minY || maxY > r.maxY ) ) { result.push(frame); if (disposeOp === APNG.DISPOSE_OP_BACKGROUND) { filledRects.push({ minX, maxX, minY, maxY }); } } } } return result.reverse(); } #makeClearRenderData() { return { lastFrame: undefined, frames: [] }; } #renderFrames(canvas, prevCanvas, data) { const { lastFrame, frames } = data; const context = canvas.getContext("2d"); const prevContext = prevCanvas.getContext("2d"); if (frames.length !== 0) { let prevFrame = lastFrame; for (const frame of frames) { this.#renderSingleFrame(context, prevContext, frame, prevFrame); prevFrame = frame; } } else { this.#disposeOfPrevFrame(context, prevContext, lastFrame); } } #renderSingleFrame(context, prevContext, frame, prevFrame) { this.#disposeOfPrevFrame(context, prevContext, prevFrame); this.#savePrevFrameIfRequired(context, prevContext, frame); this.#drawFrameImage(context, frame); } #disposeOfPrevFrame(context, prevContext, prevFrame) { if (prevFrame !== undefined) { this.#applyDisposeOp(context, prevContext, prevFrame); } else { const { canvas } = context; context.clearRect(0, 0, canvas.width, canvas.height); } } #applyDisposeOp(context, prevContext, frame) { const { disposeOp } = frame; switch (disposeOp) { case APNG.DISPOSE_OP_NONE: return this.#applyDisposeOpNone(); case APNG.DISPOSE_OP_BACKGROUND: return this.#applyDisposeOpBackground(context, frame); case APNG.DISPOSE_OP_PREVIOUS: return this.#applyDisposeOpPrevious(context, prevContext, frame); default: throw new Error(`invalid APNG disposeOp: ${disposeOp}`); } } #applyDisposeOpNone() {} #applyDisposeOpBackground(context, frame) { const { offsetX, offsetY, width, height } = frame; context.clearRect(offsetX, offsetY, width, height); } #applyDisposeOpPrevious(context, prevContext, frame) { const { offsetX, offsetY, width, height } = frame; context.clearRect(offsetX, offsetY, width, height); context.drawImage( prevContext.canvas, offsetX, offsetY, width, height, offsetX, offsetY, width, height ); } #savePrevFrameIfRequired(context, prevContext, frame) { const { offsetX, offsetY, width, height, disposeOp } = frame; if (disposeOp === APNG.DISPOSE_OP_PREVIOUS) { prevContext.clearRect(offsetX, offsetY, width, height); prevContext.drawImage( context.canvas, offsetX, offsetY, width, height, offsetX, offsetY, width, height ); } } #drawFrameImage(context, frame) { const { offsetX, offsetY, width, height, image } = frame; if (this.#shouldClearRect(frame)) { context.clearRect(offsetX, offsetY, width, height); } context.drawImage(image, offsetX, offsetY, width, height); } #shouldClearRect(frame) { const { blendOp } = frame; switch (blendOp) { case APNG.BLEND_OP_SOURCE: return true; case APNG.BLEND_OP_OVER: return false; default: throw new Error(`invalid APNG blendOp: ${blendOp}`); } } } class MenuActorImageSprite extends Sprite { initialize() { super.initialize(); this._actor = null; this._path = undefined; this._time = 0; this._innerSprite = this.initApngSprite(); } initApngSprite() { const sprite = new ApngSprite(); sprite.anchor.set(0.5, 1); return this.addChild(sprite); } setup(actor) { this._actor = actor; this.updateBitmap(); } update() { super.update(); this.updateTime(); this.updateBitmap(); } updateTime() { this._time++; } updateBitmap() { const ASSUMED_FPS = 60; const actor = this._actor; const time = this._time; const sprite = this._innerSprite; const path = actor !== null ? ACTOR_IMAGE_MAP.get(actor.actorId()) : undefined; if (this._path !== path) { this._path = path; ImageManager.loadApngPicture(path, (apng) => sprite.setImage(apng)); } sprite.time = (time * 1000) / ASSUMED_FPS; } } class MenuGaugeSprite extends Sprite_Gauge { bitmapWidth() { return STATUS_WINDOW_CONFIG.gaugeWidth; } } class MenuHelpWindow extends Window_Help { refresh() { const text = this._text; const lines = text.split("\n"); const rect = this.baseTextRect(); const lineHeight = this.lineHeight(); this.contents.clear(); for (const [i, line] of lines.entries()) { this.drawText( line, rect.x, rect.y + lineHeight * i, rect.width, "left" ); } } } class MenuGoldWindow extends Window_Gold { initialize(...args) { super.initialize(...args); this._playtime = 0; } lineHeight() { return GOLD_WINDOW_CONFIG.lineHeight; } update() { super.update(); this.updatePlaytime(); } updatePlaytime() { const playtime = $gameSystem.playtime(); if (this._playtime !== playtime) { this._playtime = playtime; this.refreshPlaytimeValue(); } } resetFontSettings() { super.resetFontSettings(); this.contents.fontSize = GOLD_WINDOW_CONFIG.fontSize; } refresh() { this.contents.clear(); this.drawGoldLabel(); this.drawGoldValue(); this.drawPlaytimeLabel(); this.drawPlaytimeValue(); } refreshPlaytimeValue() { this.clearPlaytimeValue(); this.drawPlaytimeValue(); } drawGoldLabel() { const { x, y, width } = this.itemLineRect(0); this.resetTextColor(); this.drawText(GOLD_WINDOW_CONFIG.goldLabel, x, y, width, "left"); } drawGoldValue() { const { x, y, width } = this.itemLineRect(1); this.drawCurrencyValue(this.value(), this.currencyUnit(), x, y, width); } drawPlaytimeLabel() { const { x, y, width } = this.itemLineRect(2); this.resetTextColor(); this.drawText(GOLD_WINDOW_CONFIG.playtimeLabel, x, y, width, "left"); } drawPlaytimeValue() { const { x, y, width } = this.itemLineRect(3); this.resetTextColor(); this.drawText($gameSystem.playtimeText(), x, y, width, "right"); } clearPlaytimeValue() { const { x, y, width, height } = this.itemLineRect(3); this.contents.clearRect(x, y, width, height); } } const swapSpriteGauge = Z.swapper("Sprite_Gauge"); const swapWindowSelectable = Z.swapper("Window_Selectable"); const swapWindowHelp = Z.swapper("Window_Help"); const swapWindowGold = Z.swapper("Window_Gold"); Z.redef(ImageManager, ($) => { const PICTURE_DIR = "img/pictures/"; const APNG_EXT = ".png"; const ENCRYPTED_FILE_SUFFIX = "_"; $._apngEntries = new Map(); $.modifyApngEntry = function (url, modifyFn) { const entries = this._apngEntries; const oldEntry = entries.get(url); const newEntry = modifyFn(oldEntry); const nextEntry = newEntry.status === "loaded" ? this.onApngEntryLoaded(newEntry) : newEntry; entries.set(url, nextEntry); }; $.onApngEntryLoaded = function (entry) { const { callbacks, value } = entry; for (const callback of callbacks) { callback(value); } return { ...entry, callbacks: [] }; }; $.loadApngPicture = function (filename, callback) { this.loadApng(PICTURE_DIR, filename, callback); }; $.loadApng = function (folder, filename, callback) { if (filename !== "") { const url = folder + Utils.encodeURI(filename) + APNG_EXT; this.loadApngFromUrl(url, callback); } else { callback(APNG.EMPTY); } }; $.loadApngFromUrl = function (url, callback) { this.modifyApngEntry(url, (entry) => { if (entry !== undefined) { return { ...entry, callbacks: [...entry.callbacks, callback], }; } else { return this.startLoadApngEntry( { status: "none", url, callbacks: [callback], }, (result) => this.applyApngLoadResult(url, result) ); } }); }; $.retryLoadApngFromUrl = function (url) { this.modifyApngEntry(url, (entry) => { if (entry.status === "error") { return this.startLoadApngEntry(entry, (result) => this.applyApngLoadResult(url, result) ); } else { return entry; } }); }; $.applyApngLoadResult = function (url, result) { this.modifyApngEntry(url, (entry) => { const { url, callbacks } = entry; if (result.ok) { return { status: "loaded", url, callbacks, value: result.value, }; } else { return { status: "error", url, callbacks, error: result.error, }; } }); }; $.startLoadApngEntry = function (entry, callback) { const { url, callbacks } = entry; const controller = new AbortController(); const { signal } = controller; const invokeCallback = (result) => { if (!signal.aborted) { callback(result); } }; this.loadApngFromUrlAsync(url, { signal }).then( (value) => invokeCallback({ ok: true, value }), (error) => invokeCallback({ ok: false, error }) ); return { status: "loading", url, callbacks, controller, }; }; $.loadApngFromUrlAsync = async function (url, options) { const encrypted = Utils.hasEncryptedImages(); const actualUrl = encrypted ? url + ENCRYPTED_FILE_SUFFIX : url; const response = await fetch(actualUrl, options); const buffer = await response.arrayBuffer(); const data = encrypted ? Utils.decryptArrayBuffer(buffer) : buffer; const apng = await APNG.parse(data); return apng; }; const base$clear = $.clear; $.clear = function () { base$clear.apply(this, arguments); this.clearApngImages(); }; $.clearApngImages = function () { const entries = this._apngEntries; for (const entry of entries.values()) { if (entry.status === "loading") { entry.controller.abort(); } } entries.clear(); }; const base$isReady = $.isReady; $.isReady = function () { const normalReady = base$isReady.apply(this, arguments); const apngReady = this.checkApngLoaded(); return normalReady && apngReady; }; $.checkApngLoaded = function () { const entries = Array.from(this._apngEntries.values()); const error = entries.find((entry) => entry.status === "error"); if (error !== undefined) { const { url } = error; const retry = this.retryLoadApngFromUrl.bind(this, url); throw ["LoadError", url, retry]; } else { return entries.every((entry) => entry.status === "loaded"); } }; }); Z.redef(Window_MenuCommand.prototype, ($) => { $.updateHelp = function () { const { descriptions } = HELP_WINDOW_CONFIG; const helpWindow = this._helpWindow; if (helpWindow !== null) { const symbol = this.currentSymbol(); const text = symbol !== null && descriptions.hasOwnProperty(symbol) ? descriptions[symbol] : ""; helpWindow.setText(text); } }; const base$addSaveCommand = $.addSaveCommand; $.addSaveCommand = function () { base$addSaveCommand.apply(this, arguments); this.addLoadCommand(); }; $.addLoadCommand = function () { const { useLoadCommand, loadCommandName } = COMMAND_WINDOW_CONFIG; if (useLoadCommand) { const enabled = this.isLoadEnabled(); this.addCommand(loadCommandName, LOAD_COMMAND_SYMBOL, enabled); } }; $.isLoadEnabled = function () { return !DataManager.isEventTest() && DataManager.isAnySavefileExists(); }; }); Z.redef(Window_MenuStatus.prototype, ($) => { $.maxCols = function () { return STATUS_WINDOW_CONFIG.columns; }; $.itemHeight = function () { return this.innerHeight; }; $.drawItemImage = function (_index) {}; $.drawItemStatus = function (index) { const { baseX: baseOffsetX, baseY: baseOffsetY, imageY: imageOffsetY, gaugeY: gaugeOffsetY, gaugeWidth, } = STATUS_WINDOW_CONFIG; const actor = this.actor(index); const { x, y, width } = this.itemRect(index); const lineHeight = this.lineHeight(); const baseX = x + baseOffsetX; const baseY = y + baseOffsetY; const imageX = x + width / 2; const imageY = y + imageOffsetY; const gaugeX = x + Math.floor((width - gaugeWidth) / 2); const gaugeY = y + gaugeOffsetY; this.drawActorName(actor, baseX, baseY + lineHeight * 0); this.drawActorLevel(actor, baseX, baseY + lineHeight * 1); this.placeActorImage(actor, imageX, imageY); swapSpriteGauge(globalThis, MenuGaugeSprite, () => { this.placeBasicGauges(actor, gaugeX, gaugeY); }); }; $.placeActorImage = function (actor, x, y) { const key = "actor%1-image".format(actor.actorId()); const sprite = this.createInnerSprite(key, MenuActorImageSprite); sprite.setup(actor); sprite.move(x, y); sprite.show(); }; }); Z.redef(Scene_Menu.prototype, ($) => { $.isBottomHelpMode = function () { return false; }; $.helpAreaHeight = function () { return this.calcWindowHeight(HELP_WINDOW_CONFIG.lines, false); }; const base$create = $.create; $.create = function () { base$create.apply(this, arguments); this.createHelpWindow(); this._commandWindow.setHelpWindow(this._helpWindow); }; const base$createHelpWindow = $.createHelpWindow; $.createHelpWindow = function () { swapWindowHelp(globalThis, MenuHelpWindow, () => base$createHelpWindow.apply(this, arguments) ); }; const base$createCommandWindow = $.createCommandWindow; $.createCommandWindow = function () { base$createCommandWindow.apply(this, arguments); this._commandWindow.setHandler( LOAD_COMMAND_SYMBOL, this.commandLoad.bind(this) ); }; const base$createGoldWindow = $.createGoldWindow; $.createGoldWindow = function () { swapWindowGold(globalThis, MenuGoldWindow, () => base$createGoldWindow.apply(this, arguments) ); }; $.goldWindowRect = function () { return swapWindowSelectable(globalThis, MenuGoldWindow, () => { const ww = this.mainCommandWidth(); const wh = this.calcWindowHeight(4, true); const wx = this.isRightInputMode() ? Graphics.boxWidth - ww : 0; const wy = this.mainAreaBottom() - wh; return new Rectangle(wx, wy, ww, wh); }); }; $.commandLoad = function () { SceneManager.push(Scene_Load); }; }); }