WTS/game/scripts/utility/editor.rpy
Gouvernathor b3c16ff0d7 Fix conflicting properties
big work
what was reliable before is now not, in recent versions of renpy, which is why we added that new config (which I put in say.rpy, but it can be moved somewhere else)
Also fixes the game_menu's title position, incidentally

some changes are incompatible with my diary and achievement branches, respectively, they will need to be merged with a bit of manual care
2023-11-18 20:30:51 +01:00

593 lines
21 KiB
Plaintext

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 == "<string>":
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("Type {!r} is not implemented.".format(what))
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 = "\"{}\"".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.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("\"") # 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))
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)