手掌指针

This commit is contained in:
2026-10-09 15:28:00 +08:00
parent bbb0c5db14
commit bc88fef43a
8 changed files with 246 additions and 3 deletions
+16
View File
@@ -31,3 +31,19 @@
- `player_pickup.gd` 接入 `throw_carry`(Q 键)→ `_throw()`:取 `-_hold_pos.global_transform.basis.z` 作投掷方向(持握点挂在摄像机下且仅有平移,其 basis 即视线朝向,故无需再注入摄像机),`set_linear_velocity(dir * throw_speed)` 后置 `_current = null`。
- 新增导出参数 `throw_speed: float = 12.0`。
- 备选手感(注释里留了行):改用 `apply_central_impulse` 则重物飞得慢。
### 可拖拽时准心变手型
- 新增素材 `character/assets/hand_cursor.svg`(新建了 `character/assets/` 目录):简笔手(4 指 + 手掌 + 拇指的圆角矩形),双层 —— 底层深色 `#1a1a1a` stroke-width 4 当描边,上层白色 stroke-width 1.5 覆盖内部接缝。32×32,**未写 `<title>`**(避免 ThorVG 解析风险)。
- `ui/crosshair.gd`:加 `class_name Crosshair`;新增 `hand_texture`(自动加载上面的 svg) / `hand_size`(=26)、`set_grabbable(bool)`;`_draw()` 在 `_grabbable && hand_texture != null` 时走 `_draw_hand()`,否则走 `_draw_dot()`。
- `player_pickup.gd`:新增 `signal hover_changed(grabbable: bool)`,`physics_step()` 里先调 `_update_hover()`——用 `_ray.force_raycast_update()` 强制刷新(否则结果滞后于本帧 move_and_slide,跑动中瞄到物体慢一帧),**只在状态变化时**发信号。
- `player.gd`:`@onready var crosshair: Crosshair = $CameraRig/HUD/Crosshair`;`_ready()` 权威分支里 `pickup.hover_changed.connect(_on_pickup_hover_changed)`;handler 里既切准心,也 `Input.set_custom_mouse_cursor()` 换系统指针(含指尖热点),鼠标未捕获时该行无副作用。
- **注意**:用户侧已把 `AnimationPlayer` 从 `Body/AnimationPlayer` 提到 Player 根节点(`$AnimationPlayer`),并把 `animator` 变量改名为 `animation_player`;相机 scale 改为 5。
### 换用真实光标素材(grab / grab_hover)
- 素材来源:`C:\Users\yvmou\Downloads\cursors\light\`(light 主题 = 深色手型,32×32 RGBA)。
- 已复制到 `character/assets/grab.png`(指着可拖拽物)与 `character/assets/grab_hover.png`(正抓着),Godot 已生成 `.import`。
- **删除了临时的 `character/assets/hand_cursor.svg` 及其 `.import`**(已被真实素材取代)。
- `ui/crosshair.gd` 改为三态:`enum GrabState { NONE, HOVER, HOLDING }`;导出 `hover_texture` / `hold_texture` / `hand_size`(=32,1:1 不缩放) / `icon_modulate` / `sync_os_cursor`;公开 `texture_for_state(state)` 供系统指针复用素材;`_sync_os_cursor()` 内部完成 `Input.set_custom_mouse_cursor`(`Engine.is_editor_hint()` 下跳过),热点在图标高度 12% 处 —— **系统指针逻辑已从 player.gd 移入准心**。
- `player_pickup.gd`:信号由 `hover_changed(bool)` 改为 `interact_state_changed(hovering, holding)`,`_update_hover()` 改名 `_update_interact_state()` 并同时跟踪 `_holding`。
- `player.gd`:handler 改名 `_on_pickup_interact_state_changed(false)`,只负责把两个布尔量收敛成 `Crosshair.GrabState`(优先级 抓着 > 指着 > 没有)。
- 待用户确认:像素手在非整数倍分辨率缩放下发糊的话,需把 Crosshair 的 `texture_filter` 设为 Nearest。
Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c67qe66m2aft4"
path="res://.godot/imported/grab.png-15ccc11b1ce7bc67c3d60db6d0ffc1ed.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://character/assets/grab.png"
dest_files=["res://.godot/imported/grab.png-15ccc11b1ce7bc67c3d60db6d0ffc1ed.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bkq6ya350cae8"
path="res://.godot/imported/grab_hover.png-ae4fc79bd64160559a632cc7a661f9e3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://character/assets/grab_hover.png"
dest_files=["res://.godot/imported/grab_hover.png-ae4fc79bd64160559a632cc7a661f9e3.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+16 -1
View File
@@ -5,13 +5,15 @@ extends CharacterBody3D
## Look 视角 character/player/player_look.gd
## Motor 移动跳跃 character/player/player_motor.gd
## Pickup 拾取 character/player/player_pickup.gd
## Animator 动画表现 character/player/player_animator.gd(挂在 Body/AnimationPlayer 上)
## Animator 动画表现 character/player/player_animator.gd(挂在 AnimationPlayer 上)
##
## 本脚本不实现任何操作,只负责「谁先谁后」和「谁有权跑」。
@onready var body: Node3D = $Body
@onready var camera_rig: Node3D = $CameraRig
@onready var camera: Camera3D = $CameraRig/Camera
## 准心(挂在 CameraRig/HUD 下)。远端玩家的 HUD 会随 camera_rig 一起被释放。
@onready var crosshair: Crosshair = $CameraRig/HUD/Crosshair
@onready var animation_player: PlayerAnimator = $AnimationPlayer
@onready var look: PlayerLook = $Look
@onready var motor: PlayerMotor = $Motor
@@ -43,6 +45,8 @@ func _ready() -> void:
camera.get_node("InteractionRay") as RayCast3D,
camera.get_node("InteractionPos") as Marker3D
)
# 准心接线:拾取组件一发现交互状态变化,就通知准心换外观(点 / 手 / 抓)。
pickup.interact_state_changed.connect(_on_pickup_interact_state_changed)
set_mouse_captured(true)
@@ -62,6 +66,17 @@ func set_mouse_captured(captured: bool) -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if captured else Input.MOUSE_MODE_VISIBLE
## 把拾取组件的两个布尔量收敛成准心的三态。优先级:抓着 > 指着 > 没有。
## 系统鼠标指针由准心自己同步(Crosshair.sync_os_cursor),这里不用管。
func _on_pickup_interact_state_changed(hovering: bool, holding: bool) -> void:
var state := Crosshair.GrabState.NONE
if holding:
state = Crosshair.GrabState.HOLDING
elif hovering:
state = Crosshair.GrabState.HOVER
crosshair.set_grab_state(state)
func _physics_process(delta: float) -> void:
# 顺序固定:算速度 → 推进 → 播动画 → 搬物体。
# 由根节点统一编排,各组件自己不许写 _physics_process ——
+26
View File
@@ -12,9 +12,17 @@ extends Node
## 投掷初速度(米/秒):按 Q 甩出去时给物体的速度大小。
@export var throw_speed: float = 12.0
## 交互状态变化时发出。
## hovering = 准心正指着可拖拽的刚体(还没抓);holding = 正抓着东西。
## Player 接住这个信号去切准心外观。只在两者之一变化时发,避免每帧刷 UI。
signal interact_state_changed(hovering: bool, holding: bool)
var _current: RigidBody3D = null
var _ray: RayCast3D
var _hold_pos: Marker3D
## 上一次发出的状态,用来只在「变化那一刻」发信号。
var _hovering: bool = false
var _holding: bool = false
## 由 Player 在 _ready() 注入依赖。
@@ -42,6 +50,7 @@ func _unhandled_input(event: InputEvent) -> void:
## 用速度把物体拽向持握点,而不是直接改 global_position ——
## 这样它仍会和场景里其他刚体正常碰撞,不会被硬塞进墙里。
func physics_step() -> void:
_update_interact_state()
# 用 is_instance_valid 而不是 `== null`:物体可能在拖拽途中被 destroy,
# 那种情况下引用不是 null 而是「已被释放」,直接取 global_position 会报错。
if not is_instance_valid(_current) or not is_instance_valid(_hold_pos):
@@ -50,6 +59,23 @@ func physics_step() -> void:
_current.set_linear_velocity(to_hold * pick_power)
## 检测「指着可拖拽物 / 正抓着东西」这两个状态,**只在变化时**发信号。
## 每帧都发会让 UI 无谓重绘,所以这里自己记着上一次的结果。
func _update_interact_state() -> void:
if _ray == null:
return
# 主动刷新一次:RayCast3D 的结果通常滞后于本帧的 move_and_slide,
# 不强制更新的话,跑动中瞄到物体要慢一帧才亮。
_ray.force_raycast_update()
var hovering := _ray.get_collider() is RigidBody3D
var holding := is_instance_valid(_current)
if hovering == _hovering and holding == _holding:
return
_hovering = hovering
_holding = holding
interact_state_changed.emit(_hovering, _holding)
func _grab() -> void:
if _ray == null:
return
+108 -2
View File
@@ -1,11 +1,31 @@
@tool
class_name Crosshair
extends Control
## 简易准心:屏幕正中央的一个点,用 _draw() 实时绘制,不依赖任何贴图。
## 准心 / 指针:屏幕正中央。
## 三种状态:
## NONE 没指着可拖拽物 → 画一个点
## HOVER 指着可拖拽物 → 显示 grab.png(张开的手,提示「可以抓」)
## HOLDING 正抓着东西 → 显示 grab_hover.png(抓取态)
##
## 挂法:CanvasLayer > Crosshair(Control),Control 的锚点设为「Full Rect」
## (anchor 全 0/1),这样 size 就等于视口尺寸,中心即屏幕中心。
## 因为是 CanvasLayer 子节点,在 window/stretch/mode=canvas_items 下会自动跟随缩放。
##
## 状态由 Player 驱动:拾取组件的 interact_state_changed 信号 → set_grab_state()。
## 指着可拖拽物体时的图标。没在 Inspector 里指定时自动加载。
const HOVER_TEXTURE_PATH := "res://character/assets/grab_hover.png"
## 正抓着东西时的图标。没在 Inspector 里指定时自动加载。
const HOLD_TEXTURE_PATH := "res://character/assets/grab.png"
## 准心的三种状态。
enum GrabState {
NONE, ## 没指着可拖拽物体
HOVER, ## 指着可拖拽物体(还没抓)
HOLDING, ## 正抓着东西
}
@export_group("点")
## 点的颜色
@export var dot_color: Color = Color(1, 1, 1, 0.9):
set(v):
@@ -18,7 +38,37 @@ extends Control
dot_radius = v
queue_redraw()
## 是否给点画一圈描边(浅色背景上也看得清)
@export_group("手型")
## 指着可拖拽物体时的图标。留空则自动加载 grab.png
@export var hover_texture: Texture2D:
set(v):
hover_texture = v
queue_redraw()
## 正抓着东西时的图标。留空则自动加载 grab_hover.png
@export var hold_texture: Texture2D:
set(v):
hold_texture = v
queue_redraw()
## 图标显示的边长(像素)。素材是 32×32,默认 32 就是 1:1 不缩放,像素最清晰。
@export_range(8.0, 96.0, 1.0) var hand_size: float = 32.0:
set(v):
hand_size = v
queue_redraw()
## 图标着色。素材自带颜色,默认 Color.WHITE 表示按原样显示(乘法调色)。
@export var icon_modulate: Color = Color(1, 1, 1, 1):
set(v):
icon_modulate = v
queue_redraw()
## 是否把同一张图同步给系统鼠标指针。
## 游戏内鼠标是 MOUSE_MODE_CAPTURED 锁定隐藏的,这行只在鼠标可见时(Esc / 菜单)才有可见效果。
@export var sync_os_cursor: bool = true
@export_group("描边")
## 是否给点画描边(浅色背景上也看得清)
@export var outline: bool = true:
set(v):
outline = v
@@ -36,10 +86,17 @@ extends Control
outline_width = v
queue_redraw()
## 当前状态。
var _state: int = GrabState.NONE
func _ready() -> void:
# 准心不吃鼠标事件,否则会挡住后面的 UI / 拖拽
mouse_filter = Control.MOUSE_FILTER_IGNORE
if hover_texture == null and ResourceLoader.exists(HOVER_TEXTURE_PATH):
hover_texture = load(HOVER_TEXTURE_PATH)
if hold_texture == null and ResourceLoader.exists(HOLD_TEXTURE_PATH):
hold_texture = load(HOLD_TEXTURE_PATH)
queue_redraw()
@@ -49,9 +106,58 @@ func _notification(what: int) -> void:
queue_redraw()
## 由 Player 调用,切换准心状态(GrabState)。
func set_grab_state(state: int) -> void:
if state == _state:
return
_state = state
if sync_os_cursor and not Engine.is_editor_hint():
_sync_os_cursor()
queue_redraw()
## 某个状态该用哪张图。公开出来是为了让系统指针复用同一份素材,不必两处各加载一遍。
func texture_for_state(state: int) -> Texture2D:
match state:
GrabState.HOVER:
return hover_texture if hover_texture != null else hold_texture
GrabState.HOLDING:
return hold_texture if hold_texture != null else hover_texture
_:
return null
func _draw() -> void:
var c := size * 0.5 # 控件中心 = 屏幕中心
var tex := texture_for_state(_state)
if tex != null:
_draw_icon(c, tex)
else:
_draw_dot(c)
## 一个点(可选描边)
func _draw_dot(c: Vector2) -> void:
if outline and outline_width > 0.0:
draw_circle(c, dot_radius + outline_width, outline_color)
draw_circle(c, dot_radius, dot_color)
## 手型图标:居中、按原始宽高比缩放(非方形素材不会被拉变形)。
func _draw_icon(c: Vector2, tex: Texture2D) -> void:
var tex_size := Vector2(tex.get_width(), tex.get_height())
var k := hand_size / maxf(tex_size.x, tex_size.y)
var draw_size := tex_size * k
draw_texture_rect(tex, Rect2(c - draw_size * 0.5, draw_size), false, icon_modulate)
## 把当前状态的图标同步给系统鼠标指针。热点放在指尖附近,
## 这样「点下去」的位置落在手指上,而不是图标左上角。
func _sync_os_cursor() -> void:
var tex := texture_for_state(_state)
if tex == null:
Input.set_custom_mouse_cursor(null, Input.CURSOR_ARROW)
return
var hotspot := Vector2(tex.get_width() * 0.5, tex.get_height() * 0.12)
Input.set_custom_mouse_cursor(tex, Input.CURSOR_ARROW, hotspot)