phaserjs / phaser-editor-issues

Phaser Editor's bug tracker.
0 stars 0 forks source link

Feature: Let custom components influence the editor element #8

Open Ariorh1337 opened 4 months ago

Ariorh1337 commented 4 months ago

Currently all custom components are executed only inside the game, please add the ability to write custom logic that will be executed in the editor and affect the element in it.

The example is extremely abstract, please do not rely on it when deciding on implementation Usage example:

// @ts-nocheck
// You can write more code here

/* START OF COMPILED CODE */

import Phaser from "phaser";
import UserComponent from "./UserComponent";
/* START-USER-IMPORTS */

/* END-USER-IMPORTS */

export default class LocalizationComponent extends UserComponent {

    constructor(gameObject: Phaser.GameObject.Text) {
        super(gameObject);

        this.gameObject = gameObject;
        (gameObject as any)["__LocalizationComponent"] = this;

        /* START-USER-CTR-CODE */

        /* END-USER-CTR-CODE */
    }

    static getComponent(gameObject: Phaser.GameObject.Text): ButtonComponent {
        return (gameObject as any)["__LocalizationComponent"];
    }

    public i18n_key: string = "";

    /* START-USER-CODE */

    // some code to implement in game component logic

    /* END-USER-CODE */
}

/* END OF COMPILED CODE */

/* START-EDITOR-BEHAVIOUR-CODE */

class LocalizationComponentEditorUI {
    static __inEditorInit(gameobj, componentUI) {

        /* START-USER-CODE */

        const lib = gameobj.scene.cache.tilemap.get("i18n").data;

        this.i18n = ((lang, key) => {
            return // some  code to parse csv
        });

        /* END-USER-CODE */

    }

    static __inEditorChanged(gameobj, componentUI, property, newValue) {

        /* START-USER-CODE */

        if (property === "i18n_key") {
            gameobj.text = this.i18n("en", newValue);
        }

        /* END-USER-CODE */

    }

    static __inEditorRemoved(gameobj, componentUI) {

        /* START-USER-CODE */

        /* END-USER-CODE */

    }
}

/* END-EDITOR-BEHAVIOUR-CODE */

// You can write more code here
Ariorh1337 commented 4 months ago

This is working example how useful this feature is. Example plugin allows to add own small inline JS logic and make components more useful.

Disclaimer: The code is for demonstration purposes only, rest assured that you will encounter errors when using it.

Plugin:

(() => {
    const UserProperty = phasereditor2d.scene.ui.sceneobjects.UserProperty;
    class SmartUserProperty extends UserProperty {
        readJSON(data) {
            super.readJSON(data);
            this._info.setValue = data.setValue;
        }

        writeJSON(data) {
            if (this._info.setValue) {
                data.setValue = this._info.setValue;
            }

            super.writeJSON(data);
        }
    }
    phasereditor2d.scene.ui.sceneobjects.UserProperty = SmartUserProperty;

    ///////////////////////////////

    const UserComponentPropertyWrapper = phasereditor2d.scene.ui.sceneobjects.UserComponentPropertyWrapper;
    class SmartUserComponentPropertyWrapper extends UserComponentPropertyWrapper {
        setValue(obj, value) {
            super.setValue(obj, value);

            if (this._userProp._info.setValue) {
                new Function("gameobj", "value", this._userProp._info.setValue)(obj, value);
            }
        }
    }
    phasereditor2d.scene.ui.sceneobjects.UserComponentPropertyWrapper = SmartUserComponentPropertyWrapper;

    ///////////////////////////////

    const UserComponentPropertySection = phasereditor2d.scene.ui.editor.usercomponent.UserComponentPropertySection;
    class SmartUserComponentPropertySection extends UserComponentPropertySection {
        createForm(parent) {
            super.createForm(parent);

            this.addUpdater(() => {
                const prop = this.getProperty();
                const info = prop.getInfo();

                if (!info.setValue) info.setValue = "";

                this.codeAreaField(this._propArea, info, "setValue", "setValue(gameobj,value)", "On editor property set");
            });
        }

        codeAreaField(parent, propInfo, infoProp, fieldLabel, fieldTooltip, updateCallback) {
            this.createLabel(parent, fieldLabel, fieldTooltip);
            const text = this.createTextArea(parent);

            text.value = propInfo[infoProp];
            const editor = CodeMirror.fromTextArea(text, {
                mode: "javascript"
            });

            editor.on("change", e => {
                editor.save();

                this.runOperation(() => {
                    propInfo[infoProp] = text.value;
                    if (updateCallback) {
                        updateCallback();
                    }
                }, false);
            });
        }
    }
    phasereditor2d.scene.ui.editor.usercomponent.UserComponentPropertySection = SmartUserComponentPropertySection;
})();

Component JSON:

{
    "components": [
        {
            "name": "LocalizationComp",
            "displayName": "Localization",
            "objectDisplayFormat": "",
            "baseClass": "UserComponent",
            "gameObjectType": "Phaser.GameObjects.Text",
            "properties": [
                {
                    "name": "i18n",
                    "label": "Localization key",
                    "tooltip": "",
                    "defValue": "\"\"",
                    "customDefinition": false,
                    "type": {
                        "id": "expression",
                        "expressionType": "string"
                    },
            "setValue": "gameobj._text = value"
                },
            ]
        }
    ]
}
PhaserEditor2D commented 4 months ago

So do you mean you write the code in the same component definition? And that code is executed in the editor?

We have two aspects here:

Do you say the code to run is set in the UI, as a parameter of the component? And the context, should be the component?

Ariorh1337 commented 4 months ago

Yes, the plugin I provided as an example works like that. we declare a component and its properties. each property may contain logic that will be executed when the property is changed in the editor. execution in isolation occurs without being bound to a context since the method was executed from a string. so now only the game object and the new value are accessable.

Ariorh1337 commented 4 months ago

Video of usage example:

https://github.com/phaserjs/phaser-editor-issues/assets/35579597/a48700c2-dfc5-41cd-9fd3-121d9be8eb5d

PhaserEditor2D commented 4 months ago

Ok, I see how the code is executed in the scene editor... But how will the "custom code" be executed in the game run time?

Ariorh1337 commented 4 months ago

This type of code is should not execute inside the game, this is only to make components more "smart" inside editor itself. Сomponents already has it's own file with code for the game, no need to pass this editor logic too. This approach will allow to implement smart custom components without having to suffer with plugins for a small function

PhaserEditor2D commented 4 months ago

Oh! I misunderstood this.

Well, if the thing is to run a logic bit in the editor and not in the game, then I think the best is to implement a plugin. Sure, I can think of new ways of writing a plugin. Let's say, a lightweight plugin, that you can load/register easily. The thing is that it will be many times better to code in vscode than to code in a user component field. I will think about it.

Ariorh1337 commented 4 months ago

The problem with implementing logic with a separate plugin is that it cannot be supported by other developers. Each component that will require the simplest logic will need separate documentation. The idea is that the component code would be inside or next to the component, similar to the component code that is assembled into the game.

PS: If you simplify writing a plugin and connecting it, it still won't make the job easier. The problem is the lack of internal documentation, the documentation that is here “https://help-v3.phasereditor2d.com/workbench/main-menu.html” is absolutely useless for writing a plugin since it does not describe the internal structure, code, relationships.

PhaserEditor2D commented 4 months ago

I will think about this. And yes, making a plugin is not documented at all.

Ariorh1337 commented 4 months ago

The current implementation creates a *.js file in the component directory. The path to the file will be included in the component structure.

Small video for people how would want to use this plugin while waiting for native implementation

https://github.com/phaserjs/phaser-editor-issues/assets/35579597/57f55abe-7879-4002-bfd4-c94883a4eddf

(() => {
    const UserProperty = phasereditor2d.scene.ui.sceneobjects.UserProperty;
    class SmartUserProperty extends UserProperty {
        readJSON(data) {
            super.readJSON(data);
            this._info.setValueFile = data.setValueFile;
        }

        writeJSON(data) {
            if (this._info.setValueFile) {
                data.setValueFile = this._info.setValueFile;
            }
            super.writeJSON(data);
        }
    }
    phasereditor2d.scene.ui.sceneobjects.UserProperty = SmartUserProperty;

    ///////////////////////////////

    const UserComponentPropertyWrapper = phasereditor2d.scene.ui.sceneobjects.UserComponentPropertyWrapper;

    UserComponentPropertyWrapper.prototype._setValue = UserComponentPropertyWrapper.prototype.setValue;

    UserComponentPropertyWrapper.prototype.setValue = async function setValue(obj, value) {
        var userCode;

        if (!this._setValueFunction) {
            if (this._userProp._info.setValueFile) {
                try {
                    const fileStorage = colibri.ui.ide.Workbench.getWorkbench().getFileStorage();
                    const pathParts = this._userProp._info.setValueFile.split("/");
                    const file = pathParts.reduce((file, name) => {
                        return file.getFile(name);
                    }, fileStorage.getRoot());

                    const fileContent = await fileStorage.getFileString(file);
                    userCode = fileContent;
                } catch (error) {
                    console.error("Error reading file content:", error);
                }
            }
        }

        if (userCode || this._setValueFunction) {
            const component = this.getComponent(obj);
            const name = this._userComp.getName();
            const c = {};

            this._userComp._properties._properties.forEach(prop => {
                Object.defineProperty(c, prop._info.name, {
                    get: function () {
                        return component._propData[`${name}.${prop._info.name}`];
                    },
                    set: function (value) {
                        prop._componentProperty.setValue(obj, value);
                    }
                });
            });

            if (!this._setValueFunction) {
                this._setValueFunction = new Function("component", "gameobj", "value", "colibri", "phasereditor2d", userCode);
            }

            this._setValueFunction.call(this, c, obj, value, colibri, phasereditor2d);
        }

        this._setValue(obj, value);
    }

    ///////////////////////////////

    const UserComponentPropertySection = phasereditor2d.scene.ui.editor.usercomponent.UserComponentPropertySection;

    class FileLabelProvider extends colibri.ui.controls.viewers.LabelProvider {
        getLabel(obj) {
            return obj.getName();
        }

        getIcon(obj) {
            // Optional: return an icon based on file type if needed
            return super.getIcon(obj);
        }
    }

    class SmartUserComponentPropertySection extends UserComponentPropertySection {
        createForm(parent) {
            super.createForm(parent);

            this.addUpdater(() => {
                const prop = this.getProperty();
                const info = prop.getInfo();

                if (!info.setValueFile) info.setValueFile = "";

                this.fileField(this._propArea, info, "setValueFile", "on editor change", "Path to the file containing setValue logic");
            });
        }

        fileField(parent, propInfo, infoProp, fieldLabel, fieldTooltip) {
            this.createLabel(parent, fieldLabel, fieldTooltip);
            const text = this.createText(parent, true);

            text.value = propInfo[infoProp];
            text.readOnly = false;

            text.addEventListener("input", () => {
                this.runOperation(() => {
                    propInfo[infoProp] = text.value;
                });
            });

            this.createButton(parent, "Select File", async () => {
                const dlg = new phasereditor2d.pack.ui.dialogs.AssetSelectionDialog("tree", false);
                dlg.create();
                dlg.setTitle("Select File");

                const input = this.getFileInput();

                dlg.getViewer().setInput(input);
                dlg.getViewer().setLabelProvider(new FileLabelProvider());
                const result = await dlg.getResultPromise();

                if (result && result.length > 0) {
                    const file = result[0];
                    this.runOperation(() => {
                        propInfo[infoProp] = file.getFullName();
                        text.value = file.getFullName();
                    });
                }
            });

            const createButton = this.createButton(parent, "Create New File", async () => {
                const inputDialog = new colibri.ui.controls.dialogs.InputDialog();
                inputDialog.create();
                inputDialog.setTitle("Create New JS File");
                inputDialog.setMessage("Enter the name for the new JS file:");

                inputDialog.setInputValidator(value => {
                    return value.trim().length > 0;
                });

                inputDialog.setResultCallback(async value => {
                    if (!value.trim()) {
                        return;
                    }

                    const workbench = colibri.ui.ide.Workbench.getWorkbench();
                    const fileStorage = workbench.getFileStorage();
                    const activeEditor = workbench.getActiveEditor();
                    const editorInput = activeEditor._input;

                    const rootName = fileStorage.getRoot().getName();

                    const parentFolder = editorInput.getParent();
                    const newFileName = `${value.trim()}.js`;

                    const fileContent = `/* -- function("component", "gameobj", "value", "colibri", "phasereditor2d") { -- */\n\n// Your code here\n\n/* -- } -- */\n`;

                    console.log("Creating new file in directory: ", parentFolder.getFullName());

                    if (typeof fileStorage.createFile === "function") {
                        await fileStorage.createFile(parentFolder, newFileName, fileContent);

                        const newFilePath = parentFolder.getFile(newFileName);

                        this.runOperation(() => {
                            let fullPath = newFilePath.getFullName();
                            if (fullPath.startsWith(rootName)) {
                                fullPath = fullPath.substring(rootName.length + 1);
                            }

                            propInfo[infoProp] = fullPath;
                            text.value = fullPath;
                        });
                    } else {
                        console.error("fileStorage.createFile is not a function");
                    }
                });
            });
        }

        getFileInput() {
            const fileStorage = colibri.ui.ide.Workbench.getWorkbench().getFileStorage();
            const root = fileStorage.getRoot();

            const assets = [];
            root.visit(file => {
                if (file.isFile() && file.getExtension() === "js") {
                    assets.push(file);
                }
            });

            return assets;
        }
    }

    phasereditor2d.scene.ui.editor.usercomponent.UserComponentPropertySection = SmartUserComponentPropertySection;
})();