找回密码
 立即注册

QQ登录

只需一步,快速开始

查看: 1539|回复: 4

[教程] 【变换组件】鼠标视差

[复制链接]
发表于 2025-9-12 03:30:44 | 显示全部楼层 |阅读模式

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

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

×
本帖最后由 Maz马 于 2026-8-3 09:37 编辑

视差是一个非常能提升时髦值的东西
无论是用在主界面或是做一些伪3D场景

另外,来自版主的,关于视差的另一个帖子:

用bar制作简单移动视差效果(renpy进阶学习经验五)
https://www.renpy.cn/forum.php?mod=viewthread&tid=1557

26.8.2更新
1.支持多图片字典入参,不再是一张图一个组件
2.不再包装可显示对象,直接在内部包装
3.组件尺寸不再是全屏,而是图片尺寸,可以直观的使用位置特性
4.提供了鼠标感应锚点的模式
5.添加了晃动检测回调
6.兼容安卓陀螺仪,如果不用安卓方案,代码也很干净,放心食用

安卓水平仪支持方案
https://www.renpy.cn/forum.php?mod=viewthread&tid=1843

[RenPy] 纯文本查看 复制代码
# ---------- 视差组件 ----------
init python:
    """
    Author: Maz
    License: MIT
    """
    import pygame
    class ParallaxImage(renpy.Displayable):
        def __init__(self,layers,mode="screen",callback=None,**kwargs):
            super(ParallaxImage,self).__init__()
            # 感应模式(感受鼠标偏移的基准):
            #   "screen" = 屏幕中心  (坐标输入,手机电脑通用)
            #   "canvas" = 画布中心  (坐标输入,手机电脑通用)
            #   "rotate" = 水平仪旋转(需要ANDROID_SENSOR,仅支持传感器的设备)
            self.mode = mode if mode in ("screen", "canvas", "rotate") else "screen"
            # 每层独立
            self.layers = [{"child": child,"offset": offset,"x": 0,"y": 0} for child,offset in layers.items()]
            
            # 首次渲染时初始化组件尺寸
            self.w = 0
            self.h = 0
            self.initsize = False
             
            # 输入源(水平仪/鼠标)
            self.input_x = 0
            self.input_y = 0
            self.input_z = 0
            # 时间轴
            self.old_time = 0
            self.delta_time = 0
 
            # 晃动检测
            self.shake_time = 0.0                          # 采样时间戳
            self.shake_timeout = 0.2                       # 采样间隔(秒)
            self.shake_window = 2.0                        # 判定间隔(秒):窗口内波动剧烈视为晃动
            self.shake_history = []                        # 窗口内采样记录 [(time, x, y, z), ...]
            self.shake_threshold = config.screen_width*1.5 # 判定阈值(像素):窗口内三轴最大-最小之和超过视为剧烈
            self.shake_callback = callback                 # 触发晃动时调用的回调
 
        # 晃动检测
        def _check_shake(self,st):
            # 守卫拦截
            if not self.shake_callback:
                return
            # 采样节流
            if st - self.shake_time < self.shake_timeout:
                return
            self.shake_time = st
            # 记录输入源采样,只保留窗口内的
            self.shake_history.append((st,self.input_x,self.input_y,self.input_z))
            cutoff = st - self.shake_window
            self.shake_history = [s for s in self.shake_history if s[0] >= cutoff]
            if len(self.shake_history) < 2:
                return
            # 窗口内波动大于阈值 清空历史触发回调
            xs = [s[1] for s in self.shake_history]
            ys = [s[2] for s in self.shake_history]
            zs = [s[3] for s in self.shake_history]
            wave = (max(xs) - min(xs)) + (max(ys) - min(ys)) + (max(zs) - min(zs))
            if wave > self.shake_threshold:
                self.shake_history = []
                if self.shake_callback:
                    self.shake_callback()
        
        def render(self,width,height,st,at):
            # 组件尺寸 首次渲染取图片尺寸,之后每帧直接复用
            if not self.initsize:
                child_render = renpy.render(renpy.displayable(self.layers[0]["child"]),width,height,st,at)
                self.w,self.h = child_render.get_size()
                self.initsize = True
            
            render = renpy.Render(self.w, self.h)
              
            # 输入源与归一化基准按模式区分;输入源缺失直接报错
            if self.mode == "screen":
                pos = renpy.get_mouse_pos()
                if pos is None:
                    raise Exception("ParallaxImage screen 模式需要鼠标/触摸坐标")
                mx,my = pos
                self.input_x = mx - config.screen_width / 2
                self.input_y = my - config.screen_height / 2
                self.input_z = 0
                half_w = config.screen_width / 2
                half_h = config.screen_height / 2
            elif self.mode == "canvas":
                pos = renpy.get_mouse_pos()
                if pos is None:
                    raise Exception("ParallaxImage canvas 模式需要鼠标/触摸坐标")
                mx,my = pos
                self.input_x = mx - self.w / 2
                self.input_y = my - self.h / 2
                self.input_z = 0
                half_w = min(self.w / 2, config.screen_width - self.w / 2)
                half_h = min(self.h / 2, config.screen_height - self.h / 2)
            elif self.mode == "rotate":
                if not ANDROID_SENSOR:
                    raise Exception("ParallaxImage rotate 模式需要 ANDROID_SENSOR(水平仪)")
                self.input_x,self.input_y,self.input_z = ANDROID_SENSOR.update()
                half_w = config.screen_width / 2
                half_h = config.screen_height / 2
                        
            # 计算渲染间隔
            if self.old_time == 0:
                self.old_time = st
            self.delta_time = st - self.old_time
            self.old_time = st
              
            # 各层共用输入源按各自 offset 绘制
            for layer in self.layers:
                if layer["offset"] == 0:
                    # 不位移
                    layer["x"] = 0
                    layer["y"] = 0
                else:
                    # 归一化到 ±1 并钳位,保证最大位移 = abs(offset)
                    norm_x = (self.input_x / half_w) if half_w else 0
                    norm_y = (self.input_y / half_h) if half_h else 0
                    norm_x = max(-1.0, min(1.0, norm_x))
                    norm_y = max(-1.0, min(1.0, norm_y))
                    target_x = norm_x * layer["offset"]
                    target_y = norm_y * layer["offset"]
                    # 缓动公式(系数钳位到 ≤1:避免首帧/卡顿时 k>1 的过冲振荡)
                    k = 1.5 * self.delta_time * abs(layer["offset"])
                    k = max(0.0, min(1.0, k))
                    layer["x"] += (target_x - layer["x"]) * k
                    layer["y"] += (target_y - layer["y"]) * k

                # 画布 = 图片尺寸,中心项抵消,无需再计算
                obj_render = renpy.render(renpy.displayable(layer["child"]),width,height,st,at)
                render.blit(obj_render,(layer["x"],layer["y"]))
 
            renpy.redraw(self,0)
            self._check_shake(st)
            return render
 
# 使用方法:
#
# 定义一个图层字典,格式是 {图片:偏移}
# 设计约定所有图层大小必须一致,组件尺寸 = 统一图片尺寸(制图需预留偏移出血,偏移超出部分被截断)
# define xx = {"sky.png"  :-25,
#              "mount.png":-15}
#
# 正数=同向移动,负数=反向移动,数值绝对值 = 最大移动像素
#
# 然后可以当成image显示,也可以在screen里直接add,以下是参数含义
#
# layers   你写好的图层
# mode     感受鼠标偏移的模式 屏幕中心"screen"(默认)/ 画布中心"canvas" / 水平仪旋转"rotate"
# callback 扩展的回调,检测到持续晃动时可以调用某些行为,默认是None,如果lambda: renpy.open_url("https://www.pinduoduo.com/")就是"摇一摇打开拼多多"
#
# image xx = ParallaxImage(xx,mode="canvas")


废弃旧版(屎山留底)

[RenPy] 纯文本查看 复制代码
### 跟随鼠标移动的视差 ###################################################################
init python:
    import pygame
    class TrackCursor(renpy.Displayable):
        def __init__(self,child,paramod=1.0,**kwargs):
            super(TrackCursor, self).__init__()
            # 图片
            self.child = child
            # 移动参数:正数=同向移动,负数=反向移动,绝对值越大移动幅度越小
            self.paramod = paramod

            # 鼠标坐标
            self.mouse_x = 0
            self.mouse_y = 0
            # 渲染坐标
            self.x = 0
            self.y = 0
            # 时间轴
            self.old_time = 0
            self.delta_time = 0
            # 尺寸
            self.child_width = 0
            self.child_height = 0
            # 自适应渲染检查
            self.size_initialized = False
    
        # 事件方法
        def event(self, ev, x, y, st):
            if ev.type == pygame.MOUSEMOTION:
                # 计算鼠标相对于屏幕中心的偏移量
                center_x = config.screen_width / 2
                center_y = config.screen_height / 2
                offset_x = (x - center_x) / self.paramod
                offset_y = (y - center_y) / self.paramod

                self.mouse_x = offset_x
                self.mouse_y = offset_y
    
        # 渲染显示对象
        def render(self, width, height, st, at):
            # 首次渲染时初始化子对象尺寸
            if not self.size_initialized:
                child_render = renpy.render(self.child, width, height, st, at)
                self.child_width, self.child_height = child_render.get_size()
                self.size_initialized = True
            
            render = renpy.Render(width, height)
            
            # 计算渲染间隔
            if self.old_time == 0:
                self.old_time = st
            self.delta_time = st - self.old_time
            self.old_time = st
            
            # 缓动公式
            self.x += (self.mouse_x - self.x) * 1.5 * self.delta_time * abs(self.paramod)
            self.y += (self.mouse_y - self.y) * 1.5 * self.delta_time * abs(self.paramod)
            
            # 计算最终绘制位置:图片中心 + 偏移量
            draw_x = (width - self.child_width) / 2 + self.x
            draw_y = (height - self.child_height) / 2 + self.y
            
            obj_render = renpy.render(self.child, width, height, st, at)
            render.blit(obj_render, (draw_x, draw_y))
            renpy.redraw(self, 0)
            return render

#使用方法:
#两个入参,(图片和偏移系数)
#图片得传入renpy.displayable("xxx")
#偏移系数不能为0,大致来说越靠近0,偏移程度越大。正值跟随鼠标移动,负值反向移动。20-60是一个比较舒适的范围。
#示例:
#image a = TrackCursor(renpy.displayable("background.png"), paramod=-20)
#default b = TrackCursor(renpy.displayable("background.png"), paramod=20)

另外,来自群友@blurred 设计的草案,应用手机传感器根据偏向角的视差(就是安卓没有鼠标,用陀螺仪来检测)
我这里也代为贴上了,由于各个设备不同,因此安卓的偏转参数需要自己测试调整(会自行检测当前设备是PC还是安卓)
[RenPy] 纯文本查看 复制代码
init python:
    import pygame
    import math
    
    # 倾角传感器管理器
    class TiltSensorManager:
        def __init__(self):
            self.pitch = 0.0  # 俯仰角(前后翻动)
            self.roll = 0.0   # 横滚角(左右倾斜)
            self.yaw = 0.0    # 偏航角(左右转动)
            self.initialized = False
            self.sensor_listener = None
            self.sensor_manager = None
            self.accel_values = [0, 0, 0]  # 加速度传感器
            self.magnetic_values = [0, 0, 0]  # 地磁传感器
            # 尝试初始化传感器
            self.init_sensors()
        def init_sensors(self):
            try:
                # Android相关
                import jnius
                PythonActivity = jnius.autoclass('org.renpy.android.PythonSDLActivity')
                Context = jnius.autoclass('android.content.Context')
                Sensor = jnius.autoclass('android.hardware.Sensor')
                SensorManager = jnius.autoclass('android.hardware.SensorManager')
                activity = PythonActivity.mActivity
                self.sensor_manager = activity.getSystemService(Context.SENSOR_SERVICE)
                # 加速度传感器和地磁传感器
                self.accelerometer = self.sensor_manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
                self.magnetometer = self.sensor_manager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)    
                if self.accelerometer is None or self.magnetometer is None:
                    print("设备缺少必要的传感器")
                    return
                class SensorListener(jnius.PythonJavaClass):
                    __javainterfaces__ = ['android/hardware.SensorEventListener']
                    def __init__(self, manager, **kwargs):
                        super(SensorListener, self).__init__(**kwargs)
                        self.manager = manager
                    @jnius.java_method('(Landroid/hardware/SensorEvent;)V')
                    def onSensorChanged(self, event):
                        # 根据传感器类型存储数据
                        if event.sensor.getType() == Sensor.TYPE_ACCELEROMETER:
                            self.manager.accel_values = [event.values[0], event.values[1], event.values[2]]
                        elif event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD:
                            self.manager.magnetic_values = [event.values[0], event.values[1], event.values[2]]
                        # 计算倾角
                        self.manager.calculate_orientation()
                    @jnius.java_method('(Landroid/hardware/Sensor;I)V')
                    def onAccuracyChanged(self, sensor, accuracy):
                        pass
                self.sensor_listener = SensorListener(self)
                self.sensor_manager.registerListener(
                    self.sensor_listener,
                    self.accelerometer,
                    SensorManager.SENSOR_DELAY_GAME
                )
                self.sensor_manager.registerListener(
                    self.sensor_listener,
                    self.magnetometer,
                    SensorManager.SENSOR_DELAY_GAME
                )
                self.initialized = True
                print("倾角传感器初始化成功")
                
            except Exception as e:
                print(f"无法初始化倾角传感器: {e}")
                self.initialized = False
        
        def calculate_orientation(self):
            """根据加速度和地磁数据计算设备方向"""
            try:
                import jnius
                SensorManager = jnius.autoclass('android.hardware.SensorManager')
                # 计算旋转矩阵
                R = [0] * 9
                I = [0] * 9
                success = SensorManager.getRotationMatrix(R, I, self.accel_values, self.magnetic_values)
                if success:
                    # 获方向数据
                    orientation = [0] * 3
                    SensorManager.getOrientation(R, orientation)
                    # 转换为角都(弧度转角度)
                    self.yaw = math.degrees(orientation[0])   # 偏航角(左右转动)
                    self.pitch = math.degrees(orientation[1]) # 俯仰角(前后翻动)
                    self.roll = math.degrees(orientation[2])  # 横滚角(左右倾斜)                 
            except Exception as e:
                print(f"计算方向时出错: {e}")
        
        def stop(self):
            if self.sensor_manager and self.sensor_listener:
                try:
                    self.sensor_manager.unregisterListener(self.sensor_listener)
                except:
                    pass
    
    # 创建全局倾角传感器管理器
    tilt_sensor_manager = None
    if renpy.variant("mobile") and not (renpy.variant("pc") or renpy.variant("web")):
        tilt_sensor_manager = TiltSensorManager()
    
    # 老马出品的视差
    class TrackCursor(renpy.Displayable):
        def __init__(self, child, paramod=1.0, sensitivity=0.5, threshold=5.0, 
                    flip_sensitivity=1.0, dead_zone=10.0, **kwargs):
            super(TrackCursor, self).__init__()
            # 图片
            self.child = child
            # 移动参数
            self.paramod = paramod
            # 灵敏度控制
            self.sensitivity = sensitivity * 1.5
            # 倾斜阈值(度),超过此值才开始移动
            self.threshold = threshold
            # 翻转灵敏度(控制翻转方向的影响程度)
            self.flip_sensitivity = flip_sensitivity
            # 死区角度(度),在此范围内不响应翻转
            self.dead_zone = dead_zone
            
            # 输入数据
            self.input_x = 0
            self.input_y = 0
            # 渲染坐标
            self.x = 0
            self.y = 0
            # 时间轴
            self.old_time = 0
            self.delta_time = 0
            # 尺寸
            self.child_width = 0
            self.child_height = 0
            # 自适应渲染检查
            self.size_initialized = False
            
            # 判断平台
            self.is_mobile = renpy.variant("mobile") and not (renpy.variant("pc") or renpy.variant("web"))
            
            # 基准角度(初始位置)
            self.base_yaw = 0  
            self.base_pitch = 0 
            self.calibrated = False
            
            # 翻转状态跟踪
            self.last_flip_direction = 0  # 0: 未翻转, 1: 正向翻转, -1: 反向翻转
        
        def event(self, ev, x, y, st):
            if not self.is_mobile and ev.type == pygame.MOUSEMOTION:
                # 计算鼠标相对于屏幕中心的偏移量
                center_x = config.screen_width / 150
                center_y = config.screen_height / 5.5
                offset_x = (x - center_x) / self.paramod
                offset_y = (y - center_y) / self.paramod
                
                self.input_x = offset_x
                self.input_y = offset_y
            elif ev.type == pygame.MOUSEBUTTONDOWN and self.is_mobile:
                self.calibrate_sensors()
        
        def calibrate_sensors(self):
            """校准传感器,设置当前姿态为基准"""
            if tilt_sensor_manager and tilt_sensor_manager.initialized:
                self.base_yaw = tilt_sensor_manager.yaw
                self.base_pitch = tilt_sensor_manager.pitch
                self.calibrated = True
                print(f"传感器已校准: yaw={self.base_yaw}, pitch={self.base_pitch}")
        
        def calculate_flip_direction(self, angle_diff):
            """计算翻转方向并应用死区"""
            if abs(angle_diff) < self.dead_zone:
                return 0  # 在死区内,不响应
            
            # 计算翻转方向(1: 正向, -1: 反向)
            direction = 1 if angle_diff > 0 else -1
            
            # 如果翻转方向改变,应用翻转灵敏度
            if direction != self.last_flip_direction:
                self.last_flip_direction = direction
                return angle_diff * self.flip_sensitivity
            
            return angle_diff
        
        # 渲染显示对象
        def render(self, width, height, st, at):
            # 首次渲染时初始化子对象尺寸
            if not self.size_initialized:
                child_render = renpy.render(self.child, width, height, st, at)
                self.child_width, self.child_height = child_render.get_size()
                self.size_initialized = True
            
            render = renpy.Render(width, height)
            
            # 计算渲染间隔
            if self.old_time == 0:
                self.old_time = st
            self.delta_time = st - self.old_time
            self.old_time = st
            
            # 根据平台获取输入数据
            if self.is_mobile and tilt_sensor_manager and tilt_sensor_manager.initialized:
                if not self.calibrated:
                    self.calibrate_sensors()
                
                # 计算相对于基准的角度差异
                yaw_diff = tilt_sensor_manager.yaw - self.base_yaw
                pitch_diff = tilt_sensor_manager.pitch - self.base_pitch
                
                # 处理偏航角(左右转动)的360度跳变问题
                if yaw_diff > 180:
                    yaw_diff -= 360
                elif yaw_diff < -180:
                    yaw_diff += 360
                
                # 应用翻转方向检测和死区
                yaw_diff = self.calculate_flip_direction(yaw_diff)
                pitch_diff = self.calculate_flip_direction(pitch_diff)
                
                if abs(yaw_diff) > self.threshold:
                    # 偏航角(左右转动)控制左右移动
                    self.input_x = yaw_diff * self.sensitivity
                else:
                    self.input_x = 0

                if abs(pitch_diff) > self.threshold:
                    # 俯仰角(前后翻动)控制上下移动
                    self.input_y = pitch_diff * self.sensitivity
                else:
                    self.input_y = 0
                    
            elif not self.is_mobile:
                pass
            else:
                self.input_x = 0
                self.input_y = 0
            
            # 缓动公式
            self.x += (self.input_x - self.x) * 1.5 * self.delta_time * abs(self.paramod)
            self.y += (self.input_y - self.y) * 1.5 * self.delta_time * abs(self.paramod)
            
            draw_x = (width - self.child_width) / 2 + self.x
            draw_y = (height - self.child_height) / 2 + self.y        
            obj_render = renpy.render(self.child, width, height, st, at)
            render.blit(obj_render, (draw_x, draw_y))
            renpy.redraw(self, 0)
            return render

# image sky = TrackCursor(renpy.displayable("sky.png"), paramod=-25, sensitivity=1.0, flip_sensitivity=1.5, dead_zone=5.0)
# image mount = TrackCursor(renpy.displayable("mount.png"), paramod=-15, sensitivity=1.0, flip_sensitivity=1.2, dead_zone=8.0)

# paramod           # 移动幅度参数(PC)
# sensitivity       # 基本灵敏度
# threshold         # 倾斜阈值(度)超过这个角度才会发生偏转
# flip_sensitivity  # 翻转灵敏度
# dead_zone         # 死区角度(度)在这个角度范围内需要偏转threshold才会相应偏转,在这个范围外翻转任意角度都会偏转


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








评分

参与人数 1活力 +300 干货 +1 收起 理由
烈林凤 + 300 + 1 感谢分享!

查看全部评分

发表于 2025-9-12 17:29:21 | 显示全部楼层
之前那篇帖子已经是一年前写的了啊……时间过的真快(望天)
先前我所采用的方法主要就是为了避免使用过于复杂的python语句,尤其是要用到pygame库,更方便新手用renpy原生界面语句搓出自己想要的界面效果
这篇帖子所使用的方法显然利用率更高,用到了cdd和pygame库,确实更适合renpy糕手们去使用,但因为咱也不会使用cdd,所以只能用用老方法了(
回复 支持 1 抱歉 0

使用道具 举报

发表于 2025-9-27 03:19:17 | 显示全部楼层
烈林凤 发表于 2025-9-12 17:29
之前那篇帖子已经是一年前写的了啊……时间过的真快(望天)
先前我所采用的方法主要就是为了避免使用过于 ...

所以为什么不用DynamicDisplayable
回复 支持 抱歉

使用道具 举报

发表于 2025-9-27 12:47:21 | 显示全部楼层
Aaron栩生阿龙 发表于 2025-9-27 03:19
所以为什么不用DynamicDisplayable

因为当时不知道)
回复 支持 抱歉

使用道具 举报

发表于 2025-10-12 16:02:24 | 显示全部楼层
烈林凤 发表于 2025-9-27 12:47
因为当时不知道)

文档抄一百遍下次课上课检查
回复 支持 抱歉

使用道具 举报

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

本版积分规则

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

GMT+8, 2026-8-6 18:40 , Processed in 0.028759 second(s), 10 queries , Redis On.

Powered by Discuz! X3.5

© 2001-2026 Discuz! Team.

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