diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b450b3df..b0da67b92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add shortcut to search scenes when in world mode by pressing `/` - Add support from adding sound effects to a project by dragging files into project window (to match how this works for other asset types) - Add native Mac ARM support for M1/M2/M3+ devices +- Add script debugger pane to World view, when game is run while this is open allows inspecting currently running scripts, setting breakpoints and updating live variable values ### Changed diff --git a/appData/wasm/binjgb/binjgb.wasm b/appData/wasm/binjgb/binjgb.wasm index 81fe205a4..aa40412c5 100755 Binary files a/appData/wasm/binjgb/binjgb.wasm and b/appData/wasm/binjgb/binjgb.wasm differ diff --git a/appData/wasm/binjgb/css/style.css b/appData/wasm/binjgb/css/style.css index 7eb9aa659..70843a6d6 100644 --- a/appData/wasm/binjgb/css/style.css +++ b/appData/wasm/binjgb/css/style.css @@ -319,3 +319,49 @@ body { opacity: 0.5; } } + +#debug { + display:flex; + flex-direction:column; + align-items:center; + padding:10px; + position:fixed; + top:0px; + left:0px; + right:0px; + bottom:0px; + background:rgba(0,0,0,0.5); +} + +#debug div { + background: white; + color: black; + border: 1px solid #ddd; + border-radius: 4px; + transform: position; + font-size: 11px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + display: flex; + align-items: center; +} + +#debug span { + padding: 5px 8px; +} + +#debug button { + background: transparent; + border: none; + border-left: 1px solid #ddd; + display: flex; + align-items: center; + height: 100%; +} + +#debug button:hover { + background: #eee; +} + +#debug button:active { + background: #ddd; +} \ No newline at end of file diff --git a/appData/wasm/binjgb/index.html b/appData/wasm/binjgb/index.html index 7d76babc7..1eae2f11a 100644 --- a/appData/wasm/binjgb/index.html +++ b/appData/wasm/binjgb/index.html @@ -31,4 +31,5 @@ + \ No newline at end of file diff --git a/appData/wasm/binjgb/js/debugger.js b/appData/wasm/binjgb/js/debugger.js new file mode 100644 index 000000000..1062f2638 --- /dev/null +++ b/appData/wasm/binjgb/js/debugger.js @@ -0,0 +1,551 @@ +/* global EVENT_NEW_FRAME, EVENT_AUDIO_BUFFER_FULL, EVENT_UNTIL_TICKS, vm, emulator, API */ + +let debug; + +// Consts + +const EVENT_BREAKPOINT = 8; +const EXECUTING_CTX_SYMBOL = "_executing_ctx"; +const FIRST_CTX_SYMBOL = "_first_ctx"; +const SCRIPT_MEMORY_SYMBOL = "_script_memory"; +const CURRENT_SCENE_SYMBOL = "_current_scene"; +const MAX_GLOBAL_VARS = "MAX_GLOBAL_VARS"; + +// Helpers + +const parseDebuggerSymbol = (input) => { + const match = input.match( + /GBVM\$([^$]+)\$([^$]+)\$([^$]+)\$([^$]+)\$([^$]+)\$([^$]+)/ + ); + if (!match) { + return undefined; + } + return { + scriptSymbol: match[1], + scriptEventId: match[2].replace(/_/g, "-"), + sceneId: match[3].replace(/_/g, "-"), + entityType: match[4], + entityId: match[5].replace(/_/g, "-"), + scriptKey: match[6], + }; +}; + +const parseDebuggerEndSymbol = (input) => { + const match = input.match(/GBVM_END\$([^$]+)\$([^$]+)/); + if (!match) { + return undefined; + } + return { + scriptSymbol: match[1], + }; +}; + +// Debugger + +class Debug { + constructor(emulator) { + this.emulator = emulator; + this.module = emulator.module; + this.e = emulator.e; + + this.vramCanvas = document.createElement("canvas"); + this.vramCanvas.width = 256; + this.vramCanvas.height = 256; + + this.memoryMap = {}; + this.globalVariables = {}; + this.variableMap = {}; + this.memoryDict = new Map(); + + this.breakpoints = []; + this.pauseOnScriptChanged = false; + this.pauseOnWatchedVariableChanged = true; + this.pauseOnVMStep = false; + this.currentScriptSymbol = ""; + this.scriptContexts = []; + this.pausedUI = null; + this.prevGlobals = []; + this.watchedVariables = []; + + this.debugRunUntil = (ticks) => { + while (true) { + const event = this.module._emulator_run_until_f64(this.e, ticks); + if (event & EVENT_NEW_FRAME) { + this.emulator.rewind.pushBuffer(); + this.emulator.video.uploadTexture(); + } + if (event & EVENT_BREAKPOINT) { + // Breakpoint hit + const firstCtxAddr = this.memoryMap[FIRST_CTX_SYMBOL]; + const executingCtxAddr = this.memoryMap[EXECUTING_CTX_SYMBOL]; + + const currentCtx = this.readMemInt16(executingCtxAddr); + let firstCtx = debug.readMemInt16(firstCtxAddr); + let scriptContexts = []; + let currentCtxData = undefined; + const prevCtxs = this.scriptContexts; + + while (firstCtx !== 0) { + const ctxAddr = debug.readMemInt16(firstCtx); + const ctxBank = debug.readMem(firstCtx + 2); + const closestAddr = debug.getClosestAddress(ctxBank, ctxAddr); + const closestSymbol = debug.getSymbol(ctxBank, closestAddr); + const closestGBVMSymbol = parseDebuggerSymbol(closestSymbol); + const prevCtx = prevCtxs[scriptContexts.length]; + + const ctxData = { + address: ctxAddr, + bank: ctxBank, + current: currentCtx === firstCtx, + closestAddr, + closestSymbol, + closestGBVMSymbol, + prevClosestSymbol: prevCtx?.closestSymbol, + prevClosestGBVMSymbol: prevCtx?.closestGBVMSymbol, + }; + + scriptContexts.push(ctxData); + if (ctxData.current) { + currentCtxData = ctxData; + } + + firstCtx = debug.readMemInt16(firstCtx + 3); + } + this.scriptContexts = scriptContexts; + + if (currentCtxData) { + // If pausing on VM Step and current script block changed + if ( + this.pauseOnVMStep && + currentCtxData.closestGBVMSymbol && + currentCtxData.closestGBVMSymbol.scriptEventId !== "end" && + currentCtxData.closestSymbol !== currentCtxData.prevClosestSymbol + ) { + emulator.pause(); + this.pauseOnVMStep = false; + break; + } + // If manual breakpoint is hit + if ( + currentCtxData.closestGBVMSymbol && + currentCtxData.address === currentCtxData.closestAddr && + this.breakpoints.includes( + currentCtxData.closestGBVMSymbol.scriptEventId + ) + ) { + this.pauseOnVMStep = true; + emulator.pause(); + break; + } + + if ( + this.pauseOnScriptChanged && + // Found matching GBVM event + currentCtxData.closestGBVMSymbol && + // GBVM event has changed since last pause + (!currentCtxData.prevClosestGBVMSymbol || + currentCtxData.closestGBVMSymbol.scriptSymbol !== + currentCtxData.prevClosestGBVMSymbol.scriptSymbol) + ) { + this.pauseOnVMStep = true; + emulator.pause(); + break; + } + + if (this.pauseOnWatchedVariableChanged) { + const globals = this.getGlobals(); + if (this.prevGlobals.length > 0) { + // Check if watched has change + const modified = !this.prevGlobals.every( + (v, i) => v === globals[i] + ); + if (modified) { + const changedVariable = this.watchedVariables.find( + (variableId) => { + const variableData = this.variableMap[variableId]; + const symbol = variableData?.symbol; + const variableIndex = this.globalVariables[symbol]; + if (variableIndex !== undefined) { + return ( + this.prevGlobals[variableIndex] !== undefined && + globals[variableIndex] !== + this.prevGlobals[variableIndex] + ); + } + return false; + } + ); + if (changedVariable) { + this.pauseOnVMStep = true; + emulator.pause(); + } + } + } + this.prevGlobals = globals; + } + } + } + if (event & EVENT_AUDIO_BUFFER_FULL && !this.emulator.isRewinding) { + this.emulator.audio.pushBuffer(); + } + if (event & EVENT_UNTIL_TICKS) { + break; + } + } + if (this.module._emulator_was_ext_ram_updated(this.e)) { + vm.extRamUpdated = true; + } + }; + + // replace the emulator run method with the debug one + this.emulator.runUntil = this.debugRunUntil; + } + + initialize( + memoryMap, + globalVariables, + variableMap, + pauseOnScriptChanged, + pauseOnWatchedVarChanged, + breakpoints, + watchedVariables + ) { + this.memoryMap = memoryMap; + this.globalVariables = globalVariables; + this.variableMap = variableMap; + this.pauseOnScriptChanged = pauseOnScriptChanged; + this.pauseOnWatchedVariableChanged = pauseOnWatchedVarChanged; + this.breakpoints = breakpoints; + this.watchedVariables = watchedVariables; + + const memoryDict = new Map(); + Object.keys(memoryMap).forEach((k) => { + // Banked resources + const match = k.match(/___bank_(.*)/); + if (match) { + const label = `_${match[1]}`; + const bank = memoryMap[k]; + if (memoryMap[label]) { + const n = memoryDict.get(bank) ?? new Map(); + const ptr = memoryMap[label] & 0x0ffff; + n.set(ptr, label); + memoryDict.set(bank, n); + } + } + // Script debug symbols + // const matchGBVM = k.match(/GBVM\$([^$]*)\$([^$]*)/); + const matchGBVM = parseDebuggerSymbol(k); + if (matchGBVM) { + const bankLabel = `___bank_${matchGBVM.scriptSymbol}`; + const label = k; + const bank = memoryMap[bankLabel]; + if (memoryMap[label]) { + const n = memoryDict.get(bank) ?? new Map(); + const ptr = memoryMap[label] & 0x0ffff; + n.set(ptr, label); + memoryDict.set(bank, n); + } + } + + const matchEnd = parseDebuggerEndSymbol(k); + if (matchEnd) { + const bankLabel = `___bank_${matchEnd.scriptSymbol}`; + const label = k; + const bank = memoryMap[bankLabel]; + if (memoryMap[label]) { + const n = memoryDict.get(bank) ?? new Map(); + const ptr = memoryMap[label] & 0x0ffff; + if (!n.get(ptr)) { + n.set(ptr, label); + memoryDict.set(bank, n); + } + } + } + }); + + this.memoryDict = memoryDict; + + // Break on VM_STEP + this.module._emulator_set_breakpoint(this.e, memoryMap["_VM_STEP"]); + + // Add paused UI + + this.initializeUI(); + this.initializeKeyboardShortcuts(); + } + + initializeUI() { + const pausedUI = document.createElement("div"); + const pausedUIContainer = document.createElement("div"); + const pausedUILabel = document.createElement("span"); + const pausedUIResumeBtn = document.createElement("button"); + const pausedUIStepBtn = document.createElement("button"); + const pausedUIStepFrameBtn = document.createElement("button"); + + document.body.appendChild(pausedUI); + pausedUI.appendChild(pausedUIContainer); + pausedUIContainer.appendChild(pausedUILabel); + pausedUIContainer.appendChild(pausedUIResumeBtn); + pausedUIContainer.appendChild(pausedUIStepBtn); + pausedUIContainer.appendChild(pausedUIStepFrameBtn); + + pausedUI.id = "debug"; + pausedUILabel.innerHTML = "Paused in debugger"; + + pausedUIResumeBtn.innerHTML = ``; + pausedUIResumeBtn.title = "Resume execution - F8"; + pausedUIResumeBtn.addEventListener("click", this.resume.bind(this)); + + pausedUIStepBtn.innerHTML = ``; + pausedUIStepBtn.title = "Step - F9"; + pausedUIStepBtn.addEventListener("click", this.step.bind(this)); + + pausedUIStepFrameBtn.innerHTML = ``; + pausedUIStepFrameBtn.title = "Step Frame - F10"; + pausedUIStepFrameBtn.addEventListener("click", this.stepFrame.bind(this)); + + this.pausedUI = pausedUI; + } + + initializeKeyboardShortcuts() { + window.addEventListener("keydown", (e) => { + if (e.key === "F8") { + this.togglePlayPause(); + } else if (e.key === "F9") { + this.step(); + } else if (e.key === "F10") { + this.stepFrame(); + } + }); + } + + getClosestAddress(bank, address) { + const bankScripts = this.memoryDict.get(bank); + const currentAddress = address; + let closestAddress = -1; + if (bankScripts) { + const addresses = Array.from(bankScripts.keys()).sort(); + for (let i = 0; i < addresses.length; i++) { + if (addresses[i] > currentAddress) { + break; + } else { + closestAddress = addresses[i]; + } + } + } + return closestAddress; + } + + getSymbol(bank, address) { + const symbol = this.memoryDict.get(bank)?.get(address) ?? ""; + return symbol.replace(/^_/, ""); + } + + readMem(addr) { + return this.module._emulator_read_mem(this.e, addr); + } + + readMemInt16(addr) { + return ( + (this.module._emulator_read_mem(this.e, addr + 1) << 8) | + this.module._emulator_read_mem(this.e, addr) + ); + } + + writeMem(addr, value) { + this.module._emulator_write_mem(this.e, addr, value & 0xff); + } + + writeMemInt16(addr, value) { + this.module._emulator_write_mem(this.e, addr, value & 0xff); + this.module._emulator_write_mem(this.e, addr + 1, value >> 8); + } + + readVariables(addr, size) { + const ptr = this.module._emulator_get_wram_ptr(this.e) - 0xc000; + return new Int16Array( + this.module.HEAP8.buffer.slice(ptr + addr, ptr + addr + size * 2) + ); + } + + renderVRam() { + var ctx = this.vramCanvas.getContext("2d"); + var imgData = ctx.createImageData(256, 256); + var ptr = this.module._malloc(4 * 256 * 256); + this.module._emulator_render_vram(this.e, ptr); + var buffer = new Uint8Array(this.module.HEAP8.buffer, ptr, 4 * 256 * 256); + imgData.data.set(buffer); + ctx.putImageData(imgData, 0, 0); + this.module._free(ptr); + return this.vramCanvas.toDataURL("image/png"); + } + + setBreakPoints(breakpoints) { + this.breakpoints = breakpoints; + } + + setWatchedVariables(watchedVariables) { + this.watchedVariables = watchedVariables; + } + + pause() { + this.pauseOnVMStep = true; + this.emulator.pause(); + } + + resume() { + this.pauseOnVMStep = false; + this.emulator.resume(); + } + + togglePlayPause() { + if (this.isPaused()) { + this.resume(); + } else { + this.pause(); + } + } + + step() { + if (this.isPaused()) { + this.resume(); + this.pauseOnVMStep = true; + } + } + + stepFrame() { + if (this.isPaused()) { + const ticks = this.module._emulator_get_ticks_f64(this.e) + 70224; + this.emulator.runUntil(ticks); + this.emulator.video.renderTexture(); + } + } + + isPaused() { + return this.emulator.isPaused || this.pauseOnVMStep; + } + + getGlobals() { + const variablesStartAddr = this.memoryMap[SCRIPT_MEMORY_SYMBOL]; + const variablesLength = this.globalVariables[MAX_GLOBAL_VARS]; + return this.readVariables(variablesStartAddr, variablesLength); + } + + setGlobal(symbol, value) { + const offset = (this.globalVariables[symbol] ?? 0) * 2; + const variablesStartAddr = this.memoryMap[SCRIPT_MEMORY_SYMBOL]; + this.writeMemInt16(variablesStartAddr + offset, value); + this.prevGlobals = this.getGlobals(); + } + + getCurrentSceneSymbol() { + const currentSceneAddr = this.memoryMap[CURRENT_SCENE_SYMBOL]; + return this.getSymbol( + this.readMem(currentSceneAddr), + this.readMemInt16(currentSceneAddr + 1) + ); + } + + getNumScriptCtxs() { + const firstCtxAddr = this.memoryMap[FIRST_CTX_SYMBOL]; + let firstCtx = debug.readMemInt16(firstCtxAddr); + let numCtxs = 0; + while (firstCtx !== 0) { + numCtxs++; + firstCtx = debug.readMemInt16(firstCtx + 3); + } + return numCtxs; + } +} + +// Debugger Initialisation + +let ready = setInterval(() => { + const debugEnabled = window.location.href.includes("debug=true"); + if (!debugEnabled) { + // Debugging not enabled + clearInterval(ready); + return; + } + + console.log("Waiting for emulator...", emulator); + if (emulator !== null) { + debug = new Debug(emulator); + clearInterval(ready); + + API.debugger.sendToProjectWindow({ + action: "initialized", + }); + + API.events.debugger.data.subscribe((_, packet) => { + const { action, data } = packet; + + switch (action) { + case "listener-ready": + debug.initialize( + data.memoryMap, + data.globalVariables, + data.variableMap, + data.pauseOnScriptChanged, + data.pauseOnWatchedVariableChanged, + data.breakpoints, + data.watchedVariables + ); + + setInterval(() => { + if (debug.pausedUI) { + debug.pausedUI.style.visibility = debug.isPaused() + ? "visible" + : "hidden"; + } + + const scriptContexts = + debug.getNumScriptCtxs() > 0 ? debug.scriptContexts : []; + + if (scriptContexts.length === 0) { + debug.pauseOnVMStep = false; + } + + API.debugger.sendToProjectWindow({ + action: "update-globals", + data: debug.getGlobals(), + vram: debug.renderVRam(), + isPaused: debug.isPaused(), + scriptContexts, + currentSceneSymbol: debug.getCurrentSceneSymbol(), + }); + }, 100); + break; + case "set-breakpoints": + debug.setBreakPoints(data); + break; + case "pause": + debug.pause(); + break; + case "resume": + debug.resume(); + break; + case "step": + debug.step(); + break; + case "step-frame": + debug.stepFrame(); + break; + case "pause-on-script": + debug.pauseOnScriptChanged = data; + break; + case "pause-on-var": + debug.pauseOnWatchedVariableChanged = data; + break; + case "set-global": + debug.setGlobal(data.symbol, data.value); + break; + case "set-watched": + debug.setWatchedVariables(data); + break; + default: + // console.warn(event); + } + }); + } +}, 200); diff --git a/appData/wasm/binjgb/js/script.js b/appData/wasm/binjgb/js/script.js index 22c4e8a06..2c69ad02f 100644 --- a/appData/wasm/binjgb/js/script.js +++ b/appData/wasm/binjgb/js/script.js @@ -113,7 +113,7 @@ class VM { let oldPaused = this.paused_; this.paused_ = newPaused; if (!emulator) return; - if (newPaused == oldPaused) return; + if (newPaused === oldPaused) return; if (newPaused) { emulator.pause(); this.ticks = emulator.ticks; diff --git a/forge.config.js b/forge.config.js index 2a3fcbb31..38d3bc957 100644 --- a/forge.config.js +++ b/forge.config.js @@ -114,6 +114,12 @@ module.exports = async () => { "vendor-lodash", ], }, + { + name: "game_window", + preload: { + js: "./src/app/game/preload.ts", + }, + }, ], }, }, diff --git a/src/app/game/preload.ts b/src/app/game/preload.ts new file mode 100644 index 000000000..089bbd9bd --- /dev/null +++ b/src/app/game/preload.ts @@ -0,0 +1,6 @@ +import { contextBridge } from "electron"; +import APISetup from "renderer/lib/api/setup"; + +contextBridge.exposeInMainWorld("API", APISetup); + +export default contextBridge; diff --git a/src/app/project/initProject.ts b/src/app/project/initProject.ts index 8f6f6deb3..ca21e90a5 100644 --- a/src/app/project/initProject.ts +++ b/src/app/project/initProject.ts @@ -12,6 +12,7 @@ import engineActions from "store/features/engine/engineActions"; import scriptEventDefsActions from "store/features/scriptEventDefs/scriptEventDefsActions"; import errorActions from "store/features/error/errorActions"; import consoleActions from "store/features/console/consoleActions"; +import debuggerActions from "store/features/debugger/debuggerActions"; import { clampSidebarWidth } from "renderer/lib/window/sidebar"; import { initKeyBindings } from "renderer/lib/keybindings/keyBindings"; import { TRACKER_REDO, TRACKER_UNDO } from "consts"; @@ -287,8 +288,14 @@ API.events.menu.zoom.subscribe((_, zoomType) => { } }); -API.events.menu.run.subscribe(() => { - store.dispatch(buildGameActions.buildGame()); +API.events.menu.run.subscribe((_, debugEnabled) => { + store.dispatch( + buildGameActions.buildGame({ + buildType: "web", + exportBuild: false, + debugEnabled, + }) + ); }); API.events.menu.build.subscribe((_, buildType) => { @@ -333,3 +340,41 @@ API.events.settings.settingChanged.subscribe((_, key, value) => { }) ); }); + +// Debugger + +API.events.debugger.data.subscribe((_, packet) => { + switch (packet.action) { + case "initialized": { + break; + } + case "update-globals": { + store.dispatch( + debuggerActions.setRAMData({ + vramPreview: packet.vram, + variablesData: packet.data, + scriptContexts: packet.scriptContexts, + currentSceneSymbol: packet.currentSceneSymbol, + isPaused: packet.isPaused, + }) + ); + break; + } + } +}); + +API.events.debugger.symbols.subscribe( + (_, { variableMap, sceneMap, gbvmScripts }) => { + store.dispatch( + debuggerActions.setSymbols({ + variableDataBySymbol: variableMap, + sceneMap, + gbvmScripts, + }) + ); + } +); + +API.events.debugger.disconnected.subscribe(() => { + store.dispatch(debuggerActions.disconnect()); +}); diff --git a/src/components/debugger/DebuggerActorLink.tsx b/src/components/debugger/DebuggerActorLink.tsx new file mode 100644 index 000000000..cbcf15fe4 --- /dev/null +++ b/src/components/debugger/DebuggerActorLink.tsx @@ -0,0 +1,40 @@ +import React, { useCallback } from "react"; +import { actorSelectors } from "store/features/entities/entitiesState"; +import editorActions from "store/features/editor/editorActions"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import { actorName } from "shared/lib/entities/entitiesHelpers"; +import { LinkButton } from "ui/debugger/LinkButton"; + +interface DebuggerActorLinkProps { + id: string; + sceneId: string; +} + +const DebuggerActorLink = ({ id, sceneId }: DebuggerActorLinkProps) => { + const dispatch = useAppDispatch(); + const actor = useAppSelector((state) => actorSelectors.selectById(state, id)); + const actorIndex = useAppSelector((state) => + actorSelectors.selectIds(state).indexOf(id) + ); + + const onSelect = useCallback(() => { + dispatch( + editorActions.selectActor({ + sceneId, + actorId: id, + }) + ); + dispatch(editorActions.editSearchTerm("")); + dispatch(editorActions.editSearchTerm(sceneId)); + }, [dispatch, id, sceneId]); + + if (!actor) { + return null; + } + + return ( + {actorName(actor, actorIndex)} + ); +}; + +export default DebuggerActorLink; diff --git a/src/components/debugger/DebuggerBreakpointItem.tsx b/src/components/debugger/DebuggerBreakpointItem.tsx new file mode 100644 index 000000000..6771d32b3 --- /dev/null +++ b/src/components/debugger/DebuggerBreakpointItem.tsx @@ -0,0 +1,103 @@ +import React, { useCallback, useRef } from "react"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import l10n, { L10NKey } from "shared/lib/lang/l10n"; +import { scriptEventSelectors } from "store/features/entities/entitiesState"; +import { selectScriptEventDefs } from "store/features/scriptEventDefs/scriptEventDefsState"; +import settingsActions from "store/features/settings/settingsActions"; +import { BreakpointData } from "store/features/settings/settingsState"; +import DebuggerScriptCtxBreadcrumb from "components/debugger/DebuggerScriptCtxBreadcrumb"; +import styled from "styled-components"; +import { Button } from "ui/buttons/Button"; +import { BreakpointIcon, CloseIcon } from "ui/icons/Icons"; +import useHover from "ui/hooks/use-hover"; + +interface DebuggerBreakpointItemProps { + breakpoint: BreakpointData; +} + +const Wrapper = styled.div` + display: flex; + padding: 5px 10px; + padding-left: 7px; + + font-size: 11px; + border-bottom: 1px solid ${(props) => props.theme.colors.sidebar.border}; + background: ${(props) => props.theme.colors.scripting.form.background}; + display: flex; + align-items: center; + + ${Button} { + margin-right: 3px; + svg { + width: 15px; + height: 15px; + min-width: 15px; + min-height: 15px; + opacity: 0.3; + } + } + + :hover ${Button} { + svg { + opacity: 1; + width: 10px; + height: 10px; + min-width: 10px; + min-height: 10px; + } + } +`; + +const BreakpointLabel = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; +`; + +const BreakpointName = styled.div` + font-weight: bold; +`; + +const BreakpointPath = styled.div``; + +const DebuggerBreakpointItem = ({ + breakpoint, +}: DebuggerBreakpointItemProps) => { + const dispatch = useAppDispatch(); + const ref = useRef(null); + const isHovered = useHover(ref); + const scriptEventId = breakpoint.scriptEventId; + + const scriptEvent = useAppSelector((state) => + scriptEventSelectors.selectById(state, scriptEventId) + ); + const scriptEventDefs = useAppSelector((state) => + selectScriptEventDefs(state) + ); + const command = scriptEvent?.command ?? ""; + const localisedCommand = l10n(command as L10NKey); + const eventName = + localisedCommand !== command + ? localisedCommand + : (scriptEventDefs[command] && scriptEventDefs[command]?.name) || command; + + const onToggle = useCallback(() => { + dispatch(settingsActions.toggleBreakpoint(breakpoint)); + }, [breakpoint, dispatch]); + + return ( + + + + {eventName} + + + + + + ); +}; + +export default DebuggerBreakpointItem; diff --git a/src/components/debugger/DebuggerBreakpointsPane.tsx b/src/components/debugger/DebuggerBreakpointsPane.tsx new file mode 100644 index 000000000..36b3bfeb2 --- /dev/null +++ b/src/components/debugger/DebuggerBreakpointsPane.tsx @@ -0,0 +1,102 @@ +import React, { useCallback, useEffect } from "react"; +import { getSettings } from "store/features/settings/settingsState"; +import settingsActions from "store/features/settings/settingsActions"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import styled from "styled-components"; +import { SplitPaneHeader } from "ui/splitpane/SplitPaneHeader"; +import { CheckboxField } from "ui/form/CheckboxField"; +import l10n from "shared/lib/lang/l10n"; +import API from "renderer/lib/api"; +import DebuggerBreakpointItem from "components/debugger/DebuggerBreakpointItem"; + +const Content = styled.div` + background: ${(props) => props.theme.colors.scripting.form.background}; + padding: 10px; + + & > div ~ div { + margin-top: 5px; + } +`; + +const BreakpointsWrapper = styled.div` + border-top: 1px solid ${(props) => props.theme.colors.sidebar.border}; +`; + +const DebuggerBreakpointsPane = () => { + const dispatch = useAppDispatch(); + const isCollapsed = useAppSelector((state) => + getSettings(state).debuggerCollapsedPanes.includes("breakpoints") + ); + const pauseOnScriptChanged = useAppSelector( + (state) => getSettings(state).debuggerPauseOnScriptChanged + ); + const pauseOnWatchedVariableChanged = useAppSelector( + (state) => getSettings(state).debuggerPauseOnWatchedVariableChanged + ); + const breakpoints = useAppSelector( + (state) => getSettings(state).debuggerBreakpoints + ); + + const onToggleCollapsed = useCallback(() => { + dispatch(settingsActions.toggleDebuggerPaneCollapsed("breakpoints")); + }, [dispatch]); + + const onTogglePauseOnScriptChange = useCallback(() => { + API.debugger.setPauseOnScriptChanged(!pauseOnScriptChanged); + dispatch( + settingsActions.editSettings({ + debuggerPauseOnScriptChanged: !pauseOnScriptChanged, + }) + ); + }, [dispatch, pauseOnScriptChanged]); + + const onTogglePauseOnWatchedVariableChange = useCallback(() => { + API.debugger.setPauseOnWatchVariableChanged(!pauseOnWatchedVariableChanged); + dispatch( + settingsActions.editSettings({ + debuggerPauseOnWatchedVariableChanged: !pauseOnWatchedVariableChanged, + }) + ); + }, [dispatch, pauseOnWatchedVariableChanged]); + + useEffect(() => { + API.debugger.setBreakpoints(breakpoints.map((b) => b.scriptEventId)); + }, [breakpoints]); + + return ( + <> + + Breakpoints + + {!isCollapsed && ( + + + + + )} + {!isCollapsed && ( + + {breakpoints.map((breakpoint) => ( + + ))} + + )} + + ); +}; + +export default DebuggerBreakpointsPane; diff --git a/src/components/debugger/DebuggerControls.tsx b/src/components/debugger/DebuggerControls.tsx new file mode 100644 index 000000000..b82afaffb --- /dev/null +++ b/src/components/debugger/DebuggerControls.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useEffect } from "react"; +import API from "renderer/lib/api"; +import l10n from "shared/lib/lang/l10n"; +import { useAppSelector } from "store/hooks"; +import { Button } from "ui/buttons/Button"; +import { PlayStartIcon, PauseIcon, NextIcon, StepIcon } from "ui/icons/Icons"; +import { FixedSpacer } from "ui/spacing/Spacing"; + +const DebuggerControls = () => { + const initialized = useAppSelector((state) => state.debug.initialized); + const isPaused = useAppSelector((state) => state.debug.isPaused); + + const onPlayPause = useCallback(() => { + if (isPaused) { + API.debugger.resume(); + } else { + API.debugger.pause(); + } + }, [isPaused]); + + const onStep = useCallback(() => { + API.debugger.step(); + }, []); + + const onStepFrame = useCallback(() => { + API.debugger.stepFrame(); + }, []); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "F8") { + onPlayPause(); + } else if (e.key === "F9") { + onStep(); + } else if (e.key === "F10") { + onStepFrame(); + } + }, + [onPlayPause, onStep, onStepFrame] + ); + + useEffect(() => { + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, [onKeyDown]); + + if (!initialized) { + return null; + } + + return ( + <> + + + + + + + ); +}; + +export default DebuggerControls; diff --git a/src/components/debugger/DebuggerCustomEventLink.tsx b/src/components/debugger/DebuggerCustomEventLink.tsx new file mode 100644 index 000000000..77ba264d4 --- /dev/null +++ b/src/components/debugger/DebuggerCustomEventLink.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from "react"; +import { customEventSelectors } from "store/features/entities/entitiesState"; +import editorActions from "store/features/editor/editorActions"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import { customEventName } from "shared/lib/entities/entitiesHelpers"; +import { LinkButton } from "ui/debugger/LinkButton"; + +interface DebuggerCustomEventLinkProps { + id: string; +} + +const DebuggerCustomEventLink = ({ id }: DebuggerCustomEventLinkProps) => { + const dispatch = useAppDispatch(); + const customEvent = useAppSelector((state) => + customEventSelectors.selectById(state, id) + ); + const customEventIndex = useAppSelector((state) => + customEventSelectors.selectIds(state).indexOf(id) + ); + + const onSelect = useCallback(() => { + dispatch(editorActions.selectCustomEvent({ customEventId: id })); + }, [dispatch, id]); + + if (!customEvent) { + return null; + } + + return ( + + {customEventName(customEvent, customEventIndex)} + + ); +}; + +export default DebuggerCustomEventLink; diff --git a/src/components/debugger/DebuggerPanes.tsx b/src/components/debugger/DebuggerPanes.tsx new file mode 100644 index 000000000..cdd6121d4 --- /dev/null +++ b/src/components/debugger/DebuggerPanes.tsx @@ -0,0 +1,127 @@ +import React, { useCallback } from "react"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import styled from "styled-components"; +import useResizeObserver from "ui/hooks/use-resize-observer"; +import DebuggerScriptPane from "components/debugger/DebuggerScriptPane"; +import DebuggerVariablesPane from "components/debugger/DebuggerVariablesPane"; +import DebuggerVRAMPane from "components/debugger/DebuggerVRAMPane"; +import DebuggerState from "components/debugger/DebuggerState"; +import DebuggerBreakpointsPane from "components/debugger/DebuggerBreakpointsPane"; +import DebuggerPausedPane from "components/debugger/DebuggerPausedPane"; +import buildGameActions from "store/features/buildGame/buildGameActions"; +import { Button } from "ui/buttons/Button"; +import l10n from "shared/lib/lang/l10n"; + +const COL1_WIDTH = 290; +const COL2_WIDTH = 350; + +const Wrapper = styled.div` + display: flex; + flex-direction: row; + width: 100%; + height: 100%; + background: ${(props) => props.theme.colors.scripting.form.background}; + + img { + image-rendering: pixelated; + } +`; + +const Column = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + box-sizing: border-box; + overflow-y: auto; + border-right: 1px solid ${(props) => props.theme.colors.sidebar.border}; + font-size: 11px; + + &:last-of-type { + border-right: 0; + } +`; + +const NotInitializedWrapper = styled.div` + font-size: 11px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 100px; + button { + margin-top: 10px; + } +`; + +const DebuggerPanes = () => { + const dispatch = useAppDispatch(); + const [wrapperEl, wrapperSize] = useResizeObserver(); + + const initialized = useAppSelector((state) => state.debug.initialized); + const buildStatus = useAppSelector((state) => state.console.status); + const running = buildStatus === "running"; + + const onRun = useCallback(() => { + dispatch( + buildGameActions.buildGame({ + buildType: "web", + exportBuild: false, + debugEnabled: true, + }) + ); + }, [dispatch]); + + const numColumns = !wrapperSize.width + ? 0 + : wrapperSize.width > 960 + ? 3 + : wrapperSize.width > 560 + ? 2 + : 1; + + return ( + + {!initialized && ( + + {l10n("FIELD_DEBUGGER_NOT_CONNECTED")} + + + )} + {initialized && numColumns > 0 && ( + <> + 1 ? { maxWidth: COL1_WIDTH } : undefined}> + + + + + {numColumns < 3 && } + {numColumns < 2 && } + + {numColumns > 2 && ( + + + + )} + {numColumns > 1 && ( + + + + )} + + )} + + ); +}; + +export default DebuggerPanes; diff --git a/src/components/debugger/DebuggerPausedPane.tsx b/src/components/debugger/DebuggerPausedPane.tsx new file mode 100644 index 000000000..6f3539e15 --- /dev/null +++ b/src/components/debugger/DebuggerPausedPane.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import l10n from "shared/lib/lang/l10n"; +import { useAppSelector } from "store/hooks"; +import styled from "styled-components"; +import { InfoIcon } from "ui/icons/Icons"; + +const PausedMessage = styled.div` + display: flex; + flex-shrink: 0; + padding: 0 5px; + height: 31px; + box-sizing: border-box; + align-items: center; + font-weight: bold; + background: ${(props) => props.theme.colors.highlight}; + color: ${(props) => props.theme.colors.highlightText}; + + svg { + width: 15px; + margin-right: 3px; + fill: ${(props) => props.theme.colors.highlightText}; + } +`; + +const DebuggerPausedPane = () => { + const isPaused = useAppSelector((state) => state.debug.isPaused); + + if (!isPaused) { + return null; + } + + return ( + + {l10n("FIELD_DEBUGGER_PAUSED")} + + ); +}; + +export default DebuggerPausedPane; diff --git a/src/components/debugger/DebuggerSceneLink.tsx b/src/components/debugger/DebuggerSceneLink.tsx new file mode 100644 index 000000000..7cfc35f93 --- /dev/null +++ b/src/components/debugger/DebuggerSceneLink.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from "react"; +import { sceneSelectors } from "store/features/entities/entitiesState"; +import editorActions from "store/features/editor/editorActions"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import { sceneName } from "shared/lib/entities/entitiesHelpers"; +import { LinkButton } from "ui/debugger/LinkButton"; + +interface DebuggerSceneLinkProps { + id: string; +} + +const DebuggerSceneLink = ({ id }: DebuggerSceneLinkProps) => { + const dispatch = useAppDispatch(); + const scene = useAppSelector((state) => sceneSelectors.selectById(state, id)); + const sceneIndex = useAppSelector((state) => + sceneSelectors.selectIds(state).indexOf(id) + ); + + const onSelect = useCallback(() => { + dispatch(editorActions.selectScene({ sceneId: id })); + dispatch(editorActions.editSearchTerm("")); + dispatch(editorActions.editSearchTerm(id)); + }, [dispatch, id]); + + if (!scene) { + return null; + } + + return ( + {sceneName(scene, sceneIndex)} + ); +}; + +export default DebuggerSceneLink; diff --git a/src/components/debugger/DebuggerScriptCtxBreadcrumb.tsx b/src/components/debugger/DebuggerScriptCtxBreadcrumb.tsx new file mode 100644 index 000000000..6c51ba77c --- /dev/null +++ b/src/components/debugger/DebuggerScriptCtxBreadcrumb.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import DebuggerActorLink from "components/debugger/DebuggerActorLink"; +import DebuggerCustomEventLink from "components/debugger/DebuggerCustomEventLink"; +import DebuggerSceneLink from "components/debugger/DebuggerSceneLink"; +import DebuggerTriggerLink from "components/debugger/DebuggerTriggerLink"; +import type { ScriptEditorCtx } from "shared/lib/scripts/context"; +import styled from "styled-components"; + +interface DebuggerScriptCtxBreadcrumbProps { + context: ScriptEditorCtx; +} + +const Separator = styled.span` + margin: 0 5px; + opacity: 0.5; +`; + +const DebuggerScriptCtxBreadcrumb = ({ + context, +}: DebuggerScriptCtxBreadcrumbProps) => { + const { sceneId, entityType, entityId, scriptKey } = context; + return ( + <> + {sceneId && entityType !== "customEvent" && ( + <> + + / + + )} + {entityType === "actor" && ( + <> + + / + + )} + {entityType === "trigger" && ( + <> + + / + + )} + {entityType === "customEvent" && ( + <> + + / + + )} + {scriptKey} + + ); +}; + +export default DebuggerScriptCtxBreadcrumb; diff --git a/src/components/debugger/DebuggerScriptPane.tsx b/src/components/debugger/DebuggerScriptPane.tsx new file mode 100644 index 000000000..e403a2b51 --- /dev/null +++ b/src/components/debugger/DebuggerScriptPane.tsx @@ -0,0 +1,334 @@ +import ScriptEditor from "components/script/ScriptEditor"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import l10n from "shared/lib/lang/l10n"; +import { getSettings } from "store/features/settings/settingsState"; +import settingsActions from "store/features/settings/settingsActions"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import styled, { css } from "styled-components"; +import { Button } from "ui/buttons/Button"; +import { CodeEditor } from "ui/form/CodeEditor"; +import { ScriptEditorCtx } from "shared/lib/scripts/context"; +import { ScriptEditorContext } from "components/script/ScriptEditorContext"; +import { + actorSelectors, + customEventSelectors, + sceneSelectors, + triggerSelectors, +} from "store/features/entities/entitiesState"; +import { + ActorScriptKey, + SceneScriptKey, + TriggerScriptKey, +} from "shared/lib/entities/entitiesTypes"; +import { SplitPaneHeader } from "ui/splitpane/SplitPaneHeader"; +import { TabBar } from "ui/tabs/Tabs"; +import API from "renderer/lib/api"; +import DebuggerScriptCtxBreadcrumb from "components/debugger/DebuggerScriptCtxBreadcrumb"; + +interface DebuggerScriptPaneProps { + collapsible?: boolean; +} + +interface TabWrapperProps { + collapsible?: boolean; +} + +const TabWrapper = styled.div` + height: 32px; + border-left: 1px solid ${(props) => props.theme.colors.input.border}; + overflow: hidden; + button { + height: 31px; + border-bottom: 0; + outline: 0; + } +`; + +const PlayPauseMessage = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 100px; + button { + margin-top: 10px; + } +`; + +const Content = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + max-height: calc(100% - 31px); +`; + +const ScriptPath = styled.div` + padding: 5px 10px; + color: ${(props) => props.theme.colors.input.text}; + background: ${(props) => props.theme.colors.input.background}; + border-bottom: 1px solid ${(props) => props.theme.colors.input.border}; +`; + +interface ScrollWrapperProps { + scrollable?: boolean; +} + +const ScrollWrapper = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + + ${(props) => + props.scrollable + ? css` + overflow: auto; + max-height: 100%; + ` + : ""} +`; + +const CodeEditorWrapper = styled.div` + flex-grow: 1; + + & > div { + min-height: 100%; + border-radius: 0; + border: 0; + } +`; + +const DebuggerScriptPane = ({ collapsible }: DebuggerScriptPaneProps) => { + const dispatch = useAppDispatch(); + const [thread, setThread] = useState(0); + + const isPaused = useAppSelector((state) => state.debug.isPaused); + const scriptContexts = useAppSelector((state) => state.debug.scriptContexts); + const gbvmScripts = useAppSelector((state) => state.debug.gbvmScripts); + const viewScriptType = useAppSelector( + (state) => getSettings(state).debuggerScriptType + ); + const isCollapsed = useAppSelector( + (state) => + !!collapsible && + getSettings(state).debuggerCollapsedPanes.includes("script") + ); + + const onSetScriptTypeEditor = useCallback(() => { + dispatch( + settingsActions.editSettings({ + debuggerScriptType: "editor", + }) + ); + }, [dispatch]); + + const onSetScriptTypeGBVM = useCallback(() => { + dispatch( + settingsActions.editSettings({ + debuggerScriptType: "gbvm", + }) + ); + }, [dispatch]); + + const onToggleCollapsed = useCallback(() => { + dispatch(settingsActions.toggleDebuggerPaneCollapsed("script")); + }, [dispatch]); + + const tabs = useMemo(() => { + return scriptContexts.reduce((memo, ctx, i) => { + memo[i] = `Thread ${i}`; + return memo; + }, {} as Record); + }, [scriptContexts]); + + const runningThreadIndex = useMemo(() => { + return scriptContexts.findIndex((thread) => thread.current); + }, [scriptContexts]); + + useEffect(() => { + setThread(runningThreadIndex); + }, [runningThreadIndex]); + + const currentThread = useMemo(() => { + return scriptContexts[thread] ?? scriptContexts[0]; + }, [scriptContexts, thread]); + + const currentScriptSymbol = + currentThread?.closestGBVMSymbol?.scriptSymbol ?? ""; + + const currentGBVMScript = gbvmScripts[`${currentScriptSymbol}.s`] ?? ""; + + const scriptCtx: ScriptEditorCtx | undefined = useMemo( + () => + currentThread?.closestGBVMSymbol + ? { + type: + currentThread.closestGBVMSymbol.entityType === "customEvent" + ? "script" + : "entity", + entityType: currentThread.closestGBVMSymbol.entityType, + entityId: currentThread.closestGBVMSymbol.entityId, + sceneId: currentThread.closestGBVMSymbol.sceneId, + scriptKey: currentThread.closestGBVMSymbol.scriptKey, + executingId: currentThread.closestGBVMSymbol?.scriptEventId ?? "", + } + : undefined, + [currentThread?.closestGBVMSymbol] + ); + + const currentScriptLineNum = useMemo(() => { + if ( + currentGBVMScript && + currentThread?.closestSymbol && + currentThread?.closestSymbol.startsWith("GBVM$") + ) { + const lines = currentGBVMScript.split("\n"); + for (let l = 0; l < lines.length; l++) { + if (lines[l].includes(currentThread.closestSymbol)) { + return l + 1; + } + } + } + return -1; + }, [currentGBVMScript, currentThread?.closestSymbol]); + + const actor = useAppSelector((state) => + actorSelectors.selectById(state, scriptCtx?.entityId ?? "") + ); + const trigger = useAppSelector((state) => + triggerSelectors.selectById(state, scriptCtx?.entityId ?? "") + ); + const customEvent = useAppSelector((state) => + customEventSelectors.selectById(state, scriptCtx?.entityId ?? "") + ); + const scene = useAppSelector((state) => + sceneSelectors.selectById(state, scriptCtx?.sceneId ?? "") + ); + + const currentScript = useMemo(() => { + if (!scriptCtx) { + return []; + } + if (scriptCtx.entityType === "actor" && actor) { + return actor[scriptCtx.scriptKey as ActorScriptKey]; + } else if (scriptCtx.entityType === "trigger" && trigger) { + return trigger[scriptCtx.scriptKey as TriggerScriptKey]; + } else if (scriptCtx.entityType === "scene" && scene) { + return scene[scriptCtx.scriptKey as SceneScriptKey]; + } else if (scriptCtx.entityType === "customEvent" && customEvent) { + return customEvent.script; + } + return []; + }, [actor, scriptCtx, scene, trigger, customEvent]); + + const onPlayPause = useCallback(() => { + if (isPaused) { + API.debugger.resume(); + } else { + API.debugger.pause(); + } + }, [isPaused]); + + const stopPropagagtion = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + }, + [] + ); + + return ( + <> + + + / + + + ) + } + > + {isCollapsed || !isPaused || scriptContexts.length === 0 ? ( + l10n("FIELD_THREADS") + ) : ( + + { + setThread(parseInt(value, 10)); + }} + /> + + )} + + {!isCollapsed && !isPaused && ( + + {l10n("FIELD_PAUSE_TO_VIEW_SCRIPTS")} + + + )} + {!isCollapsed && isPaused && !currentGBVMScript && ( + + {l10n("FIELD_NO_SCRIPT_RUNNING")} + + + )} + {!isCollapsed && isPaused && currentGBVMScript && ( + + {currentGBVMScript && scriptCtx && ( + + + + )} + + {viewScriptType === "editor" && scriptCtx ? ( + + + + ) : undefined} + {viewScriptType === "gbvm" && currentGBVMScript ? ( + + {}} + currentLineNum={currentScriptLineNum} + /> + + ) : undefined} + + + )} + + ); +}; + +export default DebuggerScriptPane; diff --git a/src/components/debugger/DebuggerState.tsx b/src/components/debugger/DebuggerState.tsx new file mode 100644 index 000000000..bd5178db8 --- /dev/null +++ b/src/components/debugger/DebuggerState.tsx @@ -0,0 +1,73 @@ +import React, { useCallback } from "react"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import styled from "styled-components"; +import { SplitPaneHeader } from "ui/splitpane/SplitPaneHeader"; +import { getSettings } from "store/features/settings/settingsState"; +import settingsActions from "store/features/settings/settingsActions"; +import DebuggerSceneLink from "components/debugger/DebuggerSceneLink"; +import l10n from "shared/lib/lang/l10n"; + +const Content = styled.div` + background: ${(props) => props.theme.colors.scripting.form.background}; + padding: 10px; +`; + +const DataRow = styled.div` + padding-bottom: 5px; + &:last-of-type { + padding-bottom: 0; + } +`; + +const DataLabel = styled.span` + font-weight: bold; + padding-right: 5px; +`; + +const DebuggerState = () => { + const dispatch = useAppDispatch(); + + const sceneMap = useAppSelector((state) => state.debug.sceneMap); + const currentSceneSymbol = useAppSelector( + (state) => state.debug.currentSceneSymbol + ); + const scriptContexts = useAppSelector((state) => state.debug.scriptContexts); + + const currentSceneData = sceneMap[currentSceneSymbol] ?? undefined; + + const isCollapsed = useAppSelector((state) => + getSettings(state).debuggerCollapsedPanes.includes("state") + ); + + const onToggleCollapsed = useCallback(() => { + dispatch(settingsActions.toggleDebuggerPaneCollapsed("state")); + }, [dispatch]); + + return ( + <> + + {l10n("FIELD_CURRENT_STATE")} + + {!isCollapsed && ( + + {currentSceneData && ( + + {l10n("SCENE")}: + + + )} + + {l10n("FIELD_THREADS")}: + {scriptContexts.length} + + + )} + + ); +}; + +export default DebuggerState; diff --git a/src/components/debugger/DebuggerTriggerLink.tsx b/src/components/debugger/DebuggerTriggerLink.tsx new file mode 100644 index 000000000..6d6414a48 --- /dev/null +++ b/src/components/debugger/DebuggerTriggerLink.tsx @@ -0,0 +1,44 @@ +import React, { useCallback } from "react"; +import { triggerSelectors } from "store/features/entities/entitiesState"; +import editorActions from "store/features/editor/editorActions"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import { triggerName } from "shared/lib/entities/entitiesHelpers"; +import { LinkButton } from "ui/debugger/LinkButton"; + +interface DebuggerTriggerLinkProps { + id: string; + sceneId: string; +} + +const DebuggerTriggerLink = ({ id, sceneId }: DebuggerTriggerLinkProps) => { + const dispatch = useAppDispatch(); + const trigger = useAppSelector((state) => + triggerSelectors.selectById(state, id) + ); + const triggerIndex = useAppSelector((state) => + triggerSelectors.selectIds(state).indexOf(id) + ); + + const onSelect = useCallback(() => { + dispatch( + editorActions.selectTrigger({ + sceneId, + triggerId: id, + }) + ); + dispatch(editorActions.editSearchTerm("")); + dispatch(editorActions.editSearchTerm(sceneId)); + }, [dispatch, id, sceneId]); + + if (!trigger) { + return null; + } + + return ( + + {triggerName(trigger, triggerIndex)} + + ); +}; + +export default DebuggerTriggerLink; diff --git a/src/components/debugger/DebuggerVRAMPane.tsx b/src/components/debugger/DebuggerVRAMPane.tsx new file mode 100644 index 000000000..13a3c643d --- /dev/null +++ b/src/components/debugger/DebuggerVRAMPane.tsx @@ -0,0 +1,42 @@ +import React, { useCallback } from "react"; +import { getSettings } from "store/features/settings/settingsState"; +import settingsActions from "store/features/settings/settingsActions"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import styled from "styled-components"; +import { SplitPaneHeader } from "ui/splitpane/SplitPaneHeader"; + +const Content = styled.div` + background: ${(props) => props.theme.colors.scripting.form.background}; + padding: 10px; +`; + +const DebuggerVRAMPane = () => { + const dispatch = useAppDispatch(); + const vramPreview = useAppSelector((state) => state.debug.vramPreview); + const isCollapsed = useAppSelector((state) => + getSettings(state).debuggerCollapsedPanes.includes("vram") + ); + + const onToggleCollapsed = useCallback(() => { + dispatch(settingsActions.toggleDebuggerPaneCollapsed("vram")); + }, [dispatch]); + + return ( + <> + + VRAM + + {!isCollapsed && ( + + + + )} + + ); +}; + +export default DebuggerVRAMPane; diff --git a/src/components/debugger/DebuggerVariablesPane.tsx b/src/components/debugger/DebuggerVariablesPane.tsx new file mode 100644 index 000000000..5d7f8f8e3 --- /dev/null +++ b/src/components/debugger/DebuggerVariablesPane.tsx @@ -0,0 +1,379 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import l10n from "shared/lib/lang/l10n"; +import settingsActions from "store/features/settings/settingsActions"; +import editorActions from "store/features/editor/editorActions"; +import { useAppDispatch, useAppSelector } from "store/hooks"; +import styled, { css } from "styled-components"; +import { Button } from "ui/buttons/Button"; +import { NumberInput } from "ui/form/NumberInput"; +import { SearchInput } from "ui/form/SearchInput"; +import { StarIcon } from "ui/icons/Icons"; +import { FlexGrow } from "ui/spacing/Spacing"; +import type { VariableMapData } from "lib/compiler/compileData"; +import { getSettings } from "store/features/settings/settingsState"; +import { SplitPaneHeader } from "ui/splitpane/SplitPaneHeader"; +import { castEventToInt } from "renderer/lib/helpers/castEventValue"; +import API from "renderer/lib/api"; + +interface DebuggerVariablesPaneProps { + collapsible?: boolean; +} + +const HeaderSearchInput = styled(SearchInput)` + width: 100%; + max-width: 100px; + height: 22px; + font-size: 11px; + margin: -10px 5px; + padding: 5px; + border: 1px solid ${(props) => props.theme.colors.input.border}; +`; + +const VariableRow = styled.div` + padding: 5px 10px; + font-size: 11px; + border-bottom: 1px solid ${(props) => props.theme.colors.sidebar.border}; + background: ${(props) => props.theme.colors.scripting.form.background}; + display: flex; + align-items: center; +`; + +const VariableName = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + max-width: calc(100% - 115px); +`; + +const Symbol = styled.span` + opacity: 0.5; + font-size: 8px; + padding-top: 5px; + + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; + overflow: hidden; +`; + +const Eq = styled.span` + text-align right; + margin-right: 5px; +`; + +const InputWrapper = styled.div` + width: 70px; + margin-right: 5px; +`; + +const ValueButton = styled.button` + background: transparent; + color: ${(props) => props.theme.colors.text}; + display: inline; + padding: 0; + font-size: 11px; + border: 0; + margin: 0; + height: 11px; + text-align: left; + + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; + overflow: hidden; + line-height: 11px; + + :hover { + color: ${(props) => props.theme.colors.highlight}; + } +`; + +const Content = styled.div` + flex-grow: 1; + background: ${(props) => props.theme.colors.scripting.form.background}; +`; + +interface MenuItemFavoriteProps { + visible: boolean; + isFavorite: boolean; +} + +const VariableRowFavorite = styled.div` + opacity: 0; + svg { + display: inline-block; + width: 10px; + height: 10px; + fill: ${(props) => props.theme.colors.text}; + } + + ${Button} { + margin-right: -5px; + transition: all 0.1s ease-out; + height: 16px; + width: 16px; + padding: 0; + min-width: 10px; + } + + ${(props) => + props.isFavorite + ? css` + opacity: 1; + ` + : ""} + ${(props) => + !props.isFavorite + ? css` + svg { + opacity: 0.3; + } + + ${Button}:active { + transform: scale(1.5); + svg { + opacity: 1; + } + } + ` + : ""} + ${VariableRow}:hover > & { + opacity: 1; + } +`; + +const DebuggerVariablesPane = ({ collapsible }: DebuggerVariablesPaneProps) => { + const dispatch = useAppDispatch(); + + const variableDataBySymbol = useAppSelector( + (state) => state.debug.variableDataBySymbol + ); + const variableSymbols = useAppSelector( + (state) => state.debug.variableSymbols + ); + const variablesFilter = useAppSelector( + (state) => getSettings(state).debuggerVariablesFilter + ); + const variablesData = useAppSelector((state) => state.debug.variablesData); + const watchedVariableIds = useAppSelector( + (state) => getSettings(state).debuggerWatchedVariables + ); + const isCollapsed = useAppSelector( + (state) => + !!collapsible && + getSettings(state).debuggerCollapsedPanes.includes("variables") + ); + const [varSearchTerm, setVarSearchTerm] = useState(""); + + const onSearchVariables = useCallback( + (e: React.ChangeEvent) => { + setVarSearchTerm(e.currentTarget.value); + }, + [] + ); + + const onSetVariablesFilterAll = useCallback(() => { + dispatch( + settingsActions.editSettings({ + debuggerVariablesFilter: "all", + }) + ); + }, [dispatch]); + + const onSetVariablesFilterWatched = useCallback(() => { + dispatch( + settingsActions.editSettings({ + debuggerVariablesFilter: "watched", + }) + ); + }, [dispatch]); + + const onSelectScene = useCallback( + (sceneId: string) => { + dispatch(editorActions.selectScene({ sceneId })); + dispatch(editorActions.editSearchTerm("")); + dispatch(editorActions.editSearchTerm(sceneId)); + }, + [dispatch] + ); + + const onSelectActor = useCallback( + (actorId: string, sceneId: string) => { + dispatch(editorActions.selectActor({ sceneId, actorId })); + dispatch(editorActions.editSearchTerm("")); + dispatch(editorActions.editSearchTerm(sceneId)); + }, + [dispatch] + ); + + const onSelectTrigger = useCallback( + (triggerId: string, sceneId: string) => { + dispatch( + editorActions.selectTrigger({ + sceneId, + triggerId, + }) + ); + dispatch(editorActions.editSearchTerm("")); + dispatch(editorActions.editSearchTerm(sceneId)); + }, + [dispatch] + ); + + const onSelectVariable = useCallback( + (variableData: VariableMapData) => { + if (!variableData.isLocal) { + dispatch( + editorActions.selectVariable({ + variableId: variableData.id, + }) + ); + } else if (variableData.entityType === "scene") { + onSelectScene(variableData.sceneId); + } else if (variableData.entityType === "actor") { + onSelectActor(variableData.entityId, variableData.sceneId); + } else if (variableData.entityType === "trigger") { + onSelectTrigger(variableData.entityId, variableData.sceneId); + } + }, + [dispatch, onSelectActor, onSelectScene, onSelectTrigger] + ); + + const onToggleWatchedVariable = useCallback( + (variableId: string) => { + dispatch(settingsActions.toggleWatchedVariable(variableId)); + }, + [dispatch] + ); + + const onToggleCollapsed = useCallback(() => { + dispatch(settingsActions.toggleDebuggerPaneCollapsed("variables")); + }, [dispatch]); + + const filteredVariables = useMemo(() => { + return variableSymbols + .filter((symbol) => { + const key = + `${symbol} ${variableDataBySymbol[symbol]?.name}`.toUpperCase(); + return key.includes(varSearchTerm.toUpperCase()); + }) + .map((symbol) => { + const variableData = variableDataBySymbol[symbol]; + if (!variableData) { + return undefined; + } + const isFavorite = watchedVariableIds.includes(variableData.id); + if (variablesFilter === "watched" && !isFavorite) { + return undefined; + } + return { + ...variableData, + isFavorite, + value: variablesData[variableSymbols.indexOf(variableData.symbol)], + }; + }) + .filter((i) => i) as (VariableMapData & { + isFavorite: boolean; + value: number; + })[]; + }, [ + varSearchTerm, + variableDataBySymbol, + variableSymbols, + variablesData, + variablesFilter, + watchedVariableIds, + ]); + + useEffect(() => { + API.debugger.setWatchedVariableIds(watchedVariableIds); + }, [watchedVariableIds]); + + return ( + <> + + + + / + + + ) + } + > + {l10n("FIELD_VARIABLES")} + + {!isCollapsed && ( + + {filteredVariables.map((variableData) => { + return ( + + + onSelectVariable(variableData)}> + {variableData.name ?? variableData.symbol} + + {variableData.symbol} + + + = + + { + const newValue = castEventToInt(e, 0); + API.debugger.setGlobal(variableData.symbol, newValue); + }} + /> + + + + + + ); + })} + + )} + + ); +}; + +export default DebuggerVariablesPane; diff --git a/src/components/editors/ActorEditor.tsx b/src/components/editors/ActorEditor.tsx index 64d51e8fd..2cf211208 100644 --- a/src/components/editors/ActorEditor.tsx +++ b/src/components/editors/ActorEditor.tsx @@ -51,6 +51,7 @@ import { castEventToInt, } from "renderer/lib/helpers/castEventValue"; import { useAppDispatch, useAppSelector } from "store/hooks"; +import type { ScriptEditorCtx } from "shared/lib/scripts/context"; interface ActorEditorProps { id: string; @@ -332,6 +333,19 @@ export const ActorEditor: FC = ({ dispatch(editorActions.setLockScriptEditor(!lockScriptEditor)); }; + const scriptKey = getScriptKey(scriptMode, scriptModeSecondary); + + const scriptCtx: ScriptEditorCtx = useMemo( + () => ({ + type: "entity", + entityType: "actor", + entityId: id, + sceneId, + scriptKey, + }), + [id, sceneId, scriptKey] + ); + if (!scene || !actor) { return ; } @@ -359,8 +373,6 @@ export const ActorEditor: FC = ({ ); - const scriptKey = getScriptKey(scriptMode, scriptModeSecondary); - const scriptButton = ( = ({ )} - - + + diff --git a/src/components/editors/CustomEventEditor.tsx b/src/components/editors/CustomEventEditor.tsx index 8897eea6e..3b8e9a00b 100644 --- a/src/components/editors/CustomEventEditor.tsx +++ b/src/components/editors/CustomEventEditor.tsx @@ -30,6 +30,7 @@ import { Label } from "ui/form/Label"; import { ScriptEditorContext } from "components/script/ScriptEditorContext"; import l10n from "shared/lib/lang/l10n"; import { useAppDispatch, useAppSelector } from "store/hooks"; +import { ScriptEditorCtx } from "shared/lib/scripts/context"; const customEventName = ( customEvent: CustomEventNormalized, @@ -182,6 +183,17 @@ const CustomEventEditor = ({ id, multiColumn }: CustomEventEditorProps) => { const selectSidebar = () => dispatch(editorActions.selectSidebar()); + const scriptCtx: ScriptEditorCtx = useMemo( + () => ({ + type: "script", + entityType: "customEvent", + entityId: id, + sceneId: "", + scriptKey: "script", + }), + [id] + ); + if (!customEvent) { return ; } @@ -376,13 +388,8 @@ const CustomEventEditor = ({ id, multiColumn }: CustomEventEditorProps) => { } /> - - + + diff --git a/src/components/editors/SceneEditor.tsx b/src/components/editors/SceneEditor.tsx index 330d29f47..a0694ca6c 100644 --- a/src/components/editors/SceneEditor.tsx +++ b/src/components/editors/SceneEditor.tsx @@ -57,6 +57,7 @@ import Alert, { AlertItem } from "ui/alerts/Alert"; import { sceneName } from "shared/lib/entities/entitiesHelpers"; import l10n from "shared/lib/lang/l10n"; import { useAppDispatch, useAppSelector } from "store/hooks"; +import { ScriptEditorCtx } from "shared/lib/scripts/context"; interface SceneEditorProps { id: string; @@ -386,6 +387,19 @@ export const SceneEditor = ({ id, multiColumn }: SceneEditorProps) => { ); }, [dispatch, id]); + const scriptKey = getScriptKey(scriptMode, scriptModeSecondary); + + const scriptCtx: ScriptEditorCtx = useMemo( + () => ({ + type: "entity", + entityType: "scene", + entityId: id, + sceneId: id, + scriptKey, + }), + [id, scriptKey] + ); + if (!scene) { return ; } @@ -425,7 +439,6 @@ export const SceneEditor = ({ id, multiColumn }: SceneEditorProps) => { const showParallaxButton = scene.width && scene.width > SCREEN_WIDTH; const showParallaxOptions = showParallaxButton && scene.parallax; - const scriptKey = getScriptKey(scriptMode, scriptModeSecondary); const scriptButton = ( { {scene.autoFadeSpeed === null && scriptKey === "script" && ( )} - + diff --git a/src/components/editors/TriggerEditor.tsx b/src/components/editors/TriggerEditor.tsx index 1b5713953..b8fd95a11 100644 --- a/src/components/editors/TriggerEditor.tsx +++ b/src/components/editors/TriggerEditor.tsx @@ -36,6 +36,7 @@ import { ScriptEditorContext } from "components/script/ScriptEditorContext"; import { triggerName } from "shared/lib/entities/entitiesHelpers"; import l10n from "shared/lib/lang/l10n"; import { useAppDispatch, useAppSelector } from "store/hooks"; +import { ScriptEditorCtx } from "shared/lib/scripts/context"; interface TriggerEditorProps { id: string; @@ -251,6 +252,17 @@ export const TriggerEditor = ({ [scriptKey, trigger] ); + const scriptCtx: ScriptEditorCtx = useMemo( + () => ({ + type: "entity", + entityType: "trigger", + entityId: id, + sceneId, + scriptKey, + }), + [id, sceneId, scriptKey] + ); + if (!scene || !trigger) { return ; } @@ -383,13 +395,8 @@ export const TriggerEditor = ({ /> )} - - + + diff --git a/src/components/editors/VariableEditor.tsx b/src/components/editors/VariableEditor.tsx index f73b5ff6d..d02dcae0b 100644 --- a/src/components/editors/VariableEditor.tsx +++ b/src/components/editors/VariableEditor.tsx @@ -137,6 +137,8 @@ export const VariableEditor: FC = ({ id }) => { }; const setSelectedId = (id: string, item: VariableUse) => { + dispatch(editorActions.editSearchTerm("")); + dispatch(editorActions.editSearchTerm(item.sceneId)); if (item.type === "actor") { dispatch( editorActions.selectActor({ actorId: id, sceneId: item.sceneId }) diff --git a/src/components/forms/ActorSelect.tsx b/src/components/forms/ActorSelect.tsx index 795045283..991f8b8e5 100644 --- a/src/components/forms/ActorSelect.tsx +++ b/src/components/forms/ActorSelect.tsx @@ -47,36 +47,34 @@ export const ActorSelect = ({ frame, }: ActorSelectProps) => { const context = useContext(ScriptEditorContext); - const editorType = useAppSelector((state) => state.editor.type); const [options, setOptions] = useState([]); const [currentValue, setCurrentValue] = useState(); - const sceneId = useAppSelector((state) => state.editor.scene); const sceneType = useAppSelector( - (state) => sceneSelectors.selectById(state, sceneId)?.type + (state) => sceneSelectors.selectById(state, context.sceneId)?.type ); const scenePlayerSpriteSheetId = useAppSelector( - (state) => sceneSelectors.selectById(state, sceneId)?.playerSpriteSheetId + (state) => + sceneSelectors.selectById(state, context.sceneId)?.playerSpriteSheetId ); const defaultPlayerSprites = useAppSelector( (state) => state.project.present.settings.defaultPlayerSprites ); - const contextEntityId = useAppSelector((state) => state.editor.entityId); const sceneActorIds = useAppSelector((state) => - getSceneActorIds(state, { id: sceneId }) + getSceneActorIds(state, { id: context.sceneId }) ); const actorsLookup = useAppSelector((state) => actorSelectors.selectEntities(state) ); const customEvent = useAppSelector((state) => - customEventSelectors.selectById(state, contextEntityId) + customEventSelectors.selectById(state, context.entityId) ); - const selfIndex = sceneActorIds?.indexOf(contextEntityId); - const selfActor = actorsLookup[contextEntityId]; + const selfIndex = sceneActorIds?.indexOf(context.entityId); + const selfActor = actorsLookup[context.entityId]; const playerSpriteSheetId = scenePlayerSpriteSheetId || (sceneType && defaultPlayerSprites[sceneType]); useEffect(() => { - if (context === "script" && customEvent) { + if (context.type === "script" && customEvent) { setOptions([ { label: "Player", @@ -90,9 +88,11 @@ export const ActorSelect = ({ }; }), ]); - } else if (context === "entity" && sceneActorIds) { + } else if (context.type === "entity" && sceneActorIds) { setOptions([ - ...(editorType === "actor" && selfActor && selfIndex !== undefined + ...(context.entityType === "actor" && + selfActor && + selfIndex !== undefined ? [ { label: `${l10n("FIELD_SELF")} (${actorName( @@ -132,9 +132,7 @@ export const ActorSelect = ({ }, [ actorsLookup, context, - contextEntityId, customEvent, - editorType, playerSpriteSheetId, sceneActorIds, selfActor, diff --git a/src/components/forms/PropertySelect.tsx b/src/components/forms/PropertySelect.tsx index 0fb004471..cbdf656b0 100644 --- a/src/components/forms/PropertySelect.tsx +++ b/src/components/forms/PropertySelect.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { useAppSelector } from "store/hooks"; import { ActorNormalized, UnitType } from "shared/lib/entities/entitiesTypes"; import { @@ -19,6 +19,7 @@ import l10n from "shared/lib/lang/l10n"; import SpriteSheetCanvas from "components/world/SpriteSheetCanvas"; import styled from "styled-components"; import { UnitsSelectButtonInputOverlay } from "./UnitsSelectButtonInputOverlay"; +import { ScriptEditorContext } from "components/script/ScriptEditorContext"; interface PropertySelectProps { name: string; @@ -55,37 +56,36 @@ export const PropertySelect = ({ unitsAllowed, onChangeUnits, }: PropertySelectProps) => { + const context = useContext(ScriptEditorContext); const [options, setOptions] = useState([]); const [currentValue, setCurrentValue] = useState(); - const editorType = useAppSelector((state) => state.editor.type); - const sceneId = useAppSelector((state) => state.editor.scene); const sceneType = useAppSelector( - (state) => sceneSelectors.selectById(state, sceneId)?.type + (state) => sceneSelectors.selectById(state, context.sceneId)?.type ); const scenePlayerSpriteSheetId = useAppSelector( - (state) => sceneSelectors.selectById(state, sceneId)?.playerSpriteSheetId + (state) => + sceneSelectors.selectById(state, context.sceneId)?.playerSpriteSheetId ); const defaultPlayerSprites = useAppSelector( (state) => state.project.present.settings.defaultPlayerSprites ); - const contextEntityId = useAppSelector((state) => state.editor.entityId); const sceneActorIds = useAppSelector((state) => - getSceneActorIds(state, { id: sceneId }) + getSceneActorIds(state, { id: context.sceneId }) ); const actorsLookup = useAppSelector((state) => actorSelectors.selectEntities(state) ); const customEvent = useAppSelector((state) => - customEventSelectors.selectById(state, contextEntityId) + customEventSelectors.selectById(state, context.entityId) ); - const selfIndex = sceneActorIds?.indexOf(contextEntityId); - const selfActor = actorsLookup[contextEntityId]; + const selfIndex = sceneActorIds?.indexOf(context.entityId); + const selfActor = actorsLookup[context.entityId]; const playerSpriteSheetId = scenePlayerSpriteSheetId || (sceneType && defaultPlayerSprites[sceneType]); useEffect(() => { - if (editorType === "customEvent" && customEvent) { + if (context.entityType === "customEvent" && customEvent) { setOptions( [ { @@ -139,7 +139,9 @@ export const PropertySelect = ({ } else if (sceneActorIds) { setOptions( [ - ...(editorType === "actor" && selfActor && selfIndex !== undefined + ...(context.entityType === "actor" && + selfActor && + selfIndex !== undefined ? [ { label: `${l10n("FIELD_SELF")} (${actorName( @@ -211,9 +213,8 @@ export const PropertySelect = ({ } }, [ actorsLookup, - contextEntityId, + context.entityType, customEvent, - editorType, playerSpriteSheetId, sceneActorIds, selfActor, diff --git a/src/components/forms/VariableSelect.tsx b/src/components/forms/VariableSelect.tsx index 3a08b17ba..81d51ff7d 100644 --- a/src/components/forms/VariableSelect.tsx +++ b/src/components/forms/VariableSelect.tsx @@ -150,7 +150,6 @@ export const VariableSelect: FC = ({ const [options, setOptions] = useState([]); const [currentVariable, setCurrentVariable] = useState(); const [currentValue, setCurrentValue] = useState