找回密码
 立即注册

QQ登录

只需一步,快速开始

查看: 1521|回复: 2

[教程] 【功能组件】极简手机聊天信息系统

  [复制链接]
发表于 2026-1-13 01:36:41 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
本帖最后由 Maz马 于 2026-8-17 20:35 编辑

26/8/17更新
(在此之前使用的注意新的版本,更新了样式的格式,滑窗算法减少渲染压力,是一次比较大的更新



参考原帖出处:
https://nighten.itch.io/yet-another-phone-renpy

26/1/13
  1.提取了参数,方便小白使用,我相信东一榔头西一棒槌的布局是大家最头疼的...
  2.写了缩略图放大功能
26/4/13
  3.自动匹配角色的头像 头像框 对话框,支持群聊,多角色对话
  4.模拟聊天记录,再次进入同组信息会在上次阅读的最后一条继续,而之前的不会请求互动
26/8/17
  5.注册新的音频通道,建立预设资源库,部分布局变量格式化,以供隔离和扩展
  6.将调用环境黑箱化,避免在label内设一大堆环境变量
  7.抽离多语言逻辑,现在配置时无关多语言,只按编写时的字面量运行,多语言自动支持
  8.解决渲染树ID问题,新写滑窗算法,避免视口渲染大批量可视组件,减少性能压力

第一部分 系统
直接复制为一个新的rpy文件
原始代码插入了一些我自己的资源路径,请注意修改
[RenPy] 纯文本查看 复制代码
# ---------- 信息模拟系统 ----------
init python:
    """
    Phone System (Ver 1.0 Ren'py 8.3.6)
    Author: Maz
    """
# ---------- 样式配置 ----------
# 预设默认图
image phone_fg_default:
    "#00000000"
image phone_bg_default:
    "#000000dd"
image phone_icon_default:
    "MAZ_PHONESYS/phone_icon_default.png"
image phone_icon_frame_default:
    "#0000"
image phone_mesg_frame_default:
    "MAZ_PHONESYS/phone_mesg_frame_default.png"

# 预设头像库
image phone_icon_frame_christmas:
    "MAZ_PHONESYS/icon_frame/christmas.png"
define phone_icon_frames = {
    "default"  :("phone_icon_frame_default",(130,130)),
    "christmas":("phone_icon_frame_christmas",(130,130)),}

# 预设气泡库
image phone_mesg_frame_hollow:
    "MAZ_PHONESYS/mesg_frame/hollow.png"
image phone_mesg_frame_cat:
    "MAZ_PHONESYS/mesg_frame/cat1.png"
    pause 0.5
    "MAZ_PHONESYS/mesg_frame/cat2.png"
    pause 0.5
    "MAZ_PHONESYS/mesg_frame/cat3.png"
    pause 0.5
    "MAZ_PHONESYS/mesg_frame/cat2.png"
    pause 0.5
    repeat
define phone_mesg_frames = {
    "default" :("phone_mesg_frame_default", Borders(30,30,30,30), (20,10,20,15)),
    "hollow"  :("phone_mesg_frame_hollow",  Borders(30,30,30,30), (20,10,20,15)),
    "blackcat":("phone_mesg_frame_blackcat",Borders(103,66,30,30),(60,60,30,30)),}

# 预设字体库
# {字体标识符:字体路径}
define phone_text_fonts = {
    "default"          :          gui.text_font,
    "SourceHanSansLite":"SourceHanSansLite.ttf",}

default phone_xy = (0.5,0.5)                             # 手机界面在屏幕上的坐标
default phone_xysize = (600,1080)                        # 手机界面的尺寸(外框尺寸)
default phone_frame_xy = (0.5,0.56)                      # 聊天窗口在手机界面里的坐标
default phone_frame_xysize = (580,1000)                  # 聊天窗口的尺寸(比外框小)
default phone_frame_padding = (0,0,0,0)                  # 普通消息距离侧方边界的偏移
default phone_frame_spacing = 10                         # 聊天消息之间的上下间隔
default phone_narr_width = 380                           # 旁白消息的最大宽度
default phone_mesg_width = 380                           # 普通消息的最大宽度

default phone_narr_sound = "MAZ_PHONESYS/sound/narr.mp3" # 默认旁白音效
default phone_send_sound = "MAZ_PHONESYS/sound/send.mp3" # 默认己方音效
default phone_recv_sound = "MAZ_PHONESYS/sound/recv.mp3" # 默认对方音效
default phone_item_sound = "MAZ_PHONESYS/sound/item.mp3" # 默认对方音效

default phone_text_font = phone_text_fonts['default']    # 文本字体
default phone_text_size = 27                             # 文本大小
default phone_text_cps = 0                               # 文本速度

default phone_bg = "phone_bg_default"                    # 默认背景图片
default phone_fg = "phone_fg_default"                    # 默认外框图片
default phone_icon = "phone_icon_default"                # 默认头像图片

default phone_icon_frame = phone_icon_frames['default']  # 默认头像框图片 (图片,整个头像区域的大小)

default phone_narr_frame = phone_mesg_frames['default']  # 默认旁白气泡框 (图片,九方缩放的边距,文字和边框的边距)
default phone_item_frame = phone_mesg_frames['default']  # 默认选项气泡框
default phone_send_frame = phone_mesg_frames['default']  # 默认己方气泡框
default phone_recv_frame = phone_mesg_frames['default']  # 默认对方气泡框

default phone_bg_color = ["#000000",0.5]               # 背景图像颜色 图片使用6位码 + 透明度
default phone_fg_color = ["#000000",0.5]               # 外框图像颜色 

default phone_narr_frame_color = ["#ffffff",1.0]       # 旁白气泡颜色 
default phone_item_frame_color = ["#ffffff",1.0]       # 选项气泡颜色 
default phone_send_frame_color = ["#ffffff",1.0]       # 己方气泡颜色 
default phone_recv_frame_color = ["#ffffff",1.0]       # 对方气泡颜色 

default phone_narr_text_color = "#000000ff"            # 旁白文本颜色 文本使用8位码
default phone_send_text_color = "#000000ff"            # 己方文本颜色
default phone_recv_text_color = "#000000ff"            # 对方文本颜色
default phone_item_text_color = "#000000ff"            # 选项文本颜色

default phone_narr_line_color = "#00000000"            # 旁白描边颜色
default phone_send_line_color = "#00000000"            # 己方描边颜色
default phone_recv_line_color = "#00000000"            # 对方描边颜色
default phone_item_line_color = "#00000000"            # 选项描边颜色

default phone_thl1 = "#ff0000ff"                       # 文本强调色1 phone_texthighlight 为方便编写文本标签使用缩写
default phone_thl2 = "#ffaf53ff"                       # 文本强调色2
default phone_thl3 = "#ff009dff"                       # 文本强调色3
default phone_thl4 = "#49ff67ff"                       # 文本强调色4

# 没有动画效果
transform phone_none_anim():
    pass
# 头像动画效果
transform phone_icon_anim():
    zoom 0.0
    easein_back 0.5 zoom 1.0
# 旁白动画效果
transform phone_narr_anim():
    alpha 0.0
    yoffset -50
    parallel:
        ease 0.5 alpha 1.0
    parallel:
        easein_back 0.5 yoffset 0
# 选项动画效果
transform phone_item_anim():
    alpha 0.0
    xoffset 50
    parallel:
        ease 0.5 alpha 1.0
    parallel:
        easein_back 0.5 xoffset 0
# 己方动画效果
transform phone_send_anim():
    alpha 0.0
    xoffset 50
    parallel:
        ease 0.5 alpha 1.0
    parallel:
        easein_back 0.5 xoffset 0
# 对方动画效果
transform phone_recv_anim():
    alpha 0.0
    xoffset -50
    parallel:
        ease 0.5 alpha 1.0
    parallel:
        easein_back 0.5 xoffset 0

# ---------- 系统环境 ----------
default phone_mode = None                             # 区分正常NVL模式和手机组件的变量

define config.say_arguments_callback = Phone_MesgDict # 对话回调函数
define config.nvl_list_length = 999                   # 最大对话记录数(在gui中也存在一条,这里直接覆写)

default phone_view_adj = PhoneAdjustment(20)          # 滑窗对象,切片 20 条

default phone_mesglist = None                         # 控制当前使用哪个信息列表
default phone_mesgdata = {}                           # 信息组名字:信息列表
default phone_mesghash = None                         # 当前对话哈希

default phone_chardata = {}                           # 系统使用的角色数据库
default phone_callback = None                         # 预留数据更新的回调函数
default nvl_narr = NVLCharacter(kind=nvl)             # 覆写旁白角色
default nvl_main = NVLCharacter(kind=nvl)             # 套壳主控角色

init -1 python:
    # 新建专用的音效通道
    renpy.music.register_channel("phone",mixer="phone",loop=False,stop_on_mute=True,tight=False)
    
    # 自定义 Adjustment 子类重写change(),提供滑窗算法切片 + 全量检测切片
    class PhoneAdjustment(renpy.display.behavior.Adjustment):
        def __init__(self,size):
            super().__init__()
            self.size = size       # 切片大小,最多同时渲染的消息数
            self.cutting = False   # 脏标记,避免 change 递归
            self.mark = 0          # 基准标记,当前窗口切片起始索引
            self.last = 0          # 更新对齐,记录上次对话的总长度
        # 滑窗算法切片
        def change(self, value, end_animation=True):
            if self.cutting:
                return None
            rv = super().change(value, end_animation)
            # 向上逐条切片
            if self.value == 0 and self.mark > 0:
                self.cutting = True
                self.mark -= 1
                self.cutting = False
                renpy.restart_interaction()
            # 向下逐条切片
            elif self.value == self.range and self.range > 0:
                _max_mark = max(0, self.last - self.size)
                if self.mark < _max_mark:
                    self.cutting = True
                    self.mark += 1
                    self.cutting = False
                    renpy.restart_interaction()
            return rv
        # 全量检测切片
        def phone_cut_dialogue(self,dialogue):
            _len = len(dialogue)
            # 全量数据重置时重置对齐
            if _len == 0:
                self.last = 0
            # 全量数据增长时更新对齐
            if _len > self.last:
                self.mark = max(0, _len - self.size)
                self.last = len(dialogue)
            # 始终切片
            _start = max(0, min(self.mark, _len - self.size))
            _slice = dialogue[_start:_start + self.size]
            return _slice
    # 已读信息记录字典
    def Phone_MesgDict(who,interact=True):
        # 如果当前流程phone_mesglist(信息组的名字)非None,即为应用了某个信息组
        if store.phone_mesglist:
            # phone_mesglist(信息组的名字)不在字典中,动态注册新的信息组列表
            if store.phone_mesglist not in store.phone_mesgdata:
                store.phone_mesgdata[store.phone_mesglist] = []
            # 通过翻译系统获取当前对话哈希值,锁定唯一对话,避免说话角色相同,内容相同,但不是同一句话的情况
            store.phone_mesghash = renpy.get_translation_identifier()
            # 如果当前哈希,在当前的信息组列表中,并且不是上次阅读的最后一句话,跳过互动
            if store.phone_mesghash in store.phone_mesgdata[store.phone_mesglist]:
                if store.phone_mesghash != store.phone_mesgdata[store.phone_mesglist][-1]:
                    interact = False
            # 否则,把哈希记录在当前的信息组列表
            else:
                store.phone_mesgdata[store.phone_mesglist].append(store.phone_mesghash)
        # 当手机模式被打开,且没有跳过互动时,播放音效
        if phone_mode == "phone" and interact == True:
            if who.name == None:
                renpy.music.play(phone_narr_sound, channel="phone")
            elif who.name == store.nvl_main.name:
                renpy.music.play(phone_send_sound, channel="phone")
            elif who.name != store.nvl_main.name:
                renpy.music.play(phone_recv_sound, channel="phone")
        return (),{"interact":interact} # 返回需不需要互动的参数给renpy的say系统
    
    # 可入参的showscreen文本标签
    def show_screen_handler(target):
        if "," in target:
            screen_name,params_str = target.split(",",1)
            params = {}
            for pair in params_str.split(","):
                if "=" in pair:
                    key,val = pair.split("=",1)
                    params[key.strip()] = val.strip()
            renpy.show_screen(screen_name,**params)
        else:
            renpy.show_screen(target)
        return True
    config.hyperlink_handlers["show"] = show_screen_handler
    def showscreen_tag(tag,argument,contents):
        return [
            (renpy.TEXT_TAG,f"a=show:{argument}"),
            *contents,
            (renpy.TEXT_TAG,"/a")]
    config.custom_text_tags["showscreen"] = showscreen_tag
    
    # 统一开关
    def Phone_Switch(use=None,mainchar=None,chardata=None,mesglist=None):
        if use:
            store.phone_mode = "phone"
        else:
            store.phone_mode = None
        if mainchar:
            store.nvl_main = mainchar
        if chardata:
            store.phone_chardata = chardata
        if mesglist:
            store.phone_mesglist = mesglist
        else:
            store.phone_mesglist = None
        return
    # 删除指定消息组的已读记录(不传则删除当前消息组)
    def Phone_Redata(mesglist=None):
        if not mesglist:
            mesglist = store.phone_mesglist
        if mesglist and mesglist in store.phone_mesgdata:
            del store.phone_mesgdata[mesglist]

# ---------- 界面框架 ----------
# 缩略放大
screen phone_cg(phonecg):
    zorder 102
    modal True
    frame:
        anchor (0.5,0.5)
        xysize phone_xysize
        pos phone_xy
        background Frame("#000000")
        foreground phone_fg
        imagebutton:
            align (0.5,0.5)
            idle phonecg
            action Hide("phone_cg")
# 消息界面
screen phone_dialogue(dialogue,items=None):
    # 预留的回调接口
    if callable(phone_callback):
        $ phone_callback(dialogue)
    # 劫持对话,绕过渲染树的必要判定(将必须持有id返回渲染树的当前对象移到看不到地方,以完成say生命周期)
    if dialogue:
        $ real_d = dialogue[-1]
        text real_d.what id real_d.what_id:
            pos (10086,10086)
            color "#00000000"
            outlines [(absolute(0),"#00000000")]
    # 滑窗切片算法,劫持对话后,可随意安排注入对话列表
    $ cut_dialogue = phone_view_adj.phone_cut_dialogue(dialogue)
    zorder 101
    frame:
        anchor (0.5,0.5)
        pos phone_xy
        background Transform(phone_bg,matrixcolor=TintMatrix(Color(phone_bg_color[0])),alpha=phone_bg_color[1])
        foreground Transform(phone_fg,matrixcolor=TintMatrix(Color(phone_fg_color[0])),alpha=phone_fg_color[1])
        xysize phone_xysize
        padding phone_frame_padding
        viewport: # 可滚动区域
            anchor (0.5,0.5)
            pos phone_frame_xy
            xysize phone_frame_xysize
            draggable True
            mousewheel True
            # 视口一定会在边缘添加滚动条空间,即便你不使用滚动条这个空间也一定会被占据
            # 因此添加滚动条尺寸的偏移才是“居中”
            xoffset gui.scrollbar_size//2
            yadjustment phone_view_adj
            yinitial 1.0 # 跟踪底部
            vbox:
                xalign 0.5
                spacing phone_frame_spacing
                xfill True
                use phone_contents(cut_dialogue)
                # 选项部分
                if items != None:
                    vbox:
                        xalign 1.0
                        xoffset -phone_icon_frame[1][0]//2
                        for i in items:
                            textbutton i.caption action i.action:
                                background Transform(
                                    Frame(phone_item_frame[0],phone_item_frame[1]),
                                    matrixcolor=TintMatrix(Color(phone_item_frame_color[0])),
                                    alpha=phone_item_frame_color[1])
                                padding phone_item_frame[2]
                                xsize phone_mesg_width
                                text_font phone_text_font
                                text_size phone_text_size
                                text_color phone_item_text_color
                                text_outlines [(absolute(1),phone_item_line_color)]
                                text_xalign 0.5
                                text_prefer_emoji True
                                at phone_item_anim()
        # 调出动态设置界面(扩展存在时才显示,无扩展可删除)
        if renpy.has_screen("phone_setting"):
            hbox:
                align (1.0,1.0)
                xoffset -60
                yoffset -60
                spacing 20
                textbutton "设置" action ToggleScreen("phone_setting")
# 消息内容
screen phone_contents(dialogue):
    zorder 101
    # 遍历所有对话
    for id_d,d in enumerate(dialogue):
        # 先清除翻译系统的修改,以进行字符串匹配
        if d.who == None:
            $ temp_name = None
        else:
            $ temp_name = renpy.translate_string(d.who,None)
        # 判断角色的身份,使用对应的动效和样式
        if temp_name == None:
            $ temp_mesg_trans = phone_narr_anim()
        else:
            if temp_name == nvl_main.name:
                $ temp_mesg_trans = phone_send_anim()
                $ temp_mesg_frame = phone_send_frame
                $ temp_mesg_frame_color = phone_send_frame_color
                $ temp_text_color = phone_send_text_color
                $ temp_line_color = phone_send_line_color
                $ temp_reverse = True
                $ temp_align = 1.0
            else:
                $ temp_mesg_trans = phone_recv_anim()
                $ temp_mesg_frame = phone_recv_frame
                $ temp_mesg_frame_color = phone_recv_frame_color
                $ temp_text_color = phone_recv_text_color
                $ temp_line_color = phone_recv_line_color
                $ temp_reverse = False
                $ temp_align = 0.0
            # 从角色数据库获取头像,头像框,对话框,如果为None或捕获不到,使用默认
            $ temp_icon = phone_chardata.get(temp_name,{}).get("icon") or phone_icon
            $ temp_icon_frame = phone_chardata.get(temp_name,{}).get("icon_frame") or phone_icon_frame
            $ temp_mesg_frame = phone_chardata.get(temp_name,{}).get("mesg_frame") or temp_mesg_frame
        # 动态调整样式时,控制历史条目不会重新被唤起
        if d.current:
            $ temp_cps = phone_text_cps
            $ temp_icon_trans = phone_icon_anim()
        else:
            $ temp_cps = False
            $ temp_icon_trans = phone_none_anim()
            $ temp_mesg_trans = phone_none_anim()
        # 旁白消息
        if temp_name == None:
            frame:
                align (0.5,0.5)
                background Transform(
                    Frame(phone_narr_frame[0],phone_narr_frame[1]),
                    matrixcolor=TintMatrix(Color(phone_narr_frame_color[0])),
                    alpha=phone_narr_frame_color[1])
                padding phone_narr_frame[2]
                at temp_mesg_trans
                text d.what:# id d.what_id 因为已经劫持了say必须的id控件,所以此处不能再使用id,否则会出现id冲突,出现各种意外报错
                    align (0.5,0.5)
                    xsize phone_narr_width
                    font phone_text_font
                    size phone_text_size
                    color phone_narr_text_color
                    outlines [(absolute(1),phone_narr_line_color)]
                    slow_cps temp_cps
                    text_align 0.5
                    min_width 0
                    prefer_emoji True
        # 角色消息
        else:
            hbox:
                spacing 10
                box_reverse temp_reverse
                xalign temp_align
                # 头像
                fixed:
                    xysize temp_icon_frame[1]
                    at temp_icon_trans
                    add temp_icon align (0.5,0.5)
                    add temp_icon_frame[0] align (0.5,0.5)
                vbox:
                    spacing 10
                    # 名称
                    text d.who:
                        size phone_text_size + 4
                        font phone_text_font
                        textalign temp_align
                        xalign temp_align
                        prefer_emoji True
                    # 气泡
                    frame:
                        background Transform(
                            Frame(temp_mesg_frame[0],temp_mesg_frame[1]),
                            matrixcolor=TintMatrix(Color(temp_mesg_frame_color[0])),
                            alpha=temp_mesg_frame_color[1])
                        padding temp_mesg_frame[2]
                        at temp_mesg_trans
                        # 消息文本
                        text d.what:# id d.what_id 因为已经劫持了say必须的id控件,所以此处不能再使用id,否则会出现id冲突,出现各种意外报错
                            align (0.5,0.5)
                            min_width 0
                            xsize phone_mesg_width
                            font phone_text_font
                            size phone_text_size
                            color temp_text_color
                            outlines [(absolute(1),temp_line_color)]
                            text_align temp_align
                            slow_cps temp_cps
                            prefer_emoji True


第二部分 使用方法
[RenPy] 纯文本查看 复制代码
# ---------- 最小示例 ----------
# 定义phone角色 注意,如果在Character中设置样式,会覆盖系统样式
default nvl_a = Character("我", kind=nvl)
default nvl_b = Character("他", kind=nvl)
default nvl_c = Character("她", kind=nvl)
# 角色外观数据库
# 角色名 → 头像/头像框/气泡框
# icon = 头像
# icon_frame = (图片, 整个头像区域大小)
# mesg_frame = (图片, Borders九方边距, 文字边距)
# 没有捕获到的角色会直接使用默认
default testdata = {
    "她": {"icon": "phone_icon_default",
        "icon_frame": ("phone_icon_frame_christmas",(130,130)),
        "mesg_frame": ("phone_mesg_frame_hollow", Borders(30,30,30,30), (20,10,20,15)),},
    }

label PHONESYS_test:
    # 1. 打开手机模式,设定主控角色、角色外观数据库、消息组
    $ Phone_Switch(use=True, mainchar=nvl_a, chardata=testdata, mesglist="test")
    # 2. 发消息:旁白 / 对方 / 己方
    nvl_narr "这是旁白的写法"
    nvl_b "使用专用的角色开始对话"
    nvl_a "这是另一边的角色在说话"
    nvl_b "这样插入一张 emoji 或 小图{image=phone/emoji/clap.png}"
    menu(nvl=True):
        nvl_narr "选项菜单要入参 nvl=True"
        "选项1":
            pass
        "选项2":
            pass
    nvl_b "然后是 一个可放大的图像,点击图像将会放大,但形状要自己裁切\n{showscreen=phone_cg,phonecg=phone cg01ex}{image=phone cg01}{/showscreen}"
    nvl_b "phonecg=phone cg01ex 是放大的图片,image=phone cg01 是缩略图,都可以是帧动画,只要是声明的image"
    nvl_c "现在尝试多角色对话"
    nvl_c "支持{color=[phone_thl1]}着重色{/color}"
    nvl_c "你也可以自由颜色,着重色使用特定变量的原因是以便模块整体扩展"
    # 可使用 Phone_Redata() 清理对应消息组的聊天记录
    # 3. 关闭手机模式,清空nvl界面
    $ Phone_Switch(use=False)
    nvl clear
    return


第三部分 修改(和原生的项目工程对接,仅修改一句话)
以上部分可以直接建成新文件,以下是需要在源文件screen.rpy中修改的,只加一句话
[RenPy] 纯文本查看 复制代码
screen nvl(dialogue, items=None):
    zorder 101
    ### 在这里增加应用手机系统的分支 ###
    if nvl_mode == "phone":
        use phone_dialogue(dialogue,items)
    else:
    ### 以下是源代码只增加了缩进没有任何改动 ###
        window:
            style "nvl_window"

            has vbox:
                spacing gui.nvl_spacing

            ## 在 vpgrid 或 vbox 中显示对话框。
            if gui.nvl_height:

                vpgrid:
                    cols 1
                    yinitial 1.0

                    use nvl_dialogue(dialogue)

            else:

                use nvl_dialogue(dialogue)

            ## 显示菜单,如果给定的话。如果 config.narrator_menu 设置为 True,则菜单
            ## 可能显示不正确。
            for i in items:

                textbutton i.caption:
                    action i.action
                    style "nvl_button"

    add SideImage() xalign 0.0 yalign 1.0


第四部分 扩展
因为布局是变量,因此扩展一个可以动态修改界面,属于是雕花了
[RenPy] 纯文本查看 复制代码
# ---------- 信息模拟系统 ----------
init python:
    """
    Phone System EX (Ver 1.0 Ren'py 8.3.6)
    Author: Maz
    """
# ---------- 样式扩展 ----------
# 占位背景图
image alphablack:
    "#000000dd"
# 颜色环境
init python:
    phone_setting_color_map = {
        "BG Img"  :"phone_bg_color",
        "FG Img"  :"phone_fg_color",
        "Narr Img":"phone_narr_frame_color",
        "User Img":"phone_send_frame_color",
        "Char Img":"phone_recv_frame_color",
        "Item Img":"phone_item_frame_color",
        "Narr Txt":"phone_narr_text_color",
        "User Txt":"phone_send_text_color",
        "Char Txt":"phone_recv_text_color",
        "Item Txt":"phone_item_text_color",
        "Narr Out":"phone_narr_line_color",
        "User Out":"phone_send_line_color",
        "Char Out":"phone_recv_line_color",
        "Item Out":"phone_item_line_color",
        "Accent 1":"phone_thl1",
        "Accent 2":"phone_thl2",
        "Accent 3":"phone_thl3",
        "Accent 4":"phone_thl4",}
    phone_setting_color_preset = [
        "#00000000","#00000080","#000000ff","#808080ff",
        "#ffffffff","#fffbe5ff","#f3ffe5ff","#ece5ffff",
        "#ff0000ff","#ffa600ff","#ffe600ff","#00ff00ff",
        "#00ffffff","#0000ffff","#b700ffff","#ff6767ff",]
    #颜色解析/格式化函数
    def _color(val,mode="hex8"):
        """
        val: str(3/4/6/8位hex) / list[hex,alpha] / list[h,s,v,alpha]
        mode: "hex8"→8位hex  "hexa"→[6位hex,alpha]  "hsva"→[h,s,v,alpha]
        """
        # 先把输入统一转成(hex6,alpha)
        if isinstance(val,str):
            c = Color(val)
            hex6,alpha = c.hexcode[:7],c.alpha
        elif isinstance(val,list):
            if len(val) == 2:
                hex6,alpha = val[0][:7],val[1]
            elif len(val) == 4:
                c = Color(hsv=(val[0],val[1],val[2]))
                hex6,alpha = "#"+c.hexcode.replace("#","")[:6],val[3]
            else:
                hex6,alpha = "#ffffff",1.0
        else:
            hex6,alpha = "#ffffff",1.0
        # 按mode输出
        if mode == "hex8":
            c = Color(hex6)
            return "#"+c.hexcode.replace("#","")[:6]+"{:02x}".format(int(alpha*255))
        elif mode == "hexa":
            return [hex6,alpha]
        elif mode == "hsva":
            c = Color(hex6)
            return [c.hsv[0],c.hsv[1],c.hsv[2],alpha]
        return hex6,alpha
    # 预览色块 读取目标数据,转hex8代码
    def color_get_pre(target):
        var = phone_setting_color_map[target]
        return _color(getattr(store,var),"hex8")
    # 预设色块 读取颜色代码,转hsva分量,赋值给目标
    def color_set_pre(color):
        store.phone_setting_h,store.phone_setting_s,store.phone_setting_v,store.phone_setting_a = _color(color,"hsva")
        color_set()
    # 选定目标 读取目标数据,转hsva分量
    def color_select(target):
        var = phone_setting_color_map[target]
        store.phone_setting_h,store.phone_setting_s,store.phone_setting_v,store.phone_setting_a = _color(getattr(store,var),"hsva")
        store.phone_setting_color_obj = target
    # 修改目标 将hsva分量,匹配格式,赋值给目标
    def color_set():
        if not store.phone_setting_color_obj:
            return
        var = phone_setting_color_map[store.phone_setting_color_obj]
        if "Img" in store.phone_setting_color_obj:
            setattr(store,var,_color([store.phone_setting_h,store.phone_setting_s,store.phone_setting_v,store.phone_setting_a],"hexa"))
        else:
            setattr(store,var,_color([store.phone_setting_h,store.phone_setting_s,store.phone_setting_v,store.phone_setting_a],"hex8"))

    # 将图像预设应用到当前主控角色
    def set_icon_frame(data):
        store.phone_chardata.setdefault(store.nvl_main.name, {})["icon_frame"] = data
    def set_mesg_frame(data):
        store.phone_chardata.setdefault(store.nvl_main.name, {})["mesg_frame"] = data

# 中转变量
default phone_setting_page = "Basic"
default phone_setting_color_obj = "BG Img"
default phone_setting_h = 0.0
default phone_setting_s = 0.0
default phone_setting_v = 0.0
default phone_setting_a = 0.0

# 界面样式
style phone_setting_text:
    size 28
    color "#ffffff"
style phone_setting_slider:
    xpos 30
    xsize 420
style phone_setting_button_text:
    size 30
    idle_color "#ffffff"
    hover_color "#ffcc00"
    selected_color "#ffcc00"

style switch_text is phone_setting_text
style switch_button:
    xpos 30
    foreground "gui/button/check_[prefix_]foreground.png"
style switch_button_text:
    size 28
    xpos 30
    yalign 0.5
    idle_color "#888888"
    hover_color "#ffffff"
    selected_color "#ffffff"
style switch_reverse_button:
    size 28
    xpos 30
    idle_foreground "gui/button/check_selected_foreground.png"
    hover_foreground "gui/button/check_hover_foreground.png"
    selected_hover_foreground "gui/button/check_hover_foreground.png"
    selected_idle_foreground "gui/button/check_idle_foreground.png"
style switch_reverse_button_text:
    size 28
    xpos 30
    yalign 0.5
    idle_color "#ffffff"
    hover_color "#ffffff"
    selected_hover_color "#ffffff"
    selected_idle_color "#888888"
# 设置界面
screen phone_setting():
    # 设置界面禁止回滚
    on "show" action Function(renpy.block_rollback)
    dismiss action ToggleScreen("phone_setting")
    zorder 102
    style_prefix "phone_setting"
    # 布局区域
    frame:
        background Frame("alphablack")
        align (phone_xy[0],0.0)
        xysize (phone_xysize[0],400)
        modal True
        vbox:
            xalign 0.5
            # 选项区域
            hbox:
                xfill True
                textbutton _("阅读") xalign 0.5 action SetVariable("phone_setting_page","Basic")
                textbutton _("音频") xalign 0.5 action SetVariable("phone_setting_page","Audio")
                textbutton _("颜色") xalign 0.5 action SetVariable("phone_setting_page","Color")
                textbutton _("图像") xalign 0.5 action SetVariable("phone_setting_page","Image")
            null height 10
            # 内容区域
            hbox:
                xsize 800
                xalign 0.5
                if phone_setting_page == "Basic":
                    vbox:
                        style_prefix "switch"
                        xalign 0.0
                        text _("语言")
                        vbox:
                            hbox:
                                textbutton _("English") action Language("english")
                                null width 60
                            hbox:
                                textbutton _("中文") action Language(None)
                                null width 60
                        text _("快进")
                        vbox:
                            hbox:
                                textbutton _("未读文本") action Preference("skip","toggle")
                                null width 60
                            hbox:
                                textbutton _("忽略转场") action InvertSelected(Preference("transitions","toggle"))
                                null width 60
                            hbox:
                                textbutton _("选项后继续") action Preference("after choices","toggle")
                                null width 60
                    vbox:
                        style_prefix "phone_setting"
                        xalign 1.0
                        text _("自动前进时间")
                        hbox:
                            bar value Preference("auto-forward time")
                            null width 30
                        text _("文字速度")
                        hbox:
                            bar value VariableValue("phone_text_cps",60,step=1) style "phone_setting_slider"
                            null width 30
                        text _("文本大小 ([phone_text_size])")
                        hbox:
                            bar value VariableValue("phone_text_size",60,step=1) style "phone_setting_slider"
                            null width 30
                        text _("文本字体")
                        hbox:
                            null width 30
                            frame:
                                background Frame("alphablack")
                                xsize 420
                                textbutton _("[phone_text_font]") action CaptureFocus("font_drop")
                elif phone_setting_page == "Audio":
                    vbox:
                        xalign 0.0
                        text _("通道")
                        textbutton _("全部启用") action Preference("all mute","toggle") style "switch_reverse_button"
                    vbox:
                        xalign 1.0
                        text _("信息")
                        hbox:
                            bar value Preference("phone volume")
                            null width 30
                        text _("音乐")
                        hbox:
                            bar value Preference("music volume")
                            null width 30
                        text _("音效")
                        hbox:
                            bar value Preference("sound volume")
                            null width 30
                        text _("语音")
                        hbox:
                            bar value Preference("voice volume")
                            null width 30
                elif phone_setting_page == "Color":
                    vbox:
                        xalign 0.0
                        text _("对象")
                        hbox:
                            null width 30
                            frame:
                                background Frame("alphablack")
                                xsize 140
                                textbutton _("[phone_setting_color_obj]")action CaptureFocus("color_drop")
                        $ temp = color_get_pre(phone_setting_color_obj)
                        text _("预览")
                        hbox:
                            null width 30
                            fixed:
                                xysize(140,140)
                                add Solid(temp)
                                text "[temp]" align (0.5,1.0)
                            null width 30
                            grid 4 4:
                                spacing 5
                                for c in phone_setting_color_preset:
                                    button:
                                        xysize (30,30)
                                        background Solid(c)
                                        action Function(color_set_pre,c)
                    vbox:
                        xalign 1.0
                        text _("色相")
                        hbox:
                            bar value VariableValue("phone_setting_h",1.0,action=color_set) style "phone_setting_slider"
                            null width 30
                        text _("饱和度")
                        hbox:
                            bar value VariableValue("phone_setting_s",1.0,action=color_set) style "phone_setting_slider"
                            null width 30
                        text _("明度")
                        hbox:
                            bar value VariableValue("phone_setting_v",1.0,action=color_set) style "phone_setting_slider"
                            null width 30
                        text _("透明度")
                        hbox:
                            bar value VariableValue("phone_setting_a",1.0,action=color_set) style "phone_setting_slider"
                            null width 30
                elif phone_setting_page == "Image":
                    vbox:
                        xalign 0.5
                        xsize 780
                        spacing 5
                        text _("头像框")
                        viewport:
                            ysize 110
                            xsize 780
                            draggable True
                            mousewheel True
                            hbox:
                                spacing 5
                                for name, data in phone_icon_frames.items():
                                    vbox:
                                        button:
                                            xysize (100,100)
                                            background Frame("alphablack")
                                            foreground Frame(data[0])
                                            action Function(set_icon_frame, data)
                        text _("气泡框")
                        viewport:
                            ysize 110
                            xsize 780
                            draggable True
                            mousewheel True
                            hbox:
                                spacing 5
                                for name, data in phone_mesg_frames.items():
                                    vbox:
                                        button:
                                            xysize (100,100)
                                            background Frame("alphablack")
                                            foreground Frame(data[0], data[1])
                                            action Function(set_mesg_frame, data)
    if GetFocusRect("font_drop"):
        dismiss action ClearFocus("font_drop")
        nearrect:
            focus "font_drop"
            frame:
                modal True
                background Frame("alphablack")
                xsize 420
                vbox:
                    textbutton _("默认") action [SetVariable("phone_text_font",phone_text_fonts["default"]),ClearFocus("font_drop")]
                    textbutton _("思源黑体 细线") action [SetVariable("phone_text_font",phone_text_fonts["SourceHanSansLite"]),ClearFocus("font_drop")]
    if GetFocusRect("color_drop"):
        dismiss action ClearFocus("color_drop")
        nearrect:
            focus "color_drop"
            frame:
                modal True
                background Frame("alphablack")
                xsize 140
                ysize 320
                viewport:
                    draggable True
                    mousewheel True
                    vbox:
                        textbutton _("背景") action Function(color_select,"BG Img"),ClearFocus("color_drop")
                        textbutton _("外框") action Function(color_select,"FG Img"),ClearFocus("color_drop")
                        null height 10
                        textbutton _("旁白气泡") action Function(color_select,"Narr Img"),ClearFocus("color_drop")
                        textbutton _("用户气泡") action Function(color_select,"User Img"),ClearFocus("color_drop")
                        textbutton _("角色气泡") action Function(color_select,"Char Img"),ClearFocus("color_drop")
                        textbutton _("选项气泡") action Function(color_select,"Item Img"),ClearFocus("color_drop")
                        null height 10
                        textbutton _("旁白文本") action Function(color_select,"Narr Txt"),ClearFocus("color_drop")
                        textbutton _("用户文本") action Function(color_select,"User Txt"),ClearFocus("color_drop")
                        textbutton _("角色文本") action Function(color_select,"Char Txt"),ClearFocus("color_drop")
                        textbutton _("选项文本") action Function(color_select,"Item Txt"),ClearFocus("color_drop")
                        null height 10
                        textbutton _("旁白描边") action Function(color_select,"Narr Out"),ClearFocus("color_drop")
                        textbutton _("用户描边") action Function(color_select,"User Out"),ClearFocus("color_drop")
                        textbutton _("角色描边") action Function(color_select,"Char Out"),ClearFocus("color_drop")
                        textbutton _("选项描边") action Function(color_select,"Item Out"),ClearFocus("color_drop")
                        null height 10
                        textbutton _("强调色1") action Function(color_select,"Accent 1"),ClearFocus("color_drop")
                        textbutton _("强调色2") action Function(color_select,"Accent 2"),ClearFocus("color_drop")
                        textbutton _("强调色3") action Function(color_select,"Accent 3"),ClearFocus("color_drop")
                        textbutton _("强调色4") action Function(color_select,"Accent 4"),ClearFocus("color_drop")


上方是完整代码以及使用方式
下方
新手可以完全不看,是我更新过程中,逐步解决的问题以及思路,有一定难度以及理解要求,
但假如遇到问题,可以看看下方尝试解决,实在解决不了可以留言

额外内容:聊天记录中的分支顺序问题
在聊天记录功能完成之后就有一个问题,假设你的聊天事件中具有外部开关管控的内容
[RenPy] 纯文本查看 复制代码
label phonelabel_xx:
    $ nvl_mode = "phone" # 先打开手机模式
    $ phone_message_list = "test" # 设置当前消息组的名称
    nvl_narrator "1"
    if ss:
        nvl_narrator "2"
    if cc:
        nvl_narrator "3"
    nvl_narrator "4"
    $ phone_message_list = None # 重置当前的消息组
    $ nvl_mode = None # 关闭手机模式(养成封闭习惯)
    nvl clear # 清空nvl界面,释放缓存
    return


那么聊天记录中呈现的将会是顺序显示1,2,3,4
而不是根据玩家解锁顺序把新的消息放在最下方
例如先不开开关,然后开cc,再开ss,我们期望的顺序应该是1,4,3,2
解决这个问题,我有一个方案,但暂时没有进行整合,这里先进行提出
那就是记录时间表,用队列形式进行排序,并且label的变更内容做成新的label,而不是单独的对话

[RenPy] 纯文本查看 复制代码
default phonelabel_xx_list = []
label phonelabel_xx:
    $ nvl_mode = "phone" # 先打开手机模式
    $ phone_message_list = "test"
    nvl_narrator "1"
    # 假设这里开始分支
    if ss and "phonelabel_xx_1" not in phonelabel_xx_list:
        $ phonelabel_xx_list.append("phonelabel_xx_1")
    if cc and "phonelabel_xx_2" not in phonelabel_xx_list:
        $ phonelabel_xx_list.append("phonelabel_xx_2")
    if phonelabel_xx_list != []:
        $ index = 0
        $ renpy.jump(phonelabel_xx_list[index])
    else:
        pass
    $ phone_message_list = None
    $ nvl_mode = None
    nvl clear
    jump start

label phonelabel_xx_1:
    $ nvl_mode = "phone" # 先打开手机模式
    $ phone_message_list = "test"
    "2"
    $ index = phonelabel_xx_list.index("phonelabel_xx_1")+1
    if index <= len(phonelabel_xx_list):
        $ renpy.jump(phonejs_kbn[index])
    else:
        pass
    $ phone_message_list = None
    $ nvl_mode = None
    nvl clear
    return

label phonelabel_xx_2:
    $ phone_message_list = "test"
    "3"
    $ index = phonelabel_xx_list.index("phonelabel_xx_2")+1
    if index < len(phonelabel_xx_list):
        $ renpy.jump(phonejs_kbn[index])
    else:
        pass
    $ phone_message_list = None
    $ nvl_mode = None
    nvl clear
    return

此时,聊天记录的顺序除了最开始的label,分支内容则由按照开关解锁的顺序队列进行,而不是依托于label语法糖的严格上下顺序。


这一点上使用有些微难度,但这本质是因为这个系统依托于renpy本身的语法糖,能够很好兼容say,nvl,choice,history等模块
假如完全独立于renpy的系统之外构筑一个使用类、函数注册等方法的系统,手动管理消息组列表,自然不需要这么麻烦
但也会失去renpy本身的便捷语法,同时还要独立接入其他非核心的模块(choice,history),这是另一个层次上的麻烦,而且在系统的架构上也更加复杂、

1/25之前更新处
[RenPy] 纯文本查看 复制代码
#在第二部分
default phone_avatars = {...}
#修改为
init python:
    def update_phone_avatars():
        store.phone_avatars = {...}


#在第一部分
# 手机消息显示组件
screen phone_nvltext(dialogue):
    zorder 101
    # 添加了这一句
    $ update_phone_avatars()


原因:
游戏生命周期中,default在初始化时被决定,因此立即翻译函数并不会被执行,或只执行一次
[RenPy] 纯文本查看 复制代码
renpy.translate_string()

所以,这个字典会被锁死在最初的游戏语言,一旦切换语言,字典就会对不上,没有动态化
所以将其注册为函数,每次打开界面时运行,实现动态匹配多语言角色

4/13之前更新处
在第一部分添加了
[RenPy] 纯文本查看 复制代码
# say回调函数
define config.say_arguments_callback = phone_message_dict

init python:
    # 手机字典集
    def phone_message_dict(who,interact=True):
        # 如果当前流程phone_message_list(信息组的名字)非None,即为应用了某个信息组
        if store.phone_message_list:
            # phone_message_list(信息组的名字)不在字典中,动态注册新的信息组列表
            if store.phone_message_list not in store.phone_message_dict:
                store.phone_message_dict[store.phone_message_list] = []
            # 通过翻译系统获取当前对话哈希值,锁定唯一对话,避免说话角色相同,内容相同,但不是同一句话的情况
            message_hash = renpy.get_translation_identifier()
            # 如果当前哈希,在当前的信息组列表中,并且不是上次阅读的最后一句话,跳过互动
            if message_hash in store.phone_message_dict[store.phone_message_list]:
                if message_hash != store.phone_message_last[store.phone_message_list]:
                    interact = False
            # 否则,把哈希记录在当前的信息组列表,并在另一个字典记录这句话的哈希,也就是形成映射
            else:
                store.phone_message_dict[store.phone_message_list].append(message_hash)
                store.phone_message_last[store.phone_message_list] = message_hash
            # 映射字典实际为
            # {信息组名字:已读信息的列表}
            # {信息组名字:上次阅读的最后一条信息}
        return (), {"interact": interact} # 返回需不需要互动的参数给renpy的say系统


然后在使用时,写信息label时,用$ phone_message_list =“xx”来包裹(相同的字符串名字会被视作同一栏信息)

[RenPy] 纯文本查看 复制代码
label phonelabel_xx:
    $ nvl_mode = "phone" # 先打开手机模式
    $ phone_message_list = "test" # 设置当前消息组的名称
    "聊天消息的内容"
    $ phone_message_list = None # 重置当前的消息组
    $ nvl_mode = None # 关闭手机模式(养成封闭习惯)
    return

思考过程:
实际上聊天记录这个功能就是已读消息不需要互动直接跳过
恰好,原生say系统中,在角色定义里有一个参数为interact,决定了当前角色的对话需不需要用户互动,也就是“点击继续”的dismiss行为
我一直苦恼怎么把interact自动的,非手动覆盖传给say,毕竟一条条写判断也太糖了
实际尝试,因为最开始就有一个interact的方向,所以看源码和查阅文档后
发现文档中给出了一个配置项config.say_arguments_callback,这是一个回调函数,会在say发起时自动调用
那么问题就轻松解决了,只要记录一下哈希就可以了。

8/17之前更新处
思考过程:
陆陆续续写了不少组件和轮子了,有了一定的架构思维,直觉在数据层处理多语言很不对,于是很快就修改成了
“在输出业务前,把动态数据还原成原始数据再处理逻辑”的思路,于是很多的耦合自然解开了,
本来这里的逻辑应该抽成函数,但稍微思考,此处的逻辑是在判断样式而不是数据,留在screen更直观,就没有进行封装
[RenPy] 纯文本查看 复制代码
# 消息内容
screen phone_contents(dialogue):
    zorder 101
    # 遍历所有对话
    for id_d,d in enumerate(dialogue):
        # 先清除翻译系统的修改,以进行字符串匹配
        if d.who == None:
            $ temp_name = None
        else:
            $ temp_name = renpy.translate_string(d.who,None)

同理,在label开始前要进行一大堆环境设定,也很不合理,因此封装
[RenPy] 纯文本查看 复制代码
init -1 python:
    # 统一开关
    def Phone_Switch(use=None,mainchar=None,chardata=None,mesglist=None):
        if use:
            store.phone_mode = "phone"
        else:
            store.phone_mode = None
        if mainchar:
            store.nvl_main = mainchar
        if chardata:
            store.phone_chardata = chardata
        if mesglist:
            store.phone_mesglist = mesglist
        else:
            store.phone_mesglist = None
        return
    # 删除指定消息组的已读记录(不传则删除当前消息组)
    def Phone_Redata(mesglist=None):
        if not mesglist:
            mesglist = store.phone_mesglist
        if mesglist and mesglist in store.phone_mesgdata:
            del store.phone_mesgdata[mesglist]

最后,滑窗算法理解起来并不难,主要问题出现在对信息列表切片时持续报错,最后发现问题并不是在列表上
而是renpy的nvl/say一类会被say函数使用的screen必须返回一个带有当前id的what到渲染树才能完成生命周期,因此用简单的办法(邪道)解决
[RenPy] 纯文本查看 复制代码
# 消息界面
screen phone_dialogue(dialogue,items=None):
    # 预留的回调接口
    if callable(phone_callback):
        $ phone_callback(dialogue)
    # 劫持对话,绕过渲染树的必要判定(将必须持有id返回渲染树的当前对象移到看不到地方,以完成say生命周期)
    if dialogue:
        $ real_d = dialogue[-1]
        text real_d.what id real_d.what_id:
            pos (10086,10086)
            color "#00000000"
            outlines [(absolute(0),"#00000000")]



#点击头像 查看我写的更多屎
粉身碎骨浑不怕,要留答辩在人间

发表于 2026-1-25 14:22:26 | 显示全部楼层
收藏了,逻辑上有参考意义,谢谢分享
回复 支持 抱歉

使用道具 举报

发表于 2026-4-18 03:06:09 | 显示全部楼层
支持!!!
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|RenPy中文空间 ( 苏ICP备17067825号 )

GMT+8, 2026-8-30 00:38 , Processed in 0.028670 second(s), 8 queries , Redis On.

Powered by Discuz! X3.5

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表