├── .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 |
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
--------------------------------------------------------------------------------