diff --git a/.workbuddy/memory/2026-10-09.md b/.workbuddy/memory/2026-10-09.md index 08a16f1..9bb158f 100644 --- a/.workbuddy/memory/2026-10-09.md +++ b/.workbuddy/memory/2026-10-09.md @@ -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,**未写 ``**(避免 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。 diff --git a/character/assets/grab.png b/character/assets/grab.png new file mode 100644 index 0000000..579069e Binary files /dev/null and b/character/assets/grab.png differ diff --git a/character/assets/grab.png.import b/character/assets/grab.png.import new file mode 100644 index 0000000..895fda7 --- /dev/null +++ b/character/assets/grab.png.import @@ -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 diff --git a/character/assets/grab_hover.png b/character/assets/grab_hover.png new file mode 100644 index 0000000..d3bc19d Binary files /dev/null and b/character/assets/grab_hover.png differ diff --git a/character/assets/grab_hover.png.import b/character/assets/grab_hover.png.import new file mode 100644 index 0000000..dd3fb6d --- /dev/null +++ b/character/assets/grab_hover.png.import @@ -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 diff --git a/character/player/player.gd b/character/player/player.gd index b8ea464..f9d3627 100644 --- a/character/player/player.gd +++ b/character/player/player.gd @@ -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 —— diff --git a/character/player/player_pickup.gd b/character/player/player_pickup.gd index d20ad90..8581054 100644 --- a/character/player/player_pickup.gd +++ b/character/player/player_pickup.gd @@ -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 diff --git a/ui/crosshair.gd b/ui/crosshair.gd index 096c234..7bbd4aa 100644 --- a/ui/crosshair.gd +++ b/ui/crosshair.gd @@ -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)