├── LICENSE ├── README.md ├── __init__.py ├── demo ├── Demo.html ├── start.rpy └── this_next_passage_will_now_be_a_new_file.rpy ├── twine_to_rpy.py └── twine_to_rpy_model.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 J 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twine to Ren'Py 2 | 3 | The Twine to Ren'Py tool allows you to write a simple Ren'Py game but with the visual outlining of writing in Twine. Convert a Sugarcube Twine html file into rpy files for your Ren'Py game project. 4 | 5 | This requires Python 2.7 to run. If you don't have it, you can download the standalone application to run (on Windows only) instead here: 6 | https://ludowoods.itch.io/twine-to-renpy-tool 7 | 8 | ## Features 9 | 10 | - Converts Twine-like choices into Ren'Py menus 11 | - Converts Twine variables and conditionals to Ren'Py 12 | (Note that conditional results need to be on a new line to be processed properly. See demo Twine file uploaded as of 7/3) 13 | - Option to define characters and variables at start of script 14 | - Replaces special characters and passage titles with Ren'Py safe terms 15 | - Add custom replacement terms 16 | - Use passage tags to break up into separate rpy files 17 | - Includes Sugarcube demo for writing Ren'Py games in Twine 18 | 19 | ## How to run the Python script 20 | 21 | 1. Download the directory 22 | 2. Open the cmd prompt in the twine_to_renpy directory 23 | 3. Run python twine_to_rpy.py 24 | 25 | ## How to run the pyinstaller exe from the itch page 26 | 27 | 1. Unzip the directory 28 | 2. Run twine_to_rpy.exe inside the twine_to_rpy directory 29 | 2. Select the html file (Demo.html is included for testing) and directory to output rpy files 30 | 4. Run! 31 | 32 | ## Curious how to set up your Twine file? 33 | 34 | Open the Demo.html in Twine 2 to see how the file is written for tool processing. 35 | 36 | ## Run into a bug? 37 | 38 | Drop the steps to reproduce the bug in the itch comments. 39 | 40 | ## Suggestions? 41 | 42 | This tool won't be closely supported in the future, but if you have a suggestion for a feature that would help you with a project feel free to let me know in the itch comments or add yourself. 43 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jtuason/twine_to_renpy/0576597ac8b07ea1d69f2cc22ff31e88f2166568/__init__.py -------------------------------------------------------------------------------- /demo/start.rpy: -------------------------------------------------------------------------------- 1 | define test_character_1 = Character("test character 1") 2 | define test_character_2 = Character("test character 2") 3 | 4 | label start: 5 | "This is a demo of Twine to Ren'Py." 6 | 7 | jump go_to_the_next_passage 8 | 9 | label go_to_the_next_passage: 10 | "Here's a sample of a Twine set of choices that can be converted to a Ren'Py menu with this tool." 11 | 12 | menu: 13 | "Go to option 1": 14 | jump go_to_option_1 15 | "Go to option 2": 16 | jump go_to_option_2 17 | 18 | label go_to_option_1: 19 | test_character_1 "This is option 1!" 20 | 21 | jump continue_to_demo 22 | 23 | label go_to_option_2: 24 | test_character_2 "This is option 2" 25 | 26 | jump continue_to_demo 27 | 28 | label continue_to_demo: 29 | "Here's how to use a doc break tag to end a Ren'Py file." 30 | 31 | jump this_next_passage_will_now_be_a_new_file 32 | 33 | -------------------------------------------------------------------------------- /demo/this_next_passage_will_now_be_a_new_file.rpy: -------------------------------------------------------------------------------- 1 | label this_next_passage_will_now_be_a_new_file: 2 | "Here's an example of using passage transitions with differing text from the passage titles. In Ren'Py a menu with the text choice will be shown and the label/jump statement will use the hidden passage name." 3 | 4 | menu: 5 | "Choice text 1": 6 | jump choice_1 7 | "Choice text 2": 8 | jump choice_2 9 | 10 | label choice_1: 11 | "This is choice 1. We can even use this to go back to passages." 12 | 13 | menu: 14 | "Go back.": 15 | jump this_next_passage_will_now_be_a_new_file 16 | "Or continue on.": 17 | jump or_continue_on 18 | 19 | label choice_2: 20 | "This is choice 2. We can even use this to go back to passages." 21 | 22 | menu: 23 | "Go back.": 24 | jump this_next_passage_will_now_be_a_new_file 25 | "Or continue on.": 26 | jump or_continue_on 27 | 28 | label or_continue_on: 29 | "Let's test variables!" 30 | 31 | "What's your favorite color?" 32 | 33 | menu: 34 | "Red": 35 | jump red 36 | "Green": 37 | jump green 38 | "Blue": 39 | jump blue 40 | 41 | label red: 42 | $ favorite_color = "red" 43 | 44 | jump variables_result 45 | 46 | label green: 47 | $ favorite_color = "green" 48 | 49 | jump variables_result 50 | 51 | label blue: 52 | $ favorite_color = "blue" 53 | 54 | jump variables_result 55 | 56 | label variables_result: 57 | if favorite_color == "red": 58 | "Red? That is a cool color!" 59 | elif favorite_color == "blue": 60 | "Blue? That is my favorite color too!" 61 | else: 62 | "Green? That is so pretty!" 63 | 64 | jump now_to_the_end_of_the_demo 65 | 66 | label now_to_the_end_of_the_demo: 67 | "That's the end of the demo!" 68 | 69 | jump end 70 | 71 | label end: 72 | pass 73 | 74 | -------------------------------------------------------------------------------- /twine_to_rpy.py: -------------------------------------------------------------------------------- 1 | # Generate rpy files from a Twine HTML 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | from PyQt4.QtGui import * 7 | from twine_to_rpy_model import TwineToRenpy 8 | 9 | 10 | class TwineToRenpyView(QWidget): 11 | def __init__(self): 12 | super(TwineToRenpyView, self).__init__() 13 | 14 | self.model = TwineToRenpy() 15 | 16 | self.resize(700, 500) 17 | self.center() 18 | 19 | # MAIN TAB 20 | 21 | # PATHS 22 | self.html_path_label = QLabel('Twine HTML file', self) 23 | self.script_dir_label = QLabel('Ren\'Py script directory', self) 24 | self.gen_dir_btn = QPushButton('Autofill', self) 25 | self.html_path_le = QLineEdit(self.model.data['html_path'], self) 26 | self.script_dir_le = QLineEdit(self.model.data['script_dir'], self) 27 | self.html_path_btn = QPushButton('...', self) 28 | self.script_dir_btn = QPushButton('...', self) 29 | width = self.html_path_btn.fontMetrics().boundingRect(' ... ').width() 30 | large_width = self.html_path_btn.fontMetrics().boundingRect(' Autofill ').width() 31 | self.html_path_btn.setMaximumWidth(width) 32 | self.script_dir_btn.setMaximumWidth(width) 33 | self.gen_dir_btn.setMaximumWidth(large_width) 34 | 35 | # Path layouts 36 | html_path_layout = QHBoxLayout() 37 | script_dir_layout = QHBoxLayout() 38 | html_path_layout.addWidget(self.html_path_label) 39 | html_path_layout.addWidget(self.html_path_le) 40 | html_path_layout.addWidget(self.html_path_btn) 41 | script_dir_layout.addWidget(self.script_dir_label) 42 | script_dir_layout.addWidget(self.script_dir_le) 43 | script_dir_layout.addWidget(self.gen_dir_btn) 44 | script_dir_layout.addWidget(self.script_dir_btn) 45 | paths_layout = QVBoxLayout() 46 | paths_layout.addLayout(html_path_layout) 47 | paths_layout.addLayout(script_dir_layout) 48 | # paths_layout.addWidget(self.gen_dir_btn) 49 | 50 | # Path groupbox 51 | paths_groupbox = QGroupBox('Filepaths', self) 52 | paths_groupbox.setLayout(paths_layout) 53 | 54 | # SETTINGS 55 | settings_layout = QFormLayout() 56 | 57 | vars_layout = QFormLayout() 58 | 59 | # Twine or Ren'Py mode 60 | self.twine_mode_label = QLabel('Game originally written for', self) 61 | self.twine_mode_combobox = QComboBox(self) 62 | twine_mode_items = ['Ren\'Py (Don\'t add narrator quotes)', 'Twine (Add narrator quotes)'] 63 | self.twine_mode_combobox.addItems(twine_mode_items) 64 | self.twine_mode_combobox.setCurrentIndex(self.model.get_config_value('twine_mode')) 65 | settings_layout.addRow(self.twine_mode_label, self.twine_mode_combobox) 66 | 67 | # Document break tag 68 | self.start_name_label = QLabel('Start file name', self) 69 | self.start_name_le = QLineEdit(self.model.get_config_value('start_name'), self) 70 | settings_layout.addRow(self.start_name_label, self.start_name_le) 71 | 72 | # Document break tag 73 | self.doc_break_label = QLabel('End of document tag', self) 74 | self.doc_break_le = QLineEdit(self.model.get_config_value('doc_break'), self) 75 | settings_layout.addRow(self.doc_break_label, self.doc_break_le) 76 | 77 | # Label starting with number 78 | self.number_first_label = QLabel('When labels start with a number:', self) 79 | self.number_first_combobox = QComboBox(self) 80 | number_first_items = ['Convert first digit to string', 'Add string to beginning of label'] 81 | self.number_first_combobox.addItems(number_first_items) 82 | settings_layout.addRow(self.number_first_label, self.number_first_combobox) 83 | 84 | # In front of labels starting with number 85 | self.number_str_label = QLabel('String to add', self) 86 | self.number_str_label.setEnabled(False) 87 | self.number_str_le = QLineEdit(self.model.get_config_value('number_start_str'), self) 88 | self.number_str_le.setEnabled(False) 89 | settings_layout.addRow(self.number_str_label, self.number_str_le) 90 | 91 | # Settings groupbox 92 | settings_groupbox = QGroupBox('Conversion settings', self) 93 | settings_groupbox.setLayout(settings_layout) 94 | 95 | # Character definitions 96 | self.char_def_label = QLabel('Define characters', self) 97 | self.char_def_checkbox = QCheckBox(self) 98 | self.char_def_checkbox.setChecked(self.model.get_config_value('char_def')) 99 | vars_layout.addRow(self.char_def_label, self.char_def_checkbox) 100 | 101 | # Variable definitions 102 | self.var_def_label = QLabel('Define variables', self) 103 | self.var_def_checkbox = QCheckBox(self) 104 | self.var_def_checkbox.setChecked(self.model.get_config_value('var_def')) 105 | vars_layout.addRow(self.var_def_label, self.var_def_checkbox) 106 | 107 | # Variable default values 108 | self.var_default_label = QLabel('Set variables by default to:', self) 109 | self.var_default_combobox = QComboBox(self) 110 | var_default_items = ['Passage value', 'A default value'] 111 | self.var_default_combobox.addItems(var_default_items) 112 | self.var_default_combobox.setCurrentIndex(self.model.get_config_value('var_mode')) 113 | vars_layout.addRow(self.var_default_label, self.var_default_combobox) 114 | 115 | # Boolean default value 116 | self.bool_default_label = QLabel('Boolean default value', self) 117 | self.bool_default_combobox = QComboBox(self) 118 | var_default_items = ['False', 'True'] 119 | self.bool_default_combobox.addItems(var_default_items) 120 | self.bool_default_combobox.setCurrentIndex(self.model.get_config_value('bool_default')) 121 | vars_layout.addRow(self.bool_default_label, self.bool_default_combobox) 122 | 123 | # Number default value 124 | self.num_default_label = QLabel('Number default value', self) 125 | self.num_default_spinbox = QSpinBox() 126 | self.num_default_spinbox.setValue(self.model.get_config_value('num_default')) 127 | vars_layout.addRow(self.num_default_label, self.num_default_spinbox) 128 | 129 | # String default value 130 | self.str_default_label = QLabel('String default value', self) 131 | self.str_default_combobox = QComboBox(self) 132 | str_default_items = ['None', 'Blank string'] 133 | self.str_default_combobox.addItems(str_default_items) 134 | self.str_default_combobox.setCurrentIndex(self.model.get_config_value('str_default')) 135 | vars_layout.addRow(self.str_default_label, self.str_default_combobox) 136 | 137 | # Chars and variables groupbox 138 | vars_groupbox = QGroupBox('Characters and variables', self) 139 | vars_groupbox.setLayout(vars_layout) 140 | 141 | # CONFIG 142 | self.config_load_btn = QPushButton('Load', self) 143 | self.config_save_btn = QPushButton('Save', self) 144 | self.config_reset_btn = QPushButton('Reset to default', self) 145 | self.config_open_btn = QPushButton('Open', self) 146 | 147 | # Config layout 148 | config_layout = QHBoxLayout() 149 | config_layout.addWidget(self.config_load_btn) 150 | config_layout.addWidget(self.config_save_btn) 151 | config_layout.addWidget(self.config_reset_btn) 152 | config_layout.addWidget(self.config_open_btn) 153 | 154 | # Config groupbox 155 | config_groupbox = QGroupBox('Config', self) 156 | config_groupbox.setLayout(config_layout) 157 | 158 | # RUN BUTTON 159 | main_spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) 160 | self.execute_btn = QPushButton('Run', self) 161 | 162 | # Main layout 163 | main_layout = QVBoxLayout() 164 | main_layout.addWidget(paths_groupbox) 165 | main_layout.addWidget(settings_groupbox) 166 | main_layout.addWidget(vars_groupbox) 167 | main_layout.addWidget(config_groupbox) 168 | main_layout.addItem(main_spacer) 169 | main_layout.addWidget(self.execute_btn) 170 | 171 | # REPLACE CHARS TAB 172 | 173 | # Header 174 | self.og_char_label = QLabel('Twine term', self) 175 | self.new_char_label = QLabel('Ren\'Py term', self) 176 | char_title_layout = QHBoxLayout() 177 | char_title_layout.addWidget(self.og_char_label) 178 | char_title_layout.addWidget(self.new_char_label) 179 | replace_chars_layout = QVBoxLayout() 180 | replace_chars_layout.addLayout(char_title_layout) 181 | 182 | # Scroll area 183 | self.replace_scroll = QScrollArea() 184 | self.replace_scroll.setFixedHeight(400) 185 | self.replace_scroll.setWidgetResizable(True) 186 | 187 | # Scroll widget 188 | self.scroll_widget = QWidget() 189 | self.replace_lineedits_layout = QVBoxLayout(self.scroll_widget) 190 | 191 | # Char replace lists 192 | self.char_replace_layout_list = [] 193 | self.og_char_le_list = [] 194 | self.new_char_le_list = [] 195 | self.del_button_list = [] 196 | 197 | # Create the term replace UI 198 | for i, char_dict in enumerate(self.model.custom_char_replace_list): 199 | for og_char, new_char in char_dict.iteritems(): 200 | # Create widgets 201 | og_char_le = QLineEdit(og_char, self) 202 | new_char_le = QLineEdit(new_char, self) 203 | char_del_button = QPushButton('x', self) 204 | width = char_del_button.fontMetrics().boundingRect(' x ').width() + 7 205 | char_del_button.setMaximumWidth(width) 206 | 207 | # Add widgets to a horizontal layout 208 | char_layout = QHBoxLayout() 209 | char_layout.addWidget(og_char_le) 210 | char_layout.addWidget(new_char_le) 211 | char_layout.addWidget(char_del_button) 212 | 213 | # Add to the char layout 214 | self.replace_lineedits_layout.addLayout(char_layout) 215 | 216 | # Make connections 217 | og_char_le.textEdited.connect(lambda: self.update_og_term(og_char_le)) 218 | new_char_le.textEdited.connect(lambda: self.update_new_term(new_char_le)) 219 | 220 | # Add to the lists 221 | self.char_replace_layout_list.append(char_layout) 222 | self.og_char_le_list.append(og_char_le) 223 | self.new_char_le_list.append(new_char_le) 224 | self.del_button_list.append(char_del_button) 225 | 226 | # Add spacer at the end so they line up from the top nicely 227 | # instead of weirdly evenly spaced 228 | self.replace_spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) 229 | self.replace_lineedits_layout.addItem(self.replace_spacer) 230 | 231 | # Set a widget to scroll widget in order for it to work 232 | self.replace_scroll.setWidget(self.scroll_widget) 233 | 234 | self.add_char_btn = QPushButton('Add term', self) 235 | replace_chars_layout.addWidget(self.replace_scroll) 236 | replace_chars_layout.addWidget(self.add_char_btn) 237 | 238 | # TABS 239 | tabs = QTabWidget() 240 | 241 | # Create tabs 242 | tab1 = QWidget() 243 | tab2 = QWidget() 244 | 245 | # Resize width and height 246 | tabs.resize(690, 490) 247 | 248 | # Set tab layouts 249 | tab1.setLayout(main_layout) 250 | tab2.setLayout(replace_chars_layout) 251 | 252 | # Add tabs 253 | tabs.addTab(tab1, 'Main') 254 | tabs.addTab(tab2, 'Replace') 255 | 256 | tabs_layout = QVBoxLayout() 257 | tabs_layout.addWidget(tabs) 258 | 259 | # Status 260 | self.status_label = QLabel('Ready!') 261 | 262 | # Add tabs and status label 263 | all_layout = QVBoxLayout() 264 | all_layout.addLayout(tabs_layout) 265 | all_layout.addWidget(self.status_label) 266 | 267 | self.setLayout(all_layout) 268 | 269 | self.setWindowTitle('Twine to Ren\'Py') 270 | self.show() 271 | 272 | self.make_connections() 273 | 274 | def center(self): 275 | """ 276 | Center the window on the screen 277 | """ 278 | qr = self.frameGeometry() 279 | cp = QDesktopWidget().availableGeometry().center() 280 | qr.moveCenter(cp) 281 | self.move(qr.topLeft()) 282 | 283 | def get_path(self, lineedit): 284 | """ 285 | Display a dialog to get the path of the Twine html file and update the lineedit with it 286 | 287 | Args: 288 | lineedit: (lineedit) The lineedit displaying the filepath 289 | """ 290 | filepath = QFileDialog().getOpenFileName(self, 'Select file', '/', 'HTML files (*.html)') 291 | if filepath: 292 | lineedit.setText(filepath) 293 | 294 | def get_dir(self, lineedit): 295 | """ 296 | Display a dialog to get the directory and update the lineedit with it 297 | 298 | Args: 299 | lineedit: (lineedit) The lineedit displaying the directory 300 | """ 301 | file_dir = QFileDialog().getExistingDirectory(self, 'Select directory') 302 | if file_dir: 303 | lineedit.setText(file_dir) 304 | 305 | def gen_dir(self): 306 | """ 307 | Generate the directory 308 | """ 309 | dir_path = str(self.html_path_le.text()) 310 | self.script_dir_le.setText(os.path.dirname(dir_path)) 311 | self.set_status('Set directory path "{}"'.format(dir_path)) 312 | 313 | def update_terms(self, og_char, new_char, index): 314 | """ 315 | Update the terms in the dictionary by completely clearing it then setting the new one 316 | The dictionary should only ever be one pair 317 | 318 | Args: 319 | og_char: (str) The Twine term to be replaced 320 | new_char: (str) The Ren'Py term 321 | index: (int) The index of the term to be updated 322 | """ 323 | self.model.custom_char_replace_list[index].clear() 324 | self.model.custom_char_replace_list[index] = {og_char: new_char} 325 | 326 | # TODO Not sure if this is broken... 327 | def update_og_term(self, og_char_le): 328 | """ 329 | Update the old term in the model data 330 | 331 | Args: 332 | og_char_le: (lineedit) The lineedit of the old term 333 | """ 334 | # Get the index 335 | index = self.og_char_le_list.index(og_char_le) 336 | # Get both terms 337 | og_char = str(og_char_le.text()) 338 | new_char = str(self.new_char_le_list[index].text()) 339 | self.update_terms(og_char, new_char, index) 340 | 341 | def update_new_term(self, new_char_le): 342 | """ 343 | Update the new term in the model data 344 | 345 | Args: 346 | new_char_le: (lineedit) The lineedit of the new term 347 | """ 348 | # Get the index 349 | index = self.new_char_le_list.index(new_char_le) 350 | # Get both terms 351 | og_char = str(self.new_char_le_list[index].text()) 352 | new_char = str(new_char_le.text()) 353 | self.update_terms(og_char, new_char, index) 354 | 355 | def add_term(self): 356 | """ 357 | Add a new Twine to Ren'Py replacement term by creating the UI and adding a line to the list 358 | """ 359 | # Create term widgets 360 | og_char_le = QLineEdit(self) 361 | new_char_le = QLineEdit(self) 362 | char_del_button = QPushButton('x', self) 363 | width = char_del_button.fontMetrics().boundingRect(' x ').width() + 7 364 | char_del_button.setMaximumWidth(width) 365 | 366 | # Add widgets to a horizontal layout 367 | char_layout = QHBoxLayout() 368 | char_layout.addWidget(og_char_le) 369 | char_layout.addWidget(new_char_le) 370 | char_layout.addWidget(char_del_button) 371 | 372 | # Add to the char layout 373 | self.replace_lineedits_layout.removeItem(self.replace_spacer) 374 | self.replace_lineedits_layout.addLayout(char_layout) 375 | self.replace_lineedits_layout.addItem(self.replace_spacer) 376 | 377 | # Make connections 378 | og_char_le.textEdited.connect(lambda: self.update_og_term(og_char_le)) 379 | new_char_le.textEdited.connect(lambda: self.update_new_term(new_char_le)) 380 | 381 | # Add to lists 382 | self.char_replace_layout_list.append(char_layout) 383 | self.og_char_le_list.append(og_char_le) 384 | self.new_char_le_list.append(new_char_le) 385 | self.del_button_list.append(char_del_button) 386 | char_del_button.clicked.connect(lambda state, x=char_del_button: self.del_term(x)) 387 | 388 | # Add the blank term to the data 389 | self.model.custom_char_replace_list.append({}) 390 | 391 | # Update status 392 | self.set_status('Added term') 393 | 394 | def del_term(self, del_btn): 395 | """ 396 | Delete a replacement term 397 | 398 | Args: 399 | del_btn: (pushbutton) The delete button associated with the term line to delete 400 | """ 401 | # Get the index of the button to delete 402 | del_index = self.del_button_list.index(del_btn) 403 | # Delete the layout 404 | self.del_layout(self.char_replace_layout_list.pop(del_index)) 405 | # Remove the widgets from the lists 406 | del self.og_char_le_list[del_index] 407 | del self.new_char_le_list[del_index] 408 | del self.del_button_list[del_index] 409 | # Delete the data line 410 | del self.model.custom_char_replace_list[del_index] 411 | 412 | # Status 413 | self.set_status('Deleted term') 414 | 415 | def del_layout(self, layout): 416 | """ 417 | Remove a layout and all its associated widgets 418 | 419 | Args: 420 | layout: (layout) PyQt layout to remove 421 | """ 422 | if layout is not None: 423 | while layout.count(): 424 | item = layout.takeAt(0) 425 | widget = item.widget() 426 | if widget is not None: 427 | widget.setParent(None) 428 | else: 429 | self.del_layout(item.layout()) 430 | 431 | def update_le_model_data(self, key_name, lineedit): 432 | """ 433 | Update model data from lineedit text 434 | 435 | Args: 436 | key_name: (str) Key of the data to update 437 | lineedit: (lineedit) Lineedit to pull the text from 438 | """ 439 | self.model.data[key_name] = str(lineedit.text()) 440 | 441 | def update_cb_model_data(self, key_name, combobox): 442 | """ 443 | Update model data with combobox index 444 | 445 | Args: 446 | key_name: (str) Key of the data to set 447 | combobox: (combobox) Combobox to get data from 448 | """ 449 | self.model.data[key_name] = combobox.currentIndex() 450 | 451 | def update_cb_bool_model_data(self): 452 | """ 453 | Update model data with combobox index 454 | """ 455 | bool_value = False 456 | if self.bool_default_combobox.currentIndex(): 457 | bool_value = True 458 | self.model.data['bool_default'] = bool_value 459 | 460 | def update_checkbox_model_data(self, key_name, checkbox): 461 | """ 462 | Update the value of the key_name in model data with combobox index 463 | 464 | Args: 465 | key_name: (str) Key of the data to set 466 | checkbox: (checkbox) Checkbox to get data from 467 | """ 468 | self.model.data[key_name] = checkbox.isChecked() 469 | 470 | def update_sb_value(self): 471 | """ 472 | Update default number in model data with spinbox number 473 | """ 474 | self.model.data['num_default'] = self.num_default_spinbox.value() 475 | 476 | def set_number_mode_state(self): 477 | """ 478 | Update model data with proper mode for handling number first labels 479 | """ 480 | number_mode = self.number_first_combobox.currentIndex() 481 | self.number_str_label.setEnabled(number_mode) 482 | self.number_str_le.setEnabled(number_mode) 483 | self.model.data['number_mode'] = number_mode 484 | 485 | def set_var_define_state(self): 486 | """ 487 | Update model data with proper mode for variable definitions 488 | """ 489 | var_def = self.var_def_checkbox.isChecked() 490 | self.model.data['var_def'] = var_def 491 | self.var_default_label.setEnabled(var_def) 492 | self.var_default_combobox.setEnabled(var_def) 493 | # Next decide wheter default values are enabled 494 | var_default_enabled = var_def 495 | # If we're turning on variables, set it to whatever the current var default mode is 496 | # If not, it will just retain the False state from the var_def 497 | if var_def: 498 | var_default_enabled = self.var_default_combobox.currentIndex() 499 | self.bool_default_label.setEnabled(var_default_enabled) 500 | self.bool_default_combobox.setEnabled(var_default_enabled) 501 | self.num_default_label.setEnabled(var_default_enabled) 502 | self.num_default_spinbox.setEnabled(var_default_enabled) 503 | self.str_default_label.setEnabled(var_default_enabled) 504 | self.str_default_combobox.setEnabled(var_default_enabled) 505 | 506 | def set_var_default_state(self): 507 | """ 508 | Update model data with proper mode for variable defaults 509 | """ 510 | var_mode = self.var_default_combobox.currentIndex() 511 | self.bool_default_label.setEnabled(var_mode) 512 | self.bool_default_combobox.setEnabled(var_mode) 513 | self.num_default_label.setEnabled(var_mode) 514 | self.num_default_spinbox.setEnabled(var_mode) 515 | self.str_default_label.setEnabled(var_mode) 516 | self.str_default_combobox.setEnabled(var_mode) 517 | self.model.data['var_mode'] = var_mode 518 | 519 | def populate_le(self, lineedit, key): 520 | """ 521 | Populate a lineedit with the data value from a key 522 | 523 | Args: 524 | lineedit: (lineedit) Lineedit to populate 525 | key: (str) Key of the data to look up 526 | """ 527 | lineedit.setText(str(self.model.data.get(key, ''))) 528 | 529 | def repopulate_ui(self): 530 | """ 531 | Populate the UI with data from the model 532 | """ 533 | self.populate_le(self.html_path_le, 'html_path') 534 | self.populate_le(self.script_dir_le, 'script_dir') 535 | self.populate_le(self.number_str_le, 'number_start_str') 536 | self.populate_le(self.doc_break_le, 'doc_break') 537 | self.number_first_combobox.setCurrentIndex(self.model.data.get('number_mode', 0)) 538 | 539 | # Clear the replace lists and delete the layout 540 | self.del_layout(self.replace_lineedits_layout) 541 | self.char_replace_layout_list = [] 542 | self.og_char_le_list = [] 543 | self.new_char_le_list = [] 544 | self.del_button_list = [] 545 | 546 | # Remake the replace terms UI 547 | for i, char_dict in enumerate(self.model.custom_char_replace_list): 548 | for og_char, new_char in char_dict.iteritems(): 549 | # Create widgets 550 | og_char_le = QLineEdit(og_char, self) 551 | new_char_le = QLineEdit(new_char, self) 552 | char_del_button = QPushButton('x', self) 553 | width = char_del_button.fontMetrics().boundingRect(' x ').width() + 7 554 | char_del_button.setMaximumWidth(width) 555 | # Add widgets to a horizontal layout 556 | char_layout = QHBoxLayout() 557 | char_layout.addWidget(og_char_le) 558 | char_layout.addWidget(new_char_le) 559 | char_layout.addWidget(char_del_button) 560 | # Add to the char layout 561 | self.replace_lineedits_layout.addLayout(char_layout) 562 | # Add to the lists 563 | self.char_replace_layout_list.append(char_layout) 564 | self.og_char_le_list.append(og_char_le) 565 | self.new_char_le_list.append(new_char_le) 566 | self.del_button_list.append(char_del_button) 567 | self.replace_lineedits_layout.addItem(self.replace_spacer) 568 | # Make the term replace connections again 569 | self.make_char_connections() 570 | 571 | def load_config(self): 572 | """ 573 | Load the config data again and repopulate the UI 574 | """ 575 | if self.model.load_config(): 576 | self.repopulate_ui() 577 | self.set_status('Reloaded data from config.json') 578 | else: 579 | self.set_status('Could not reload data from config.json') 580 | 581 | def save_config(self): 582 | """ 583 | Write the config data to config.json 584 | """ 585 | self.model.write_config() 586 | self.set_status('Saved data to config.json') 587 | 588 | def reset_config(self): 589 | """ 590 | Reset config to default data and repopulate the UI 591 | """ 592 | self.model.reset_config() 593 | self.repopulate_ui() 594 | self.set_status('Reset config.json to default') 595 | 596 | def open_config(self): 597 | """ 598 | Open config in notepad 599 | """ 600 | self.set_status('Opening config in Notepad') 601 | self.model.open_config() 602 | 603 | def run(self): 604 | """ 605 | Run the script. Update status with whether it succeeds or fails 606 | """ 607 | if self.model.run(): 608 | self.set_status('Run successful! Output Ren\'Py scripts to "{}"'.format(self.model.data['script_dir'])) 609 | else: 610 | self.set_status('Failed! Check the file exists and for empty passages.') 611 | 612 | def set_status(self, text): 613 | """ 614 | Update the status label at the bottom of the UI 615 | """ 616 | self.status_label.setText(text) 617 | 618 | def make_char_connections(self): 619 | """ 620 | Make the char UI connections. This is broken out because when the UI gets repopulated, 621 | the replace term connections will need to be remade again 622 | """ 623 | # Use lambda state to make sure the current widget is passed in 624 | for del_button in self.del_button_list: 625 | del_button.clicked.connect(lambda state, x=del_button: self.del_term(x)) 626 | for og_char_le in self.og_char_le_list: 627 | og_char_le.textEdited.connect(lambda state, x=og_char_le: self.update_term(x)) 628 | for new_char_le in self.og_char_le_list: 629 | new_char_le.textEdited.connect(lambda state, x=new_char_le: self.update_term(x)) 630 | 631 | def make_connections(self): 632 | """ 633 | Make UI connections 634 | """ 635 | # Filepaths 636 | self.html_path_btn.clicked.connect(lambda: self.get_path(self.html_path_le)) 637 | self.script_dir_btn.clicked.connect(lambda: self.get_dir(self.script_dir_le)) 638 | # Text *changed* instead of edited for the below since the file dialogs will update the lineedit 639 | # But in addition this will also account for edits 640 | self.html_path_le.textChanged.connect(lambda: self.update_le_model_data('html_path', self.html_path_le)) 641 | self.script_dir_le.textChanged.connect(lambda: self.update_le_model_data('script_dir', self.script_dir_le)) 642 | self.gen_dir_btn.clicked.connect(self.gen_dir) 643 | 644 | # Conversion settings 645 | self.twine_mode_combobox.currentIndexChanged.connect( 646 | lambda: self.update_cb_model_data('twine_mode', self.twine_mode_combobox)) 647 | self.start_name_le.textChanged.connect(lambda: self.update_le_model_data('start_name', self.start_name_le)) 648 | self.doc_break_le.textChanged.connect(lambda: self.update_le_model_data('doc_break', self.doc_break_le)) 649 | self.number_first_combobox.currentIndexChanged.connect(self.set_number_mode_state) 650 | self.number_str_le.textChanged.connect(lambda: self.update_le_model_data('number_start_str', self.doc_break_le)) 651 | 652 | # Characters and variables 653 | self.char_def_checkbox.stateChanged.connect( 654 | lambda: self.update_checkbox_model_data('char_def', self.char_def_checkbox)) 655 | self.var_def_checkbox.stateChanged.connect(self.set_var_define_state) 656 | self.var_default_combobox.currentIndexChanged.connect(self.set_var_default_state) 657 | self.bool_default_combobox.currentIndexChanged.connect(self.update_cb_bool_model_data) 658 | self.num_default_spinbox.valueChanged.connect(self.update_sb_value) 659 | self.str_default_combobox.currentIndexChanged.connect( 660 | lambda: self.update_cb_model_data('str_default', self.str_default_combobox)) 661 | 662 | # Config buttons 663 | self.config_reset_btn.clicked.connect(self.reset_config) 664 | self.config_load_btn.clicked.connect(self.load_config) 665 | self.config_save_btn.clicked.connect(self.save_config) 666 | self.config_open_btn.clicked.connect(self.open_config) 667 | 668 | # Run button 669 | self.execute_btn.clicked.connect(self.run) 670 | 671 | # Replace terms 672 | self.add_char_btn.clicked.connect(self.add_term) 673 | 674 | # Replace character connections are a separate function since they need to be rerun when UI is regenerated 675 | self.make_char_connections() 676 | 677 | 678 | def main(): 679 | app = QApplication(sys.argv) 680 | TwineToRenpyView() 681 | sys.exit(app.exec_()) 682 | 683 | 684 | if __name__ == '__main__': 685 | main() 686 | -------------------------------------------------------------------------------- /twine_to_rpy_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import json 5 | import webbrowser 6 | from collections import OrderedDict 7 | from bs4 import BeautifulSoup 8 | 9 | # Replace dict: {Original term: Replace term} 10 | # Note for defaults: 11 | # Tabs get converted to spaces 12 | # Newline should have a tab in front of it 13 | # The commented out line is easier to run from python, but otherwise causes errors from pyinstaller dist 14 | DEFAULT_CONFIG_DATA = { 15 | 'html_path': '', 16 | 'script_dir': '', 17 | 'default_replace': [{'\t': ' '}, {'\n': '\n '}], 18 | 'custom_replace': [{'\u201c': '\"'}, {'\u201d': '\"'}, {'\u2019': '\\\''}, 19 | {'Double-click this passage to edit it.': 'pass'}], 20 | # 'custom_replace': [{'“': '\"'}, {'”': '\"'}, {'’': '\\\''}, {'Double-click this passage to edit it.': 'pass'}], 21 | 'twine_mode': 0, 22 | 'start_name': 'start', 23 | 'doc_break': 'doc_break', 24 | 'char_def': True, 25 | 'var_def': True, 26 | 'var_mode': 1, 27 | 'bool_default': False, 28 | 'num_default': 0, 29 | 'str_default': 1, 30 | 'number_mode': 0, 31 | 'number_start_str': 'label_' 32 | } 33 | 34 | DIGIT_TO_STR_DICT = {'0': 'zero', '1': 'one', '2': 'two', '3': 'three', '4': 'four', '5': 'five', 35 | '6': 'six', '7': 'seven', '8': 'eight', '9': 'nine', '10': 'ten'} 36 | 37 | 38 | QUOTE_CHAR_LIST = ['"', '“', '”'] 39 | 40 | 41 | def decode_bytes(o): 42 | """ 43 | Decode using UTF-8 44 | """ 45 | return o.decode('utf-8') 46 | 47 | 48 | def digit_to_string(string): 49 | """ 50 | Convert a digit to its string equivalent 51 | 52 | Args: 53 | string: (str) String to convert 54 | 55 | Returns: 56 | (str): Written out version of the digit 57 | """ 58 | # Iterate over the list of characters to update 59 | for og_char, new_char in DIGIT_TO_STR_DICT.iteritems(): 60 | string = string.replace(og_char, new_char) 61 | return string 62 | 63 | 64 | def only_whitespace_after_choice(choice_split): 65 | """ 66 | Check if there's only whitespace after a choice 67 | This is useful for finding a series of Twine choices 68 | 69 | Args: 70 | choice_split: (list) The passage string after split with '[[' then currently ']]' 71 | 72 | Returns: 73 | (bool): Whether there is only whitespace after the choice 74 | """ 75 | # Check first that the string was split in the first place 76 | if len(choice_split) > 1: 77 | # If it was, strip the string of trailing whitespace + newlines 78 | str_between_choices = choice_split[1].rstrip() 79 | # If its new length is less than 1, then it only contains whitespace 80 | if len(str_between_choices) < 1: 81 | return True 82 | else: 83 | return False 84 | 85 | 86 | def strip_quotes(choice_text): 87 | """ 88 | Strip quotes from choice text since it will be hardcoded anyway. 89 | 90 | Args: 91 | choice_text: (str) The choice text that will be on a Ren'Py 92 | 93 | Returns: 94 | (str): The choice text stripped of any quotes 95 | """ 96 | for quote_char in QUOTE_CHAR_LIST: 97 | choice_text = choice_text.replace(quote_char, '') 98 | return choice_text 99 | 100 | 101 | class TwineToRenpy: 102 | def __init__(self): 103 | # Get the config path by getting current directory 104 | current_dir = os.path.dirname(os.path.abspath(__file__)) 105 | self.config_path = os.path.join(current_dir, 'config.json') 106 | 107 | # Check if the config json exists 108 | if os.path.exists(self.config_path): 109 | # If it does, load the json data from the config path 110 | json_data = open(self.config_path).read() 111 | self.data = json.loads(json_data, object_pairs_hook=OrderedDict) 112 | else: 113 | # If not, load the default data and create a new config json file 114 | self.reset_config() 115 | 116 | # Create replace lists 117 | self.default_char_replace_list = [] 118 | self.custom_char_replace_list = [] 119 | self.char_replace_list = [] 120 | self.load_replace_lists() 121 | 122 | def load_replace_lists(self): 123 | """ 124 | Load the lists of replace terms 125 | """ 126 | self.default_char_replace_list = self.data['default_replace'] 127 | self.custom_char_replace_list = self.data['custom_replace'] 128 | 129 | def load_config(self): 130 | """ 131 | Load data from the config file 132 | 133 | Returns: 134 | (bool): Whether the config could be loaded 135 | """ 136 | # Check if the config json exists 137 | if os.path.exists(self.config_path): 138 | # If it does, load the json data from the config path 139 | json_data = open(self.config_path).read() 140 | self.data = json.loads(json_data, object_pairs_hook=OrderedDict) 141 | self.load_replace_lists() 142 | return True 143 | else: 144 | return False 145 | 146 | def write_config(self): 147 | """ 148 | Write current data to the config file 149 | """ 150 | self.data['custom_replace'] = self.custom_char_replace_list 151 | with open(self.config_path, 'w') as outfile: 152 | json.dump(self.data, outfile, indent=4) 153 | 154 | def reset_config(self): 155 | """ 156 | Reset the config.json + data to the default config data 157 | """ 158 | self.data = OrderedDict(DEFAULT_CONFIG_DATA) 159 | with open(self.config_path, 'w') as outfile: 160 | json.dump(self.data, outfile, default=decode_bytes, indent=4) 161 | 162 | def open_config(self): 163 | """ 164 | Open the config file using webbrowser 165 | """ 166 | webbrowser.open(self.config_path) 167 | 168 | def get_config_value(self, config_key): 169 | """ 170 | Get the config value if it exists 171 | If it doesn't, default to returning the default config data 172 | 173 | Args: 174 | config_key: (str) Config key to find 175 | 176 | Returns: 177 | (data): Config value if found, default value if not 178 | """ 179 | return self.data.get(config_key, DEFAULT_CONFIG_DATA.get(config_key, '')) 180 | 181 | def to_rpy_name(self, name): 182 | """ 183 | Convert passage titles/jump statements to label friendly names 184 | 185 | Args: 186 | name: (str) Original Twine passage name 187 | 188 | Returns: 189 | (str): New Ren'Py label name 190 | """ 191 | name = name.lower().replace(' ', '_') 192 | new_name = '' 193 | for char_index, char in enumerate(name): 194 | # Check if the first character is a number 195 | if char_index is 1: 196 | # If so, either change the digit to a string 197 | # or add a string in front of the label 198 | if char.isdigit(): 199 | if self.data['number_mode'] == 0: 200 | new_name = digit_to_string(char) 201 | else: 202 | new_name = self.data['number_start_str'] + char 203 | else: 204 | new_name += char 205 | else: 206 | # Check if the current character is alphanumeric or _ which is acceptable 207 | if char.isalnum() or char == '_': 208 | new_name += char 209 | return new_name 210 | 211 | def data_validated(self): 212 | """ 213 | Validate the data needed to execute the script 214 | 215 | Returns: 216 | (Bool): Whether the script has the appropriate paths to run 217 | """ 218 | # Check the html path has data stored 219 | html_path = self.data.get('html_path', False) 220 | if html_path: 221 | # If it does, check if the path exists and that the extension is .html 222 | if not os.path.exists(html_path) or os.path.splitext(str(html_path).lower())[1] != '.html': 223 | return False 224 | else: 225 | return False 226 | # Check that the script dir has a directory stored and also that it's a directory 227 | script_dir = self.data.get('script_dir', False) 228 | if script_dir: 229 | if not os.path.isdir(script_dir): 230 | return False 231 | else: 232 | return False 233 | # If this looks good, return True 234 | return True 235 | 236 | def run(self): 237 | """ 238 | Convert a Twine HTML file to Ren'Py files and output them to a script directory 239 | """ 240 | # First validate the data: If it's not enough to run, return False 241 | if not self.data_validated(): 242 | return False 243 | 244 | # Save the settings 245 | self.write_config() 246 | 247 | # Open the HTML file as a soup 248 | with open(self.data['html_path'], 'r') as html_file: 249 | soup = BeautifulSoup(html_file, 'html.parser') 250 | 251 | # Get the list of passages 252 | raw_passage_list = soup.find_all('tw-passagedata') 253 | 254 | document_passage = '' 255 | rpy_file_name = self.get_config_value('start_name') + '.rpy' 256 | create_file_name = False 257 | passage_list = [] 258 | raw_name_list = [] 259 | 260 | # Get list of all variables to declare at the beginning 261 | raw_variables_dict = {} 262 | 263 | # Check for <> 264 | # Swap to $ [variable] = [value] 265 | set_phrase = '< 0: 287 | # Check if the line starts with a letter, which is likely a speaking character 288 | # If formatted properly for Ren'Py scripting 289 | if newline[0].isalpha(): 290 | char_name_split = newline.split(' "', 1) 291 | if len(char_name_split) > 1: 292 | raw_name_list.append(char_name_split[0]) 293 | # Define variables if asked to generate 294 | if self.get_config_value('var_def'): 295 | # Check if we're setting variables 296 | if set_phrase in unicode_passage: 297 | set_passage_split_list = unicode_passage.split(set_phrase) 298 | # Start after the first instance of the set phrase 299 | for set_passage_split in set_passage_split_list[1:]: 300 | # Get the set statement by splitting at next immediate instance of >> 301 | set_passage_split_list = set_passage_split.split('>>', 1) 302 | # This is our set statement 303 | set_passage_statement = set_passage_split_list[0] 304 | # Split out the variable name and the value 305 | variable_name, variable_value = set_passage_statement.split(' to ') 306 | # Replace Twine boolean phrase with the Ren'Py/Python accepted one 307 | for boolean_phrase, boolean_replace in boolean_replace_dict.iteritems(): 308 | if boolean_phrase in set_passage_statement: 309 | variable_value = set_passage_statement.replace(boolean_phrase, 310 | boolean_replace) 311 | # If setting vars to a default value, try to discern the variable type 312 | # Then set to the specified defaults 313 | if self.get_config_value('var_mode'): 314 | if variable_value.strip() == 'True' or variable_value.strip() == 'False': 315 | variable_value = self.get_config_value('bool_default') 316 | if variable_value.isdigit(): 317 | variable_value = self.get_config_value('num_default') 318 | else: 319 | # Set this to string or None based on default str setting 320 | variable_value = '\'\'' if self.get_config_value('str_default') else None 321 | # If not using a default value, it will just be set to whatever it was in the passage 322 | raw_variables_dict[variable_name] = variable_value 323 | 324 | # Strip down to a list of unique names 325 | final_name_list = list(set(raw_name_list)) 326 | # Then generate a string of char definitions that will be attached to the top of the script 327 | char_def_str = '' 328 | for char_name in final_name_list: 329 | char_def_str += 'define {} = Character("{}")\n'.format(char_name, char_name.replace('_', ' ')) 330 | 331 | # Add character defs to the document passage if desired 332 | if self.get_config_value('char_def'): 333 | document_passage += '{}\n'.format(char_def_str) 334 | 335 | # Generate a string of variable definitions to attach after characters but before the script 336 | var_def_str = '' 337 | for variable_name, variable_value in raw_variables_dict.iteritems(): 338 | var_def_str += 'default {} = {}\n'.format(variable_name, variable_value) 339 | 340 | # Add variable defs to the document passage if desired 341 | if self.get_config_value('var_def'): 342 | document_passage += '{}\n'.format(var_def_str) 343 | 344 | # Generate an rpy file for each passage 345 | for passage_index, passage in enumerate(passage_list): 346 | # Create the passage name to use for the label (is the same as jump statement) 347 | passage_name = self.to_rpy_name(passage['name']) 348 | passage_tags = passage['tags'] 349 | 350 | # Create file name for the first passage/iteration 351 | # then set to False until the rpy file is written and it is reset 352 | if create_file_name: 353 | rpy_file_name = passage_name + '.rpy' 354 | create_file_name = False 355 | 356 | if passage is None or passage.string is None: 357 | passage.string = '' 358 | 359 | # Convert passage from ascii to unicode 360 | unicode_passage = passage.string.encode('utf-8') 361 | 362 | # A "Twine mode" that adds " to every newline as if all lines are spoken by a Ren'Py narrator 363 | quotes_passage = '' 364 | if self.get_config_value('twine_mode'): 365 | # Avoid adding quotes to lines that start with a link or if statement 366 | avoid_quotes_list = ['[[', '<<'] 367 | quotes_passage_split = unicode_passage.split('\n') 368 | for passage_split in quotes_passage_split: 369 | if passage_split == "Double-click this passage to edit it.": 370 | # This phrase usually gets replaced with pass, but with this mode pass could be encased in " 371 | # And therefore printed to Ren'Py script 372 | # We will leave it as is so it can be replace later properly 373 | # Or allow the user to apply their own custom replace 374 | quotes_passage += passage_split 375 | else: 376 | # Add \ to " in the middle of passages 377 | if "\"" in passage_split: 378 | passage_split = passage_split.replace("\"", "\\\"") 379 | 380 | # Check if the split has at least 2 characters 381 | if len(passage_split) > 1: 382 | # By default we'll apply quotes 383 | apply_quotes = True 384 | # Unless it contains a phrase that denote it's actually a code line 385 | # Then we will avoid applying quotes 386 | for avoid_phrase in avoid_quotes_list: 387 | if passage_split[0] + passage_split[1] == avoid_phrase: 388 | # If it does, set to apply quotes flag to false 389 | apply_quotes = False 390 | break 391 | if apply_quotes: 392 | quotes_passage += '"{}"\n'.format(passage_split) 393 | else: 394 | quotes_passage += '{}\n'.format(passage_split) 395 | else: 396 | quotes_passage += '{}\n'.format(passage_split) 397 | else: 398 | quotes_passage = unicode_passage 399 | 400 | # Convert the twine [[next passage]] to rpy jump statements 401 | # Start by splitting the file using [[ 402 | split_passage = quotes_passage.split('[[') 403 | 404 | # Check the passage first for a Twine like menu with list of choices 405 | passage_index_with_whitespace = [] 406 | twine_menu_indeces = [] 407 | # If the passage has at least two [[ (by having more than three indeces in the list) 408 | # then iterate through the passage sections to see if they're Twine-like menus. 409 | # Otherwise, this is just a normal jump at the end of a passage. 410 | twine_menu_indeces_groupings_dict = OrderedDict() 411 | final_menu_groupings_dict = OrderedDict() 412 | twine_menu_indeces_dict = OrderedDict() 413 | current_group_index = 0 414 | if len(split_passage) > 2: 415 | in_choice_group = False 416 | for i, passage_section in enumerate(split_passage): 417 | choice_split = passage_section.split(']]') 418 | if len(choice_split) > 1: 419 | if only_whitespace_after_choice(choice_split): 420 | # Check if we're currently in a choice group 421 | if not in_choice_group: 422 | # If not, we are now 423 | # Create a new list of indeces... 424 | in_choice_group = True 425 | twine_menu_indeces_groupings_dict[current_group_index] = [] 426 | # If this is the last index, only add the current one 427 | if i + 1 == len(split_passage): 428 | twine_menu_indeces_groupings_dict[current_group_index].append(i) 429 | passage_index_with_whitespace.append(i) 430 | # Otherwise add the current plus the one after 431 | else: 432 | twine_menu_indeces_groupings_dict[current_group_index].extend([i, i+1]) 433 | passage_index_with_whitespace.extend([i, i+1]) 434 | else: 435 | # Check if we were currently in a choice group 436 | if in_choice_group: 437 | # If we were, we aren't now 438 | in_choice_group = False 439 | # Iterate our index 440 | current_group_index += 1 441 | else: 442 | # If not in a group, this is probably a standalone choice 443 | twine_menu_indeces_groupings_dict[current_group_index] = [] 444 | twine_menu_indeces_groupings_dict[current_group_index].append(i) 445 | passage_index_with_whitespace.append(i) 446 | current_group_index += 1 447 | twine_menu_indeces = list(set(passage_index_with_whitespace)) 448 | for choice_index, choice_list in twine_menu_indeces_groupings_dict.iteritems(): 449 | final_menu_groupings_dict[choice_index] = list(set(choice_list)) 450 | for choice_index, choice_list in final_menu_groupings_dict.iteritems(): 451 | # Based on the choice index groups, we will assign whether the choices are 452 | # first, middle, or end of a group. Or whether they are standalone choices. 453 | for i, choice_split_index in enumerate(choice_list): 454 | if i == 0 and i+1 == len(choice_list): 455 | twine_menu_indeces_dict[choice_split_index] = 'standalone' 456 | elif i == 0: 457 | twine_menu_indeces_dict[choice_split_index] = 'start' 458 | elif i+1 == len(choice_list): 459 | twine_menu_indeces_dict[choice_split_index] = 'end' 460 | else: 461 | twine_menu_indeces_dict[choice_split_index] = 'middle' 462 | 463 | # Create a string for the rpy jump statement version of the passage 464 | jump_passage = '' 465 | # If the passage has at least one [[ (by having more than one index in the list) 466 | # then iterate through the passage sections 467 | if len(split_passage) > 1: 468 | # Check if using Twine-like choices for conversion to Ren'Py menus 469 | # by seeing if there are any stored indeces from above. 470 | if len(twine_menu_indeces) > 1: 471 | for passage_split_index, passage_section in enumerate(split_passage): 472 | # If the current index was one of the twine menu indeces, then process it accordingly 473 | if passage_split_index in twine_menu_indeces_dict: 474 | passage_section_split = passage_section.split(']]') 475 | jump_statement = passage_section_split[0] 476 | # Check if there are link ends at all 477 | passage_rest = '' 478 | if len(passage_section_split) > 1: 479 | passage_rest = passage_section_split[1] 480 | choice_text = jump_statement 481 | if len(jump_statement.split('|')) > 1: 482 | choice_text, jump_statement = jump_statement.split('|') 483 | # Strip the quotes if it has any since we'll be adding these anyway 484 | choice_text = strip_quotes(choice_text) 485 | 486 | # If this is the first Twine menu index, generate the menu start 487 | if twine_menu_indeces_dict[passage_split_index] == 'start': 488 | jump_passage += 'menu:\n "{}":\n jump {}\n '.\ 489 | format(choice_text, self.to_rpy_name(jump_statement)) 490 | # If it's the last one, we don't need the trailing newline 491 | elif twine_menu_indeces_dict[passage_split_index] == 'end': 492 | jump_passage += '"{}":\n jump {}{}'.\ 493 | format(choice_text, self.to_rpy_name(jump_statement), passage_rest) 494 | # If it's standalone, menu and jump 495 | elif twine_menu_indeces_dict[passage_split_index] == 'standalone': 496 | jump_passage += 'menu:\n "{}":\n jump {}{}'. \ 497 | format(choice_text, self.to_rpy_name(jump_statement), passage_rest) 498 | # All others will use the newline 499 | else: 500 | jump_passage += '"{}":\n jump {}\n '. \ 501 | format(choice_text, self.to_rpy_name(jump_statement)) 502 | # We don't immediately write the first passage to the string just in case it's actually a menu 503 | # If it isn't a menu though, then we can go ahead and write it 504 | elif passage_split_index == 0: 505 | # If this is the first passage section, populate the string 506 | jump_passage = passage_section 507 | # Otherwise use Ren'Py-like menus 508 | else: 509 | # Start by populating the first string 510 | jump_passage = split_passage[0] 511 | for passage_section in split_passage[1:]: 512 | # Split the passage into jump statement + the rest of passage 513 | passage_section_split = passage_section.split(']]') 514 | jump_statement = passage_section_split[0] 515 | if len(passage_section_split) > 1: 516 | passage_rest = passage_section_split[1] 517 | else: 518 | passage_rest = '' 519 | choice_text = jump_statement 520 | if len(jump_statement.split('|')) > 1: 521 | choice_text, jump_statement = jump_statement.split('|') 522 | # Strip the quotes if it has any since we'll be adding these anyway 523 | choice_text = strip_quotes(choice_text) 524 | 525 | # If we have a pipe, use a menu 526 | if '|' in quotes_passage: 527 | jump_passage += 'menu:\n "{}":\n jump {}\n {}'. \ 528 | format(choice_text, self.to_rpy_name(jump_statement), passage_rest) 529 | else: 530 | # Convert the jump statement to rpy name (which is used as the label) 531 | jump_passage += 'jump ' + self.to_rpy_name(jump_statement) + passage_rest 532 | else: 533 | # If the passage doesn't have any [[ then just assign it to the string 534 | jump_passage = split_passage[0] 535 | 536 | # Check for <> 537 | # Swap to $ [variable] = [value] 538 | set_passage = '' 539 | # Check if we're setting variables 540 | if set_phrase in jump_passage: 541 | set_passage_split_list = jump_passage.split(set_phrase) 542 | # Start after the first instance of the set phrase 543 | for set_passage_split in set_passage_split_list[1:]: 544 | # Get the set statement by splitting at next immediate instance of >> 545 | set_passage_split_list = set_passage_split.split('>>', 1) 546 | # This is our statement 547 | set_passage_statement = set_passage_split_list[0] 548 | # We only set what's after if it exists 549 | set_passage_end = '' 550 | if len(set_passage_split_list) > 1: 551 | set_passage_end = set_passage_split_list[1] 552 | # Replace the "to" inside the statement only 553 | set_passage_statement = '$ ' + set_passage_statement.replace(' to ', ' = ') 554 | # Replace Twine boolean phrase with the Ren'Py/Python accepted one 555 | for boolean_phrase, boolean_replace in boolean_replace_dict.iteritems(): 556 | if boolean_phrase in set_passage_statement: 557 | set_passage_statement = set_passage_statement.replace(boolean_phrase, boolean_replace) 558 | set_passage += set_passage_statement + set_passage_end 559 | else: 560 | # If we aren't, just pass the jump passage as is 561 | set_passage = jump_passage 562 | 563 | # Check for if/else statements 564 | initial_if_phrase = '<', 'gte': '>=', 568 | 'lt': '<', 'lte': '<='} 569 | if_phrase_dict = {'<>': ':'} 570 | # Check if we have any conditionals in the passage 571 | if initial_if_phrase in set_passage: 572 | # If so, let's find the phrases 573 | if_passage_split = set_passage.split('<<') 574 | # The first part of the split won't have conditionals, so add as is 575 | if_passage += if_passage_split[0] 576 | for passage_split in if_passage_split[1:]: 577 | # Only split if string isn't empty 578 | if passage_split != '': 579 | # Split again to get the conditional statement itself 580 | if_phrase_split = passage_split.split('>>') 581 | if_phrase = if_phrase_split[0] 582 | if_result = '' 583 | if len(if_phrase_split) > 1: 584 | if_result = if_phrase_split[1] 585 | for comparison_phrase, comparison_replace in comparison_phrase_dict.iteritems(): 586 | if comparison_phrase in if_phrase: 587 | # No break because we could have multiple types of comparison 588 | if_phrase = if_phrase.replace(comparison_phrase, comparison_replace) 589 | 590 | # Indent everything in the conditional result except for the last line 591 | # Hack: Don't indent after the if statement closes... 592 | if '/if>>' not in passage_split: 593 | if_newline_count = if_result.count('\n') - 1 594 | if_result = if_result.replace('\n', '\n ', if_newline_count) 595 | 596 | # Retain original formatting to make it easier to find these 597 | if_passage += '<<{}>>{}'.format(if_phrase, if_result) 598 | if_passage = if_passage.replace('<>', '') 599 | for if_phrase, if_replace in if_phrase_dict.iteritems(): 600 | if_passage = if_passage.replace(if_phrase, if_replace) 601 | else: 602 | # If we don't, just pass the previous passage along 603 | if_passage = set_passage 604 | 605 | # Check for printed variables inside of messages 606 | # These would be occurring outside of the setting + conditionals 607 | # They will always use the form $[alpha character] 608 | variable_passage = '' 609 | if '$' in if_passage: 610 | variable_passage_split_list = if_passage.split('$') 611 | for i, variable_passage_split in enumerate(variable_passage_split_list): 612 | # Anything before the first $ is not going to have a variable 613 | # We now check if there's a letter immediately after the $, which gives us a variable 614 | # First check the current passage split contains anything 615 | # If $ is at the beginning of a passage, it could be 0 characters long 616 | if len(variable_passage_split) > 0: 617 | # If first, always add passage as is 618 | if i == 0: 619 | variable_passage += variable_passage_split 620 | elif variable_passage_split[0].isalpha(): 621 | # We split immediately at the next non-alphanumeric or invalid variable character 622 | stop_char = ' ' 623 | for var_char in variable_passage_split: 624 | if not var_char.isalnum() and var_char != '_': 625 | stop_char = var_char 626 | break 627 | variable_phrase_split_list = variable_passage_split.split(stop_char, 1) 628 | 629 | # Check if we got anything 630 | if len(variable_phrase_split_list) > 1: 631 | variable_name, rest_of_var_passage = variable_phrase_split_list 632 | # Put variable in Ren'Py inside-brackets format 633 | variable_passage += '[{}]{}{}'.format(variable_name, stop_char, rest_of_var_passage) 634 | else: 635 | variable_passage += variable_passage_split 636 | else: 637 | # If not first passage, but not alpha, add back the $ 638 | variable_passage += '${}'.format(variable_passage_split) 639 | else: 640 | # If $ doesn't exist in the passage, just pass it through 641 | variable_passage = if_passage 642 | 643 | # TODO Reach goal: Adding if statements after menu options in Ren'Py 644 | # Would have to figure out the Twine equivalent (in a way that would be straightforward to catch) 645 | # https://www.renpy.org/doc/html/menus.html 646 | # https://www.reddit.com/r/RenPy/comments/7zxe5f/conditional_menuentries/ 647 | 648 | # Remove whitespace 649 | clean_passage = variable_passage.strip() 650 | 651 | # Combine the default and custom lists to make one list to iterate over 652 | self.char_replace_list = self.default_char_replace_list + self.custom_char_replace_list 653 | 654 | # Iterate over the list of terms to update 655 | # Generally we're replacing all the non-recognized characters with safe for Ren'Py ones 656 | for dict_pair in self.char_replace_list: 657 | for og_char, new_char in dict_pair.iteritems(): 658 | clean_passage = clean_passage.replace(og_char.encode('utf-8'), new_char.encode('utf-8')) 659 | 660 | # The passage label is the passage name since this is used in the jump text 661 | passage_text = 'label {}:\n {}\n\n'.format(passage_name, clean_passage) 662 | 663 | # Add the current passage text to the rpy file passage 664 | document_passage += passage_text 665 | 666 | # Check if this is a document break or if it's the end of the twine document 667 | if passage_tags == self.get_config_value('doc_break') or passage_index == len(passage_list) - 1: 668 | # If it is, write the rpy passage file 669 | passage_file = open(os.path.join(self.data['script_dir'], rpy_file_name), 'w') 670 | n = passage_file.write(document_passage) 671 | passage_file.close() 672 | 673 | # Reset passage variables 674 | document_passage = '' 675 | create_file_name = True 676 | 677 | return True 678 | --------------------------------------------------------------------------------