init python: from renpy.parser import ParseError from copy import deepcopy class Editor(python_object): doll_keywords = ["mouth", "eyes", "eyebrows", "pupils", "cheeks", "tears", "emote",\ "xpos", "ypos", "pos", "flip", "trans", "animation", "hair"] other_keywords = ["expression", "xpos", "ypos", "pos", "flip", "trans", "animation", "wand"] def __init__(self): self.node = None self._live_code = None # Volatile; Can be changed at any given moment. self.history = _dict() self.expressions = self.define_expressions() self.active_expressions = _dict() self.persistent_expressions = _set() self.last_expressions = _dict() self.active = False self.sayers = {i[:3]:i for i in states.dolls} def catch(self, *args, **kwargs): if not self.active or renpy.is_init_phase(): return self.node = None self.live_code = None #stack = renpy.get_return_stack() statement = renpy.game.context().current if not statement: return node = renpy.game.script.namemap.get(statement, None) # Console call fallback if node.filename == "": return if not isinstance(node, renpy.ast.Say): return self.node = node who = node.who file = node.filename line = node.linenumber code = node.get_code() code_file = self.read_file(file, line) # We need to use deepcopy, otherwise the dict # would be participating in rollback self.last_expressions = deepcopy(self.active_expressions) self.active_expressions.update(self.resolve_expressions()) self.live_code = code # Consistency check if not matches(code, code_file): s = "Active {color=#e54624}node code{/color} differs from the {color=#40bf77}file code{/color},"\ " would you like to update the node?\n\n\n"\ "{color=#e54624}" + code + "{/color}\n->\n"\ "{color=#40bf77}" + code_file + "{/color}" prompt = Text(s, style="editor_text") layout.yesno_screen(prompt, [SetField(e, "live_code", code_file), e.live_replace, Notify("Updated.")], Notify("Cancelled.")) self.write_history(file, line, self.live_code) def replace(self, what, contents): node = self.node if what in ("who", "what"): setattr(node, what, contents) elif what == "args": args = node.arguments if not args: args = renpy.ast.ArgumentInfo(contents, None, None) node.arguments = args else: if args.starred_indexes or args.doublestarred_indexes: raise Exception("Starred arguments are not implemented.") node.arguments.arguments = contents elif what == "temp_attr": setattr(node, "temporary_attributes", tuple(contents)) else: raise TypeError(f"Type {what!r} is not implemented.") def replace_expression(self, expr, val): node = self.node who = node.who no_kw_args= ("mouth", "eyes", "eyebrows", "pupils", "expression") kw_args = ("tears", "cheeks", "hair") # We need to make sure not to add quotes # to expressions or variables. if isinstance(val, str): val = repr(val) # Insert new expression d = self.get_expressions_active(who) d[expr] = val # Convert to list of tuples # l = [(k, repr(v)) for k, v in d.items() if not v is None] # This is faster, but not robust enough. l = _list() l2 = _list() for key, val in d.items(): if key in kw_args: if val is None: continue elif key in self.persistent_expressions: pass else: val = val.strip("\"").strip("'") # When accessed, temporary attribute values have quotes by default, we need to strip them. l2.extend((key, val)) continue # First four arguments are preferred to be keywordless, # so we'll just insert them into positions to avoid issues. if key in no_kw_args: key = None l.append((key, val.replace("'", "\""))) self.replace("args", l) self.replace("temp_attr", l2) file = node.filename line = node.linenumber code = node.get_code() self.write_file(file, line, code) self.write_history(file, line, code) self.live_code = code renpy.run(RestartStatement()) # This has to be run last. @property def live_code(self): return self._live_code @live_code.setter def live_code(self, code): if self._live_code == code: return # Additional validation goes here self._live_code = code def live_replace(self): node = self.node file = node.filename line = node.linenumber code = node.get_code() live_code = self.live_code if code == live_code: return rnode = self.parse(file, line, live_code) if rnode is None: return # It's simpler to replace node attributes # than the entire node in every linked context. node.who = rnode.who node.attributes = rnode.attributes node.temporary_attributes = rnode.temporary_attributes node.interact = rnode.interact node.what = rnode.what node.arguments = rnode.arguments node.with_ = rnode.with_ self.write_file(file, line, live_code) self.write_history(file, line, live_code) renpy.run(RestartStatement()) # This has to be run last. def live_reset(self): node = self.node self.live_code = node.get_code() def parse(self, file, line, code): renpy.game.exception_info = 'While parsing ' + file + '.' try: lines = renpy.parser.list_logical_lines(file, code, line) nested = renpy.parser.group_logical_lines(lines) except ParseError as e: renpy.notify("Parsing failed.\n{size=-8}(Check console for details){/size}") print(e) return None lexer = renpy.parser.Lexer(nested) block = renpy.parser.parse_block(lexer) if not block: renpy.notify("Parsing failed.\n{size=-8}(Check console for details){/size}") print("Fatal error while parsing the code block.") return None return block[-1] def read_file(self, file, line): file = os.path.join(config.basedir, file) line = line-1 try: with open(file, "r") as f: data = f.readlines() return data[line].partition("#")[0].strip() # Remove comments and strip spaces. except EnvironmentError as e: renpy.notify("File read error.\n{size=-8}(Check console for details){/size}") print(e) except IndexError as e: renpy.notify("File index error.\n{size=-8}(Check console for details){/size}") print(e) print("(Most likely the file was tampered with.)") def write_file(self, file, line, code): file = os.path.join(config.basedir, file) line = line-1 try: with open(file, "r+", newline="\n") as f: data = f.readlines() old = data[line].partition("#")[0].strip() # Remove comments and strip spaces. new = data[line].replace(old, code) data[line] = new f.seek(0) f.truncate() f.writelines(data) renpy.notify("Saved.") except FileNotFoundError as e: renpy.notify("Source file is missing.") print(e) except EnvironmentError as e: renpy.notify("File write error.\n{size=-8}(Check console for details){/size}") print(e) def read_history(self, file, line): # _dict, and _list methods do not participate in rollback # unlike their revertable counterparts, so that's what we'll use. return self.history.get(file, _dict()).get(line, _list()) def write_history(self, file, line, code): if code in self.read_history(file, line): return self.history.setdefault(file, _dict()).setdefault(line, _list()).append(code) def clear_history(self, file, line): self.history.setdefault(file, _dict())[line] = _list() def launch_editor(self, file, line): renpy.launch_editor([file], line) def define_expressions(self): # This function is kind of messy, # because each character has unique display methods. # Some things are required to be hardcoded. # Define expressions for Doll type characters. all_files = renpy.list_files() d = OrderedDict() for charname in states.dolls: charobj = get_character_object(charname) extensions = charobj.extensions who = charname[:3] for part in sorted(charobj.face._face.keys()): path = posixpath.join("characters", charname, "poses", charobj.pose, "face", part) for f in renpy.list_files(): fp, fn = os.path.split(f) fn, ext = os.path.splitext(fn) expression = os.path.split(fp)[1] if not fp.startswith(path) or not ext in extensions: continue if part in ("cheeks", "tears"): expressions = d.setdefault(who, OrderedDict()).setdefault(part, _list((None,))) else: expressions = d.setdefault(who, OrderedDict()).setdefault(part, _list()) if not expression in expressions: expressions.append(expression) imap = {v: i for i, v in enumerate(self.doll_keywords)} d[who] = OrderedDict(sorted(list(d[who].items()), key=lambda x: imap[x[0]])) # Define additional Tonks' hair choices. d["ton"]["hair"] = [None] + list(tonks_haircolor_table.keys()) # Define expressions for Genie. filters = None path = "characters/genie/" files = None d["gen"] = OrderedDict() d["gen"]["expression"] = ["angry", "grin", "base", "open"] # Define expressions for Snape. filters = ("b01", "b01_01", "b02", "picture_Frame", "wand") path = "characters/snape/main/" files = [x for x in all_files if path in x] d["sna"] = OrderedDict() d["sna"]["expression"] = [x.split(path)[1].split(".webp")[0] for x in files if x.endswith(".webp") and not any(f in x for f in filters)] d["sna"]["special"] = [None, "wand", "picture_frame"] return d def resolve_expressions(self): node = self.node who = node.who args = node.arguments if who in self.sayers: keywords = self.doll_keywords else: keywords = self.other_keywords # Arguments are contained within a list, # they consist of opaque tuples, # each one containing a keyword, and a value. # keyword can be a None if the keyword # is implied by the argument position. d = _dict() d[who] = _dict() # Args is an ArgumentInfo object, or None, # the true list of arguments is kept # within the object itself. if not args: return d args = args.arguments # Resolve arguments for character statements. # (There should be a simpler way to do this, right?) for i, (key, val) in enumerate(args): if key is None: key = keywords[i] d[who][key] = val # Resolve temporary attributes temp_attr = node.temporary_attributes if temp_attr: d[who].update(dict(zip(temp_attr[::2], temp_attr[1::2]))) # Sort our dictionary using an index map, # if we don't, we'll end up messing up # the order of keywordless arguments. imap = {v: i for i, v in enumerate(keywords)} d[who] = OrderedDict(sorted(list(d[who].items()), key=lambda x: imap[x[0]])) return d def get_expression_types(self, who): return self.expressions.get(who, _dict()) def get_expressions_active(self, who): return self.active_expressions.get(who, _dict()) def get_expressions_active_type(self, who, type): expr = self.get_expressions_active(who).get(type) if isinstance(expr, str): expr = strip(expr) return expr def get_expressions_last(self, who): return self.last_expressions.get(who, _dict()) def get_expressions_last_type(self, who, type): expr = self.get_expressions_last(who).get(type, None) if isinstance(expr, str): expr = strip(expr) return expr def get_node_history(self): node = self.node file = node.filename line = node.linenumber return self.read_history(file, line) def toggle_persistent(self, expr): if expr in self.persistent_expressions: self.persistent_expressions.remove(expr) else: self.persistent_expressions.add(expr) class ToggleEditor(Action, NoRollback): def __call__(self): if not config.developer: return if not hasattr(store, "e"): global e e = Editor() e.expressions = e.define_expressions() #config.all_character_callbacks.append(e.catch) # This is more efficient. config.start_interact_callbacks.append(e.catch) # This allows to catch more statements and reset them if node types don't match. if renpy.get_screen("editor", layer="interface"): e.active = False renpy.hide_screen("editor", layer="interface") else: e.active = True renpy.show_screen("editor") renpy.restart_interaction() renpy.game.context().force_checkpoint = True renpy.checkpoint(hard=True) screen editor(): zorder 50 style_prefix "editor" layer "interface" text "Active" pos (25, 25) default focused = None drag: pos (50, 50) ymaximum 500 xsize 500 frame: has vbox fixed: fit_first True text "Expression Editor {size=-2}ver 0.4b{/size}" style "editor_title" if e.node: vbox: hbox: for expr_type in e.get_expression_types(e.node.who).keys(): textbutton "[expr_type]": action [CaptureFocus(expr_type), SetScreenVariable("focused", expr_type)] selected GetFocusRect(expr_type) if e.node.who in e.sayers: textbutton "😊": action Function(e.toggle_persistent, "cheeks") selected ("cheeks" in e.persistent_expressions) tooltip "Toggle persistent cheeks" textbutton "😢": action Function(e.toggle_persistent, "tears") selected ("tears" in e.persistent_expressions) tooltip "Toggle persistent tears" add Solid("#ffffff80") xysize (480, 1) hbox: textbutton "History": action [CaptureFocus("history"), SetScreenVariable("focused", "history")] selected GetFocusRect("history") sensitive bool(e.get_node_history()) textbutton "Copy": action [Notify("Copied."), Function(set_clipboard, e.live_code)] selected False textbutton "Paste": action [Notify("Pasted."), SetField(e, "live_code", get_clipboard()), Function(e.live_replace)] selected False if focused == 0: dismiss action [SetScreenVariable("focused", 0), Function(e.live_reset)] button: input default "[e.live_code]" value FieldInputValue(e, "live_code") action NullAction() selected (focused == 0) key_events True key ["K_RETURN"] action Function(e.live_replace) key ["ctrl_K_c"] action [Notify("Copied."), Function(set_clipboard, e.live_code)] key ["ctrl_K_v"] action [Notify("Pasted."), SetField(e, "live_code", get_clipboard())] else: textbutton "[e.live_code]" action SetScreenVariable("focused", 0) if e.node: for expr_type, expr_list in e.get_expression_types(e.node.who).items(): if GetFocusRect(expr_type): dismiss action [ClearFocus(expr_type), SetScreenVariable("focused", None)] nearrect: focus expr_type frame: modal True has vbox: box_wrap True style_prefix "editor_dropdown" for expr in expr_list: textbutton "[expr]": action [ ClearFocus(expr_type), SetScreenVariable("focused", None), Function(e.replace_expression, expr_type, expr)] selected (expr == e.get_expressions_active_type(e.node.who, expr_type)) if GetFocusRect("history"): dismiss action [ClearFocus("history"), SetScreenVariable("focused", None)] nearrect: focus "history" frame: modal True has vbox: box_wrap True style_prefix "editor_history" for i, entry in enumerate(e.get_node_history(), 1): textbutton "[i]. [entry]": action [ ClearFocus("history"), SetScreenVariable("focused", None), SetField(e, "live_code", entry), Function(e.live_replace)] selected (entry == e.live_code) key ["K_ESCAPE"] action [ClearFocus(focused), SetScreenVariable("focused", None), Function(e.live_reset)] style editor_text: color "#D0D0D0" hover_color "#e54624" selected_color "#40bf77" selected_hover_color "#E6A825" insensitive_color "#6b6b6b" bold True size 13 font "DejaVuSans.ttf" outlines [ (1, "#000", 0, 0) ] adjust_spacing False style editor_button: background None hover_background "#00000099" selected_background "#00000099" align (0, 0) padding (5, 5) style editor_button_text is editor_text style editor_input is editor_text: color "#40bf77" hover_color "#e54624" caret "caret" hover_caret "hover_caret" style editor_dropdown_button is editor_button: xmaximum 200 xfill True hover_background "#00000099" style editor_dropdown_button_text is editor_text style editor_history_button is editor_dropdown_button: xmaximum 600 xfill True style editor_history_button_text is editor_text: size 10 style editor_frame: background "#00000099" style editor_title is editor_text: color "#fff" image caret = Text("<", style="editor_text", color="#e54624", size=12) image hover_caret = Text("<", style="editor_text", color="#40bf77", size=12)