LoafyLemon
cd485b2bac
* Added checkpoints to forbid the editor from rolling back to menu context * Added forced checkpoint for when the editor is being opened to avoid rolling back to before it was opened (by opening a console for example) * Alleviated the issue for when the game tries to recover from the place it cannot stop rolling back, but version var is unavailable because of it being not initialized in that moment
604 lines
22 KiB
Plaintext
604 lines
22 KiB
Plaintext
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()
|
|
self.active = False
|
|
|
|
def catch(self, *args, **kwargs):
|
|
if not self.active:
|
|
return
|
|
|
|
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 == "<string>":
|
|
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"]
|
|
|
|
# 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
|
|
|
|
# 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)
|
|
|
|
@renpy.pure
|
|
class ToggleEditor(Action, NoRollback):
|
|
def __call__(self):
|
|
if not config.developer:
|
|
return
|
|
|
|
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)
|
|
|
|
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.
|
|
else:
|
|
e = dict() # Hotkey crashes on release otherwise.
|
|
|
|
screen editor():
|
|
zorder 50
|
|
style_prefix "editor"
|
|
layer "interface"
|
|
|
|
text "Active" pos (25, 25)
|
|
|
|
default focused = None
|
|
|
|
drag:
|
|
pos (50, 50)
|
|
maximum (500, 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)
|