From bb594c74c6d977ae78edc6fc1ac7e5453bd52f5f Mon Sep 17 00:00:00 2001 From: LoafyLemon Date: Thu, 26 May 2022 21:50:54 +0100 Subject: [PATCH] Expression Editor 0.3a * Added new expression editor * Fixed error parser for mods * Unified Non-doll character expression parameter names * Moved strip quotes function to global scope * Added matches function that strips spaces before comparing --- game/modules/mods.rpy | 1 + game/scripts/characters/genie/common.rpy | 6 +- game/scripts/utility/common_functions.rpy | 11 + game/scripts/utility/editor.rpy | 1133 +++++++++++---------- game/scripts/utility/lint.rpy | 8 - 5 files changed, 593 insertions(+), 566 deletions(-) diff --git a/game/modules/mods.rpy b/game/modules/mods.rpy index 5a9b9c03..989dae6b 100644 --- a/game/modules/mods.rpy +++ b/game/modules/mods.rpy @@ -5,6 +5,7 @@ init python: import json import os import renpy.error as rpy_error + from renpy.parser import ParseError mods_list = dict() diff --git a/game/scripts/characters/genie/common.rpy b/game/scripts/characters/genie/common.rpy index 8ff1c75f..1d7c89b5 100644 --- a/game/scripts/characters/genie/common.rpy +++ b/game/scripts/characters/genie/common.rpy @@ -2,7 +2,7 @@ define character.genie_say = Character("Genie", show_icon="genie") init python: - def gen(what, attributes=None, xpos=None, ypos=None, pos=None, flip=None, trans=None, animation=False, **kwargs): + def gen(what, face=None, xpos=None, ypos=None, pos=None, flip=None, trans=None, animation=False, **kwargs): global genie_xpos, genie_ypos, genie_offset, genie_flip, genie_zoom redraw = False tag = "genie_main" @@ -24,8 +24,8 @@ init python: else: genie_offset = (0, 600) - if attributes: - variant = "genie {}".format(attributes) + if face: + variant = "genie {}".format(face) renpy.set_tag_attributes(variant) side = "genie" diff --git a/game/scripts/utility/common_functions.rpy b/game/scripts/utility/common_functions.rpy index c43c41dd..d093a6df 100644 --- a/game/scripts/utility/common_functions.rpy +++ b/game/scripts/utility/common_functions.rpy @@ -10,6 +10,7 @@ init -1 python: import fnmatch import posixpath import re + import string from bisect import bisect from operator import itemgetter from operator import add as _add @@ -208,3 +209,13 @@ init -1 python: continue i.ret(who) + + def strip(s): + # We need a custom strip implementation because we cannot tell + # if the raw argument isn't encapsulated in double, or triple quotes + if s.startswith(('"', "'")) and s.endswith(('"', "'")): + return s[1:-1] + return s + + def matches(s1, s2, filter=" "): + return s1.replace(filter, "") == s2.replace(filter, "") diff --git a/game/scripts/utility/editor.rpy b/game/scripts/utility/editor.rpy index 10c0f8bf..709e6700 100644 --- a/game/scripts/utility/editor.rpy +++ b/game/scripts/utility/editor.rpy @@ -1,555 +1,578 @@ -# init python: -# def catch_character_call(label, called): -# if called: -# if label.startswith(("her_main", "cho_main", "ast_main", "ton_main", "sus_main", "lun_main", "hoo_main")): -# editor.catch(label) - -# def lookup(what): -# if isinstance(what, (int, str, float, bool)): -# return None - -# for k, v in store.__dict__.iteritems(): -# if k.startswith("_"): -# continue - -# if v == what: -# return k -# return None - -# class ExpressionEditor(NoRollback): -# transforms = ("move_fade", "sprite_fly_idle") - -# char = {"her_main": "hermione", -# "ton_main": "tonks", -# "ast_main": "astoria", -# "cho_main": "cho", -# "sus_main": "susan", -# "lun_main": "luna", -# "hoo_main": "hooch", -# } - -# def __init__(self): -# self.label = None -# self.line = None -# self.line_contents = None -# self.args = None - -# self._file = None -# self._file_contents = None - -# self.define_expressions() -# self.changes = _dict() - -# def launch_editor(self, file=None, line=None): -# """Launches external text editor.""" -# if renpy.in_rollback() or renpy.in_fixed_rollback(): -# return - -# renpy.launch_editor([file], line) -# return - -# def overwrite_statement(self): - -# # Backup file contents -# with open(self._file, "r") as f: -# self._file_contents = f.readlines() - -# found = False - -# try: -# new = self.get_new_statement() -# old = self.line_contents - -# # Overwrite the file contents -# with open(self._file, "w") as f: -# for n, l in enumerate(self._file_contents, 1): -# if n == self.line and l.partition("#")[0].strip() == old: -# found = True - -# f.writelines(l.replace(old, new)) - -# # Is it a new line, or have we already edited it? -# if n not in self.changes.get(self._file, _list()): -# self.changes.setdefault(self._file, _dict()).setdefault(n, [old, new, self.args, self.char[self.label], None]) -# else: -# self.changes[self._file][n] = [old, new, self.args, self.char[self.label], None] - -# self.line_contents = new -# else: -# f.writelines(l) - -# if not found: -# renpy.notify("Line {} contents changed unexpectedly, no changes were made.".format(self.line)) -# return -# except: -# # Restore backup -# with open(self._file, "w") as f: -# for l in self._file_contents: -# f.writelines(l) -# renpy.notify("An error occurred, no changes were made.") -# return - -# renpy.notify("Saved.") -# return - -# def get_new_statement(self): -# text = "\"\", " if not self.args["text"] else "\"{}\", ".format(self.args["text"].replace("\"", "\\\"")) - -# mouth = "" if self.args["mouth"] == False else "\"{}\", ".format(self.args["mouth"]) -# eyes = "" if self.args["eyes"] == False else "\"{}\", ".format(self.args["eyes"]) -# eyebrows = "" if self.args["eyebrows"] == False else "\"{}\", ".format(self.args["eyebrows"]) -# pupils = "" if self.args["pupils"] == False else "\"{}\", ".format(self.args["pupils"]) -# hair = "" if self.label != "ton_main" or self.args["hair"] in (None, "(No change)") else "hair=\"{}\", ".format(self.args["hair"]) - -# cheeks = "" if self.args["cheeks"] in (None, "(No change)") else "cheeks=\"{}\", ".format(self.args["cheeks"]) -# tears = "" if self.args["tears"] in (None, "(No change)") else "tears=\"{}\", ".format(self.args["tears"]) -# emote = "" if self.args["emote"] == None else "emote=\"{}\", ".format(self.args["emote"]) -# face = "" if self.args["face"] == None else "face=\"{}\", ".format(self.args["face"]) - -# xpos = self.args["xpos"] -# if xpos == None: -# xpos = "" -# else: -# xpos = "xpos={}, ".format(xpos) if is_integer(xpos) else "xpos=\"{}\", ".format(xpos) - -# ypos = self.args["ypos"] -# if ypos == None: -# ypos = "" -# else: -# ypos = "ypos={}, ".format(ypos) if is_integer(ypos) else "ypos=\"{}\", ".format(ypos) - -# flip = "" if self.args["flip"] == None else "flip={}, ".format(self.args["flip"]) -# trans = "" if self.args["trans"] == None else "trans={}, ".format(self.args["trans"]) -# animation = "" if self.args["animation"] == False else "animation={}".format(self.args["animation"]) - -# new = "call {}({}{}{}{}{}{}{}{}{}{}{}{}{}{}{})".format(self.label, text, mouth, eyes, eyebrows, pupils, hair, cheeks, tears, emote, face, xpos, ypos, flip, trans, animation) - -# new = new.replace(", )", ")") -# return new - -# def catch(self, label): -# # Get file and line number -# stack = renpy.get_return_stack()[-1] -# node = renpy.game.script.namemap.get(stack, None) - -# # Console call fallback -# if node.filename == "": -# return - -# self._file = config.basedir+"/"+node.filename -# self.line = node.linenumber -# self.label = label - - -# if self.changes.get(self._file, _dict()).get(self.line, None): -# # Get arguments from cache -# self.args = self.changes[self._file][self.line][2] -# else: -# # Get arguments from node -# node = renpy.game.script.lookup_or_none(self.label) -# self.args = _dict([(k, getattr(store, k)) for k in node.parameters.apply(None, None).iterkeys()]) - -# if self.args["cheeks"] == None: -# self.args["cheeks"] = "(No change)" -# if self.args["tears"] == None: -# self.args["tears"] = "(No change)" -# if self.label == "ton_main": -# if self.args["hair"] == None: -# self.args["hair"] = "(No change)" - -# # Lookup transition name (Reverse lookup) -# trans_arg = self.args["trans"] - -# if trans_arg: -# self.args["trans"] = lookup(trans_arg) - -# #_transitions = _dict([(obj.callable, name) for (name, obj) in store.__dict__.iteritems() if name in self.transitions]) -# #self.args["trans"] = _transitions[self.args["trans"].callable] - -# # Lookup transform name -# anim_arg = self.args["animation"] - -# if not anim_arg in (False, None): -# #self.args["animation"] = lookup(anim_arg) - -# _transforms = _dict([(obj, name) for (name, obj) in store.__dict__.iteritems() if isinstance(obj, renpy.atl.ATLTransformBase)]) -# self.args["animation"] = _transforms[anim_arg] - -# # Check for already made changes and hijack the line. -# if self.changes.get(self._file, _dict()).get(self.line, None): -# self.line_contents = self.changes[self._file][self.line][1] -# else: -# # Read file and find the line in question -# with open(self._file, "U") as f: -# for n, l in enumerate(f, 1): -# if n == self.line: -# l = l.partition("#")[0].strip() # Ignore comments and strip spaces -# if l.startswith("call {}".format(self.label)): -# self.line_contents = l -# break -# return - -# def set_data(self, arg, val): -# if not isinstance(val, basestring): -# return - -# if self.args[arg] != val: -# self.args[arg] = val -# self.overwrite_statement() - -# def get_tooltip(self, file, line): - -# if self.changes[file][line][4] == None: -# imagepath = config.basedir.replace("\\", "/") -# args = self.changes[file][line][2] -# char = self.changes[file][line][3] - -# if char == "hermione": -# hair = her_hair_base -# box = (450, 275, 320, 240) -# elif char == "tonks": -# hair = ton_hair_base_new -# box = (450, 275, 320, 240) -# elif char == "astoria": -# hair = ast_hair_base -# box = (450, 320, 280, 240) -# elif char == "cho": -# hair = cho_hair_ponytail1 -# box = (450, 310, 280, 240) -# elif char == "susan": -# hair = sus_hair_base -# box = (450, 310, 280, 240) -# elif char == "luna": -# hair = lun_hair_base -# box = (450, 310, 280, 240) -# elif char == "hooch": -# hair = hoo_hair_base -# box = (370, 240, 280, 240) - -# sprites = [imagepath+"/game/characters/"+char+"/body/base/front.webp"] - -# if not "No change" in args["cheeks"]: -# sprites.append(imagepath+"/game/characters/"+char+"/face/cheeks/"+args["cheeks"]+".webp") -# sprites.append(imagepath+"/game/characters/"+char+"/face/eyes/"+args["eyes"]+".webp") -# if not any(x in args["eyes"] for x in ("closed", "happyCl")): -# m = imagepath+"/game/characters/"+char+"/face/eyes/"+args["eyes"]+"_mask.webp" -# sprites.append(AlphaMask(imagepath+"/game/characters/"+char+"/face/pupils/"+args["pupils"]+".webp", m)) -# sprites.append(imagepath+"/game/characters/"+char+"/face/eyebrows/"+args["eyebrows"]+".webp") -# if not "No change" in args["tears"]: -# sprites.append(imagepath+"/game/characters/"+char+"/face/tears/"+args["tears"]+".webp") -# sprites.append(imagepath+"/game/characters/"+char+"/face/mouth/"+args["mouth"]+".webp") - -# # Hair -# sprites.append(hair.get_back()[0]) -# if hair.back_outline: -# sprites.append(hair.back_outline) -# sprites.append(hair.get_image()) - -# sprites = tuple(itertools.chain.from_iterable(((0,0), x) for x in sprites)) - -# self.changes[file][line][4] = At(Crop(box, Composite((1010, 1200), *sprites)), Transform(zoom=0.5)) -# return self.changes[file][line][4] - -# def set_expressions(self): -# if not self.char or not self.label: -# return - -# c = getattr(renpy.store, self.char[self.label]) - -# cheeks = None if self.args["cheeks"] in (None, "(No change)") else self.args["cheeks"] -# tears = None if self.args["tears"] in (None, "(No change)") else self.args["tears"] - -# if self.label == "ton_main": -# hair = None if self.args["hair"] in (None, "(No change)") else self.args["hair"] - -# # Hardcoded for tonks -# if hair: -# if hair in ("neutral", "basic", "reset"): -# target_color = tonks_haircolor -# elif hair in ("red", "angry", "furious"): -# target_color = [[164, 34, 34, 255], [219, 83, 83, 255]] -# elif hair in ("orange", "upset", "annoyed"): -# target_color = [[228, 93, 34, 255], [246, 193, 170, 255]] -# elif hair in ("yellow", "happy", "cheerful"): -# target_color = [[255, 213, 23, 255], [255, 239, 167, 255]] -# elif hair in ("green", "disgusted"): -# target_color = [[111, 205, 75, 255], [200, 237, 186, 255]] -# elif hair in ("blue", "sad"): -# target_color = [[64, 75, 205, 255], [182, 186, 237, 255]] -# elif hair == "purple": -# target_color = [[205, 75, 205, 255], [237, 186, 237, 255]] -# elif hair in ("white", "scared"): -# target_color = [[238, 238, 241, 255], [249, 249, 250, 255]] -# elif hair in ("pink", "horny"): -# target_color = [[255, 105, 180, 255], [251, 205, 222, 255]] - -# tonks.get_equipped("hair").set_color(target_color) - -# c.set_face(mouth=self.args["mouth"], eyes=self.args["eyes"], eyebrows=self.args["eyebrows"], pupils=self.args["pupils"], cheeks=cheeks, tears=tears) - -# def get_expressions(self, type): -# return sorted(self.expressions[self.char[self.label]][type]) - -# def define_expressions(self): -# def scan_files(char): -# dirs = { -# "mouth": None, -# "eyes": None, -# "eyebrows": None, -# "pupils": None, -# "cheeks": None, -# "tears": None, -# "hair": ["(No change)", "neutral", "angry", "upset", "happy", "disgusted", "sad", "purple", "scared", "horny"], -# } - -# for k, v in dirs.iteritems(): -# if k == "hair": -# continue - -# path = "{}/game/characters/{}/face/{}/".format(config.basedir, char, k) - -# if not os.path.exists(path): -# os.makedirs(path) - -# dirs[k] = list(x.rsplit(".webp")[0] for x in os.listdir(path) if x.endswith(".webp") and not "_mask" in x and not "_skin" in x) - -# dirs["cheeks"] += ["(No change)"] -# dirs["tears"] += ["(No change)"] - -# return _dict(dirs) - -# self.expressions = _dict([(x, scan_files(x)) for x in self.char.itervalues()]) - -# def editor_reset(): -# editor.changes = _dict() - -# if config.developer and not renpy.mobile: -# editor = ExpressionEditor() - -# config.label_callback = catch_character_call -# config.after_load_callbacks.append(editor_reset) -# config.interact_callbacks.append(editor.set_expressions) - -# screen editor(): -# layer "interface" -# tag editor -# zorder 0 -# style_prefix "editor" - -# default minimised = False -# default minimised_history = False -# default minimised_cg = False -# default frame_size = (250, 500) - -# default expressions = ("mouth", "eyes", "eyebrows", "pupils", "cheeks", "tears", "hair") - -# if config.developer and not _menu: - -# # Editor -# drag: -# drag_name "editor" -# draggable True -# drag_offscreen False -# drag_handle (0, 0, 1.0, 26) -# pos (50, 50) - -# window: -# background "#00000080" -# xysize (frame_size if not minimised else (250, 28)) - -# button action NullAction() style "empty" ypos 18 - -# text "Expression Editor {size=-2}ver 0.2{/size}" size 10 color "#FFF" outlines [(1, "#00000080", 1, 0)] - -# textbutton "_" ysize 28 offset (-32, -7) text_size 15 text_yalign 0.5 xalign 1.0 action [ToggleScreenVariable("minimised", True, False), SelectedIf(None)] tooltip ("Maximise" if minimised else "Minimise") -# textbutton "x" ysize 28 offset (6, -7) text_size 15 text_yalign 0.5 xalign 1.0 action Hide("editor") tooltip "Close editor" - -# frame: -# style "empty" -# xysize (236, 2) -# pos (0, 18) -# background "#FFFFFF80" - -# if not minimised: -# if editor.label: -# $ f = editor._file.split("/")[-1] -# $ l = editor.line - -# vbox: -# ypos 24 - -# for i in expressions: -# if i == "hair" and not editor.label == "ton_main": -# pass -# else: -# text i size 14 color "#fff" outlines [(1, "#00000080", 1, 0)] - -# hbox: -# xpos 12 -# box_wrap True - -# for j in editor.get_expressions(i): -# textbutton j: -# selected (j == editor.args[i]) -# action Function(editor.set_data, i, j) -# text_size 8 -# text_selected_color "#009900" -# text_xalign 0.5 -# xminimum 32 - -# # History -# drag: -# drag_name "editor_history" -# draggable True -# drag_offscreen False -# drag_handle (0, 0, 1.0, 26) -# pos (300, 50) -# frame: -# background "#00000080" -# xysize (frame_size if not minimised_history else (250, 28)) -# button action NullAction() style "empty" xysize (frame_size if not minimised_history else (250, 28)) ypos 18 - -# text "History" size 10 color "#FFF" outlines [(1, "#00000080", 1, 0)] - -# textbutton "_" ysize 28 offset (-32, -7) text_size 15 text_yalign 0.5 xalign 1.0 action [ToggleScreenVariable("minimised_history", True, False), SelectedIf(None)] tooltip ("Maximise" if minimised_history else "Minimise") -# textbutton "x" ysize 28 offset (6, -7) text_size 15 text_yalign 0.5 xalign 1.0 action Hide("editor") tooltip "Close editor" - -# frame: -# style "empty" -# xysize (236, 2) -# pos (0, 18) -# background "#FFFFFF80" - -# if not minimised_history: -# if editor.changes: -# $ n = 0 -# $ nn = 0 -# for k, v in editor.changes.iteritems(): -# $ n += 1 -# $ nn += len(v) -# text "[nn] changes in [n] files" size 10 color "#FFF" xalign 0.5 ypos 26 outlines [(1, "#00000080", 1, 0)] - -# frame: -# style "empty" -# xsize 236 -# ymaximum 400 -# yfill True -# ypos 42 - -# side "c r": -# area (0, 0, 236, 400) - -# viewport id "editor_history": -# draggable False -# mousewheel True - -# vbox: -# for fn in editor.changes.iterkeys(): -# text (fn.split("/")[-1]) size 10 color "#FFF" xalign 0.5 outlines [(1, "#00000080", 1, 0)] -# frame style "empty" xysize (236, 2) background "#FFFFFF80" - -# for l in editor.changes[fn].iterkeys(): -# textbutton "Line [l]": -# text_size 10 -# text_color "#FFF" -# text_outlines [(1, "#00000080", 1, 0)] -# if editor.line == l: -# background "#FFFFFF80" -# tooltip editor.get_tooltip(fn, l) -# action Function(editor.launch_editor, file=fn, line=l) -# vbar value YScrollValue("editor_history") xsize 10 -# else: -# text "No history." size 15 color "#FFF" align (0.5, 0.5) outlines [(1, "#00000080", 1, 0)] - -# # CG poser - -# if renpy.get_screen("animatedCG"): -# drag: -# drag_name "editor_cg" -# draggable True -# drag_offscreen False -# drag_handle (0, 0, 1.0, 26) -# pos (550, 50) -# frame: -# background "#00000080" -# xysize (frame_size if not minimised_cg else (250, 28)) -# button action NullAction() style "empty" xysize (frame_size if not minimised_cg else (250, 28)) ypos 18 - -# text "CG Poser" size 10 color "#FFF" outlines [(1, "#00000080", 1, 0)] - -# textbutton "_" ysize 28 offset (-32, -7) text_size 15 text_yalign 0.5 xalign 1.0 action [ToggleScreenVariable("minimised_cg", True, False), SelectedIf(None)] tooltip ("Maximise" if minimised_history else "Minimise") -# textbutton "x" ysize 28 offset (6, -7) text_size 15 text_yalign 0.5 xalign 1.0 action Hide("editor") tooltip "Close editor" - -# frame: -# style "empty" -# xysize (236, 2) -# pos (0, 18) -# background "#FFFFFF80" - -# if not minimised_cg: - -# $ x, y = camera.pos -# $ zoom = camera.zoom -# $ rotate = camera.rotate -# default pos_step = 5 -# default zoom_step = 0.05 -# default rotate_step = 5 -# vbox: -# text "Pos Step ({})".format(pos_step) color "#fff" -# hbox: -# textbutton "-": -# action SetScreenVariable("pos_step", max(pos_step-5, 5)) -# textbutton "+": -# action SetScreenVariable("pos_step", min(pos_step+5, 100)) -# text "Pos (x={}, y={})".format(x, y) color "#fff" -# hbox: -# textbutton "X-": -# action [SetVariable("camera.pos", (x-pos_step, y)), Function(camera.redraw, 0)] -# textbutton "X+": -# action [SetVariable("camera.pos", (x+pos_step, y)), Function(camera.redraw, 0)] -# hbox: -# textbutton "Y-": -# action [SetVariable("camera.pos", (x, y-pos_step)), Function(camera.redraw, 0)] -# textbutton "Y+": -# action [SetVariable("camera.pos", (x, y+pos_step)), Function(camera.redraw, 0)] - -# null height 20 - -# text "Zoom Step ({})".format(zoom_step) color "#fff" -# hbox: -# textbutton "-": -# action SetScreenVariable("zoom_step", max(zoom_step-0.01, 0.01)) -# textbutton "+": -# action SetScreenVariable("zoom_step", min(zoom_step+0.01, 0.25)) -# text "Zoom (zoom={})".format(zoom) color "#fff" -# hbox: -# textbutton "-": -# action [SetVariable("camera.zoom", zoom-zoom_step), Function(camera.redraw, 0)] -# textbutton "+": -# action [SetVariable("camera.zoom", zoom+zoom_step), Function(camera.redraw, 0)] - -# null height 20 - -# text "Rotate Step ({})".format(rotate_step) color "#fff" -# hbox: -# textbutton "-": -# action SetScreenVariable("rotate_step", max(rotate_step-1, 1)) -# textbutton "+": -# action SetScreenVariable("rotate_step", min(rotate_step+1, 1)) -# text "Rotate ({})".format(rotate) color "#fff" -# hbox: -# textbutton "-": -# action [SetVariable("camera.rotate", rotate-rotate_step), Function(camera.redraw, 0)] -# textbutton "+": -# action [SetVariable("camera.rotate", rotate+rotate_step), Function(camera.redraw, 0)] - -# null height 50 -# textbutton "Copy to Clipboard" action [Function(set_clipboard, "$ camera.set(zoom={}, pos=({}, {}), rotate={}, t=1.0)".format(zoom, x, y, rotate)), Notify("Copied!")] - -# style editor_button is empty: -# margin (3, 3) - -# style editor_button_text: -# color "#cccccc" -# hover_color "#ffffff" -# outlines [(1, "#00000080", 1, 0)] +init python: + + from renpy.parser import ParseError + from copy import deepcopy + + class Editor(NoRollback): + + 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 = _dict() + self.last_expressions = _dict() + + def catch(self, *args, **kwargs): + global n # DEBUG + 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 + n = node # DEBUG + + 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 + self.apply_persistent() + + # 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 = 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 + else: + raise TypeError("Type '{}' is not implemented.".format(what)) + + 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. + + 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, basestring): + val = "\"{}\"".format(val) + + # Insert new expression + d = self.get_expressions_active(who) + d[expr] = val + + # Convert to list of tuples + # l = [(k, "\"{}\"".format(v)) for k, v in d.iteritems() if not v is None] # This is faster, but not robust enough. + + l = _list() + + for key, val in d.iteritems(): + if key in kw_args and val is None: + 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)) + + self.replace("args", l) + + @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: # Python 3 only :( + # renpy.notify("Source file is missing.") + 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. + filters = ("_mask", "_skin") + all_files = renpy.list_files() + d = _dict() + + for name in CHARACTERS: + key = name[:3] + + d[key] = OrderedDict() + + for expr in ("mouth", "eyes", "eyebrows", "pupils", "cheeks", "tears"): + path = "characters/{}/face/{}/".format(name, expr) + files = filter(lambda x: path in x, all_files) + + d[key][expr] = [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)] + + if expr in ("cheeks", "tears"): + # For cheeks, tears and hair None is a valid option, and chosen by default. + d[key][expr].insert(0, None) + + # Define additional Tonks' hair choices. + d["ton"]["hair"] = [None, "neutral", "angry", "annoyed", "happy", "disgusted", "sad", "purple", "scared", "horny"] + + # Define expressions for Genie. + filters = None + path = "characters/genie/" + files = None + + d["gen"] = _dict() + d["gen"]["expression"] = ["angry", "grin", "base", "open"] + + # Define expressions for Snape. + filters = ("b01", "b01_01", "b02", "picture_Frame", "wand") + path = "characters/snape/main/" + files = filter(lambda x: path in x, all_files) + + d["sna"] = _dict() + 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 SAYERS: + keywords = ["mouth", "eyes", "eyebrows", "pupils", "cheeks", "tears", "emote", + "face", "xpos", "ypos", "pos", "flip", "trans", "animation", "hair"] + else: + keywords = ["expression", "face", "xpos", "ypos", "pos", "flip", "trans", "animation", "wand"] + + # Args is an ArgumentInfo object, or None, + # the true list of arguments is kept + # within the object itself. + if args: + args = args.arguments + + # 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() + + # 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 + + # 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(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, basestring): + expr = strip(expr) + return expr + + def get_expressions_persistent(self, who): + return self.persistent_expressions.get(who, _dict()) + + def get_expressions_persistent_type(self, who, type): + return self.get_expressions_persistent(who).get(type, False) + + def set_expressions_persistent_type(self, who, type): + self.persistent_expressions.setdefault(who, _dict())[type] = True + + def toggle_expressions_persistent_type(self, who, type): + val = self.get_expressions_persistent_type(who, type) + self.persistent_expressions.setdefault(who, _dict())[type] = not val + + 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, basestring): + expr = strip(expr) + return expr + + def apply_persistent(self): + # This function will break the editor + # if it participates in rollback + if renpy.in_rollback() or renpy.in_fixed_rollback(): + return + + node = self.node + who = node.who + + persistent = self.get_expressions_persistent(who) + last = self.get_expressions_last(who) + active = self.get_expressions_active(who) + + if last == active: + return + + for type, val in persistent.iteritems(): + if val is False: + continue + + last = self.get_expressions_last_type(who, type) + active = self.get_expressions_active_type(who, type) + + if last == active: + continue + + self.replace_expression(type, last) + + def get_node_history(self): + node = self.node + file = node.filename + line = node.linenumber + + return self.read_history(file, line) + + if config.developer: + e = Editor() + + #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. + +screen editor(): + zorder 50 + style_prefix "editor" + + text "Active" pos (25, 25) + + default focused = None + + drag: + pos (50, 50) + xminimum 500 + xmaximum 500 + xsize 500 + + frame: + has vbox + + fixed: + fit_first True + text "Expression Editor {size=-2}ver 0.3a{/size}" style "editor_title" + + if e.node: + vbox: + hbox: + for expr_type in e.get_expression_types(e.node.who).iterkeys(): + textbutton "[expr_type]": + action [CaptureFocus(expr_type), SetScreenVariable("focused", expr_type)] + selected GetFocusRect(expr_type) + + if e.node.who in SAYERS: + textbutton "😊": + action Function(e.toggle_expressions_persistent_type, e.node.who, "cheeks") + selected (e.get_expressions_persistent_type(e.node.who, "cheeks")) + tooltip "Toggle persistent cheeks" + textbutton "😢": + action Function(e.toggle_expressions_persistent_type, e.node.who, "tears") + selected (e.get_expressions_persistent_type(e.node.who, "tears")) + tooltip "Toggle persistent tears" + + if e.node.who == "ton": + textbutton "✂️": + action Function(e.toggle_expressions_persistent_type, e.node.who, "hair") + selected (e.get_expressions_persistent_type(e.node.who, "hair")) + tooltip "Toggle persistent hair" + + 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).iteritems(): + + 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) diff --git a/game/scripts/utility/lint.rpy b/game/scripts/utility/lint.rpy index d82857ef..e5b69b4f 100644 --- a/game/scripts/utility/lint.rpy +++ b/game/scripts/utility/lint.rpy @@ -16,14 +16,6 @@ init -1 python: if config.developer: def lint_characters(): - - def strip(s): - # We need a custom strip implementation because we cannot tell - # if the raw argument isn't encapsulated in double, or triple quotes - if s.startswith(('"', "'")) and s.endswith(('"', "'")): - return s[1:-1] - return s - renpy.execute_default_statement(False) # Add images to linting list to avoid undefined errors