UNPKG

rpg-dialogue-js

Version:

A simple roleplay game dialogue engine and editor.

516 lines (463 loc) 18.8 kB
/** * Helpers for the graph editor. * * @require PlotBoilerplate * * @author Ikaros Kappler * @date 2023-07-28 * @version 1.0.0 **/ import { MouseHandler, PlotBoilerplate, XMouseEvent, XYCoords, XYDimension } from "plotboilerplate"; import { IDialogueConfig, IMiniQuestionaire, IMiniQuestionaireWithPosition, IOptionIdentifyer } from "./interfaces"; import { RPGDOMHelpers } from "./domHelpers"; import { EditorRenderer } from "./editorRenderer"; import { Editor } from "./Editor"; import { DialogueMetaHelpers } from "./metaHelpers"; export class EditorHelper { editor: Editor; pb: PlotBoilerplate; boxSize: XYDimension; // TODO: convert into node identifyer /** * The highlighted node's name or null if none is highlighted. * Used to highlight nodes when the mouse is over. */ highlightedNodeName: string | null = null; /** * The highlighted node itself or null if none is highligted. * Used to determine rendering colors. */ highlightedNode: IMiniQuestionaireWithPosition | null = null; /** * The selected node's name or null if none is selected. * Used to determine the node editor's contents. */ selectedNodeName: string | null = null; /** * The selected node itself or null if none is selected. * Used to determine the node editor's contents. */ selectedNode: IMiniQuestionaireWithPosition | null = null; /** * The currently selected option or null if none is selected. * Used to re-connect an option with a new successor node. */ selectedOption: IOptionIdentifyer | null = null; /** * The currently highlighted option. * Used to draw on-mouse-over options with a different color. */ hightlightedOption: IOptionIdentifyer | null = null; /** * The current mouse position (or null if mouse is not on canvas). * In local relative coordinate system. */ relativeMousePosition: XYCoords | null = null; domHelper: RPGDOMHelpers; dialogConfigWithPositions: IDialogueConfig<IMiniQuestionaireWithPosition>; metaHelpers: DialogueMetaHelpers; constructor(editor: Editor, pb: PlotBoilerplate, boxSize: XYDimension) { this.editor = editor; this.pb = pb; this.boxSize = boxSize; this.selectedNodeName = null; this.domHelper = new RPGDOMHelpers(this); this.metaHelpers = new DialogueMetaHelpers(this); } setDialogConfig(dialogConfigWithPositions: IDialogueConfig<IMiniQuestionaireWithPosition>) { this.dialogConfigWithPositions = dialogConfigWithPositions; } setSelectedOption(selectedOption: IOptionIdentifyer | null, noRedraw?: boolean) { // console.log("Set selected option", selectedOption); this.selectedOption = selectedOption; if (!noRedraw) { this.pb.redraw(); } } setHighlightedOption(hightlightedOption: IOptionIdentifyer | null) { // const isRedrawRequired = this.hightlightedOption !== hightlightedOption; const isRedrawRequired = !this.isEqualOptionIdentifyer(this.hightlightedOption, hightlightedOption); this.hightlightedOption = hightlightedOption; if (isRedrawRequired) { this.pb.redraw(); } } setHighlightedNode(nodeName: string | null, noRedraw?: boolean) { this.highlightedNodeName = nodeName; this.highlightedNode = nodeName ? this.dialogConfigWithPositions.graph[nodeName] : null; if (!noRedraw) { this.pb.redraw(); } } /** * A helper function to create random safe positions in the viewport area. * @param {PlotBoilerplate} pb * @param {XYDimension} boxSize * @returns */ getRandomPosition(): XYCoords { const viewport = this.pb.viewport(); return { x: viewport.min.x + this.boxSize.width + (viewport.width - 2 * this.boxSize.width) * Math.random(), y: viewport.min.y + this.boxSize.height + (viewport.height - 2 * this.boxSize.height) * Math.random() }; } setSelectedNode(nodeName: string | null, node: IMiniQuestionaireWithPosition | null) { this.selectedNodeName = nodeName; this.selectedNode = node; if (nodeName && node) { // this.domHelper.editorElement.classList.remove("d-none"); this.domHelper.toggleVisibility(true); this.domHelper.showAnswerOptions(nodeName, this.selectedNode); } else { // this.domHelper.editorElement.classList.add("d-none"); this.domHelper.toggleVisibility(false); this.domHelper.showAnswerOptions(null, null); } this.pb.redraw(); } /** * A helper function to make sure all graph nodes have valid positions. Those without * valid positions (eg like those being loaded from an incomplete JSON file) will be * assigned to a random position inside the viewport. * * @param {PlotBoilerplate} pb * @param {XYDimension} boxSize * @returns */ enrichPositions(baseConfig: IDialogueConfig<IMiniQuestionaire>): IDialogueConfig<IMiniQuestionaireWithPosition> { // Clone? const configWithPositions: IDialogueConfig<IMiniQuestionaireWithPosition> = baseConfig as IDialogueConfig<IMiniQuestionaireWithPosition>; for (var nodeName in configWithPositions.graph) { const graphNode: IMiniQuestionaireWithPosition = configWithPositions.graph[nodeName]; if (!graphNode) { console.warn(`Warning: graph node ${nodeName} is null or undefined!`); continue; } // Anonymous member check if (!graphNode.hasOwnProperty("editor")) { graphNode.editor = { position: this.getRandomPosition() }; } else if (!(graphNode.editor as IMiniQuestionaireWithPosition).hasOwnProperty("position")) { (graphNode.editor as any).position = this.getRandomPosition(); } else { if (!(graphNode.editor as any).position.hasOwnProperty("x") || isNaN(graphNode.editor?.position?.x ?? NaN)) { if (graphNode.editor?.position) { graphNode.editor.position.x = this.getRandomPosition().x; } } if (!(graphNode.editor as any).position.hasOwnProperty("y") || isNaN(graphNode.editor?.position?.y ?? NaN)) { if (graphNode.editor?.position) { graphNode.editor.position.y = this.getRandomPosition().y; } } } } return configWithPositions; } /** * Check if the meta data is valid and – if not – add missing default fields. * @param dialogueConfig */ enrichMetaData(dialogueConfig: Object) { const result = dialogueConfig as IDialogueConfig<IMiniQuestionaireWithPosition>; if (!dialogueConfig.hasOwnProperty("meta")) { result.meta = { name: "noname", npcs: [] }; } if (!result.meta.npcs) { result.meta.npcs = []; } if (result.meta.npcs.length === 0) { result.meta.npcs.push({ name: "NPC #0" }); } } isPosInGraphNodeBox(pos: XYCoords, graphNode: IMiniQuestionaireWithPosition): boolean { return Boolean( graphNode.editor && graphNode.editor.position && graphNode.editor.position.x <= pos.x && graphNode.editor.position.y <= pos.y && graphNode.editor.position.x + this.boxSize.width > pos.x && graphNode.editor.position.y + this.boxSize.height > pos.y ); } isPosInOptionNodeBox(pos: XYCoords, graphNode: IMiniQuestionaireWithPosition, optionIndex: number): boolean { // EditorRenderer.OPTION_OFFSET_X; return Boolean( graphNode.editor && graphNode.editor.position && graphNode.editor.position.x + EditorRenderer.OPTION_OFFSET_X <= pos.x && graphNode.editor.position.y + (optionIndex + 1) * this.boxSize.height <= pos.y && graphNode.editor.position.x + EditorRenderer.OPTION_OFFSET_X + this.boxSize.width > pos.x && graphNode.editor.position.y + (optionIndex + 1) * this.boxSize.height + this.boxSize.height > pos.y ); } locateNodeBoxNameAtPos(pos: XYCoords): string | null { for (var nodeName in this.dialogConfigWithPositions.graph) { const graphNode: IMiniQuestionaireWithPosition = this.dialogConfigWithPositions.graph[nodeName]; if (this.isPosInGraphNodeBox(pos, graphNode)) { return nodeName; } } return null; } locateOptionBoxNameAtPos(pos: XYCoords): IOptionIdentifyer | null { for (var nodeName in this.dialogConfigWithPositions.graph) { const graphNode: IMiniQuestionaireWithPosition = this.dialogConfigWithPositions.graph[nodeName]; for (var i = 0; i < graphNode.o.length; i++) { if (this.isPosInOptionNodeBox(pos, graphNode, i)) { return { nodeName: nodeName, node: graphNode, optionIndex: i }; } } } return null; } isNodeHighlighted(nodName: string) { return this.highlightedNodeName === nodName; } isOptionHighlighted(nodeName: string, optionIndex: number): boolean { return Boolean( this.hightlightedOption && this.hightlightedOption.nodeName === nodeName && this.hightlightedOption.optionIndex === optionIndex ); } isOptionSelected(nodeName: string, optionIndex: number): boolean { return Boolean( this.selectedOption && this.selectedOption.nodeName === nodeName && this.selectedOption.optionIndex === optionIndex ); } addNewDialogueNode() { // Place two box units to the right if currently there is a selected node. // Otherwise random position. const position: XYCoords = this.selectedNode && this.selectedNode.editor && this.selectedNode.editor.position ? { x: this.selectedNode.editor.position.x + 2 * this.boxSize.width, y: this.selectedNode.editor.position.y + this.boxSize.height } : this.getRandomPosition(); const nodeName = this.randomNodeKey(); const newNode: IMiniQuestionaireWithPosition = { q: "", o: [{ a: "", next: null }], editor: { position: position } }; this.dialogConfigWithPositions.graph[nodeName] = newNode; this.selectedNodeName = nodeName; this.selectedNode = newNode; this.domHelper.showAnswerOptions(nodeName, newNode); this.pb.redraw(); } removeNewDialogueNode(nodeName: string) { delete this.dialogConfigWithPositions.graph[nodeName]; this.selectedNodeName = null; this.selectedNode = null; this.domHelper.showAnswerOptions(null, null); this.pb.redraw(); } boxMovehandler() { const _self = this; // +--------------------------------------------------------------------------------- // | Add a mouse listener to track the mouse position. // +------------------------------- var mouseDownPos: XYCoords | null = null; var lastMouseDownPos: XYCoords | null = null; var draggingNode: IMiniQuestionaireWithPosition | null = null; var draggingNodeName: string | null = null; const handler = new MouseHandler(this.pb.eventCatcher) .down((evt: XMouseEvent) => { mouseDownPos = this.pb.transformMousePosition(evt.params.mouseDownPos.x, evt.params.mouseDownPos.y); lastMouseDownPos = { x: evt.params.mouseDownPos.x, y: evt.params.mouseDownPos.y }; draggingNodeName = this.locateNodeBoxNameAtPos(mouseDownPos); if (draggingNodeName) { draggingNode = this.dialogConfigWithPositions.graph[draggingNodeName]; } }) .up((_evt: XMouseEvent) => { mouseDownPos = null; draggingNode = null; }) .drag((evt: XMouseEvent) => { if (!mouseDownPos || !draggingNode || !draggingNode.editor || !draggingNode.editor.position) { return; } // const diff = evt.params.dragAmount; draggingNode.editor.position.x += evt.params.dragAmount.x / this.pb.draw.scale.x; draggingNode.editor.position.y += evt.params.dragAmount.y / this.pb.draw.scale.y; }) .move((evt: XMouseEvent) => { // console.log("move", evt); // Check if mouse pointer hovers over an option -> set highlighted const mouseMovePos = this.pb.transformMousePosition(evt.params.pos.x, evt.params.pos.y); _self.relativeMousePosition = { x: mouseMovePos.x, y: mouseMovePos.y }; const hoveringOptionIdentifyer: IOptionIdentifyer | null = this.locateOptionBoxNameAtPos(mouseMovePos); // Can be null _self.setHighlightedOption(hoveringOptionIdentifyer); if (!hoveringOptionIdentifyer) { // Check if hover on graph node const hoveringNodeName = this.locateNodeBoxNameAtPos(mouseMovePos); this.setHighlightedNode(hoveringNodeName); } else { this.setHighlightedNode(null); } }) .click((evt: XMouseEvent) => { // Stop if mouse was moved // console.log("lastMouseDownPos", lastMouseDownPos, " evt.params.pos", evt.params.pos); if (lastMouseDownPos && (lastMouseDownPos.x !== evt.params.pos.x || lastMouseDownPos.y !== evt.params.pos.y)) { return; } // Check if mouse pointer hovers over an option -> set selected AND select node const mouseClickPos = this.pb.transformMousePosition(evt.params.pos.x, evt.params.pos.y); _self.handleClick(mouseClickPos); }); return handler; } /** * Check if a question box or an answer box was clicked. * @param mouseClickPos */ handleClick(mouseClickPos: XYCoords) { const clickedOptionIdentifyer: IOptionIdentifyer | null = this.locateOptionBoxNameAtPos(mouseClickPos); if (clickedOptionIdentifyer) { this.setSelectedOption(clickedOptionIdentifyer); } else { // Otherwise (no option was clicked) check if a node was clicked directly. const clickedNodeName = this.locateNodeBoxNameAtPos(mouseClickPos); // console.log("Click", clickedNodeName); if (clickedNodeName) { if (this.selectedOption) { this.handleOptionReconnect(clickedNodeName); this.pb.redraw(); } else { this.setSelectedNode(clickedNodeName, this.dialogConfigWithPositions.graph[clickedNodeName]); } } else { this.setSelectedNode(null, null); } this.setSelectedOption(null, false); } } handleOptionReconnect(clickedNodeName: string) { if (!this.selectedOption) { // && !this.selectedNodeName) { // Actually this fuction should not be called at all in that case. console.warn("Warn: cannot reconnect option when no option is selected."); return; } const graph = this.dialogConfigWithPositions.graph; const clickedNode: IMiniQuestionaireWithPosition = graph[clickedNodeName]; const sourceNode = this.selectedOption.node; // console.log("Reconnect"); sourceNode.o[this.selectedOption.optionIndex].next = clickedNodeName; this.domHelper.showAnswerOptions(this.selectedNodeName, this.selectedNode); } isEqualOptionIdentifyer(identA: IOptionIdentifyer | null, identB: IOptionIdentifyer | null): boolean { if ((!identA && identB) || (identA && !identB)) { return false; } if ( (typeof identA === "undefined" && typeof identB !== "undefined") || (typeof identA !== "undefined" && typeof identB === "undefined") ) { return false; } if (identA === identB || (typeof identA === "undefined" && typeof identB === "undefined")) { return true; } return identA?.nodeName === identB?.nodeName && identA?.optionIndex === identB?.optionIndex; } renameGraphNode(oldName: string, newName: string): boolean { if (!this.dialogConfigWithPositions.graph.hasOwnProperty(oldName)) { console.warn("Warning: cannot rename node, because old name does not exist.", oldName); return false; } if (oldName === "intro") { console.warn("Warning: cannot rename node, because 'intro' must not be renamed'."); return false; } if (this.dialogConfigWithPositions.graph.hasOwnProperty(newName)) { console.warn("Warning: cannot rename node, because new name already exists.", newName); return false; } if (newName === oldName) { console.warn("Warning: cannot rename node, because old name and new name are the same.", oldName); return false; } const graphNode = this.dialogConfigWithPositions.graph[oldName]; this.dialogConfigWithPositions.graph[newName] = graphNode; delete this.dialogConfigWithPositions.graph[oldName]; // Update all references for (var nodeName in this.dialogConfigWithPositions.graph) { if (!this.dialogConfigWithPositions.graph.hasOwnProperty(nodeName)) { continue; } const tmpNode = this.dialogConfigWithPositions.graph[nodeName]; for (var j = 0; j < tmpNode.o.length; j++) { if (tmpNode.o[j].next === oldName) { tmpNode.o[j].next = newName; } } } // Update local selected fields if (oldName === this.selectedNodeName) { this.selectedNodeName = newName; this.selectedNode = this.dialogConfigWithPositions.graph[newName]; } this.pb.redraw(); return true; } static ellipsify(text: string, maxLength: number): string { if (!text || text.length <= maxLength) { return text; } return `${text.substring(0, maxLength)}...`; } static fromObject(object: object): IDialogueConfig<IMiniQuestionaire> { // Must be of type object if (typeof object !== "object") { throw `Cannot convert non-objects to dialogue config: type is ${typeof object}.`; } // Must have a 'graph' member. if (!object.hasOwnProperty("graph")) { throw "Cannot convert object to dialogue config: object missing member `graph`."; } const graph = object["graph"]; // Check if 'intro' is present? // All members must be of correct type for (var key in object) { if (!object.hasOwnProperty(key)) { continue; } const questionaire = object[key]; if (typeof questionaire !== "object") { throw "Cannot converto bject to dialogue config: all graph members must be objects."; } // Check if 'q' (string) and 'o' (array) attributes are present? } return object as IDialogueConfig<IMiniQuestionaire>; } static removePositions(dialogueConfig: IDialogueConfig<IMiniQuestionaireWithPosition>): IDialogueConfig<IMiniQuestionaire> { const clone: IDialogueConfig<IMiniQuestionaire> = JSON.parse(JSON.stringify(dialogueConfig)); for (var nodeName in clone.graph) { const node = clone.graph[nodeName]; if (node.hasOwnProperty("editor")) { delete node["editor"]; } } return clone; } private randomNodeKey(): string { const keys = Object.keys(this.dialogConfigWithPositions.graph); var count = keys.length; let key = "dia_" + count; while (this.dialogConfigWithPositions.graph.hasOwnProperty(key)) { key = "dia_" + count; count++; } return key; } }