├── .gitignore ├── Demo ├── demo_InteractiveLive2D.rpy ├── demo_advanced_character │ ├── character_task.rpy │ ├── images │ │ ├── Alice_VNSpriteSet │ │ │ ├── Alice_Blush.png │ │ │ ├── Alice_Default.png │ │ │ ├── Alice_Doubt.png │ │ │ ├── Alice_Embarrassed.png │ │ │ ├── Alice_Happy.png │ │ │ ├── Alice_Teasing.png │ │ │ ├── Alice_Worried.png │ │ │ └── READ ME.txt │ │ ├── Sprite - Female Pink Hair Starter Pack │ │ │ ├── Read Me (non-commercial license).txt │ │ │ ├── Sprite F PinkH Professional Angry01.png │ │ │ ├── Sprite F PinkH Professional Angry02.png │ │ │ ├── Sprite F PinkH Professional Annoyed01.png │ │ │ ├── Sprite F PinkH Professional Annoyed02.png │ │ │ ├── Sprite F PinkH Professional Neutral01.png │ │ │ ├── Sprite F PinkH Professional Neutral02.png │ │ │ ├── Sprite F PinkH Professional Sad01.png │ │ │ ├── Sprite F PinkH Professional Sad02.png │ │ │ ├── Sprite F PinkH Professional Sad03.png │ │ │ ├── Sprite F PinkH Professional Smile01.png │ │ │ ├── Sprite F PinkH Professional Smile02.png │ │ │ ├── Sprite F PinkH Professional Smile03.png │ │ │ └── Sprite F PinkH Professional Smile04.png │ │ └── Sprite Starter Pack - Female White Hair │ │ │ ├── FWH angry01.png │ │ │ ├── FWH annoyed01.png │ │ │ ├── FWH caring01.png │ │ │ ├── FWH confused01.png │ │ │ ├── FWH neutral01.png │ │ │ ├── FWH sad01.png │ │ │ ├── FWH sinister01.png │ │ │ ├── FWH smile01.png │ │ │ ├── FWH surprised01.png │ │ │ ├── FWH worried01.png │ │ │ └── Read Me (non-commercial license).txt │ └── speaking_group.rpy ├── demo_ren_chatgpt.rpy └── demo_ren_communicator │ ├── client.rpy │ └── server.rpy ├── LICENSE ├── README.md └── RenPyUtil ├── 00InteractiveLive2D_ren.py ├── Positioner ├── 00Positioner.rpy └── color_picker.rpy ├── RenCommunicator ├── ren_communicator_ren.py └── ren_communicator_screen.rpy ├── advanced_character_ren.py └── ren_chatgpt_ren.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .vscode 3 | -------------------------------------------------------------------------------- /Demo/demo_InteractiveLive2D.rpy: -------------------------------------------------------------------------------- 1 | define mao = InteractiveLive2D( 2 | "mtn_01", 3 | eye_follow=True, # 开启眼部跟随 4 | eye_center=(372, 268), 5 | head_follow=True, # 开启头部跟随 6 | head_center=(372, 268), 7 | body_follow=True, # 开启身体跟随 8 | body_center=(365, 495), 9 | range=(0, 0, 700, 700), 10 | filename="live2d/mao_pro", 11 | loop=True, 12 | seamless=True 13 | ) 14 | 15 | 16 | screen live2d(): 17 | add mao 18 | 19 | 20 | label start: 21 | 22 | show expression renpy 23 | ## 使用界面 24 | # show screen live2d() 25 | pause 26 | 27 | return -------------------------------------------------------------------------------- /Demo/demo_advanced_character/character_task.rpy: -------------------------------------------------------------------------------- 1 | init python: 2 | 3 | 4 | # 一个任务函数 5 | def love_event(speaker, name): 6 | 7 | speaker(f"{name}, I love you.") 8 | recieve = renpy.input("So, your answer is......") 9 | 10 | return recieve 11 | 12 | # 使用threading_task装饰的函数将在子线程中运行 13 | @threading_task 14 | def thread_event(): 15 | renpy.notify("Messages") 16 | 17 | 18 | # 使用default语句定义高级角色对象 19 | default e = AdvancedCharacter("艾琳", what_color="#FF8C00", who_color="#00CED1") 20 | 21 | 22 | # 游戏在此开始。 23 | 24 | label start: 25 | 26 | python: 27 | # 高级角色增添属性 28 | e.add_attr(love_point=50) 29 | e.add_attr(thread=False) 30 | e.add_attr(strength=100, health=40) 31 | 32 | # 输出角色所有的自定义属性及其值 33 | e "[e.customized_attr_dict!q]" 34 | 35 | python: 36 | 37 | # 创建一个角色任务 38 | love_task = CharacterTask(single_use=True, # single_use参数若为True则该任务为一次性任务 39 | love_point=100, 40 | health=50, 41 | ) 42 | 43 | thread_task = CharacterTask(False, thread=True) 44 | 45 | # 绑定任务函数 46 | love_task.add_func(love_event, e, name="ZYKsslm") 47 | thread_task.add_func(thread_event) 48 | 49 | # 绑定角色任务 50 | e.add_task(love_task) 51 | e.add_task(thread_task) 52 | 53 | e.love_point += 50 54 | e.health += 10 55 | 56 | e.thread = True 57 | 58 | # 获取任务函数返回值 59 | recieve = love_task.func_return["love_event"] 60 | 61 | if recieve: 62 | e "Your answer is '[recieve!q]'" 63 | 64 | return -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Blush.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Blush.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Default.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Doubt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Doubt.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Embarrassed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Embarrassed.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Happy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Happy.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Teasing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Teasing.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Worried.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Alice_VNSpriteSet/Alice_Worried.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Alice_VNSpriteSet/READ ME.txt: -------------------------------------------------------------------------------- 1 | Attribution-NonCommercial 4.0 International (CC BY-NC 4.0) 2 | 3 | You are free to: 4 | Share — copy and redistribute the material in any medium or format 5 | Adapt — remix, transform, and build upon the material 6 | 7 | as long as I remain credited. 8 | 9 | ALICE Visual Novel Sprite [1019x1568 px] 10 | For custom assets, visit my site at aucrowne.art 11 | 12 | =========================================== 13 | Please support me on Patreon [https://www.patreon.com/AuCrowne] 14 | =========================================== 15 | 16 | My Twitter [https://twitter.com/AuCrowne] 17 | My Pixiv [http://pixiv.me/aucrowne] 18 | My Facebook [https://web.facebook.com/AuCrowneOfficial] -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Read Me (non-commercial license).txt: -------------------------------------------------------------------------------- 1 | The contents of this sprite pack is licensed under Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND). 2 | 3 | You may use the sprites in this pack for personal and non-commercial projects on the following conditions: 4 | 1) the creator of the sprites is credited appropriately as one of the following: 'Red Chan', 'RedBaby' or 'Withoutpenorpaper', 5 | 2) the sprites are not used in a way that promotes discrimination or criminal acts, 6 | 3) the user does not sell the sprites or use them in a way to directly gain monetary profits. 7 | 8 | The artwork in this pack cannot be altered, traced, or used commercially without first discussing and receiving written approval from the creator. 9 | 10 | Creator's social media: 11 | 12 | e-mail: withoutpenorpaper@live.com 13 | Portfolio: https://withoutpenorpaperportfolio.weebly.com 14 | Twitter: https://twitter.com/RedChan17 15 | Itch.io: https://red-baby.itch.io/ 16 | Patreon: https://www.patreon.com/redchan17 -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Angry01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Angry01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Angry02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Angry02.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Annoyed01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Annoyed01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Annoyed02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Annoyed02.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Neutral01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Neutral01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Neutral02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Neutral02.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Sad01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Sad01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Sad02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Sad02.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Sad03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Sad03.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Smile01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Smile01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Smile02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Smile02.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Smile03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Smile03.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Smile04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Smile04.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH angry01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH angry01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH annoyed01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH annoyed01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH caring01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH caring01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH confused01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH confused01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH neutral01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH neutral01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH sad01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH sad01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH sinister01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH sinister01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH smile01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH smile01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH surprised01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH surprised01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH worried01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZYKsslm/RenPyUtil/ec53a0015bbc393edb9db7f469ea9fc3a5e88974/Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/FWH worried01.png -------------------------------------------------------------------------------- /Demo/demo_advanced_character/images/Sprite Starter Pack - Female White Hair/Read Me (non-commercial license).txt: -------------------------------------------------------------------------------- 1 | The contents of this sprite pack is licensed under Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License (CC BY-NC-ND). 2 | 3 | You may use the sprites in this pack for personal and non-commercial projects on the following conditions: 4 | 1) the creator of the sprites is credited appropriately as one of the following: 'Red Chan', 'RedBaby' or 'Withoutpenorpaper', 5 | 2) the sprites are not used in a way that promotes discrimination or criminal acts, 6 | 3) the user does not re-sell the sprites or use them in a way to directly gain monetary profits. 7 | 8 | The artwork in this pack cannot be altered, traced, or used commercially without first discussing and receiving written approval from the creator. 9 | 10 | Creator's social media: 11 | 12 | e-mail: withoutpenorpaper@live.com 13 | Portfolio: https://withoutpenorpaperportfolio.weebly.com 14 | Twitter: https://twitter.com/RedChan17 15 | Itch.io: https://red-baby.itch.io/ 16 | Patreon: https://www.patreon.com/redchan17 -------------------------------------------------------------------------------- /Demo/demo_advanced_character/speaking_group.rpy: -------------------------------------------------------------------------------- 1 | # 游戏的脚本可置于此文件中。 2 | 3 | default a = AdvancedCharacter( 4 | "Alice", 5 | image="alice", # 绑定相应角色的立绘图像标签 6 | ) 7 | 8 | default m = AdvancedCharacter( 9 | "Mary", 10 | image="mary", 11 | ) 12 | 13 | default s = AdvancedCharacter( 14 | "Sylvie", 15 | image="sylvie", 16 | ) 17 | 18 | # 定义一个对话组 19 | default speaking_group = SpeakingGroup(a, m, s) 20 | 21 | 22 | # 定义角色不同表情的立绘 23 | image alice blush = "images/Alice_VNSpriteSet/Alice_Blush.png" 24 | image alice default = "images/Alice_VNSpriteSet/Alice_Default.png" 25 | image alice worried = "images/Alice_VNSpriteSet/Alice_Worried.png" 26 | image alice doubt = "images/Alice_VNSpriteSet/Alice_Doubt.png" 27 | 28 | image mary angry = "images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Angry01.png" 29 | image mary smile = "images/Sprite - Female Pink Hair Starter Pack/Sprite F PinkH Professional Smile01.png" 30 | 31 | image sylvie smile = "images/Sprite Starter Pack - Female White Hair/FWH smile01.png" 32 | image sylvie angry = "images/Sprite Starter Pack - Female White Hair/FWH angry01.png" 33 | 34 | # 游戏在此开始。 35 | 36 | label start: 37 | scene bg: 38 | xysize (1920, 1080) 39 | truecenter 40 | 41 | # 将角色加入对话组中 42 | #$ speaking_group.add_characters(a, m, s) 43 | 44 | a "Hello, my name is Alice. How can I help you today?" 45 | 46 | show alice blush: 47 | zoom 0.65 48 | center 49 | 50 | a "a" 51 | a @ default "a default" 52 | 53 | show mary angry: 54 | zoom 0.7 55 | left 56 | 57 | m "m" 58 | m @ smile "m smile" 59 | 60 | show sylvie smile: 61 | zoom 0.65 62 | right 63 | 64 | s "s" 65 | s @ angry "s angry" 66 | 67 | a "return to a" 68 | 69 | m "return to m" 70 | 71 | # 当需要移除角色时(一位角色离场) 72 | $ speaking_group.del_characters(s) 73 | hide sylvie 74 | 75 | "Sylvie left." 76 | 77 | m "She has left now." 78 | a "This is our turn." 79 | 80 | return 81 | -------------------------------------------------------------------------------- /Demo/demo_ren_chatgpt.rpy: -------------------------------------------------------------------------------- 1 | define e = Character("艾琳") 2 | define gpt = RenChatGPT( 3 | api = "https://api.openai.com/v1/models", 4 | key=None 5 | ) 6 | 7 | label start: 8 | while True: 9 | python: 10 | content = renpy.input("说点什么") 11 | gpt.chat(content) 12 | 13 | if not gpt.error: 14 | # 提取对话 15 | msgs = gpt.parse_words(gpt.msg) 16 | else: 17 | e("[gpt.error!q]") 18 | 19 | for msg in msgs: 20 | e("[msg!q]") -------------------------------------------------------------------------------- /Demo/demo_ren_communicator/client.rpy: -------------------------------------------------------------------------------- 1 | init python: 2 | 3 | client = RenClient("192.168.2.23", 8888) 4 | 5 | @client.on_conn() 6 | def conn_handler(client): 7 | renpy.notify("连接成功") 8 | 9 | @client.on_disconn() 10 | def disconn_handler(client): 11 | renpy.notify("连接断开") 12 | 13 | define s = Character("server") 14 | 15 | label start: 16 | 17 | python: 18 | with client: 19 | for msg in client.get_message(): 20 | s(msg.get_message()) 21 | 22 | return 23 | -------------------------------------------------------------------------------- /Demo/demo_ren_communicator/server.rpy: -------------------------------------------------------------------------------- 1 | init python: 2 | 3 | server = RenServer() 4 | 5 | @server.on_conn() 6 | def conn_handler(server, client_name, client_socket): 7 | renpy.notify(f"{client_name} 已连接") 8 | 9 | @server.on_disconn() 10 | def disconn_handler(server, client_name): 11 | renpy.notify(f"{client_name} 已断开连接") 12 | 13 | 14 | define f = Character("friend") 15 | 16 | 17 | label start: 18 | 19 | python: 20 | with server: 21 | for client_socket, msg in server.get_message(): 22 | f(msg.get_message()) 23 | 24 | return -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 ZSSLM 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 | # RenPyUtil 2 | 3 |

4 | Ren'Py logo 5 |

6 | 7 | > 一个Ren'Py工具包,提供了一系列基于Ren'Py的功能类,供Ren'Py开发者调用。 8 | 9 | ## :cd: 如何使用 10 | 11 | 1. 将所需的[第三方库](./lib)放置于您的游戏 `game` 目录下。 12 | 2. 将[RenPyUtil](./RenPyUtil) 目录放置于您的游戏 `game` 目录下。 13 | 14 | ## :rocket: 功能概览 15 | 16 | - [x] 高级角色类,轻松创建RPG和养成类游戏,具备丰富功能。 17 | - [x] 基于socket的TCP协议多线程网络通信模块,让多个玩家可以在网络中交流。 18 | - [x] ChatGPT接口适配,便于集成智能对话功能。 19 | - [x] Positioner定位工具,更加便捷地定位游戏内的组件位置。 20 | - [x] InteractiveLive2D类,对 `Live2D` 提供更高级的支持。 21 | 22 | --- 23 | 24 | ## :bookmark: 使用示范 25 | 26 | 每个模块都有相应的使用示范,请在 [Demo](./Demo) 中查看。 27 | 28 | 1. **`advanced_character`** 29 | - [角色任务示例](./Demo/demo_advanced_character/character_task.rpy) 30 | - [对话组示例](./Demo/demo_advanced_character/speaking_group.rpy) 31 | 2. **`ren_communicator`** 32 | - [客户端通信示例](./Demo/demo_ren_communicator/client.rpy) 33 | - [服务端通信示例](./Demo/demo_ren_communicator/server.rpy) 34 | 3. **`ren_chatgpt`** 35 | - [与ChatGPT对话示例](./Demo/demo_ren_chatgpt.rpy) 36 | 4. **`InteractiveLive2D`** 37 | - [Live2D示例](./Demo/demo_InteractiveLive2D.rpy) 38 | 39 | ## :bar_chart: 已实现模块列表 40 | 41 | 1. [`advanced_character`](./RenPyUtil/advanced_character_ren.py) 42 | 2. [`ren_communicator`](./RenPyUtil/RenCommunicator/) 43 | 3. [`ren_chatgpt`](./RenPyUtil/ren_chatgpt_ren.py) 44 | 4. [`InteractiveLive2D`](./RenPyUtil/00InteractiveLive2D_ren.py/) 45 | 46 | ## :bulb: 工具 47 | 1. [`Positioner`](./RenPyUtil/Positioner) 48 | 49 | ## :book: 说明 50 | 51 | **`resource_preserver`模块已暂时移除。** 52 | 53 | 该项目使用MIT协议开源,使用时请在程序中注明。 54 | -------------------------------------------------------------------------------- /RenPyUtil/00InteractiveLive2D_ren.py: -------------------------------------------------------------------------------- 1 | # 此文件提供了一系列基于Ren'Py的功能类,以供Ren'Py开发者调用 2 | # 作者 ZYKsslm 3 | # 仓库 https://github.com/ZYKsslm/RenPyUtil 4 | # 声明 该源码使用 MIT 协议开源,但若使用需要在程序中标明作者信息 5 | """renpy 6 | python early: 7 | """ 8 | 9 | 10 | import os 11 | import json 12 | import pygame 13 | 14 | from typing import Union 15 | 16 | Live2D = Live2D # type: ignore 17 | renpy = renpy # type: ignore 18 | store = store # type: ignore 19 | 20 | 21 | class Live2DAssembly: 22 | def __init__(self, 23 | *areas, 24 | motions: Union[str, list[str]] = None, 25 | expressions: Union[str, list[str]] = None, # 非排他性表情列表 26 | audio: str = None, 27 | mouse: str = None, 28 | attr_getter: callable = None, 29 | hovered: callable = None, 30 | unhovered: callable = None, 31 | action: callable = None, 32 | keep=False 33 | ): 34 | if isinstance(motions, str): 35 | motions = [motions] 36 | if isinstance(expressions, str): 37 | expressions = [expressions] 38 | 39 | self.areas = areas 40 | self.motions = motions or [] 41 | self.expressions = expressions or [] 42 | self.audio = audio 43 | self.mouse = mouse 44 | self.attr_getter = attr_getter 45 | self.hovered = hovered 46 | self.unhovered = unhovered 47 | self.action = action 48 | self.keep = keep 49 | 50 | self._st = 0.0 # 开始时刻 51 | self.t = 0.0 # 触发时刻 52 | self.duration = 0.0 # 持续时长 53 | self.modal = False # 是否为模态动作 54 | 55 | def set_duration(self, common): 56 | self.duration = 0.0 57 | for motion in self.motions: 58 | self.duration += common.motions[motion].duration 59 | 60 | return self.duration 61 | 62 | def get_assembly(self): 63 | if self.attr_getter: 64 | self.motions, self.expressions = self.attr_getter() 65 | 66 | return self.motions, self.expressions 67 | 68 | def contained(self, x, y): 69 | for area in self.areas: 70 | if Live2DAssembly.contained_rect(area, x, y): 71 | return True 72 | return False 73 | 74 | def activate(self, common, st): 75 | self.set_duration(common) 76 | self.st = st 77 | 78 | return self 79 | 80 | def _action(self): 81 | run = True 82 | if self.action: 83 | res = self.action() 84 | run = res if res is not None else True 85 | 86 | return run 87 | 88 | @staticmethod 89 | def contained_rect(area: tuple[int, int, int, int], x: int, y: int): 90 | if area[0] < x < area[0] + area[2] and area[1] < y < area[1] + area[3]: 91 | return True 92 | else: 93 | return False 94 | 95 | @property 96 | def st(self): 97 | return self._st 98 | 99 | @st.setter 100 | def st(self, value): 101 | self._st = value 102 | self.t = value + self.duration 103 | 104 | def end(self, t): 105 | if self.keep: 106 | return False 107 | else: 108 | return self.t <= t 109 | 110 | 111 | class InteractiveLive2D(Live2D): 112 | """ `Live2D` 动作交互实现""" 113 | 114 | def __init__(self, 115 | idle_motions: Union[str, list[str]], 116 | idle_exps: Union[str, list[str]] = None, 117 | live2d_assemblies: list[Live2DAssembly] = None, 118 | eye_follow=False, 119 | head_follow=False, 120 | body_follow=False, 121 | eye_center=None, 122 | head_center=None, 123 | body_center=None, 124 | rotate_strength=0.02, 125 | max_angle=None, 126 | min_angle=None, 127 | range=None, 128 | **properties 129 | ): 130 | super().__init__(**properties) 131 | self.all_motions = list(self.common.motions.keys()) 132 | self.all_expressions = list(self.common.expressions.keys()) 133 | 134 | if isinstance(idle_motions, str): 135 | idle_motions = [idle_motions] 136 | if isinstance(idle_exps, str): 137 | idle_exps = [idle_exps] 138 | 139 | if not set(idle_motions).issubset(self.all_motions): 140 | raise ValueError(f"未知的动作: {idle_motions}") 141 | if idle_exps and (not set(idle_exps).issubset(self.all_expressions)): 142 | raise ValueError(f"未知的表情: {idle_exps}") 143 | 144 | self.motions = idle_motions 145 | self.used_nonexclusive = idle_exps or [] 146 | 147 | if eye_follow or head_follow or body_follow: 148 | filename: str = properties["filename"] 149 | if filename.endswith(".model3.json"): 150 | filename = filename.replace("model3", "physics3") 151 | else: 152 | name = os.path.basename(filename) 153 | filename = f"{filename}/{name}.physics3.json" 154 | 155 | try: 156 | with renpy.loader.load(filename) as f: 157 | physics_data = json.load(f) 158 | angle = physics_data["PhysicsSettings"][0]["Normalization"]["Angle"] 159 | self.max_angle = angle["Maximum"] 160 | self.min_angle = angle["Minimum"] 161 | except Exception as e: 162 | if max_angle and min_angle: 163 | self.max_angle = max_angle 164 | self.min_angle = min_angle 165 | else: 166 | raise ValueError(f"无法获取模型角度参数: {filename},请手动添加 max_angle 和 min_angle 参数") from e 167 | 168 | self.idle_motions = idle_motions 169 | self.idle_exps = idle_exps or [] 170 | self.live2d_assemblies = live2d_assemblies or [] 171 | 172 | self.eye_follow = eye_follow 173 | self.head_follow = head_follow 174 | self.body_follow = body_follow 175 | if not self.head_follow and self.body_follow: 176 | self.head_follow = True 177 | self.eye_center = eye_center 178 | self.head_center = head_center 179 | self.body_center = body_center 180 | self.rotate_strength = rotate_strength 181 | self.angle_params = { 182 | "ParamAngleX": 0.0, 183 | "ParamBodyAngleX": 0.0, 184 | "ParamEyeBallX": 0.0, 185 | "ParamAngleY": 0.0, 186 | "ParamBodyAngleY": 0.0, 187 | "ParamEyeBallY": 0.0 188 | } 189 | 190 | self.st = None 191 | self.mouse_pos = (0, 0) 192 | self.range = range 193 | self.size = (0, 0) 194 | self.toggled_motions = None 195 | self.toggled_exps = None 196 | self.current_assembly = None 197 | self.hovered_assembly = None 198 | self._modal = False 199 | 200 | @property 201 | def modal(self): 202 | return self._modal 203 | 204 | @modal.setter 205 | def modal(self, value): 206 | for live2d_assembly in self.live2d_assemblies: 207 | live2d_assembly.modal = value 208 | 209 | if live2d_assembly.mouse and hasattr(store, "default_mouse"): 210 | del store.default_mouse 211 | 212 | self._modal = value 213 | 214 | def turn_to_assembly(self, live2d_assembly: Live2DAssembly): 215 | if live2d_assembly._action(): 216 | self.motions, self.used_nonexclusive = live2d_assembly.get_assembly() 217 | self.current_assembly = live2d_assembly.activate(self.common, self.st) 218 | 219 | renpy.redraw(self, 0) 220 | 221 | def toggle_motion(self, motions: Union[str, list[str]], reset_exps=False): 222 | self.modal = False 223 | if isinstance(motions, str): 224 | motions = [motions] 225 | 226 | if motions == self.motions: 227 | self.toggled_motions = motions 228 | self.motions = self.idle_motions 229 | else: 230 | self.toggled_motion = None 231 | self.motions = motions 232 | 233 | if reset_exps: 234 | self.used_nonexclusive = self.idle_exps 235 | renpy.redraw(self, 0) 236 | 237 | def toggle_exp(self, exps: Union[str, list[str]], reset_motions=False): 238 | self.modal = False 239 | if isinstance(exps, str): 240 | exps = [exps] 241 | 242 | exps_set = set(exps) 243 | used_nonexclusive_set = set(self.used_nonexclusive) 244 | if exps_set.issubset(used_nonexclusive_set): 245 | self.toggled_exp = exps 246 | self.used_nonexclusive = list(used_nonexclusive_set - exps_set) 247 | else: 248 | self.toggled_exp = None 249 | self.used_nonexclusive += exps 250 | 251 | if reset_motions: 252 | self.motions = self.idle_motions 253 | renpy.redraw(self, 0) 254 | 255 | def reset_assembly(self): 256 | self.modal = False 257 | self.current_assembly = None 258 | self.motions = self.idle_motions 259 | self.used_nonexclusive = self.idle_exps 260 | renpy.redraw(self, 0) 261 | 262 | def _end_assembly(self, st): 263 | if self.current_assembly.end(st): 264 | self.current_assembly = None 265 | self.motions = self.idle_motions 266 | self.used_nonexclusive = self.idle_exps 267 | renpy.redraw(self, 0) 268 | 269 | def update_angle(self, rotate_center): 270 | if self.range and (not Live2DAssembly.contained_rect(self.range, *self.mouse_pos)): 271 | x, y = 0.0, 0.0 272 | else: 273 | d_x = self.mouse_pos[0] - rotate_center[0] 274 | d_y = rotate_center[1] - self.mouse_pos[1] 275 | x = d_x * self.rotate_strength 276 | y = d_y * self.rotate_strength 277 | 278 | if x < self.min_angle: x = self.min_angle 279 | elif x > self.max_angle: x = self.max_angle 280 | 281 | if y < self.min_angle: y = self.min_angle 282 | elif y > self.max_angle: y = self.max_angle 283 | 284 | return x, y 285 | 286 | def update(self, common, st, st_fade): 287 | """ 288 | This updates the common model with the information taken from the 289 | motions associated with this object. It returns the delay until 290 | Ren'Py needs to cause a redraw to occur, or None if no delay 291 | should occur. 292 | """ 293 | 294 | if not self.motions: 295 | return 296 | 297 | # True if the motion should be faded in. 298 | do_fade_in = True 299 | 300 | # True if the motion should be faded out. 301 | do_fade_out = True 302 | 303 | # True if this is the last frame of a series of motions. 304 | last_frame = False 305 | 306 | # The index of the current motion in self.motions. 307 | current_index = 0 308 | 309 | # The motion object to display. 310 | motion = None 311 | 312 | # Determine the current motion. 313 | 314 | motion_st = st 315 | 316 | if st_fade is not None: 317 | motion_st = st - st_fade 318 | 319 | for m in self.motions: 320 | motion = common.motions.get(m, None) 321 | 322 | if motion is None: 323 | continue 324 | 325 | if motion.duration > st: 326 | break 327 | 328 | elif (motion.duration > motion_st) and not common.is_seamless(m): 329 | break 330 | 331 | motion_st -= motion.duration 332 | st -= motion.duration 333 | current_index += 1 334 | 335 | else: 336 | 337 | if motion is None: 338 | return None 339 | 340 | m = self.motions[-1] 341 | 342 | if (not self.loop) or (not motion.duration): 343 | st = motion.duration 344 | last_frame = True 345 | 346 | elif (st_fade is not None) and not common.is_seamless(m): 347 | # This keeps a motion from being restarted after it would have 348 | # been faded out. 349 | motion_start = motion_st - motion_st % motion.duration 350 | 351 | if (st - motion_start) > motion.duration: 352 | st = motion.duration 353 | last_frame = True 354 | 355 | if motion is None: 356 | return None 357 | 358 | # Determine the name of the current, last, and next motions. These are 359 | # None if there is no motion. 360 | 361 | if current_index < len(self.motions): 362 | current_name = self.motions[current_index] 363 | else: 364 | current_name = self.motions[-1] 365 | 366 | if current_index > 0: 367 | last_name = self.motions[current_index - 1] 368 | else: 369 | last_name = None 370 | 371 | if current_index < len(self.motions) - 1: 372 | next_name = self.motions[current_index + 1] 373 | elif self.loop: 374 | next_name = self.motions[-1] 375 | else: 376 | next_name = None 377 | 378 | # Handle seamless. 379 | 380 | if (last_name == current_name) and common.is_seamless(current_name): 381 | do_fade_in = False 382 | 383 | if (next_name == current_name) and common.is_seamless(current_name) and (st_fade is None): 384 | do_fade_out = False 385 | 386 | # Apply the motion. 387 | 388 | motion_data = motion.get(st, st_fade, do_fade_in, do_fade_out) 389 | 390 | if self.head_follow: 391 | self.angle_params["ParamAngleX"], self.angle_params["ParamAngleY"] = self.update_angle(self.head_center) 392 | if self.body_follow: 393 | self.angle_params["ParamBodyAngleX"], self.angle_params["ParamBodyAngleY"] = self.update_angle(self.body_center) 394 | if self.eye_follow: 395 | self.angle_params["ParamEyeBallX"], self.angle_params["ParamEyeBallY"] = self.update_angle(self.eye_center) 396 | 397 | for k, v in motion_data.items(): 398 | 399 | kind, key = k 400 | factor, value = v 401 | 402 | if kind == "PartOpacity": 403 | common.model.set_part_opacity(key, value) 404 | 405 | elif kind == "Parameter": 406 | if ( 407 | self.head_follow and key in ("ParamAngleX", "ParamAngleY") or 408 | self.body_follow and key in ("ParamBodyAngleX", "ParamBodyAngleY") or 409 | self.eye_follow and key in ("ParamEyeBallX", "ParamEyeBallY") 410 | ): 411 | value = self.angle_params[key] 412 | 413 | common.model.set_parameter(key, value, factor) 414 | 415 | elif kind == "Model": 416 | common.model.set_parameter(key, value, factor) 417 | 418 | if last_frame: 419 | return None 420 | else: 421 | return motion.wait(st, st_fade, do_fade_in, do_fade_out) 422 | 423 | def update_expressions(self, st): 424 | try: 425 | return super().update_expressions(st) 426 | except: 427 | renpy.gl2.live2d.states[self.name].old_expressions = [] 428 | 429 | def render(self, width, height, st, at): 430 | render = super().render(width, height, st, at) 431 | self.size = render.get_size() 432 | self.st = st 433 | if self.motions != self.idle_motions and self.current_assembly: 434 | self._end_assembly(st) 435 | 436 | return render 437 | 438 | def event(self, ev, x, y, st): 439 | self.mouse_pos = (x, y) 440 | for live2d_assembly in self.live2d_assemblies: 441 | if live2d_assembly.modal: 442 | continue 443 | if live2d_assembly.contained(x, y): 444 | self.hovered_assembly = live2d_assembly 445 | if live2d_assembly.mouse: 446 | store.default_mouse = live2d_assembly.mouse 447 | if live2d_assembly.hovered: 448 | live2d_assembly.hovered(live2d_assembly) 449 | if ev.type == pygame.MOUSEBUTTONDOWN and ev.button == 1: 450 | if live2d_assembly._action(): 451 | if live2d_assembly.audio: 452 | renpy.music.play(live2d_assembly.audio, channel="voice") 453 | self.motions, self.used_nonexclusive = live2d_assembly.get_assembly() 454 | self.current_assembly = live2d_assembly.activate(self.common, st) 455 | else: 456 | if live2d_assembly is self.hovered_assembly: 457 | if hasattr(store, "default_mouse"): 458 | del store.default_mouse 459 | if live2d_assembly.unhovered: 460 | live2d_assembly.unhovered(live2d_assembly) 461 | self.hovered_assembly = None 462 | 463 | print(x, y) 464 | renpy.redraw(self, 0) 465 | 466 | -------------------------------------------------------------------------------- /RenPyUtil/Positioner/00Positioner.rpy: -------------------------------------------------------------------------------- 1 | #* Positioner - 一个开源的 Ren'Py 定位工具 2 | #* 作者 ZYKsslm 3 | #! 开源协议 MIT 4 | #* 感谢 Feniks @ feniksdev.com 提供的非常实用的开源工具 color_picker 5 | 6 | 7 | python early: 8 | import pygame 9 | 10 | class Positioner(renpy.Displayable): 11 | def __init__(self, name="", size=(100, 100), color=Color("#00d9ff", alpha=0.7), image="", **properties): 12 | super().__init__(**properties) 13 | self._name = name 14 | self._name_color = Color("#e5ff00") 15 | self.name_displayable = Text(str(name), color=self.name_color) 16 | self._size = size # 大小 17 | self._zoom = (1.0, 1.0) # 图像缩放比例 18 | self._color = color 19 | self._image = image 20 | self.opacity = 1.0 21 | self.image_displayable = renpy.displayable(image) if image else None 22 | self._pos = (0, 0) # 左上角顶点的坐标 23 | self._relative_size = (0, 0) 24 | self.rect = (*self.pos, *self.size) 25 | self.pressed = False 26 | self.lock = False 27 | self.follow_mouse = False 28 | self.show = True 29 | 30 | @property 31 | def pos(self): 32 | return self._pos 33 | 34 | @pos.setter 35 | def pos(self, value): 36 | self._pos = value 37 | self.rect = (*self.pos, *self.size) 38 | self._update_display() 39 | 40 | @property 41 | def size(self): 42 | return (round(self._size[0], 2), round(self._size[1], 2)) 43 | 44 | @size.setter 45 | def size(self, value): 46 | self._size = value 47 | self.rect = (*self.pos, *self.size) 48 | self._update_display() 49 | 50 | @property 51 | def zoom(self): 52 | return self._zoom 53 | 54 | @zoom.setter 55 | def zoom(self, value): 56 | self._zoom = value 57 | self._update_display() 58 | 59 | @property 60 | def color(self): 61 | return self._color 62 | 63 | @color.setter 64 | def color(self, value): 65 | self._color = Color(color=value.hexcode, alpha=0.7) 66 | self._update_display() 67 | 68 | @property 69 | def image(self): 70 | return self._image 71 | 72 | @image.setter 73 | def image(self, value): 74 | self._image = value 75 | self._update_display() 76 | 77 | @property 78 | def name(self): 79 | return self._name 80 | 81 | @name.setter 82 | def name(self, value): 83 | self._name = value 84 | self.name_displayable = Text(str(value), color=self.name_color) 85 | self._update_display() 86 | 87 | @property 88 | def name_color(self): 89 | return self._name_color 90 | 91 | @name_color.setter 92 | def name_color(self, value): 93 | self._name_color = value 94 | self.name_displayable = Text(str(self.name), color=self.name_color) 95 | self._update_display() 96 | 97 | def _update_display(self): 98 | renpy.redraw(self, 0) 99 | renpy.restart_interaction() 100 | 101 | def reset(self): 102 | if self.image: 103 | self.zoom = (1.0, 1.0) 104 | else: 105 | self.size = (100, 100) 106 | 107 | self.opacity = 1.0 108 | 109 | self._update_display() 110 | 111 | def modify(self, factor, x=True, y=True): 112 | if self.image: 113 | if x: 114 | self.zoom = (self.zoom[0] * factor, self.zoom[1]) 115 | if y: 116 | self.zoom = (self.zoom[0], self.zoom[1] * factor) 117 | else: 118 | if x: 119 | self.size = (self.size[0] * factor, self.size[1]) 120 | if y: 121 | self.size = (self.size[0], self.size[1] * factor) 122 | 123 | def plus(self, x=True, y=True): 124 | self.modify(1.1, x, y) 125 | 126 | def minus(self, x=True, y=True): 127 | self.modify(0.9, x, y) 128 | 129 | def render(self, width, height, st, at): 130 | render = renpy.Render(width, height) 131 | if self.show: 132 | if self.image: 133 | self.image_displayable = Transform(renpy.displayable(self.image), alpha=self.opacity, xzoom=self.zoom[0], yzoom=self.zoom[1]) 134 | image_render = renpy.render(self.image_displayable, width, height, st, at) 135 | render.blit(image_render, self.pos) 136 | self.size = image_render.get_size() 137 | else: 138 | canvas = render.canvas() 139 | self.color = self.color.replace_opacity(self.opacity) 140 | canvas.rect(self.color, (*self.pos, *self._size)) 141 | if self.name: 142 | name_render = renpy.render(self.name_displayable, width, height, st, at) 143 | render.blit(name_render, self.pos) 144 | return render 145 | 146 | def event(self, ev, x, y, st): 147 | if self.lock: 148 | return 149 | if ev.type == pygame.MOUSEBUTTONDOWN: 150 | if ev.button == 1: 151 | if self.rect[0] <= x <= self.rect[0] + self.rect[2] and self.rect[1] <= y <= self.rect[1] + self.rect[3]: 152 | self._relative_size = (x - self.pos[0], y - self.pos[1]) 153 | self.pressed = True 154 | elif ev.button == 4: 155 | self.plus() 156 | elif ev.button == 5: 157 | self.minus() 158 | 159 | elif ev.type == pygame.MOUSEBUTTONUP: 160 | self.pressed = False 161 | self._relative_size = (0, 0) 162 | 163 | if self.pressed or self.follow_mouse: 164 | if self.pressed: 165 | self.follow_mouse = False 166 | self.pos = (x - self._relative_size[0], y - self._relative_size[1]) 167 | renpy.restart_interaction() 168 | 169 | renpy.redraw(self, 0) 170 | 171 | class PositionerGroup(renpy.Displayable): 172 | def __init__(self, *positioners, **properties): 173 | super().__init__(**properties) 174 | self.positioners = list(positioners) 175 | if not self.positioners: 176 | self.create() 177 | else: 178 | self.selected_positioner = self.positioners[-1] 179 | 180 | def create(self, *args, **kwargs): 181 | positioner = Positioner(*args, **kwargs) 182 | self.positioners.append(positioner) 183 | self.selected_positioner = positioner 184 | renpy.redraw(self, 0) 185 | 186 | def remove(self, positioner): 187 | self.positioners.remove(positioner) 188 | if not self.positioners: 189 | self.create() 190 | self.selected_positioner = self.positioners[-1] 191 | renpy.redraw(self, 0) 192 | 193 | def render(self, width, height, st, at): 194 | render = renpy.Render(width, height) 195 | for positioner in self.positioners: 196 | render.blit(positioner.render(width, height, st, at), (0, 0)) 197 | 198 | return render 199 | 200 | def event(self, ev, x, y, st): 201 | if ev.type == pygame.MOUSEBUTTONDOWN and ev.button == 1: 202 | for positioner in self.positioners: 203 | area = (*positioner.pos, *positioner.size) 204 | if area[0] <= x <= area[0] + area[2] and area[1] <= y <= area[1] + area[3]: 205 | self.selected_positioner = positioner 206 | renpy.restart_interaction() 207 | 208 | self.selected_positioner.event(ev, x, y, st) 209 | renpy.redraw(self, 0) 210 | 211 | def visit(self): 212 | return self.positioners 213 | 214 | screen color_picker(obj, field, default_color): 215 | modal True 216 | style_prefix 'cpicker' 217 | 218 | default picker = ColorPicker(500, 500, default_color) 219 | default picker_swatch = DynamicDisplayable(picker_color, picker=picker, xsize=100, ysize=100) 220 | default picker_hex = DynamicDisplayable(picker_hexcode, picker=picker) 221 | 222 | label "{i}color_picker{/i} 工具由 {u}@ feniksdev.com{/u} 提供" 223 | hbox: 224 | vbar value FieldValue(picker, "hue_rotation", 1.0) 225 | vbox: 226 | add picker 227 | bar value FieldValue(picker, "hue_rotation", 1.0) 228 | vbox: 229 | xsize 200 spacing 10 align (0.0, 0.0) 230 | add picker_swatch 231 | add picker_hex 232 | textbutton "完成" action [SetField(obj, field, picker.color), Return()] 233 | 234 | style cpicker_vbox: 235 | align (0.5, 0.5) 236 | spacing 25 237 | style cpicker_hbox: 238 | align (0.5, 0.5) 239 | spacing 25 240 | style cpicker_vbar: 241 | xysize (50, 500) 242 | base_bar At(Transform("#000", xysize=(50, 500)), spectrum(horizontal=False)) 243 | thumb Transform("selector_bg", xysize=(50, 20)) 244 | thumb_offset 10 245 | style cpicker_bar: 246 | xysize (500, 50) 247 | base_bar At(Transform("#000", xysize=(500, 50)), spectrum()) 248 | thumb Transform("selector_bg", xysize=(20, 50)) 249 | thumb_offset 10 250 | style cpicker_text: 251 | color "#fff" 252 | style cpicker_button: 253 | padding (4, 4) insensitive_background "#fff" 254 | style cpicker_button_text: 255 | color "#aaa" 256 | hover_color "#fff" 257 | style cpicker_image_button: 258 | xysize (104, 104) 259 | padding (4, 4) 260 | hover_foreground "#fff2" 261 | 262 | 263 | screen change_positioner_image(positioner): 264 | default notice_value = FieldInputValue(positioner, "image") 265 | 266 | frame: 267 | xysize (850, 500) 268 | align (0.5, 0.5) 269 | 270 | hbox: 271 | align (0.5, 0.5) 272 | spacing 10 273 | 274 | vbox: 275 | spacing 10 276 | xysize (400, 350) 277 | label "手动输入:" align (0.5, 0.0) 278 | input: 279 | align (0.5, 0.5) 280 | pixel_width 390 281 | multiline True 282 | copypaste True 283 | value notice_value 284 | vbox: 285 | spacing 10 286 | xysize (400, 350) 287 | label "选择图像:" align (0.5, 0.0) 288 | viewport: 289 | xysize (400, 350) 290 | align (0.5, 0.5) 291 | mousewheel True 292 | draggable True 293 | scrollbars "vertical" 294 | 295 | vbox: 296 | xysize (400, 350) 297 | spacing 10 298 | for image in renpy.list_images(): 299 | textbutton "[image]": 300 | xalign 0.5 301 | action SetField(positioner, "image", image) 302 | 303 | textbutton "完成" align (0.5, 0.99) action Return() 304 | 305 | screen change_positioner_name(positioner): 306 | default notice_value = FieldInputValue(positioner, "name") 307 | 308 | frame: 309 | xysize (500, 300) 310 | align (0.5, 0.5) 311 | 312 | label "请输入名称:" align (0.5, 0.15) 313 | input: 314 | align (0.5, 0.5) 315 | pixel_width 390 316 | multiline True 317 | copypaste True 318 | value notice_value 319 | 320 | hbox: 321 | spacing 100 322 | align (0.5, 0.75) 323 | textbutton "颜色" action ShowMenu("color_picker", obj=positioner, field="name_color", default_color=positioner.name_color) 324 | textbutton "完成" action Return() 325 | 326 | screen position_helper(*displayables): 327 | default positioner_group = PositionerGroup() 328 | default show_menu = True 329 | $ positioner = positioner_group.selected_positioner 330 | 331 | for displayable in displayables: 332 | add displayable 333 | 334 | add positioner_group 335 | 336 | if show_menu: 337 | use positioner(positioner, positioner_group) 338 | 339 | key "v" action ToggleScreenVariable("show_menu") 340 | 341 | screen positioner(positioner, positioner_group): 342 | drag: 343 | align (0.02, 0.1) 344 | draggable True 345 | frame: 346 | background Color("#ffffff", alpha=0.3) 347 | has vbox 348 | spacing 20 349 | 350 | label "当前参数" 351 | label "[positioner.rect]" 352 | label "x&y轴" 353 | textbutton "放大" action Function(positioner.plus) 354 | textbutton "缩小" action Function(positioner.minus) 355 | textbutton "重置" action Function(positioner.reset) 356 | label "x轴" 357 | textbutton "放大" action Function(positioner.plus, y=False) 358 | textbutton "缩小" action Function(positioner.minus, y=False) 359 | label "y轴" 360 | textbutton "放大" action Function(positioner.plus, x=False) 361 | textbutton "缩小" action Function(positioner.minus, x=False) 362 | label "透明度" 363 | bar: 364 | xsize 250 365 | value FieldValue(positioner, "opacity", 1.0) 366 | 367 | drag: 368 | align (0.98, 0.1) 369 | draggable True 370 | frame: 371 | background Color("#ffffff", alpha=0.3) 372 | has vbox 373 | spacing 20 374 | 375 | label "状态" 376 | hbox: 377 | spacing 5 378 | text "名称:" 379 | add positioner.name_displayable 380 | text "位置: [positioner.pos]" 381 | text "大小: [positioner.size]" 382 | label "操作" 383 | textbutton "添加图片" action ShowMenu("change_positioner_image", positioner=positioner) 384 | textbutton "锁定/解锁" action ToggleField(positioner, "lock") 385 | textbutton "显示/隐藏" action ToggleField(positioner, "show") 386 | textbutton "跟随/取消" action ToggleField(positioner, "follow_mouse") 387 | textbutton "修改定位器名称" action ShowMenu("change_positioner_name", positioner=positioner) 388 | textbutton "修改定位器颜色" action ShowMenu("color_picker", obj=positioner, field="color", default_color=positioner.color) 389 | textbutton "创建定位器" action Function(positioner_group.create) 390 | textbutton "删除定位器" action Function(positioner_group.remove, positioner) 391 | -------------------------------------------------------------------------------- /RenPyUtil/Positioner/color_picker.rpy: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | ## 3 | ## Color Picker for Ren'Py by Feniks (feniksdev.itch.io / feniksdev.com) 4 | ## 5 | ################################################################################ 6 | ## This file contains code for a colour picker in Ren'Py. 7 | ## If you use this code in your projects, credit me as Feniks @ feniksdev.com 8 | ## 9 | ## If you'd like to see how to use this tool, check the other file, 10 | ## color_picker_examples.rpy! 11 | ## You can also see this tool in action in the image tint tool, also on itch: 12 | ## https://feniksdev.itch.io/image-tint-tool 13 | ## 14 | ## Leave a comment on the tool page on itch.io or an issue on the GitHub 15 | ## if you run into any issues. 16 | ## https://feniksdev.itch.io/color-picker-for-renpy 17 | ## https://github.com/shawna-p/renpy-color-picker 18 | ################################################################################ 19 | ################################################################################ 20 | ## SHADERS & TRANSFORMS 21 | ################################################################################ 22 | init python: 23 | ## A shader which creates a gradient for a colour picker. 24 | renpy.register_shader("feniks.color_picker", variables=""" 25 | uniform vec4 u_gradient_top_right; 26 | uniform vec4 u_gradient_top_left; 27 | uniform vec4 u_gradient_bottom_left; 28 | uniform vec4 u_gradient_bottom_right; 29 | uniform vec2 u_model_size; 30 | varying float v_gradient_x_done; 31 | varying float v_gradient_y_done; 32 | attribute vec4 a_position; 33 | """, vertex_300=""" 34 | v_gradient_x_done = a_position.x / u_model_size.x; 35 | v_gradient_y_done = a_position.y / u_model_size.y; 36 | """, fragment_300=""" 37 | // Mix the two top colours 38 | vec4 top = mix(u_gradient_top_left, u_gradient_top_right, v_gradient_x_done); 39 | // Mix the two bottom colours 40 | vec4 bottom = mix(u_gradient_bottom_left, u_gradient_bottom_right, v_gradient_x_done); 41 | // Mix the top and bottom 42 | gl_FragColor = mix(bottom, top, 1.0-v_gradient_y_done); 43 | """) 44 | 45 | ## A shader which creates a spectrum. Generally for colour pickers. 46 | renpy.register_shader("feniks.spectrum", variables=""" 47 | uniform float u_lightness; 48 | uniform float u_saturation; 49 | uniform float u_horizontal; 50 | uniform vec2 u_model_size; 51 | varying float v_gradient_x_done; 52 | varying float v_gradient_y_done; 53 | attribute vec4 a_position; 54 | """, vertex_300=""" 55 | v_gradient_x_done = a_position.x / u_model_size.x; 56 | v_gradient_y_done = a_position.y / u_model_size.y; 57 | """, fragment_functions=""" 58 | // HSL to RGB conversion adapted from 59 | // https://stackoverflow.com/questions/2353211/hsl-to-rgb-color-conversion 60 | float hue2rgb(float p, float q, float t){ 61 | if(t < 0.0) t += 1.0; 62 | if(t > 1.0) t -= 1.0; 63 | if(t < 1.0/6.0) return p + (q - p) * 6.0 * t; 64 | if(t < 1.0/2.0) return q; 65 | if(t < 2.0/3.0) return p + (q - p) * (2.0/3.0 - t) * 6.0; 66 | return p; 67 | } 68 | vec3 hslToRgb(float h, float l, float s) { 69 | float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s; 70 | float p = 2.0 * l - q; 71 | float r = hue2rgb(p, q, h + 1.0/3.0); 72 | float g = hue2rgb(p, q, h); 73 | float b = hue2rgb(p, q, h - 1.0/3.0); 74 | return vec3(r, g, b); 75 | } 76 | """, fragment_300=""" 77 | float hue = u_horizontal > 0.5 ? v_gradient_x_done : 1.0-v_gradient_y_done; 78 | vec3 rgb = hslToRgb(hue, u_lightness, u_saturation); 79 | gl_FragColor = vec4(rgb.r, rgb.g, rgb.b, 1.0); 80 | """) 81 | 82 | ## A transform which creates a spectrum. 83 | ## If horizontal is True, the spectrum goes from left to right instead of 84 | ## top to bottom. You can also adjust the lightness and saturation 85 | ## (between 0 and 1). 86 | transform spectrum(horizontal=True, light=0.5, sat=1.0): 87 | shader "feniks.spectrum" 88 | u_lightness light 89 | u_saturation sat 90 | u_horizontal float(horizontal) 91 | 92 | ## A transform which creates a square with a gradient. By default, only the 93 | ## top right colour is required (to make a colour picker gradient) but four 94 | ## corner colours may also be provided clockwise from the top-right. 95 | transform color_picker(top_right, bottom_right="#000", bottom_left="#000", 96 | top_left="#fff"): 97 | shader "feniks.color_picker" 98 | u_gradient_top_right Color(top_right).rgba 99 | u_gradient_top_left Color(top_left).rgba 100 | u_gradient_bottom_left Color(bottom_left).rgba 101 | u_gradient_bottom_right Color(bottom_right).rgba 102 | 103 | ################################################################################ 104 | ## CLASSES AND FUNCTIONS 105 | ################################################################################ 106 | init python: 107 | 108 | import pygame 109 | class ColorPicker(renpy.Displayable): 110 | """ 111 | A CDD which allows the player to pick a colour between four 112 | corner colours, with the typical setup used for a colour picker. 113 | 114 | Attributes 115 | ---------- 116 | xsize : int 117 | The width of the colour picker. 118 | ysize : int 119 | The height of the colour picker. 120 | top_left : Color 121 | The colour of the top-left corner. 122 | top_right : Color 123 | The colour of the top-right corner. 124 | bottom_left : Color 125 | The colour of the bottom-left corner. 126 | bottom_right : Color 127 | The colour of the bottom-right corner. 128 | color : Color 129 | The current colour the colour picker is focused over. 130 | selector_xpos : float 131 | The xpos of the colour selector. 132 | selector_ypos : float 133 | The ypos of the colour selector. 134 | picker : Displayable 135 | A square that is used to display the colour picker. 136 | hue_rotation : float 137 | The amount the current hue is rotated by. 138 | dragging : bool 139 | True if the indicator is currently being dragged around. 140 | saved_colors : dict 141 | A dictionary of key - Color pairs corresponding to colours the 142 | picker has selected in the past. 143 | last_saved_color : any 144 | The dictionary key of the last colour saved. 145 | mouseup_callback : callable 146 | An optional callback or list of callbacks which will be called when 147 | the player lifts their mouse after selecting a colour. 148 | """ 149 | RED = Color("#f00") 150 | def __init__(self, xsize, ysize, start_color=None, four_corners=None, 151 | saved_colors=None, last_saved_color=None, mouseup_callback=None, 152 | **kwargs): 153 | """ 154 | Create a ColorPicker object. 155 | 156 | Parameters: 157 | ----------- 158 | xsize : int 159 | The width of the colour picker. 160 | ysize : int 161 | The height of the colour picker. 162 | start_color : str 163 | A hexadecimal colour code corresponding to the starting colour. 164 | four_corners : tuple(Color, Color, Color, Color) 165 | A tuple of four colours corresponding to the four corners of the 166 | colour picker. The order is top right, bottom right, bottom 167 | left, top left. If this is not None, it will override the 168 | start_color parameter. 169 | saved_colors : dict 170 | A dictionary of key - Color pairs corresponding to colours 171 | the picker has selected in the past. 172 | last_saved_color : any 173 | The dictionary key of the last colour saved. 174 | mouseup_callback : callable 175 | An optional callback or list of callbacks which will be called 176 | when the player lifts their mouse after selecting a colour. 177 | """ 178 | super(ColorPicker, self).__init__(**kwargs) 179 | self.xsize = xsize 180 | self.ysize = ysize 181 | 182 | self.top_left = None 183 | self.top_right = None 184 | self.bottom_left = None 185 | self.bottom_right = None 186 | 187 | self.last_saved_color = last_saved_color 188 | self.saved_colors = saved_colors or dict() 189 | self.mouseup_callback = mouseup_callback 190 | 191 | if start_color is None and four_corners is None: 192 | ## Automatically start with red 193 | self.set_color("#f00") 194 | elif four_corners is None: 195 | self.set_color(start_color) 196 | else: 197 | all_corners = [Color(c) if not isinstance(c, Color) else c for c in four_corners] 198 | self.top_right, self.bottom_right, self.bottom_left, self.top_left = all_corners 199 | self.set_color(self.top_right) 200 | 201 | self.picker = Transform("#fff", xysize=(self.xsize, self.ysize)) 202 | self.dragging = False 203 | 204 | self.save_color(self.last_saved_color) 205 | 206 | def set_color(self, color): 207 | """ 208 | Set the current colour of the colour picker. 209 | 210 | Parameters 211 | ---------- 212 | color : Color 213 | The new colour to set the colour picker to. 214 | """ 215 | if not isinstance(color, Color): 216 | self.color = Color(color) 217 | else: 218 | self.color = color 219 | self.dragging = False 220 | 221 | ## Check if this has four custom corners 222 | if self.top_left is None: 223 | ## No; set to saturation/value 224 | self.selector_xpos = round(self.color.hsv[1]*255.0)/255.0 225 | self.selector_ypos = 1.0 - round(self.color.hsv[2]*255.0)/255.0 226 | self._hue_rotation = self.color.hsv[0] 227 | else: 228 | ## There isn't a good way to guess the position of a colour 229 | ## with custom corners, so just set it to the top right 230 | self.selector_xpos = 1.0 231 | self.selector_ypos = 0.0 232 | self._hue_rotation = 0.0 233 | 234 | @property 235 | def hue_rotation(self): 236 | """ 237 | The hue rotation of the colour picker. 238 | """ 239 | return self._hue_rotation 240 | 241 | @hue_rotation.setter 242 | def hue_rotation(self, value): 243 | """ 244 | Set the hue rotation of the colour picker. 245 | """ 246 | if value > 1.0: 247 | value = value % 1.0 248 | if round(self._hue_rotation*255.0) == round(value*255): 249 | return 250 | self._hue_rotation = value 251 | self.update_hue() 252 | 253 | def set_saved_color(self, key, new_color): 254 | """ 255 | Set the colour saved with key as the key to new_color. 256 | 257 | Parameters 258 | ---------- 259 | key : any 260 | The key of the colour to change. Must be a valid dictionary key. 261 | new_color : Color 262 | The new colour to set the saved colour to. 263 | """ 264 | if not isinstance(new_color, Color): 265 | self.saved_colors[key] = Color(new_color) 266 | else: 267 | self.saved_colors[key] = new_color 268 | 269 | def save_color(self, key): 270 | """ 271 | Save the current colour to the saved dictionary with key as the key. 272 | """ 273 | self.saved_colors[key] = self.color 274 | 275 | def get_color(self, key): 276 | """ 277 | Retrieve the colour saved in the dictionary with key as the key. 278 | """ 279 | return self.saved_colors.get(key, Color("#000")) 280 | 281 | def swap_to_saved_color(self, key): 282 | """ 283 | Swap to the saved colour with key as the key. 284 | """ 285 | self.set_color(self.saved_colors.get(key, Color("#000"))) 286 | self.last_saved_color = key 287 | renpy.redraw(self, 0) 288 | 289 | def render(self, width, height, st, at): 290 | """ 291 | Render the displayable to the screen. 292 | """ 293 | r = renpy.Render(self.xsize, self.ysize) 294 | 295 | if self.top_left is None: 296 | trc = self.RED.rotate_hue(self.hue_rotation) 297 | # Colorize the picker into a gradient 298 | picker = At(self.picker, color_picker(trc)) 299 | else: 300 | # Custom four corners; no spectrum sliders 301 | picker = At(self.picker, color_picker( 302 | self.top_right.rotate_hue(self.hue_rotation), 303 | self.bottom_right.rotate_hue(self.hue_rotation), 304 | self.bottom_left.rotate_hue(self.hue_rotation), 305 | self.top_left.rotate_hue(self.hue_rotation))) 306 | # Position the selector 307 | selector = Transform("selector", anchor=(0.5, 0.5), 308 | xpos=self.selector_xpos, ypos=self.selector_ypos) 309 | final = Fixed(picker, selector, xysize=(self.xsize, self.ysize)) 310 | # Render it to the screen 311 | ren = renpy.render(final, self.xsize, self.ysize, st, at) 312 | r.blit(ren, (0, 0)) 313 | return r 314 | 315 | def update_hue(self): 316 | """ 317 | Update the colour based on the hue in the top-right corner 318 | (or in all 4 corners). 319 | """ 320 | # Figure out the colour under the selector 321 | if self.top_left is None: 322 | trc = self.RED.rotate_hue(self.hue_rotation) 323 | tlc = Color("#fff") 324 | brc = Color("#000") 325 | blc = Color("#000") 326 | else: 327 | tlc = self.top_left.rotate_hue(self.hue_rotation) 328 | trc = self.top_right.rotate_hue(self.hue_rotation) 329 | brc = self.bottom_right.rotate_hue(self.hue_rotation) 330 | blc = self.bottom_left.rotate_hue(self.hue_rotation) 331 | 332 | self.color = tlc.interpolate(trc, self.selector_xpos) 333 | bottom = blc.interpolate(brc, self.selector_xpos) 334 | self.color = self.color.interpolate(bottom, self.selector_ypos) 335 | self.save_color(self.last_saved_color) 336 | renpy.redraw(self, 0) 337 | 338 | def event(self, ev, x, y, st): 339 | """Allow the user to drag their mouse to select a colour.""" 340 | relative_x = round(x/float(self.xsize)*255.0)/255.0 341 | relative_y = round(y/float(self.ysize)*255.0)/255.0 342 | 343 | in_range = (0.0 <= relative_x <= 1.0) and (0.0 <= relative_y <= 1.0) 344 | 345 | if renpy.map_event(ev, "mousedown_1") and in_range: 346 | self.dragging = True 347 | self.selector_xpos = relative_x 348 | self.selector_ypos = relative_y 349 | elif ev.type == pygame.MOUSEMOTION and self.dragging: 350 | self.selector_xpos = relative_x 351 | self.selector_ypos = relative_y 352 | elif renpy.map_event(ev, "mouseup_1") and self.dragging: 353 | self.dragging = False 354 | ## Update the screen 355 | renpy.restart_interaction() 356 | if self.mouseup_callback is not None: 357 | renpy.run(self.mouseup_callback, self) 358 | return 359 | else: 360 | return 361 | 362 | # Limit x/ypos 363 | self.selector_xpos = min(max(self.selector_xpos, 0.0), 1.0) 364 | self.selector_ypos = min(max(self.selector_ypos, 0.0), 1.0) 365 | self.update_hue() 366 | return None 367 | 368 | def picker_color(st, at, picker, xsize=100, ysize=100): 369 | """ 370 | A DynamicDisplayable function to update the colour picker swatch. 371 | 372 | Parameters: 373 | ----------- 374 | picker : ColorPicker 375 | The picker this swatch is made from. 376 | xsize : int 377 | The width of the swatch. 378 | ysize : int 379 | The height of the swatch. 380 | """ 381 | return Transform(picker.color, xysize=(xsize, ysize)), 0.01 382 | 383 | def picker_hexcode(st, at, picker): 384 | """ 385 | A brief DynamicDisplayable demonstration of how to display color 386 | information in real-time. 387 | """ 388 | return Text(picker.color.hexcode, style='picker_hexcode'), 0.01 389 | 390 | ################################################################################ 391 | ## IMAGES 392 | ################################################################################ 393 | init offset = -1 394 | init python: 395 | def construct_selector(w=2, sz=5): 396 | """ 397 | Constructs a white box surrounded by a black box, to use as a 398 | selector for the colour picker. 399 | 400 | Parameters 401 | ---------- 402 | w : int 403 | The width of the lines. 404 | sz : int 405 | The size of the inner box. 406 | """ 407 | ## First, the sides of the box 408 | box_leftright = [ 409 | Transform("#000", xysize=(w, sz+2*3*w), align=(0.5, 0.5)), 410 | Transform("#fff", xysize=(w, sz+2*2*w), align=(0.5, 0.5)), 411 | Transform("#000", xysize=(w, sz+2*1*w), align=(0.5, 0.5)), 412 | ] 413 | ## Then the top and bottom 414 | box_topbottom = [ 415 | Transform("#000", xysize=(sz+2*2*w, w), align=(0.5, 0.5)), 416 | Transform("#fff", xysize=(sz+2*1*w, w), align=(0.5, 0.5)), 417 | Transform("#000", xysize=(sz, w), align=(0.5, 0.5)), 418 | ] 419 | final_vbox = box_topbottom + [Null(height=sz)] + box_topbottom[::-1] 420 | final_hbox = (box_leftright + [Null(width=-w*2)] 421 | + [VBox(*final_vbox, style='empty', spacing=0)] 422 | + [Null(width=-w*2)] + box_leftright[::-1]) 423 | ## Now put it together 424 | return HBox(*final_hbox, spacing=0, style='empty') 425 | 426 | ## These can be changed; see color_picker_examples.rpy for more. 427 | ## Feel free to remove the constructor function above if you don't use these. 428 | ## Used for both the spectrum thumb and the colour indicator. 429 | image selector_img = construct_selector(2, 3) 430 | image selector_bg = Frame("selector_img", 7, 7) 431 | ## The image used for the indicator showing the current colour. 432 | image selector = Transform("selector_bg", xysize=(15, 15)) 433 | 434 | style picker_hexcode: 435 | color "#fff" 436 | font "DejaVuSans.ttf" 437 | 438 | 439 | -------------------------------------------------------------------------------- /RenPyUtil/RenCommunicator/ren_communicator_ren.py: -------------------------------------------------------------------------------- 1 | # 此文件提供了一系列基于Ren'Py的功能类,以供Ren'Py开发者调用 2 | # 作者 ZYKsslm 3 | # 仓库 https://github.com/ZYKsslm/RenPyUtil 4 | # 声明 该源码使用 MIT 协议开源,但若使用需要在程序中标明作者消息 5 | 6 | 7 | """renpy 8 | init -1 python: 9 | """ 10 | 11 | 12 | import logging 13 | import os 14 | import pickle 15 | import socket 16 | import time 17 | from typing import Optional 18 | 19 | 20 | # Ren'Py 相关 21 | renpy = renpy # type: ignore 22 | config = config # type: ignore 23 | preferences = preferences # type: ignore 24 | im = im # type: ignore 25 | AudioData = AudioData # type: ignore 26 | Movie = Movie # type: ignore 27 | 28 | 29 | def set_logger(logger_name: str, log_path: str): 30 | """返回一个日志记录器,包含文件输出和标准控制台输出。 31 | 32 | Arguments: 33 | logger_name -- 日志名称 34 | log_path -- 日志文件路径 35 | """ 36 | 37 | logger = logging.getLogger(logger_name) 38 | logger.setLevel(logging.DEBUG) 39 | 40 | file_handler = logging.FileHandler(os.path.join(config.basedir, log_path), encoding="utf-8") 41 | file_handler.setLevel(logging.DEBUG) 42 | formatter = logging.Formatter("%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(threadName)s - %(message)s") 43 | file_handler.setFormatter(formatter) 44 | 45 | console_handler = logging.StreamHandler() 46 | console_handler.setLevel(logging.DEBUG) 47 | console_handler.setFormatter(formatter) 48 | 49 | logger.addHandler(console_handler) 50 | logger.addHandler(file_handler) 51 | 52 | return logger 53 | 54 | 55 | class Message(object): 56 | """消息类,用于创建通信中收发的消息对象""" 57 | 58 | logger = set_logger("Message", "RenCommunicator.log") 59 | 60 | STRING = "string".encode() # 字符串类型 61 | IMAGE = "image".encode() # 图片类型 62 | AUDIO = "audio".encode() # 音频类型 63 | MOVIE = "movie".encode() # 影片类型 64 | OBJECT = "object".encode() # 其他 Python 对象类型 65 | 66 | def __init__(self, msg: bytes, data: bytes = None, type: bytes = None, fmt: bytes = None): 67 | """消息构建方法。一般不显示调用,而是使用类方法创建消息。 68 | 69 | Arguments: 70 | msg -- 原始消息 71 | 72 | Keyword Arguments: 73 | data -- 消息数据 (default: {None}) 74 | type -- 消息类型 (default: {None}) 75 | fmt -- 消息格式 (default: {None}) 76 | """ 77 | 78 | if not data and not type and not fmt: 79 | self.type, self.fmt, self.data = msg.split(b"|", 2) 80 | else: 81 | self.type = type 82 | self.fmt = fmt 83 | self.data = data 84 | 85 | self.msg = msg 86 | self.log_info = { 87 | "type": self.type.decode(), 88 | "size": len(self.data), 89 | "format": None, 90 | "message": None, 91 | "class": None 92 | } 93 | if self.type == self.STRING: 94 | self.log_info["message"] = self.data.decode() 95 | elif self.type == self.OBJECT: 96 | self.log_info["class"] = self.fmt.decode() 97 | else: 98 | self.log_info["format"] = self.fmt.decode() 99 | 100 | self._message = None 101 | self._image = None 102 | self._audio = None 103 | self._movie = None 104 | self._object = None 105 | 106 | @staticmethod 107 | def parse_path(*renpy_paths): 108 | """调用该静态方法,把标准 Ren'Py 路径转换为绝对路径。 109 | 110 | Returns: 111 | 一个绝对路径。 112 | """ 113 | 114 | return os.path.join(config.gamedir, *renpy_paths) 115 | 116 | @classmethod 117 | def string(cls, msg: str): 118 | """调用该类方法,创建字符串消息。 119 | 120 | Arguments: 121 | msg -- 字符串消息 122 | 123 | Returns: 124 | 一个 `Message` 对象 125 | """ 126 | 127 | prefix = cls.STRING + b"|" + b"|" 128 | data = msg.encode() 129 | msg = prefix + data 130 | return cls(msg, data, cls.STRING) 131 | 132 | @classmethod 133 | def image(cls, img_path: str): 134 | """调用该类方法,创建图片消息。 135 | 136 | Arguments: 137 | img_path -- 图片路径 138 | 139 | Returns: 140 | 一个 `Message` 对象 141 | 142 | """ 143 | 144 | if not os.path.exists(img_path): 145 | Message.logger.warning(f"未找到该图片:{img_path},请确保路径符合 Ren'Py 规范") 146 | else: 147 | with open(img_path, "rb") as img: 148 | data = img.read() 149 | 150 | fmt = os.path.splitext(img_path)[1].encode() 151 | prefix = cls.IMAGE + b"|" + fmt + b"|" 152 | msg = prefix + data 153 | return cls(msg, data, cls.IMAGE, fmt) 154 | 155 | @classmethod 156 | def audio(cls, audio_path: str): 157 | """调用该类方法,创建音频消息。 158 | 159 | Arguments: 160 | audio_path -- 音频路径 161 | 162 | Returns: 163 | 一个 `Message` 对象 164 | """ 165 | 166 | if not os.path.exists(audio_path): 167 | Message.logger.warning(f"未找到该音频:{audio_path},请确保路径符合 Ren'Py 规范") 168 | else: 169 | with open(audio_path, "rb") as audio: 170 | data = audio.read() 171 | 172 | fmt = os.path.splitext(audio_path)[1].encode() 173 | prefix = cls.AUDIO + b"|" + fmt + b"|" 174 | msg = prefix + data 175 | return cls(msg, data, cls.AUDIO, fmt) 176 | 177 | @classmethod 178 | def movie(cls, movie_path: str): 179 | """调用该类方法,创建影片消息。 180 | 181 | Arguments: 182 | movie_path -- 影片路径 183 | 184 | Returns: 185 | 一个 `Message` 对象 186 | 187 | Raises: 188 | Exception -- 若影片路径不存在,则抛出异常。 189 | """ 190 | 191 | if not os.path.exists(movie_path): 192 | Message.logger.warning(f"未找到该影片:{movie_path},请确保路径符合 Ren'Py 规范") 193 | else: 194 | with open(movie_path, "rb") as movie: 195 | data = movie.read() 196 | 197 | fmt = os.path.splitext(movie_path)[1].encode() 198 | prefix = cls.MOVIE + b"|" + fmt + b"|" 199 | msg = prefix + data 200 | return cls(msg, data, cls.MOVIE, fmt) 201 | 202 | @classmethod 203 | def object(cls, obj: object): 204 | """调用该类方法,创建其他 Python 对象消息。 205 | 206 | Arguments: 207 | obj -- 其他 Python 对象 208 | 209 | Returns: 210 | 一个 `Message` 对象 211 | """ 212 | 213 | try: 214 | data = pickle.dumps(obj) 215 | except pickle.PicklingError: 216 | Message.logger.warning(f"无法序列化 {obj} 对象") 217 | else: 218 | fmt = type(obj).__name__.encode() 219 | prefix = cls.OBJECT + b"|" + fmt + b"|" 220 | msg = prefix + data 221 | return cls(msg, data, cls.OBJECT, fmt) 222 | 223 | def get_message(self): 224 | """若消息类型为字符串,则返回该字符串。否则返回 None""" 225 | 226 | if self.type != self.STRING: 227 | return 228 | 229 | if not self._message: 230 | self._message = self.data.decode() 231 | Message.logger.debug(f"成功解析字符串消息:{self._message}") 232 | 233 | return self.data.decode() 234 | 235 | def get_image(self): 236 | """若消息类型为图片,则返回该图片的可视组件。否则返回 None""" 237 | 238 | if self.type != self.IMAGE: 239 | return 240 | 241 | if not self._image: 242 | self._image = im.Data(self.data, self.fmt.decode()) 243 | Message.logger.debug(f"成功将图片解析为可视组件:{self._image}") 244 | 245 | return self._image 246 | 247 | def get_audio(self): 248 | """若消息类型为音频,则返回一个音频对象,该对象可直接使用 `play` 语句播放。否则返回 None""" 249 | 250 | if self.type != self.AUDIO: 251 | return 252 | 253 | if not self._audio: 254 | self._audio = AudioData(self.data, self.fmt.decode()) 255 | Message.logger.debug(f"成功将音频解析为音频对象:{self._audio}") 256 | 257 | return self._audio 258 | 259 | def get_movie(self, cache_path: str = "movie_cache", **kwargs): 260 | """_summary_ 261 | 262 | Keyword Arguments: 263 | cache_path -- 视频缓存目录 (default: {None}) 264 | 265 | Returns: 266 | 一个 `Movie` 可视组件 267 | 268 | 其他关键字参数将传递给 `Movie` 类 269 | """ 270 | 271 | if self.type != self.MOVIE: 272 | return 273 | 274 | if not self._movie: 275 | cache_name = f"{int(time.time())}{self.fmt.decode()}" 276 | cache_dir = Message.parse_path(cache_path) 277 | cache_path = Message.parse_path(cache_path, cache_name) 278 | if not os.path.exists(cache_dir): 279 | os.makedirs(cache_dir) 280 | 281 | with open(cache_path, "wb") as cache: 282 | cache.write(self.data) 283 | Message.logger.debug(f"成功将影片缓存到 {cache_path}") 284 | 285 | self._movie = Movie(play=cache_path, **kwargs) 286 | Message.logger.debug(f"成功将影片解析为可视组件:{self._movie}") 287 | 288 | return self._movie 289 | 290 | def get_object(self): 291 | """若消息类型为其他 Python 对象,则返回该对象。否则返回 None""" 292 | 293 | if self.type != self.OBJECT: 294 | return 295 | 296 | if not self._object: 297 | try: 298 | self._object = pickle.loads(self.data) 299 | except pickle.UnpicklingError: 300 | RenServer.logger.warning(f"无法解析 {self.fmt.decode()} 对象") 301 | return 302 | 303 | return self._object 304 | 305 | 306 | class RenServer(object): 307 | """该类为一个服务器类。基于socket进行多线程通信""" 308 | 309 | logger = set_logger("RenServer", "RenCommunicator.log") 310 | 311 | def __init__(self, max_conn=5, max_data_size=104857600, ip="0.0.0.0", port=8888): 312 | """初始化方法。 313 | 314 | Keyword Arguments: 315 | max_conn -- 最大连接数。 (default: {5}) 316 | max_data_size -- 接收数据的最大大小。默认为100M。 (default: {104857600}) 317 | port -- 端口号。 (default: {None}) 318 | """ 319 | 320 | self.port = port 321 | self.ip = ip 322 | self.max_data_size = max_data_size 323 | self.max_conn = max_conn 324 | self.socket = None 325 | 326 | self.client_socket_dict: dict[str, socket.socket] = {} 327 | self.conn_event = [] 328 | self.disconn_event = [] 329 | self.recv_event = [] 330 | 331 | self.chat_mode = False 332 | self.chat_screen = "ren_communicator_chat" 333 | self.msg_list: list[tuple[socket.socket, Message]] = [] 334 | 335 | def run(self): 336 | """调用该方法,开始监听端口,创建连接线程。在快进状态下不会有任何效果""" 337 | 338 | if renpy.is_skipping(): 339 | return 340 | 341 | try: 342 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 343 | self.socket.bind((self.ip, self.port)) 344 | except OSError: 345 | RenServer.logger.error(f"端口 {self.port} 已被占用,请检查是否有其他进程占用或是打开了多个游戏") 346 | else: 347 | self.socket.listen(self.max_conn) 348 | RenServer.logger.info(f"服务器已启动,开始监听端口:{self.port}") 349 | renpy.invoke_in_thread(self._accept) 350 | 351 | def close(self): 352 | """调用该方法,关闭服务器""" 353 | 354 | for client_socket in self.client_socket_dict.values(): 355 | client_socket.close() 356 | self.client_socket_dict.clear() 357 | self.socket.close() 358 | 359 | def reboot(self): 360 | """调用该方法,重启服务器""" 361 | 362 | self.close() 363 | self.run() 364 | 365 | def _accept(self): 366 | """该方法用于创建连接线程,用于类内部使用,不应被调用""" 367 | 368 | while True: 369 | try: 370 | client_socket = self.socket.accept()[0] 371 | except OSError: 372 | RenServer.logger.warning("服务器已关闭") 373 | break 374 | else: 375 | client_name = f"{client_socket.getpeername()[0]}:{client_socket.getpeername()[1]}" 376 | RenServer.logger.info(f"{client_name} 已连接") 377 | if self.chat_mode: 378 | renpy.show_screen(self.chat_screen, self, True, client_socket) 379 | self.client_socket_dict[client_name] = client_socket 380 | renpy.invoke_in_thread(self._receive, client_name, client_socket) 381 | for event in self.conn_event: 382 | event(self, client_name, client_socket) 383 | 384 | def _receive(self, client_name, client_socket): 385 | """该方法用于接收线程使用,处理接收事件,用于类内部使用,不应被调用""" 386 | 387 | while True: 388 | try: 389 | data = client_socket.recv(self.max_data_size) 390 | except ConnectionError: 391 | RenServer.logger.warning(f"{client_name} 已断开连接") 392 | if client_name in self.client_socket_dict.keys(): 393 | del self.client_socket_dict[client_name] 394 | for event in self.disconn_event: 395 | event(self, client_name) 396 | break 397 | else: 398 | msg = Message(data) 399 | if self.chat_mode: 400 | self.msg_list.append((client_socket, msg)) 401 | RenServer.logger.debug(f"接收到 {client_name} 的消息:{msg.log_info}") 402 | for event in self.recv_event: 403 | event(self, client_name, client_socket, msg) 404 | 405 | def send(self, client_socket: socket.socket, msg: Message, block=False): 406 | """调用该方法,向指定客户端发送消息。 407 | 408 | Arguments: 409 | client_socket -- 客户端socket。 410 | msg -- 要发送的消息。 411 | 412 | Keyword Arguments: 413 | block -- 若为True,则该方法将阻塞,直到发送完成。 (default: {False}) 414 | """ 415 | 416 | if block: 417 | self._send(client_socket, msg) 418 | else: 419 | renpy.invoke_in_thread(self._send, client_socket, msg) 420 | 421 | def _send(self, client_socket: socket.socket, msg: Message): 422 | try: 423 | client_socket.send(msg.msg) 424 | except ConnectionError as e: 425 | RenServer.logger.warning(f"发送失败:{e}") 426 | 427 | def broadcast(self, msg: Message): 428 | """调用该方法,向所有客户端发送消息。 429 | 430 | Keyword Arguments: 431 | msg -- 要发送的消息。 432 | """ 433 | 434 | for client_socket in self.client_socket_dict.values(): 435 | self.send(client_socket, msg) 436 | 437 | def on_conn(self, thread=False): 438 | """注册一个连接事件。 439 | 440 | Keyword Arguments: 441 | thread -- 若为True,则该事件将在子线程中执行,用于极为耗时的操作。 (default: {False}) 442 | """ 443 | 444 | def decorator(func): 445 | def wrapper(server: RenServer, client_name: str, client_socket: socket.socket): 446 | if thread: 447 | renpy.invoke_in_thread(func, server, client_name, client_socket) 448 | else: 449 | func(server, client_name, client_socket) 450 | self.conn_event.append(wrapper) 451 | return wrapper 452 | 453 | return decorator 454 | 455 | def on_disconn(self, thread=False): 456 | """注册一个断开连接事件。 457 | 458 | Keyword Arguments: 459 | thread -- 若为True,则该事件将在子线程中执行,用于极为耗时的操作。 (default: {False}) 460 | """ 461 | 462 | def decorator(func): 463 | def wrapper(server: RenServer, client_name: str): 464 | if thread: 465 | renpy.invoke_in_thread(func, server, client_name) 466 | else: 467 | func(server, client_name) 468 | self.disconn_event.append(wrapper) 469 | return wrapper 470 | 471 | return decorator 472 | 473 | def on_recv(self, thread=False): 474 | """注册一个接收消息事件。 475 | 476 | Keyword Arguments: 477 | thread -- 若为True,则该事件将在子线程中执行,用于极为耗时的操作。 (default: {False}) 478 | """ 479 | 480 | def decorator(func): 481 | def wrapper(server: RenServer, client_name, client_socket: socket.socket, msg: Message): 482 | if thread: 483 | renpy.invoke_in_thread(func, server, client_name, client_socket, msg) 484 | else: 485 | func(server, client_name, client_socket, msg) 486 | self.recv_event.append(wrapper) 487 | return wrapper 488 | 489 | return decorator 490 | 491 | def quit_chat(self): 492 | """调用该方法,退出聊天模式""" 493 | 494 | preferences.afm_enable = True 495 | self.chat_mode = False 496 | self.msg_list.clear() 497 | 498 | def get_message(self, wait_msg: Optional[Message] = None, screen="ren_communicator_chat"): 499 | """进入聊天模式。该模式将一直运行,直到调用 `quit_chat` 方法退出,该模式适用于简单的两人对话式聊天。 500 | 501 | 当没有消息时,会显示等待消息并启用自动前进。若接受到消息,则显示消息并禁用自动前进。 502 | 请使用 `for` 循环获取客户端和消息,并在循环中处理消息。 503 | 504 | Keyword Arguments: 505 | wait_msg -- 等待消息,当没有消息时显示。若省略该参数则等待时将进入伪阻塞状态 (default: {None}) 506 | screen -- 聊天功能界面 (default: {"ren_communicator_chat"}) 507 | 508 | Yields: 509 | 一个元组,包含客户端(当没有消息时为 None)和消息(当没有消息时为等待消息)。 510 | """ 511 | 512 | renpy.notify("进入聊天模式") 513 | self.chat_mode = True 514 | self.chat_screen = screen 515 | renpy.show_screen(screen, self) 516 | 517 | while self.chat_mode: 518 | if self.msg_list: 519 | latest_msg = self.msg_list.pop(0) 520 | preferences.afm_enable = False 521 | yield latest_msg 522 | else: 523 | preferences.afm_enable = True 524 | if wait_msg: 525 | yield (None, wait_msg) 526 | else: 527 | renpy.pause(0) 528 | 529 | renpy.hide_screen(screen) 530 | preferences.afm_enable = False 531 | renpy.notify("退出聊天模式") 532 | 533 | def __enter__(self): 534 | # 禁止回滚 535 | config.rollback_enabled = False 536 | renpy.block_rollback() 537 | self.run() 538 | RenServer.logger.info("进入上下文管理器,回滚功能已暂时禁用") 539 | 540 | return self 541 | 542 | def __exit__(self, exc_type, exc_val, exc_tb): 543 | # 当退出with语句后恢复禁用的功能 544 | config.rollback_enabled = True 545 | renpy.block_rollback() 546 | self.close() 547 | RenServer.logger.info("退出上下文管理器,回滚功能已恢复") 548 | 549 | 550 | class RenClient(object): 551 | """该类为一个客户端类""" 552 | 553 | logger = set_logger("RenClient", "RenCommunicator.log") 554 | 555 | def __init__(self, target_ip=None, target_port=None, max_data_size=104857600): 556 | """初始化方法 557 | 558 | Keyword Arguments: 559 | target_ip -- 服务器IP。 (default: {None}) 560 | target_port -- 服务器端口。 (default: {None}) 561 | max_data_size -- 接收数据的最大大小。默认为100M。 (default: {104857600}) 562 | character -- 该参数应为一个角色对象,用于将字符串消息保存在历史记录中。 (default: {None}) 563 | """ 564 | 565 | self.target_ip = target_ip 566 | self.target_port = target_port 567 | self.target_address = f"{self.target_ip}:{self.target_port}" 568 | self.max_data_size = max_data_size 569 | self.socket = None 570 | 571 | self.conn_event = [] 572 | self.disconn_event = [] 573 | self.recv_event = [] 574 | 575 | self.chat_mode = False 576 | self.chat_screen = "ren_communicator_chat" 577 | self.msg_list: list[Message] = [] 578 | 579 | def set_target(self, target_ip, target_port): 580 | """调用该方法,设置服务器地址。 581 | 582 | Arguments: 583 | target_ip -- 服务器IP。 584 | target_port -- 服务器端口。 585 | """ 586 | 587 | self.target_ip = target_ip 588 | self.target_port = target_port 589 | self.target_address = f"{self.target_ip}:{self.target_port}" 590 | 591 | return self 592 | 593 | def run(self): 594 | """调用该方法,开始尝试连接服务器。在快进状态下不会有任何效果""" 595 | 596 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 597 | 598 | if renpy.is_skipping(): 599 | return 600 | renpy.invoke_in_thread(self._connect) 601 | 602 | def close(self): 603 | """调用该方法,关闭客户端""" 604 | 605 | self.socket.close() 606 | 607 | def reboot(self): 608 | """调用该方法,重启客户端""" 609 | 610 | self.close() 611 | self.run() 612 | 613 | def _connect(self): 614 | """该方法用于创建连接线程,用于类内部使用,不应被调用""" 615 | 616 | while True: 617 | RenClient.logger.info(f"正在尝试连接到 {self.target_address}") 618 | try: 619 | self.socket.connect((self.target_ip, self.target_port)) 620 | except TimeoutError: 621 | RenClient.logger.warning(f"连接超时,再次尝试连接") 622 | except OSError: 623 | RenClient.logger.warning("客户端已被关闭") 624 | break 625 | else: 626 | RenClient.logger.info(f"客户端已连接到 {self.target_address}") 627 | if self.chat_mode: 628 | renpy.show_screen(self.chat_screen, self, True) 629 | for event in self.conn_event: 630 | event(self) 631 | self._receive() 632 | break 633 | 634 | def _receive(self): 635 | """该方法用于接收线程使用,处理接收事件,用于类内部使用,不应被调用""" 636 | 637 | while True: 638 | try: 639 | data = self.socket.recv(self.max_data_size) 640 | except ConnectionError: 641 | RenClient.logger.warning(f"服务器已断开连接") 642 | if self.chat_mode: 643 | renpy.show_screen(self.chat_screen, self, False) 644 | for event in self.disconn_event: 645 | event(self) 646 | break 647 | else: 648 | msg = Message(data) 649 | if self.chat_mode: 650 | self.msg_list.append(msg) 651 | RenClient.logger.debug(f"接收到服务器的消息:{msg.log_info}") 652 | for event in self.recv_event: 653 | event(self, msg) 654 | 655 | def send(self, msg: Message, block=False): 656 | """调用该方法,向指定客户端发送消息。 657 | 658 | Arguments: 659 | msg -- 要发送的消息。 660 | 661 | Keyword Arguments: 662 | block -- 若为True,则该方法将阻塞,直到发送完成。 (default: {False}) 663 | """ 664 | 665 | if block: 666 | self._send(msg) 667 | else: 668 | renpy.invoke_in_thread(self._send, msg) 669 | 670 | def _send(self, msg: Message): 671 | 672 | try: 673 | self.socket.send(msg.msg) 674 | except ConnectionError as e: 675 | RenClient.logger.warning(f"发送失败:{e}") 676 | 677 | def on_conn(self, thread=False): 678 | """注册一个连接事件。 679 | 680 | Keyword Arguments: 681 | thread -- 若为True,则该事件将在子线程中执行,用于极为耗时的操作。 (default: {False}) 682 | """ 683 | 684 | def decorator(func): 685 | def wrapper(client: RenClient): 686 | if thread: 687 | renpy.invoke_in_thread(func, client) 688 | else: 689 | func(client) 690 | self.conn_event.append(wrapper) 691 | return wrapper 692 | 693 | return decorator 694 | 695 | def on_disconn(self, thread=False): 696 | """注册一个断开连接事件。 697 | 698 | Keyword Arguments: 699 | thread -- 若为True,则该事件将在子线程中执行,用于极为耗时的操作。 (default: {False}) 700 | """ 701 | 702 | def decorator(func): 703 | def wrapper(client: RenClient): 704 | if thread: 705 | renpy.invoke_in_thread(func, client) 706 | else: 707 | func(client) 708 | self.disconn_event.append(wrapper) 709 | return wrapper 710 | 711 | return decorator 712 | 713 | def on_recv(self, thread=False): 714 | """注册一个接收消息事件。 715 | 716 | Keyword Arguments: 717 | thread -- 若为True,则该事件将在子线程中执行,用于极为耗时的操作。 (default: {False}) 718 | """ 719 | 720 | def decorator(func): 721 | def wrapper(client: RenClient, msg: Message): 722 | if thread: 723 | renpy.invoke_in_thread(func, client, msg) 724 | else: 725 | func(client, msg) 726 | self.recv_event.append(wrapper) 727 | return wrapper 728 | 729 | return decorator 730 | 731 | def quit_chat(self): 732 | """调用该方法,退出聊天模式""" 733 | 734 | preferences.afm_enable = True 735 | self.chat_mode = False 736 | self.msg_list.clear() 737 | 738 | def get_message(self, wait_msg: Optional[Message] = None, screen="ren_communicator_chat"): 739 | """进入聊天模式。该模式将一直运行,直到调用 `quit_chat` 方法退出,该模式适用于简单的两人对话式聊天。 740 | 741 | 当没有消息时,会显示等待消息并启用自动前进。若接受到消息,则显示消息并禁用自动前进。 742 | 请使用 `for` 循环获取消息,并在循环中处理消息。 743 | 744 | Keyword Arguments: 745 | wait_msg -- 等待消息,当没有消息时显示。若省略该参数则等待时将进入伪阻塞状态 (default: {None}) 746 | screen -- 聊天功能界面 (default: {"ren_communicator_chat"}) 747 | 748 | Yields: 749 | 一个消息对象。 750 | """ 751 | 752 | renpy.notify("进入聊天模式") 753 | self.chat_mode = True 754 | self.chat_screen = screen 755 | renpy.show_screen(screen, self) 756 | 757 | while self.chat_mode: 758 | if self.msg_list: 759 | preferences.afm_enable = False 760 | yield self.msg_list.pop(0) 761 | else: 762 | preferences.afm_enable = True 763 | if wait_msg: 764 | yield wait_msg 765 | else: 766 | renpy.pause(0) 767 | 768 | renpy.hide_screen(screen) 769 | preferences.afm_enable = False 770 | renpy.notify("退出聊天模式") 771 | 772 | def __enter__(self): 773 | config.rollback_enabled = False 774 | renpy.block_rollback() 775 | self.run() 776 | RenClient.logger.info("进入上下文管理器,回滚功能已暂时禁用") 777 | 778 | return self 779 | 780 | def __exit__(self, exc_type, exc_val, exc_tb): 781 | config.rollback_enabled = True 782 | renpy.block_rollback() 783 | self.close() 784 | RenClient.logger.info("退出上下文管理器,回滚功能已恢复") 785 | -------------------------------------------------------------------------------- /RenPyUtil/RenCommunicator/ren_communicator_screen.rpy: -------------------------------------------------------------------------------- 1 | screen ren_communicator_chat(ren_communicator, can_send=False, socket=None): 2 | zorder 100 3 | 4 | frame: 5 | align (1.0, gui.textbox_yalign) 6 | vbox: 7 | spacing 10 8 | label "聊天" xalign 0.5 yoffset 10 9 | null height 10 10 | if can_send: 11 | textbutton "发送消息" action ShowMenu("ren_communicator_chat_input", ren_communicator, socket) xalign 0.5 12 | textbutton "重新连接" action Function(ren_communicator.reboot) xalign 0.5 13 | textbutton "退出聊天" action Function(ren_communicator.quit_chat) xalign 0.5 14 | 15 | 16 | screen ren_communicator_chat_input(ren_communicator, socket): 17 | zorder 100 18 | default msg = "" 19 | 20 | frame: 21 | xysize (800, 500) 22 | align (0.5, 0.5) 23 | 24 | label "请输入消息:" align (0.5, 0.15) 25 | 26 | default msg_value = ScreenVariableInputValue("msg") 27 | 28 | input: 29 | align (0.5, 0.5) 30 | 31 | multiline True 32 | copypaste True 33 | value msg_value 34 | 35 | textbutton "完成": 36 | align (0.5, 0.75) 37 | if socket: 38 | action [Function(ren_communicator.send, socket, Message.string(msg), block=True), Return()] 39 | else: 40 | action [Function(ren_communicator.send, Message.string(msg), block=True), Return()] 41 | 42 | text "默认输入 shift+enter 换行" align (0.5, 1.0) 43 | -------------------------------------------------------------------------------- /RenPyUtil/advanced_character_ren.py: -------------------------------------------------------------------------------- 1 | # 此文件提供了一系列基于Ren'Py的功能类,以供Ren'Py开发者调用 2 | # 作者 ZYKsslm 3 | # 仓库 https://github.com/ZYKsslm/RenPyUtil 4 | # 声明 该源码使用 MIT 协议开源,但若使用需要在程序中标明作者信息 5 | 6 | 7 | # 对话组使用的transform 8 | """renpy 9 | define config.rollback_enabled = False 10 | 11 | transform emphasize(t, l): 12 | linear t matrixcolor BrightnessMatrix(l) 13 | """ 14 | 15 | """renpy 16 | init -1 python: 17 | """ 18 | 19 | 20 | import random 21 | from typing import Callable 22 | from functools import partial 23 | 24 | renpy = renpy # type: ignore 25 | config = config # type: ignore 26 | 27 | 28 | class CharacterError(Exception): 29 | """该类为一个异常类,用于检测角色对象。""" 30 | 31 | errorType = { 32 | "typeError": "对象类型错误,应为AdvancedCharacter而非{}!", 33 | "imageTagError": "{}角色未绑定图像标签,无法支持强调!", 34 | "handlerError": "handler必须为jump或call,而非{}!", 35 | "labelArgsError": "用于跳转的脚本标签{}无法传递参数!", 36 | } 37 | 38 | def __init__(self, error_type, *args): 39 | super().__init__() 40 | self.error_type = error_type 41 | self.args = args 42 | 43 | def __str__(self): 44 | return CharacterError.errorType[self.error_type].format(*self.args) 45 | 46 | 47 | class CharacterTask: 48 | """该类为角色任务类,用于高级角色对象绑定任务。""" 49 | 50 | def __init__(self, single_use=True, priority=0): 51 | 52 | """初始化一个任务。 53 | 54 | Keyword Arguments: 55 | single_use -- 该任务是否只执行一次。 (default: {True}) 56 | priority -- 任务优先级。 (default: {0}) 57 | """ 58 | 59 | self.single_use = single_use 60 | self.priority = priority 61 | self.condition_list: list[str] = [] 62 | self.func_list = [] 63 | self.label = None 64 | 65 | def add_condition(self, exp: str, *args): 66 | """调用该方法,给任务添加一个条件。 67 | 68 | Example: 69 | task.add_condition("{health} < 50", "health") 70 | """ 71 | 72 | condition = exp.format_map({arg: f"CHARACTER.{arg}" for arg in args}) 73 | self.condition_list.append(condition) 74 | 75 | def set_label(self, label: str, handler="call", *args, **kwargs): 76 | """调用该方法,给任务绑定一个脚本标签。当条件满足时,将跳转或调用该脚本标签。 77 | 78 | Arguments: 79 | label -- 脚本标签名。 80 | 81 | Keyword Arguments: 82 | handler -- 标签处理方式。必须为 `jump` 或 `call`。 (default: {"call"}) 83 | 84 | 不定参数为标签参数。 85 | """ 86 | 87 | if handler not in ("jump", "call"): 88 | raise CharacterError("handlerError", handler) 89 | 90 | if handler == "jump": 91 | if args or kwargs: 92 | raise CharacterError("labelArgsError", label) 93 | task_label = partial(renpy.jump, label) 94 | else: 95 | task_label = partial(renpy.call, label, *args, **kwargs) 96 | 97 | self.label = task_label 98 | 99 | def add_func(self, func: Callable, *args, **kwargs): 100 | """调用该方法,给任务添加一个函数。当条件满足时,该函数将被执行。该函数的返回值将被忽略。 101 | 102 | Arguments: 103 | func -- 一个函数。 104 | 105 | Keyword Arguments: 106 | name -- 该函数的名称。 (default: {None}) 107 | 108 | 不定参数为函数参数。 109 | """ 110 | 111 | self.func_list.append(partial(func, *args, **kwargs)) 112 | 113 | 114 | class AdvancedCharacter(ADVCharacter): # type: ignore 115 | """该类继承自ADVCharacter类,在原有的基础上增添了一些新的属性和方法。""" 116 | 117 | def __init__(self, name=None, kind=None, **properties): 118 | """初始化方法。若实例属性需要被存档保存,则定义对象时请使用`default`语句或Python语句。 119 | 120 | Keyword Arguments: 121 | name -- 角色名。 (default: {NotSet}) 122 | kind -- 角色类型。 (default: {None}) 123 | """ 124 | 125 | if not name: 126 | name = renpy.character.NotSet 127 | 128 | self.task_list: list[CharacterTask] = [] 129 | super().__init__(name=name, kind=kind, **properties) 130 | 131 | def _emphasize(self, emphasize_callback, t, l): 132 | """使角色对象支持强调。""" 133 | 134 | if self.image_tag: 135 | self.display_args["callback"] = partial(emphasize_callback, self, t=t, l=l) 136 | else: 137 | raise CharacterError("imageTagError", self.name) 138 | 139 | def add_task(self, task: CharacterTask): 140 | """调用该方法,绑定一个角色任务。 141 | 142 | Arguments: 143 | task -- 一个角色任务。 144 | """ 145 | 146 | self.task_list.append(task) 147 | if self._check_task not in config.python_callbacks: 148 | config.python_callbacks.append(self._check_task) 149 | 150 | def setter(self, **attrs): 151 | """调用该方法,给该角色对象创建自定义的一系列属性。""" 152 | 153 | for a, v in attrs.items(): 154 | setattr(self, a, v) 155 | 156 | def _check_task(self): 157 | """该方法用于在更新自定义属性值时触发任务。""" 158 | 159 | if not self.task_list: 160 | return 161 | 162 | self.task_list.sort(key=lambda x: x.priority, reverse=True) 163 | 164 | satisfied_task: list[CharacterTask] = [] 165 | for task in self.task_list: 166 | all_conditions_met = True 167 | for condition in task.condition_list: 168 | if not eval(condition, {"CHARACTER": self}): 169 | all_conditions_met = False 170 | break 171 | 172 | if not all_conditions_met: 173 | continue 174 | 175 | if task.label: 176 | task.label() 177 | 178 | satisfied_task.append(task) 179 | 180 | for task in satisfied_task: 181 | for task_func in task.func_list: 182 | task_func() 183 | 184 | if task.single_use: 185 | self.task_list.remove(task) 186 | 187 | 188 | class CharacterGroup: 189 | """该类用于管理多个高级角色对象。""" 190 | 191 | def __init__(self, *characters: AdvancedCharacter): 192 | """初始化方法。""" 193 | 194 | self.character_group: list[AdvancedCharacter] = [] 195 | self.add_characters(*characters) 196 | self.task_list: list[CharacterTask] = [] 197 | self.attr_list = set() 198 | 199 | @staticmethod 200 | def _check_type(obj): 201 | """检查对象类型。""" 202 | 203 | if isinstance(obj, AdvancedCharacter): 204 | return 205 | raise CharacterError("typeError", type(obj).__name__) 206 | 207 | def add_characters(self, *characters: AdvancedCharacter): 208 | """调用该方法,向角色组中添加一个或多个角色对象。""" 209 | 210 | for character in characters: 211 | CharacterGroup._check_type(character) 212 | self.character_group.append(character) 213 | 214 | def get_random_character(self, rp=True): 215 | """调用该方法,返回角色组中随机一个角色对象。 216 | 217 | Keyword Arguments: 218 | rp -- 是否使用`renpy`随机接口。 (default: {True}) 219 | """ 220 | 221 | choice = renpy.random.choice if rp else random.choice # type: ignore 222 | 223 | return choice(list(self.character_group)) 224 | 225 | def del_characters(self, *characters: AdvancedCharacter): 226 | """调用该方法,删除角色组中的一个或多个角色。""" 227 | 228 | for character in characters: 229 | CharacterGroup._check_type(character) 230 | self.character_group.remove(character) 231 | 232 | def setter(self, **kwargs): 233 | """调用该方法,对角色组中所有角色对象创建自定义的一系列属性。 234 | 235 | Example: 236 | character_group.add_group_attr(strength=100, health=100) 237 | """ 238 | 239 | self.attr_list |= set(kwargs.keys()) 240 | 241 | for character in self.character_group: 242 | character.setter(**kwargs) 243 | 244 | def getter(self, name, rp=True): 245 | """调用该方法,获取角色组中所有角色的指定属性值。当属性值冲突时,随机返回。 246 | 247 | Keyword Arguments: 248 | name -- 属性名。 249 | rp -- 随机返回是否使用`renpy`随机接口。 (default: {True}) 250 | """ 251 | 252 | return _ChrAttrGetter(self, name, rp).getter() 253 | 254 | def add_task(self, task: CharacterTask): 255 | """调用该方法,给角色组添加一个任务,所有角色都满足条件才会触发。""" 256 | 257 | self.task_list.append(task) 258 | if self._check_task not in config.python_callbacks: 259 | config.python_callbacks.append(self._check_task) 260 | 261 | def _check_task(self): 262 | """该方法用于在角色组中所有角色属性值更新时触发任务。""" 263 | 264 | if not self.task_list: 265 | return 266 | 267 | self.task_list.sort(key=lambda x: x.priority, reverse=True) 268 | 269 | satisfied_task: list[CharacterTask] = [] 270 | for task in self.task_list: 271 | all_conditions_met = True 272 | for character in self.character_group: 273 | for condition in task.condition_list: 274 | if not eval(condition, {"CHARACTER": character}): 275 | all_conditions_met = False 276 | break 277 | 278 | if not all_conditions_met: 279 | break 280 | 281 | if not all_conditions_met: 282 | continue 283 | 284 | if task.label: 285 | task.label() 286 | 287 | satisfied_task.append(task) 288 | 289 | for task in satisfied_task: 290 | for task_func in task.func_list: 291 | task_func() 292 | 293 | if task.single_use: 294 | self.task_list.remove(task) 295 | 296 | def __getattr__(self, name): 297 | if name in ("character_group", "task_list", "attr_list", "t", "l", "started"): 298 | return getattr(self, name) 299 | 300 | return _ChrAttrSetter(self, name) 301 | 302 | def __setattr__(self, name, value): 303 | if name in ("character_group", "task_list", "attr_list", "t", "l", "started"): 304 | return super().__setattr__(name, value) 305 | 306 | if value == None: 307 | return 308 | 309 | return self.setter(**{name: value}) 310 | 311 | 312 | class _ChrAttrGetter: 313 | def __init__(self, character_group: CharacterGroup, name, rp): 314 | self.character_group = character_group 315 | self.name = name 316 | self.rp = rp 317 | 318 | def getter(self): 319 | values = set([getattr(character, self.name) for character in self.character_group.character_group]) 320 | if len(values) == 1: 321 | return values.pop() 322 | choice = renpy.random.choice if self.rp else random.choice 323 | 324 | return choice(list(values)) 325 | 326 | 327 | class _ChrAttrSetter: 328 | def __init__(self, character_group: CharacterGroup, name): 329 | self.character_group = character_group 330 | self.name = name 331 | 332 | def _apply_operation(self, op, value): 333 | for character in self.character_group.character_group: 334 | setattr(character, self.name, op(getattr(character, self.name), value)) 335 | 336 | return None 337 | 338 | def __iadd__(self, value): 339 | return self._apply_operation(lambda a, b: a + b, value) 340 | 341 | def __isub__(self, value): 342 | return self._apply_operation(lambda a, b: a - b, value) 343 | 344 | def __imul__(self, value): 345 | return self._apply_operation(lambda a, b: a * b, value) 346 | 347 | def __itruediv__(self, value): 348 | return self._apply_operation(lambda a, b: a / b, value) 349 | 350 | def __ifloordiv__(self, value): 351 | return self._apply_operation(lambda a, b: a // b, value) 352 | 353 | def __imod__(self, value): 354 | return self._apply_operation(lambda a, b: a % b, value) 355 | 356 | def __ipow__(self, value): 357 | return self._apply_operation(lambda a, b: a ** b, value) 358 | 359 | 360 | class SpeakingGroup(CharacterGroup): 361 | """该类继承自CharacterGroup类,用于管理角色发言组。""" 362 | 363 | def __init__(self, *characters: AdvancedCharacter, t=0.15, l=-0.3): 364 | """初始化方法。 365 | 366 | Arguments: 367 | t -- 转变的时长 (default: {0.15}) 368 | l -- 变暗的明度。 (default: {-1}) 369 | """ 370 | 371 | self.t = t 372 | self.l = l 373 | self.started = True 374 | super().__init__(*characters) 375 | 376 | def start(self): 377 | """调用该方法,开始进入发言强调状态。""" 378 | 379 | self.started = True 380 | 381 | def end(self): 382 | """调用该方法,结束发言强调状态。""" 383 | 384 | self.started = False 385 | 386 | def add_characters(self, *characters: AdvancedCharacter): 387 | for character in characters: 388 | CharacterGroup._check_type(character) 389 | character._emphasize(self.emphasize, self.t, self.l) # 使角色支持强调 390 | self.character_group.append(character) 391 | 392 | def del_characters(self, *characters): 393 | for character in characters: 394 | CharacterGroup._check_type(character) 395 | character.display_args["callback"] = None 396 | self.character_group.remove(character) 397 | 398 | def emphasize(self, character: AdvancedCharacter, event, t=0.15, l=-0.3, **kwargs): 399 | """该方法用于定义角色对象时作为回调函数使用。该方法可创建一个对话组,对话组中一个角色说话时,其他角色将变暗。""" 400 | 401 | if (not event == "begin") or (not self.started): 402 | return 403 | 404 | if character not in self.character_group: 405 | self.add_characters(character) 406 | 407 | image = renpy.get_say_image_tag() 408 | if renpy.showing(character.image_tag): 409 | renpy.show( 410 | image, 411 | at_list=[emphasize(t, 0)] # type: ignore 412 | ) 413 | 414 | for speaker in self.character_group: 415 | if speaker != character and renpy.showing(speaker.image_tag): 416 | renpy.show( 417 | speaker.image_tag, 418 | at_list=[emphasize(t, l)] # type: ignore 419 | ) 420 | 421 | -------------------------------------------------------------------------------- /RenPyUtil/ren_chatgpt_ren.py: -------------------------------------------------------------------------------- 1 | # 此文件提供了一系列基于Ren'Py的功能类,以供Ren'Py开发者调用 2 | # 作者 ZYKsslm 3 | # 仓库 https://github.com/ZYKsslm/RenPyUtil 4 | # 声明 该源码使用 MIT 协议开源,但若使用需要在程序中标明作者消息 5 | 6 | 7 | """renpy 8 | init -1 python: 9 | """ 10 | 11 | 12 | import re 13 | import json 14 | from httpx import Client 15 | 16 | 17 | class RenChatGPT(object): 18 | """该类用于Ren'Py兼容地与ChatGPT交互,请求参数必须与官方一致。""" 19 | 20 | def __init__(self, api, key, dialog=[]): 21 | """初始化配置。 22 | 23 | Arguments: 24 | api -- 请求API。 25 | key -- 用于请求的Key。 26 | 27 | Keyword Arguments: 28 | dialog -- 一个列表,里面为对话记录。 (default: {None}) 29 | """ 30 | 31 | self.api = api 32 | self.key = key 33 | self.dialog = dialog 34 | self.client = Client() 35 | self.msg = None 36 | self.error = None 37 | self.waiting = False 38 | 39 | def chat(self, msg, role="user", model="gpt-3.5-turbo", notice=True, **kwargs): 40 | """调用该方法,与ChatGPT进行对话,并进行非阻塞式地等待。 41 | 42 | Arguments: 43 | msg -- 对话内容。 44 | 45 | Keyword Arguments: 46 | role -- 角色。 (default: {"user"}) 47 | model -- 模型。 (default: {"gpt-3.5-turbo"}) 48 | notice -- 若为True,将在屏幕左上角显示网络请求状态。 (default: {True}) 49 | 50 | 不定参数`kwargs`为自定义的其他请求参数。 51 | """ 52 | 53 | renpy.invoke_in_thread(self._chat, msg, role, model, **kwargs) 54 | while self.waiting: 55 | renpy.pause(0) # 非阻塞式地等待 56 | 57 | def _chat(self, msg, role, model, **kwargs): 58 | self.waiting = True 59 | 60 | headers = { 61 | "Content-Type": "application/json", 62 | } 63 | 64 | if self.key: 65 | headers.update( 66 | {"Authorization": f"Bearer {self.key}"} 67 | ) 68 | 69 | content = { 70 | "role": role, 71 | "content": msg 72 | } 73 | 74 | 75 | self.dialog.append(content) 76 | 77 | data = { 78 | "model": model, 79 | "messages": self.dialog 80 | } 81 | 82 | data.update(kwargs) 83 | 84 | try: 85 | response = self.client.post(self.api, headers=headers, data=json.dumps(data)) 86 | message = response.json()["choices"][0]["message"] 87 | self.msg = message["content"] 88 | self.dialog.append(message) 89 | except Exception as e: 90 | self.msg = None 91 | self.error = e 92 | 93 | self.waiting = False 94 | 95 | def parse_words(self, text): 96 | """调用该方法,将段落分成句子。该方法旨在实现更加真实的聊天情景。 97 | 98 | Arguments: 99 | text -- 文本。 100 | 101 | Returns: 102 | 一个元素为一句话的列表。 103 | """ 104 | words = [] 105 | # 判断是否有代码块 # TODO 106 | 107 | # 获取每句话 108 | res = re.findall(r'(.*?(。|\?|?|!|!|:|:|\.|——))', text) 109 | for r in res: 110 | words.append(r[0]) 111 | # 获取最后一个标点后的所有字符 112 | p = re.compile(fr'{res[len(res)-1][0]}(.*)', flags=re.S) 113 | last_word = re.findall(p, text)[0] 114 | if last_word: 115 | words.append(last_word) 116 | 117 | return words --------------------------------------------------------------------------------