Compare commits
4
Commits
414fcafe1d
...
0484839186
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0484839186 | ||
|
|
597b5a69c5 | ||
|
|
40691e81b6 | ||
|
|
6355ac41a5 |
@@ -0,0 +1,244 @@
|
||||
# 2026-09-23
|
||||
|
||||
## 玩家动画(KayKit character-male-e 动作库)
|
||||
|
||||
用户把玩家模型换成 `character/player/assets/character-male-e.glb`,并在
|
||||
`Player/Body/character-male-e2/` 下加了 `AnimationPlayer`(动作库内嵌在 player.tscn 里,
|
||||
共 32 个片段,11 条 track,root_node = `..`)。
|
||||
|
||||
### 关键结构事实(别猜,实测)
|
||||
|
||||
- track 路径形如 `character-male-e/Skeleton3D:root`,比玩家本体多一层
|
||||
`character-male-e/` 前缀 —— 换模型/改层级会让整组 track 静默失效。
|
||||
- glb 里所有片段的 `loop_mode` 全是 `NONE`,`idle/walk/sprint` 必须在运行时补
|
||||
`Animation.LOOP_LINEAR`,否则播一遍就定在最后一帧。
|
||||
- 片段长度:idle 1.333s / walk 0.667s / sprint 0.5s / jump 0.5s / fall 0.333s。
|
||||
jump 时长恰好约等于 `jump_velocity=5 / gravity=9.8` 的上升时间。
|
||||
|
||||
### 实现(`character/player/player.gd`)
|
||||
|
||||
- `enum AnimState { IDLE, WALK, SPRINT, JUMP, FALL }` + `ANIM_CLIPS` / `ANIM_LOOPED` 常量。
|
||||
- `_prepare_animation()` 在 `_ready` 里、**本地/远端分支之前**调用(两端都要跑)。
|
||||
- `_update_animation()` 在 `_physics_process` 末尾调用,只读 `velocity` / `is_on_floor()`。
|
||||
- 两个被同步的新属性:
|
||||
- `anim_state: int` —— setter 里切片段(`play(name, anim_blend_time)`),
|
||||
状态不变时 setter 不触发,所以 `_prepare_animation` 里必须显式 `play("idle")`。
|
||||
- `anim_speed: float` —— 播放倍率 = `水平速度 / 该状态设计速度`(walk→walk_speed,
|
||||
sprint→sprint_speed),clamp 到 `[0.65, 1.6]`。远端只有 position 算不出速度,必须复制。
|
||||
- 远端玩家物理与输入都关着,动画完全靠复制过来的 `anim_state` / `anim_speed` 回放。
|
||||
|
||||
### 同步配置
|
||||
|
||||
`character/player/player_sync.tres` 从 3 项扩到 5 项,新增
|
||||
`.:anim_state` 与 `.:anim_speed`,`replication_mode` 全部保持 1 (ALWAYS)。
|
||||
|
||||
## 踩到的坑(可复用)
|
||||
|
||||
1. **编辑器进程里 `.tres` 有 ResourceCache。** 用 MCP `filesystem_manage(op="write_text")`
|
||||
改完 `player_sync.tres` 后,`filesystem_manage(op="scan")` 也刷新不掉缓存 ——
|
||||
`test_run` 里的 `load()` 仍读到旧的内存副本(只认 3 个字段)。
|
||||
→ 测试里改用 `ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE)`;
|
||||
它同时把正确的实例灌回缓存,之后再 `node_set_property` 赋值就拿到新实例了。
|
||||
2. **`Animation` 的 track 路径含 `:骨骼名`,不能直接 `get_node(track_path)`**
|
||||
(get_node 只认节点段)。要用
|
||||
`root.get_node(NodePath(track_path.get_concatenated_names()))`,
|
||||
骨骼名单独用 `get_concatenated_subnames()` + `Skeleton3D.find_bone()` 校验。
|
||||
3. **`game_eval` 的真实签名是 `{"op": "game_eval", "params": {"code": "..."}}`**,
|
||||
不是顶层 `code`。它支持多语句 + 内部 `await`,所以能在编辑器跑着的游戏里
|
||||
直接 `net.host_game()` → 等物理帧 → 驱动状态机 → 读回结果,是最强的端到端验证手段。
|
||||
4. 编辑器报告的 `play_state/is_playing=playing` 可能是陈旧的(`project_manage(op="stop")`
|
||||
回「was_running: false」即证)。
|
||||
|
||||
## 顺带修的东西
|
||||
|
||||
- `tests/test_net_replication.gd` 之前就坏了:路径还写着 `res://player/`(项目已迁到
|
||||
`res://character/player/`),且断言同步字段数为 2(实际 3)。已修正路径、改为断言
|
||||
5 个必需字段,并新增「片段存在」「5 个片段的每条 track 路径都能解析 + 骨骼名存在」两类测试。
|
||||
现在 5 个测试全过(143 条断言)。
|
||||
|
||||
## 未处理的既有问题
|
||||
|
||||
`res://addons/srcoder_thirdperson_controller/assets/mouse_lock.gd` 与 `res://mouse_lock.tscn`
|
||||
缺失,编辑器日志持续刷 `File not found`(约 60 条)。与本任务无关,未动。
|
||||
|
||||
## 模型朝向修正(按 W 却面朝相机)
|
||||
|
||||
**原因**:`player.gd` 的约定是「`body.rotation.y = 0` 即朝 -Z」(按 W 时
|
||||
`wish=(0,0,-1)` → `target_yaw = atan2(-wish.x,-wish.z) = 0`)。
|
||||
但 KayKit 的 `character-male-e.glb` 模型正面朝 **+Z**,
|
||||
场景里 `Body / character-male-e2 / character-male-e` 三个节点的 rotation 全是 0,
|
||||
没有做任何补偿 → 视觉上倒着走。
|
||||
|
||||
**修法**:给模型包装节点 `Body/character-male-e2` 加 `rotation.y = PI`(已 `scene_save`,
|
||||
磁盘上是 `transform = Transform3D(-2.5, 0, -2.19e-7, 0, 2.5, 0, 2.19e-7, 0, -2.5, 0, 0, 0)`)。
|
||||
|
||||
**为什么不是改代码**:
|
||||
- 往场景里给 `Body` 加 180° 没用 —— `body.rotation.y` 每帧被 `lerp_angle` 覆写。
|
||||
- 往 `target_yaw` 上加 PI 会污染 `Body` 的语义(`Body:rotation` 是要同步的「世界朝向」),
|
||||
而且静止时的初始朝向还得额外补一笔。
|
||||
- 加在模型根节点上:代码里的 -Z 约定保持干净,静止与移动两种状态同时正确。
|
||||
- 动画 track 是 `character-male-e/Skeleton3D:骨骼` 的相对路径,旋转包装节点不会破坏它们。
|
||||
|
||||
**验证**(`game_eval`,实测通过):`Input.action_press("move_forward")` 等 45 物理帧,
|
||||
用左右手臂骨算世界空间的 model forward(`Vector3.UP.cross(右轴)`),结果:
|
||||
|
||||
- `move_dir` = (0, -1)、`model_fwd` = (0.05, -1.0) → `dot = 0.999`
|
||||
- 与相机前向 `dot = 0.999` → 看到的是角色背面 ✅
|
||||
- `body_rot_deg = 0`、位移 3.51 m(0.75s × 5 m/s)✅
|
||||
|
||||
## 重构:动画状态机抽成 PlayerAnimator(与移动解耦)
|
||||
|
||||
用户先问「动画状态机和玩家代码耦合吗」。结论:是**结构性耦合**(5 个点),但只有**单向**
|
||||
—— `_update_animation()` 只读 `velocity`/`is_on_floor()`/`Input`,从不回写物理量。
|
||||
真实代价只有一条:这套状态机没法给 NPC 复用。用户选择抽成独立组件。
|
||||
|
||||
**新结构**(`character/player/player.tscn`):
|
||||
|
||||
Player (CharacterBody3D, player.gd) ← 输入 / 物理 / 相机 / 血条 / 联机分叉
|
||||
└─ Body ← 视觉层,换模型整棵替换
|
||||
├─ PlayerAnimator (Node, player_animator.gd) ← 动作状态机
|
||||
└─ character-male-e2 / character-male-e / Skeleton3D + AnimationPlayer
|
||||
|
||||
**接口只有一个**:`animator.set_motion(velocity, is_on_floor(), sprinting)`,在
|
||||
`move_and_slide()` 之后调用。控制器不认识任何片段名,动画器不认识输入。
|
||||
动画准备(补 loop、播 idle)搬到 `PlayerAnimator._ready()` —— 子节点先于父节点 ready,
|
||||
所以 player.gd 的 `_ready()` 里不再需要任何动画代码。
|
||||
|
||||
**关键设计决定:片段基准速度属于片段,不属于控制器。**
|
||||
`PlayerAnimator.walk_ref_speed = 5.0` / `sprint_ref_speed = 8.5` 语义是「这段动画在多少
|
||||
米/秒下按原速播」。控制器把 `walk_speed` 调到 6,动画自然播成 1.2 倍,两边不用同步改 ——
|
||||
这才是真正解掉了耦合,而不是把常量搬个家。
|
||||
|
||||
**同步路径变了**:`Body/PlayerAnimator:anim_state` 与 `Body/PlayerAnimator:anim_speed`
|
||||
(`Sync.root_path` 仍是 `..` 即 Player)。`anim_state` / `anim_speed` 现在是
|
||||
PlayerAnimator 的成员,player.gd 里已无这两个属性(测试里有一条专门守住这个边界)。
|
||||
|
||||
**`animation_player` 接线**:声明成 `@export var animation_player: AnimationPlayer`,
|
||||
场景里留空(原因见下),`_ready()` 用
|
||||
`get_parent().find_child("AnimationPlayer", true, false)` 自动找。实机确认能解析。
|
||||
|
||||
**player.gd 306 → 206 行**;测试 5 → 7 条 / 154 断言,全过。
|
||||
|
||||
**实机验证**(game_eval,跑着的游戏里):
|
||||
|
||||
- `animator_node = /root/Main/1/Body/PlayerAnimator`,`auto_find_resolved = true`
|
||||
- loop 模式 `[idle, walk, sprint, jump] = [LINEAR, LINEAR, LINEAR, NONE]`
|
||||
- idle / walk@2.5(0.65) / walk@5(1.0) / sprint@8.5(1.0) / jump / fall 全部正确
|
||||
- `controller_drives_animator = [WALK, "walk", 1.0]` —— 控制器→动画器链路真的在跑
|
||||
|
||||
## 本轮新增的 MCP 坑
|
||||
|
||||
1. **`node_set_property` 设不了 Node 类型的属性。** 传
|
||||
`/Player/Body/character-male-e2/AnimationPlayer` 会报
|
||||
`VALUE_OUT_OF_RANGE: value: Path must start with res://, uid://, or user://`
|
||||
—— 该工具只按「字符串=资源路径」加载。所以 `@export var x: AnimationPlayer`
|
||||
在场景里只能留空、靠代码兜底(或改用 `NodePath` 类型 export,那个能收字符串)。
|
||||
2. **`project_run` 会把编辑器当前场景切成主场景。** 跑完游戏再 `node_create` 会报
|
||||
`NODE_NOT_FOUND: Parent not found: /Player/Body. Scene root is "/Main"`。
|
||||
写场景节点前先 `scene_open("res://character/player/player.tscn")`。
|
||||
3. **删掉某个脚本里的 enum/常量后,所有引用处会立刻刷一屏 parse error。**
|
||||
分步 patch 时这是正常的中间态,把引用清完 diagnostics 自动归零 —— 别当成自己写错了。
|
||||
4. **中文字符串用 `\uXXXX` 转义极易打错字**(本轮把「抽搐」打成「抽搝」、「照抄」打成
|
||||
「照拄」,都要事后 grep 才发现)。直接传字面中文更稳。
|
||||
|
||||
|
||||
## 海洋效果场景(孤岛环海 · 纯视觉)
|
||||
|
||||
新增 `world/ocean/`:
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `water.gdshader` | 卡通海面:底色 / 菲涅尔 / 波光 / 泡沫 + 地平线雾霭 |
|
||||
| `island.gdshader` | 卡通小岛:径向剖面 + 噪声海岸线,按高度与坡度上色 |
|
||||
| `water_far_material.tres` | 远平面材质(同一个 shader,`swell_amount = 0`) |
|
||||
| `ocean_demo.gd` | 驱动脚本:推 world_time / 太阳方向 / 相机环绕 / 近平面跟随 |
|
||||
| `ocean_demo.tscn` | 效果场景,**F6 直接跑**(会把 Net 的联机菜单整层藏掉) |
|
||||
|
||||
## 关键设计(都是被逼出来的)
|
||||
|
||||
- **两层水面:近处细平面(520×520 / 208 细分 / 跟随相机)+ 远处大平面(6000×6000 / 12 细分 / 纯平)。**
|
||||
均匀网格做不了「近处要密」和「覆盖到地平线」这两件事。而天空在地平线以下是
|
||||
一片深色半球,**有限大的水面与真地平线之间必然露出深色带**(带宽 ≈ atan(相机高 / 平面半宽))。
|
||||
两层结构把带宽压到 0.25° 以内。近平面网格对齐到顶点间距(2.5m),否则会抖。
|
||||
- **波形用世界坐标锚定**(vertex 里先 `MODEL_MATRIX * VERTEX` 取世界 xz 再算高度)。
|
||||
这样近平面跟着相机平移时,海浪不会「跟着滑」。
|
||||
- **位移只用 3 组低频正弦(幅度 0.38m),细纹 + 噪点只动法线、不给位移。**
|
||||
所以顶点密度只影响大浪的圆滑度,细节全在法线里 —— 这是让粗网格也能看的关键。
|
||||
- `world_time` 由脚本推进,不用内置 `TIME`:联机两端启动时刻不同,同一时刻的浪不是同一片。
|
||||
脚本里用「绝对时钟差」而不是逐帧累加 delta(帧率不同会越走越偏)。
|
||||
- 岸线泡沫靠 `DEPTH_TEXTURE` 拿真实水深(水是半透明的,走透明队列、在不透明物之后绘制)。
|
||||
|
||||
## 踩的四个坑
|
||||
|
||||
1. **`TAU` / `PI` / `E` 是着色器语言的内置常量。** 自己 `const float TAU` 直接编译失败。
|
||||
更坑的是:**编译失败的外观是「没着色器的一团白」**,看起来像参数没调好,会往错的方向查。
|
||||
2. **只有正弦、没有噪声的法线 → 太阳波光变成一张规则网格。**
|
||||
高光落在「法线刚好平分视线与阳光」的位置,周期性法线场的高光自然是周期性的。
|
||||
3. **打散它的噪声必须用梯度噪声,不能用值噪声。** 值噪声当「高度」用看不出来,
|
||||
但拿来求导当法线就是一块块方格。(卡通天空挑 Perlin 也是这个理由,直接沿用。)
|
||||
4. **片元阶段的 `VERTEX` 是视图空间,不是世界空间。** 小岛 shader 一开始写
|
||||
`(MODEL_MATRIX * vec4(VERTEX, 1.0)).y` 当海拔用,算出来其实是相机相对高度 ——
|
||||
整座岛被染成一大块沙滩,而且「看上去好像也对」。改成 varying 从顶点传海拔。
|
||||
|
||||
另外:两组正弦方向**接近平行**时会相消,把整层退化成 1D 驻波(表现为规则条纹)。
|
||||
方向要显式排开、夹角 90° 以上,别拿「看起来不一样」的坐标去拼。
|
||||
|
||||
## 调参结论(实测)
|
||||
|
||||
- 浪尖泡沫的分母必须是**三组振幅之和**;拿 `wave_height` 当分母永远溢出到 1,泡沫铺满海面。
|
||||
- 岸线泡沫 `shore_amount` 别超过 ~0.7,否则是一圈白色描边(像贴纸不像浪)。
|
||||
- `band_count` = 3 是「有色块感」与「不变斑马线」的平衡点。
|
||||
- 细纹和噪点必须**按距离淡出**(`ripple_fade_distance`),不然远处满屏闪烁。
|
||||
|
||||
## MCP 相关
|
||||
|
||||
- 编辑器在 play 模式下**拒绝一切写操作**(`EDITOR_NOT_READY`)→ 改文件前先
|
||||
`project_manage(op="stop")`。
|
||||
- `.gdshader` 只能用 `filesystem_manage(op="write_text")` 写,**没有 patch 能力**,
|
||||
每次改都要重写整个文件。
|
||||
- `node_set_property` **设不了 `shader_parameter/*`**(报 `PROPERTY_NOT_ON_CLASS`,
|
||||
这些是虚拟属性)。要么改 shader 里的默认值,要么写一个 `.tres` 引用它。
|
||||
- 但 `node_set_property` **支持内嵌资源 + 嵌套资源路径**:
|
||||
`{"__class__": "Environment", "background_mode": 2, "sky": "res://..."}` 是可行的。
|
||||
- 最强的验证手段是 `editor_screenshot(source="game")` —— 把运行中的画面截回来直接看,
|
||||
比自己推断「应该没问题」可靠得多。三个真 bug 全是靠它发现的。
|
||||
|
||||
## 既有问题(未动)
|
||||
|
||||
`res://addons/ocean3d_lite/` 是坏的:`ocean_surface.gd` / `floating_body.gd` 都报
|
||||
`Identifier "Ocean" not declared`。看起来是以前试过的第三方海洋插件,与本次无关。
|
||||
|
||||
## 海洋 · 补充(回答「海是不是静态的」时顺手修的)
|
||||
|
||||
**海是动的**,只是编辑器预览里曾经完全冻结:
|
||||
|
||||
- 实测运行时 `world_time` 1.5 秒走 1.502(`time_scale = 1.0`),波形确实在推进。
|
||||
- 但 `_ready()` / `_process()` 在编辑器里不跑 → 没人推 `world_time` → **未运行时海是死帧**。
|
||||
这就是「看着像静态」的原因。
|
||||
- 修法:`world_time` 默认值改成 `-1`,加 `wave_time()`:`world_time >= 0 ? world_time : TIME`。
|
||||
编辑器用 TIME(自动动),运行时脚本一接管就换成同步时钟。
|
||||
**比加 `@tool` 安全** —— `@tool` 会让 `_process` 在编辑器里跑,脚本一写相机 transform
|
||||
就不断标脏场景、还会挪动编辑器里的摆位。
|
||||
- 同理把静态相机取景(`position` + `rotation = (-0.235, 0.96, 0)`)烘进场景,
|
||||
否则编辑器预览里相机朝向是 identity,看着是一片空海。
|
||||
|
||||
**新发现的 Godot 行为:`ShaderMaterial.get_shader_parameter()` 对没显式改过的参数返回 `null`。**
|
||||
Godot 不会把 shader 的 uniform 默认值灌进材质实例 —— 所以别用它读默认值。
|
||||
反过来也证明「只改 shader 默认值就能生效」。
|
||||
|
||||
**编辑器视口会持续重绘**(连拍两张 `editor_screenshot(source="cinematic")`,波形和云都位移了),
|
||||
所以能用截两张对比的办法验证「编辑器里动没动」。
|
||||
|
||||
海水运动速度(按默认值算):大浪相位速度 ≈ 6.7 m/s(波长 42 m),
|
||||
细纹漂移 ≈ 0.5 m/s,噪点漂移 ≈ 0.49 m/s。大浪看得清,细碎波光是缓慢流动。
|
||||
|
||||
|
||||
## 海洋应用到主场景(world/main.tscn)
|
||||
|
||||
- `world/ocean/sea.tscn` + `sea.gd`:可复用海面(Water 近平面 300/120 格 + WaterFar 6000/12 格 + water_far_material.tres),实例化即用、零接线。sea.gd 自动收集带 `world_time` uniform 的材质、自动找 `Sun` 节点、近平面按 2.5 m 步长跟随 viewport 相机。
|
||||
- `world/ocean/island_mesa.tres`:台地岛材质(island.gdshader 新增 `plateau` uniform,平顶半径占比;默认 0 = 原锥形岛,向后兼容)。
|
||||
- `island.gdshader`:新增 plateau;主场景取值 radius 40 / plateau 0.72 / height 1.5 / dip 14 / detail 0.25。
|
||||
- `main.tscn` 重写:Ground 碰撞盒 40→**58**(与平顶 r≈28.8 对齐),**删除 GroundMesh**(灰色平板,会和岛平顶 z-fight),新增 `Island`(y=-1.5)与 `Sea` 实例(y=-1.5)。
|
||||
- **关键坐标系**:海平面节点摆在 y=-1.5,岛 `island_height=1.5` → 平顶世界 y=0 = 原地面高度。玩家/碰撞/联机逻辑零改动,实测 player_y=0、on_floor=true。
|
||||
- **修了一个真 bug(两个场景都有)**:「指向太阳」是平行光节点的 **+Z** 不是 -Z。之前 sea/ocean_demo 传的是 -basis.z(朝下),波光整片消失且不报错。实测 main 的 Sun basis.z y=+0.766 才是对的。
|
||||
- 验证:实机 host_game,world_time 1.2 s 推进 1.201,近平面跟随相机 (0,3.86)→snap(0,5),玩家传送 (27.5,1,0) 仍站在平顶 y≈0,岸线沙带/泡沫/海天交界截图正常,日志无错误。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cq3awe0wjqsih"
|
||||
path.s3tc="res://.godot/imported/colormap.png-4bd9a802f0fbdd1bc388c729ce936ac0.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://character/player/assets/Textures/colormap.png"
|
||||
dest_files=["res://.godot/imported/colormap.png-4bd9a802f0fbdd1bc388c729ce936ac0.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
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=true
|
||||
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=0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://chawqtd3fcydp"
|
||||
path.s3tc="res://.godot/imported/texture_01.png-e765476e37c3bd9342318549a3523e3a.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://character/player/assets/Textures/texture_01.png"
|
||||
dest_files=["res://.godot/imported/texture_01.png-e765476e37c3bd9342318549a3523e3a.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
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=true
|
||||
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=0
|
||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://ccf670c6bkob8"
|
||||
path="res://.godot/imported/character-male-e.glb-9d9c3cbc43d44547b0be72ff662d6ebd.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://character/player/assets/character-male-e.glb"
|
||||
dest_files=["res://.godot/imported/character-male-e.glb-9d9c3cbc43d44547b0be72ff662d6ebd.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=1
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=0
|
||||
@@ -1,15 +1,19 @@
|
||||
extends CharacterBody3D
|
||||
## 第三人称控制器(胶囊体占位)
|
||||
## 第三人称控制器:只管「怎么动」—— 输入、移动物理、相机、血条、联机分叉。
|
||||
## 表现层(动画)在 Body/PlayerAnimator 里,本文件不认识任何片段名。
|
||||
##
|
||||
## 场景结构:
|
||||
## Player (CharacterBody3D) ← 只负责移动与碰撞,自身不旋转
|
||||
## ├─ Collision (CollisionShape3D, CapsuleShape3D)
|
||||
## ├─ Body (Node3D) ← 视觉层,朝移动方向转身;换角色模型时挂在它下面
|
||||
## │ └─ Mesh (MeshInstance3D)
|
||||
## │ ├─ PlayerAnimator (PlayerAnimator.gd) ← 动作状态机:只读运动、只写动画
|
||||
## │ └─ character-male-e2 (Node3D)
|
||||
## │ ├─ character-male-e/Skeleton3D ← 骨骼 + 蒙皮网格(character-male-e.glb)
|
||||
## │ └─ AnimationPlayer ← 动作库,由 PlayerAnimator 驱动
|
||||
## ├─ CameraRig (Node3D) ← 承担偏航 yaw,独立于本体旋转
|
||||
## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障
|
||||
## │ └─ Camera (Camera3D)
|
||||
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position 与 Body.rotation
|
||||
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position、Body.rotation、health、动画状态
|
||||
## └─ HealthLabel (Label3D) ← 头顶血条(节点配置,不是代码建的)
|
||||
##
|
||||
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,Esc 释放/锁定鼠标
|
||||
@@ -52,6 +56,9 @@ const JUMP_BUFFER_FRAMES: int = 6
|
||||
## 头顶血条是 player.tscn 里的 Label3D 节点(3D 世界里的文字,会跟着角色走)
|
||||
@onready var _health_label: Label3D = $HealthLabel
|
||||
|
||||
## 动画状态机(挂在 Body 下)。只把「运动结果」交给它,也只知道它会自己播动画。
|
||||
@onready var animator: PlayerAnimator = $Body/PlayerAnimator
|
||||
|
||||
var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8))
|
||||
var _yaw: float = 0.0
|
||||
var _pitch: float = 0.0
|
||||
@@ -62,6 +69,7 @@ var _is_local: bool = true
|
||||
|
||||
func _ready() -> void:
|
||||
_report_identity()
|
||||
# 动画准备不在这里 —— PlayerAnimator 有自己的 _ready(),而子节点先于本节点 ready
|
||||
_is_local = is_multiplayer_authority()
|
||||
if not _is_local:
|
||||
# 远端玩家只是一具「回放身体」:
|
||||
@@ -149,6 +157,9 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
move_and_slide()
|
||||
|
||||
# 动画只读运动结果。控制器不认识任何片段名,动画器也不认识输入。
|
||||
animator.set_motion(velocity, is_on_floor(), Input.is_action_pressed("sprint"))
|
||||
|
||||
|
||||
func _refresh_health_label() -> void:
|
||||
# 血条节点在 player.tscn 里(Hud/HealthLabel)。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,154 @@
|
||||
extends Node
|
||||
class_name PlayerAnimator
|
||||
## 角色动作状态机 —— 只负责「把运动状态翻译成播放哪段动画」,完全不碰移动。
|
||||
##
|
||||
## 挂在 Player/Body 下,与具体模型解耦:换角色模型只要在检视面板重新指定
|
||||
## animation_player(或让它自动找),代码里没有任何硬编码的节点路径。
|
||||
##
|
||||
## 对外接口只有一个:set_motion()。方向、加速度、重力、输入全归控制器管;
|
||||
## 本组件只读入一个「运动快照」,输出到 AnimationPlayer。反向不回写任何东西。
|
||||
##
|
||||
## 为什么状态与倍率必须是可复制属性:
|
||||
## 远端玩家物理与输入都关着,手里只有 position 和 Body.rotation,
|
||||
## 算不出水平速度,也分不清是上升还是下落。所以权威端把结论复制过去,
|
||||
## 远端只在 setter 里回放。
|
||||
## 对应的同步条目(Sync.root_path = "..",即 Player 节点):
|
||||
## Body/PlayerAnimator:anim_state
|
||||
## Body/PlayerAnimator:anim_speed
|
||||
|
||||
## 角色动作状态。权威端每物理帧算一次,再靠 Sync 复制给远端回放。
|
||||
enum AnimState { IDLE, WALK, SPRINT, JUMP, FALL }
|
||||
|
||||
## 状态 → 片段名。取自 character-male-e.glb 的动作库,换动作库改这里。
|
||||
const ANIM_CLIPS: Dictionary = {
|
||||
AnimState.IDLE: "idle",
|
||||
AnimState.WALK: "walk",
|
||||
AnimState.SPRINT: "sprint",
|
||||
AnimState.JUMP: "jump",
|
||||
AnimState.FALL: "fall",
|
||||
}
|
||||
|
||||
## 需要循环的状态。glb 里每个片段的 loop_mode 都是 NONE,
|
||||
## 不补循环的话 idle/walk/sprint 播一遍就定在最后一帧。
|
||||
const ANIM_LOOPED: Array[int] = [AnimState.IDLE, AnimState.WALK, AnimState.SPRINT]
|
||||
|
||||
@export_group("接线")
|
||||
## 要驱动的 AnimationPlayer。留空则在本节点的兄弟子树里自动找一个。
|
||||
## 换模型后这里重新指一下即可 —— 这也是它能脱离具体模型的原因。
|
||||
@export var animation_player: AnimationPlayer
|
||||
|
||||
@export_group("播放")
|
||||
## 动作切换的交叉淡入时间(秒)。0 为硬切
|
||||
@export var blend_time: float = 0.15
|
||||
## 低于此水平速度就视为站立(米/秒)
|
||||
@export var idle_threshold: float = 0.2
|
||||
## 播放倍率下限:被墙抵住、起步那一瞬,脚步不该慢到像在滑冰
|
||||
@export var speed_scale_min: float = 0.65
|
||||
## 播放倍率上限:防止速度被外力拉高时脚步快到抽搐
|
||||
@export var speed_scale_max: float = 1.6
|
||||
|
||||
@export_group("片段基准速度")
|
||||
## walk 片段在多少米/秒下按 1.0 倍播放。这是片段自身的属性,不是控制器的手感参数:
|
||||
## 控制器把跑速调到 6 m/s 时,这段动画自然播成 1.2 倍,脚步自动跟上,无需同步改两边。
|
||||
@export var walk_ref_speed: float = 5.0
|
||||
## sprint 片段的基准速度(米/秒)
|
||||
@export var sprint_ref_speed: float = 8.5
|
||||
|
||||
## 当前动作状态(AnimState)。权威端每物理帧写一次,Sync 复制给远端。
|
||||
var anim_state: int = AnimState.IDLE:
|
||||
set(value):
|
||||
if anim_state == value:
|
||||
return
|
||||
anim_state = value
|
||||
_play(ANIM_CLIPS.get(anim_state, ""), blend_time)
|
||||
|
||||
## 当前播放倍率(1.0 = 片段原速)。
|
||||
## 同样要复制:远端只有 position,算不出真实速度,只能照抄权威端的倍率。
|
||||
var anim_speed: float = 1.0:
|
||||
set(value):
|
||||
value = clampf(value, speed_scale_min, speed_scale_max)
|
||||
anim_speed = value
|
||||
if animation_player != null:
|
||||
animation_player.speed_scale = value
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if animation_player == null:
|
||||
animation_player = _auto_find_animation_player()
|
||||
if animation_player == null:
|
||||
push_warning("%s 没绑定 AnimationPlayer,角色不会有动画" % get_path())
|
||||
return
|
||||
_patch_loop_modes()
|
||||
# 同步可能在 _ready() 之前就把 anim_state 写进来了 —— 那时 animation_player 还是 null,
|
||||
# setter 空跑了一趟。所以这里按「当前状态」补播一遍,不能写死 idle。
|
||||
_play(ANIM_CLIPS.get(anim_state, ""), 0.0)
|
||||
|
||||
|
||||
# ---------------- 对外接口 ----------------
|
||||
|
||||
## 控制器每物理帧调用一次(放在 move_and_slide() 之后)。
|
||||
## 只读入运动快照,不回写任何物理量 —— 这就是「单向耦合」的边界。
|
||||
func set_motion(velocity: Vector3, on_floor: bool, sprinting: bool = false) -> void:
|
||||
var state := _resolve_state(velocity, on_floor, sprinting)
|
||||
# 先写倍率再切片段 —— 顺序反了会让新片段用一帧旧倍率。
|
||||
anim_speed = _speed_scale_for(state, velocity)
|
||||
anim_state = state # setter 负责切片段
|
||||
|
||||
|
||||
## 当前正在播的片段名。调试与测试用。
|
||||
func current_clip() -> String:
|
||||
if animation_player == null:
|
||||
return ""
|
||||
return str(animation_player.current_animation)
|
||||
|
||||
|
||||
# ---------------- 内部 ----------------
|
||||
|
||||
func _resolve_state(velocity: Vector3, on_floor: bool, sprinting: bool) -> int:
|
||||
if not on_floor:
|
||||
# 上升用 jump、越过顶点用 fall。jump 片段长 0.5s,
|
||||
# 恰好约等于 jump_velocity 5 / 重力 9.8 的上升时间。
|
||||
return AnimState.JUMP if velocity.y > 0.0 else AnimState.FALL
|
||||
if Vector2(velocity.x, velocity.z).length() < idle_threshold:
|
||||
return AnimState.IDLE
|
||||
return AnimState.SPRINT if sprinting else AnimState.WALK
|
||||
|
||||
|
||||
## 播放倍率 = 实际水平速度 / 该片段的基准速度。
|
||||
## 只有循环的移动动作参与缩放,跳跃/下落/站立一律原速。
|
||||
func _speed_scale_for(state: int, velocity: Vector3) -> float:
|
||||
var ref := 0.0
|
||||
match state:
|
||||
AnimState.WALK:
|
||||
ref = walk_ref_speed
|
||||
AnimState.SPRINT:
|
||||
ref = sprint_ref_speed
|
||||
_:
|
||||
return 1.0
|
||||
if ref <= 0.0:
|
||||
return 1.0
|
||||
return Vector2(velocity.x, velocity.z).length() / ref
|
||||
|
||||
|
||||
func _play(clip: String, blend: float) -> void:
|
||||
if animation_player == null or clip.is_empty():
|
||||
return
|
||||
if not animation_player.has_animation(clip):
|
||||
return
|
||||
animation_player.play(clip, blend)
|
||||
|
||||
|
||||
## 兜底:在同一层视觉节点里找一个 AnimationPlayer。只在没接线时生效。
|
||||
func _auto_find_animation_player() -> AnimationPlayer:
|
||||
var parent := get_parent()
|
||||
if parent == null:
|
||||
return null
|
||||
return parent.find_child("AnimationPlayer", true, false) as AnimationPlayer
|
||||
|
||||
|
||||
func _patch_loop_modes() -> void:
|
||||
for state in ANIM_LOOPED:
|
||||
var clip: String = ANIM_CLIPS[state]
|
||||
if not animation_player.has_animation(clip):
|
||||
continue
|
||||
animation_player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
|
||||
@@ -0,0 +1 @@
|
||||
uid://roaqmma0vjon
|
||||
@@ -10,3 +10,9 @@ properties/1/replication_mode = 1
|
||||
properties/2/path = NodePath(".:health")
|
||||
properties/2/spawn = true
|
||||
properties/2/replication_mode = 1
|
||||
properties/3/path = NodePath("Body/PlayerAnimator:anim_state")
|
||||
properties/3/spawn = true
|
||||
properties/3/replication_mode = 1
|
||||
properties/4/path = NodePath("Body/PlayerAnimator:anim_speed")
|
||||
properties/4/spawn = true
|
||||
properties/4/replication_mode = 1
|
||||
@@ -1,53 +0,0 @@
|
||||
[gd_scene format=3 uid="uid://bekuuw153mv03"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://f5ptp3is4agf" path="res://player/player.gd" id="1_oul6g"]
|
||||
[ext_resource type="SceneReplicationConfig" uid="uid://c4ubbxjfrrs47" path="res://player/player_sync.tres" id="2_rkbax"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"]
|
||||
radius = 0.4
|
||||
height = 1.8
|
||||
|
||||
[sub_resource type="CapsuleMesh" id="CapsuleMesh_g1dw6"]
|
||||
radius = 0.4
|
||||
height = 1.8
|
||||
radial_segments = 24
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_yw30f"]
|
||||
albedo_color = Color(0.23137255, 0.50980395, 0.9647059, 1)
|
||||
roughness = 0.6
|
||||
|
||||
[node name="Player" type="CharacterBody3D" unique_id=869687860]
|
||||
script = ExtResource("1_oul6g")
|
||||
|
||||
[node name="Collision" type="CollisionShape3D" parent="." unique_id=215132582]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
|
||||
shape = SubResource("CapsuleShape3D_rkbax")
|
||||
|
||||
[node name="CameraRig" type="Node3D" parent="." unique_id=1774966304]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.45, 0)
|
||||
|
||||
[node name="SpringArm" type="SpringArm3D" parent="CameraRig" unique_id=917938711]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.9659257, 0.25881964, 0, -0.25881964, 0.9659257, 0, 0, 0)
|
||||
spring_length = 4.0
|
||||
|
||||
[node name="Camera" type="Camera3D" parent="CameraRig/SpringArm" unique_id=52294557]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 4)
|
||||
fov = 82.2
|
||||
|
||||
[node name="Body" type="Node3D" parent="." unique_id=394283421]
|
||||
|
||||
[node name="Mesh" type="MeshInstance3D" parent="Body" unique_id=1230094944]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
|
||||
mesh = SubResource("CapsuleMesh_g1dw6")
|
||||
surface_material_override/0 = SubResource("StandardMaterial3D_yw30f")
|
||||
|
||||
[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210]
|
||||
replication_config = ExtResource("2_rkbax")
|
||||
|
||||
[node name="HealthLabel" type="Label3D" parent="." unique_id=300336561]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.05, 0)
|
||||
billboard = 1
|
||||
no_depth_test = true
|
||||
text = "HP 100 / 100"
|
||||
font_size = 64
|
||||
outline_size = 8
|
||||
+1
-1
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="agame"
|
||||
run/main_scene="res://world/main.tscn"
|
||||
run/main_scene="uid://bp8l1uwgshc86"
|
||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
|
||||
+159
-19
@@ -1,32 +1,55 @@
|
||||
@tool
|
||||
extends McpTestSuite
|
||||
## 回归测试:Player 的同步配置必须真的被解析成条目。
|
||||
## 回归测试:Player 的同步配置、动作库,以及「移动 / 表现」的组件边界。
|
||||
##
|
||||
## 这是一处典型的「静默失效」:如果 .tres 里的键名写错,或 replication_mode
|
||||
## 落到 NEVER,远端玩家会僵在原地不动,而且不打任何错误。用测试钉住它。
|
||||
## 三处典型的「静默失效」——都表现为「人站着不动」且不打任何错误:
|
||||
## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER → 远端收不到字段;
|
||||
## 2. track 路径带一层模型目录名前缀,换模型/改层级后整组失效;
|
||||
## 3. glb 片段的 loop_mode 默认是 NONE,idle/walk/sprint 播一遍就定在最后一帧。
|
||||
## (loop 由 PlayerAnimator._ready() 在运行时补,编辑器测试覆盖不到,靠实机验证。)
|
||||
|
||||
const CONFIG_PATH := "res://player/player_sync.tres"
|
||||
const PLAYER_SCENE_PATH := "res://player/player.tscn"
|
||||
const CONFIG_PATH := "res://character/player/player_sync.tres"
|
||||
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
|
||||
|
||||
## 从 player.gd 抽出来的动画状态机。控制器只通过它间接驱动动画。
|
||||
const ANIMATOR_PATH := "Body/PlayerAnimator"
|
||||
const ANIMATOR_SCRIPT := "res://character/player/player_animator.gd"
|
||||
## 真正干活的动画器。PlayerAnimator 没接线时按这个名字在自己兄弟子树里找它。
|
||||
const ANIM_PLAYER_PATH := "Body/character-male-e2/AnimationPlayer"
|
||||
|
||||
## player_animator.gd 的 ANIM_CLIPS 写死了这几个名字,少一个对应状态就会静默不动。
|
||||
const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "sprint", "jump", "fall"]
|
||||
|
||||
## 必须被复制到远端的字段。远端物理与输入都关着,全靠这几个值回放。
|
||||
const REQUIRED_SYNCED: Array[String] = [
|
||||
".:position",
|
||||
"Body:rotation",
|
||||
".:health",
|
||||
"Body/PlayerAnimator:anim_state",
|
||||
"Body/PlayerAnimator:anim_speed",
|
||||
]
|
||||
|
||||
|
||||
func suite_name() -> String:
|
||||
return "net"
|
||||
|
||||
|
||||
func test_config_has_expected_paths() -> void:
|
||||
var config := load(CONFIG_PATH) as SceneReplicationConfig
|
||||
assert_true(config != null, "player_sync.tres 未能加载为 SceneReplicationConfig")
|
||||
func test_config_has_required_synced_fields() -> void:
|
||||
var config := _load_config()
|
||||
assert_true(config != null, "%s 未能加载为 SceneReplicationConfig" % CONFIG_PATH)
|
||||
if config == null:
|
||||
return
|
||||
var paths := config.get_properties()
|
||||
assert_eq(paths.size(), 2, "同步字段数量不对:%s" % [paths])
|
||||
assert_true(config.has_property(^".:position"), "缺少 .:position")
|
||||
assert_true(config.has_property(^"Body:rotation"), "缺少 Body:rotation")
|
||||
for required in REQUIRED_SYNCED:
|
||||
assert_true(
|
||||
config.has_property(NodePath(required)),
|
||||
"缺少同步字段 %s,实际为 %s" % [required, paths]
|
||||
)
|
||||
|
||||
|
||||
func test_config_replication_mode_is_always() -> void:
|
||||
var config := load(CONFIG_PATH) as SceneReplicationConfig
|
||||
assert_true(config != null, "player_sync.tres 未能加载")
|
||||
var config := _load_config()
|
||||
assert_true(config != null, "%s 未能加载" % CONFIG_PATH)
|
||||
if config == null:
|
||||
return
|
||||
var paths := config.get_properties()
|
||||
@@ -35,20 +58,137 @@ func test_config_replication_mode_is_always() -> void:
|
||||
assert_eq(
|
||||
config.property_get_replication_mode(path),
|
||||
SceneReplicationConfig.REPLICATION_MODE_ALWAYS,
|
||||
"%s 的 replication_mode 不是 ALWAYS" % path
|
||||
"%s 的 replication_mode 不是 ALWAYS(改成 NEVER 会静默断掉同步)" % path
|
||||
)
|
||||
|
||||
|
||||
func test_player_scene_wires_synchronizer() -> void:
|
||||
var packed := load(PLAYER_SCENE_PATH) as PackedScene
|
||||
assert_true(packed != null, "player.tscn 未能加载")
|
||||
if packed == null:
|
||||
var player := _instantiate_player()
|
||||
if player == null:
|
||||
return
|
||||
var player := packed.instantiate()
|
||||
track(player)
|
||||
var sync := player.get_node_or_null("Sync") as MultiplayerSynchronizer
|
||||
assert_true(sync != null, "Player 下缺少 Sync 节点")
|
||||
if sync == null:
|
||||
return
|
||||
assert_true(sync.replication_config != null, "Sync 没有绑定 SceneReplicationConfig")
|
||||
assert_eq(sync.root_path, ^"..", "Sync.root_path 应为 .. (相对 Player)")
|
||||
if sync.replication_config != null:
|
||||
assert_eq(
|
||||
sync.replication_config.resource_path,
|
||||
CONFIG_PATH,
|
||||
"Sync 绑定的是别的同步配置:%s" % sync.replication_config.resource_path
|
||||
)
|
||||
|
||||
|
||||
## 组件边界:动画状态机必须是挂在 Body 下的独立节点,而不是塞回 player.gd。
|
||||
## 有人把 anim_state / anim_speed 挪回控制器时,这条会失败。
|
||||
func test_player_scene_has_animator_component() -> void:
|
||||
var player := _instantiate_player()
|
||||
if player == null:
|
||||
return
|
||||
var animator := player.get_node_or_null(ANIMATOR_PATH)
|
||||
assert_true(animator != null, "Player 下缺少 %s(动画状态机应独立于 player.gd)" % ANIMATOR_PATH)
|
||||
var player_script: Script = player.get_script()
|
||||
assert_true(player_script != null, "Player 没有挂脚本")
|
||||
if player_script != null:
|
||||
var member_names: Array[String] = []
|
||||
for member in player_script.get_script_property_list():
|
||||
member_names.append(str(member.get("name")))
|
||||
assert_false(member_names.has("anim_state"), "anim_state 应该留在 PlayerAnimator 上,不该回到 player.gd")
|
||||
assert_false(member_names.has("anim_speed"), "anim_speed 应该留在 PlayerAnimator 上,不该回到 player.gd")
|
||||
if animator == null:
|
||||
return
|
||||
var script: Script = animator.get_script()
|
||||
assert_true(script != null, "%s 没有挂脚本" % ANIMATOR_PATH)
|
||||
if script != null:
|
||||
assert_eq(script.resource_path, ANIMATOR_SCRIPT, "%s 挂的不是动画状态机脚本" % ANIMATOR_PATH)
|
||||
|
||||
|
||||
## PlayerAnimator 没接线时按名字在自己兄弟子树里找 AnimationPlayer。
|
||||
## 编辑器里跑不了它的 _ready()(非 @tool 脚本),所以这里退一步、用同样的规则找一遍:
|
||||
## 改名或挪位置会让这条失败。
|
||||
func test_animator_auto_find_target_exists() -> void:
|
||||
var player := _instantiate_player()
|
||||
if player == null:
|
||||
return
|
||||
var body := player.get_node_or_null("Body")
|
||||
assert_true(body != null, "Player 下缺少 Body")
|
||||
if body == null:
|
||||
return
|
||||
var found := body.find_child("AnimationPlayer", true, false) as AnimationPlayer
|
||||
assert_true(found != null, "Body 子树里找不到名为 AnimationPlayer 的节点,PlayerAnimator 的自动接线会失败")
|
||||
assert_true(
|
||||
player.get_node_or_null(ANIM_PLAYER_PATH) != null,
|
||||
"动画器不在预期路径 %s" % ANIM_PLAYER_PATH
|
||||
)
|
||||
|
||||
|
||||
func test_required_animation_clips_exist() -> void:
|
||||
var anim_player := _find_animation_player()
|
||||
if anim_player == null:
|
||||
return
|
||||
for clip in REQUIRED_CLIPS:
|
||||
assert_true(
|
||||
anim_player.has_animation(clip),
|
||||
"动作库里缺少片段 %s(PlayerAnimator 的 ANIM_CLIPS 会指向不存在的片段)" % clip
|
||||
)
|
||||
|
||||
|
||||
## 片段是从 glb 里拷出来的,track 路径带层级前缀。换模型/改层级后整组 track 会一起失效,
|
||||
## 而 AnimationPlayer 不报任何错 —— 现象是「人站着不动」。用测试钉住路径真的能解析。
|
||||
func test_animation_track_paths_resolve() -> void:
|
||||
var anim_player := _find_animation_player()
|
||||
if anim_player == null:
|
||||
return
|
||||
var root := anim_player.get_node_or_null(anim_player.root_node)
|
||||
assert_true(root != null, "AnimationPlayer.root_node 解析不到:%s" % anim_player.root_node)
|
||||
if root == null:
|
||||
return
|
||||
|
||||
for clip in REQUIRED_CLIPS:
|
||||
var anim := anim_player.get_animation(clip)
|
||||
if anim == null:
|
||||
continue
|
||||
assert_gt(anim.get_track_count(), 0, "%s 片段没有任何 track" % clip)
|
||||
for i in anim.get_track_count():
|
||||
var track_path := anim.track_get_path(i)
|
||||
# 不能直接 get_node(track_path):路径含 ":骨骼名" 属性部分,get_node 只认节点段
|
||||
var owner_node := root.get_node_or_null(NodePath(track_path.get_concatenated_names()))
|
||||
assert_true(
|
||||
owner_node != null,
|
||||
"%s 的 track %s 在 %s 下解析不到(动画会静默无效)" % [clip, track_path, anim_player.root_node]
|
||||
)
|
||||
if owner_node == null:
|
||||
continue
|
||||
# 属性段是骨骼名,模型换皮时要确保骨骼名没变
|
||||
var bone := str(track_path.get_concatenated_subnames())
|
||||
if owner_node is Skeleton3D and not bone.is_empty():
|
||||
assert_true(
|
||||
(owner_node as Skeleton3D).find_bone(bone) != -1,
|
||||
"%s 引用了骨骼 %s,但骨架里没有这根骨头(模型换过了?)" % [clip, bone]
|
||||
)
|
||||
|
||||
|
||||
func _find_animation_player() -> AnimationPlayer:
|
||||
var player := _instantiate_player()
|
||||
if player == null:
|
||||
return null
|
||||
var anim_player := player.get_node_or_null(ANIM_PLAYER_PATH) as AnimationPlayer
|
||||
assert_true(anim_player != null, "找不到 %s" % ANIM_PLAYER_PATH)
|
||||
return anim_player
|
||||
|
||||
|
||||
## 直接用 load() 拿到的是编辑器进程里的内存副本 —— 外部刚改过磁盘时它可能还是旧的,
|
||||
## 测试会因此假绿或假红。这里强制按磁盘重读,断言的是文件本身。
|
||||
func _load_config() -> SceneReplicationConfig:
|
||||
return ResourceLoader.load(CONFIG_PATH, "", ResourceLoader.CACHE_MODE_REPLACE) as SceneReplicationConfig
|
||||
|
||||
|
||||
func _instantiate_player() -> Node:
|
||||
var packed := load(PLAYER_SCENE_PATH) as PackedScene
|
||||
assert_true(packed != null, "%s 未能加载" % PLAYER_SCENE_PATH)
|
||||
if packed == null:
|
||||
return null
|
||||
var player := packed.instantiate()
|
||||
track(player)
|
||||
return player
|
||||
|
||||
+3
-2
@@ -3,8 +3,9 @@ extends Node3D
|
||||
##
|
||||
## 只做一件事:把玩家场景注册给 MultiplayerSpawner,并把 spawner 托管给 Net 单例。
|
||||
## 玩家的生成/回收时机完全由 Net 决定,本文件不主动调用 spawn。
|
||||
@export
|
||||
var player_scene: PackedScene
|
||||
|
||||
const PLAYER_SCENE: PackedScene = preload("res://player/player.tscn")
|
||||
const PLAYER_GROUP: String = "net_player"
|
||||
## 用节点路径而不是全局标识符访问单例:
|
||||
## autoload 的全局名只在引擎启动时注册,这样写不依赖注册时机。
|
||||
@@ -26,7 +27,7 @@ func _ready() -> void:
|
||||
## 这样「谁是本地玩家」在各端结论一致,玩家脚本里的 is_multiplayer_authority() 才可靠。
|
||||
## recursive=true 很关键 —— 节点 authority 不自动继承,Sync 子节点必须一起设。
|
||||
func _create_player(peer_id: int) -> Node:
|
||||
var player := PLAYER_SCENE.instantiate()
|
||||
var player := player_scene.instantiate()
|
||||
player.name = str(peer_id)
|
||||
player.add_to_group(PLAYER_GROUP)
|
||||
player.set_multiplayer_authority(peer_id, true)
|
||||
|
||||
+22
-18
@@ -1,19 +1,18 @@
|
||||
[gd_scene format=3 uid="uid://bp8l1uwgshc86"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://11rbhffe5dhv" path="res://world/main.gd" id="1_hyim0"]
|
||||
[ext_resource type="PackedScene" uid="uid://bekuuw153mv03" path="res://character/player/player.tscn" id="2_4f7fj"]
|
||||
[ext_resource type="Sky" uid="uid://bi632suyifms5" path="res://world/sky/cartoon_sky.tres" id="2_cartoon"]
|
||||
[ext_resource type="PackedScene" path="res://world/ocean/sea.tscn" id="3_sea"]
|
||||
[ext_resource type="Material" path="res://world/ocean/island_mesa.tres" id="4_island"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_hyim0"]
|
||||
size = Vector3(40, 0.5, 40)
|
||||
size = Vector3(58, 0.5, 58)
|
||||
|
||||
[sub_resource type="PlaneMesh" id="PlaneMesh_4f7fj"]
|
||||
size = Vector2(40, 40)
|
||||
subdivide_width = 8
|
||||
subdivide_depth = 8
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_spvjk"]
|
||||
albedo_color = Color(0.5529412, 0.5803922, 0.61960787, 1)
|
||||
roughness = 0.9
|
||||
[sub_resource type="PlaneMesh" id="IslandMesh"]
|
||||
size = Vector2(120, 120)
|
||||
subdivide_width = 160
|
||||
subdivide_depth = 160
|
||||
|
||||
[sub_resource type="Environment" id="Environment_gu74f"]
|
||||
background_mode = 2
|
||||
@@ -21,28 +20,33 @@ sky = ExtResource("2_cartoon")
|
||||
ambient_light_source = 3
|
||||
reflected_light_source = 2
|
||||
|
||||
[node name="Main" type="Node3D" unique_id=58155864]
|
||||
[node name="Main" type="Node3D" unique_id=631359418]
|
||||
script = ExtResource("1_hyim0")
|
||||
player_scene = ExtResource("2_4f7fj")
|
||||
|
||||
[node name="Ground" type="StaticBody3D" parent="." unique_id=305091665]
|
||||
[node name="Ground" type="StaticBody3D" parent="." unique_id=2052319900]
|
||||
|
||||
[node name="GroundShape" type="CollisionShape3D" parent="Ground" unique_id=8315700]
|
||||
[node name="GroundShape" type="CollisionShape3D" parent="Ground" unique_id=1200062535]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0)
|
||||
shape = SubResource("BoxShape3D_hyim0")
|
||||
|
||||
[node name="GroundMesh" type="MeshInstance3D" parent="Ground" unique_id=51344137]
|
||||
mesh = SubResource("PlaneMesh_4f7fj")
|
||||
surface_material_override/0 = SubResource("StandardMaterial3D_spvjk")
|
||||
[node name="Island" type="MeshInstance3D" parent="." unique_id=2128190205]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1.5, 0)
|
||||
material_override = ExtResource("4_island")
|
||||
mesh = SubResource("IslandMesh")
|
||||
|
||||
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=910400852]
|
||||
[node name="Sea" parent="." unique_id=1379614066 instance=ExtResource("3_sea")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1.5, 0)
|
||||
|
||||
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=174609621]
|
||||
transform = Transform3D(0.8660248, 0.3830344, -0.32138094, 0, 0.6427605, 0.76606715, 0.5000011, -0.66343313, 0.5566465, 0, 0, 0)
|
||||
light_color = Color(1, 0.97, 0.9, 1)
|
||||
shadow_enabled = true
|
||||
directional_shadow_blend_splits = true
|
||||
directional_shadow_max_distance = 40.0
|
||||
|
||||
[node name="Environment" type="WorldEnvironment" parent="." unique_id=794103784]
|
||||
[node name="Environment" type="WorldEnvironment" parent="." unique_id=1374218548]
|
||||
environment = SubResource("Environment_gu74f")
|
||||
|
||||
[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="." unique_id=1410622657]
|
||||
[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="." unique_id=1020023513]
|
||||
spawn_path = NodePath("..")
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
shader_type spatial;
|
||||
|
||||
/* ===========================================================================
|
||||
卡通小岛 · 纯程序化,零贴图
|
||||
---------------------------------------------------------------------------
|
||||
一块平面按「径向剖面 + 噪声」抬起来,再按高度与坡度上色:
|
||||
水下/湿沙 → 沙滩 → 草地 → 岩石
|
||||
|
||||
三个细节决定了它像不像岛:
|
||||
· 海岸线加噪声扰动,否则是一个完美的圆,假得刺眼
|
||||
· 岩石不只看高度,也看坡度 —— 陡的地方自然露岩,比只按海拔分层真实得多
|
||||
· profile_power 越小越像「山」,越大越像「沙洲」。1.3 左右是个锥形岛;
|
||||
调到 2 以上就变成一块平坦的沙滩,远处看着像浮在水面的饼
|
||||
|
||||
外面的裙边主动扎到水下(dip_depth),免得平面边缘悬空露馅。
|
||||
法线用有限差分重算,因为几何被位移过了,原网格法线已经不对。
|
||||
|
||||
坑(实测):**片元阶段的 VERTEX 是视图空间,不是世界空间。**
|
||||
一开始写成 (MODEL_MATRIX * vec4(VERTEX, 1.0)).y 当海拔用,算出来其实是
|
||||
相机相对高度 —— 结果是整座岛被染成一大块沙滩,而且看上去「好像也对」。
|
||||
海拔要嘛用 varying 从顶点传下来(现在这样),
|
||||
要嘛用 (INV_VIEW_MATRIX * vec4(VERTEX, 1.0)).y,别拿 MODEL_MATRIX 去乘。
|
||||
|
||||
前提:节点摆在水面高度上,不旋转、不缩放。
|
||||
=========================================================================== */
|
||||
|
||||
render_mode cull_disabled, diffuse_toon, specular_disabled;
|
||||
|
||||
// ------------------------------------------------------------- 形状 -----
|
||||
group_uniforms Shape;
|
||||
uniform float island_radius : hint_range(2.0, 60.0) = 15.0; // 米:陆地半径
|
||||
uniform float island_height : hint_range(0.5, 30.0) = 9.0; // 米:最高点高出水面多少
|
||||
// 平顶半径占 island_radius 的比例。0 = 锥形山(默认);0.7~0.85 = 台地岛。
|
||||
// 主场景要能在上面走人,必须开平顶 —— 锥形岛的斜坡无法用一块平板碰撞近似
|
||||
uniform float plateau : hint_range(0.0, 0.98) = 0.0;
|
||||
// 越小越像山、越大越像沙洲。调到 2 以上就成了一块浮在水上的饼
|
||||
uniform float profile_power : hint_range(0.5, 5.0) = 1.30;
|
||||
uniform float dip_depth : hint_range(0.0, 30.0) = 12.0; // 米:裙边扎到水下多深
|
||||
|
||||
// --------------------------------------------------------- 海岸与地表 -----
|
||||
group_uniforms Terrain;
|
||||
uniform float coast_scale : hint_range(0.01, 0.50) = 0.055; // 海岸线噪声密度
|
||||
uniform float coast_warp : hint_range(0.0, 0.60) = 0.22; // 海岸线扭曲幅度
|
||||
uniform float detail_scale : hint_range(0.02, 2.0) = 0.22; // 地表起伏密度
|
||||
uniform float detail_strength : hint_range(0.0, 8.0) = 2.20; // 地表起伏幅度(米)
|
||||
|
||||
// ------------------------------------------------------------- 上色 -----
|
||||
group_uniforms Paint;
|
||||
uniform vec3 sand_color : source_color = vec3(0.930, 0.870, 0.680);
|
||||
uniform vec3 grass_color : source_color = vec3(0.440, 0.720, 0.360);
|
||||
uniform vec3 rock_color : source_color = vec3(0.550, 0.560, 0.600);
|
||||
uniform vec3 wet_color : source_color = vec3(0.620, 0.600, 0.440); // 水下 / 水线附近的湿沙
|
||||
uniform float wet_line : hint_range(-2.0, 2.0) = 0.15; // 米:水线(y=0 就是水面)
|
||||
uniform float sand_line : hint_range(0.0, 8.0) = 0.70; // 米:沙地到此结束
|
||||
uniform float grass_line : hint_range(0.0, 20.0) = 3.40; // 米:草地到此结束,以上是岩
|
||||
uniform float rock_slope : hint_range(0.0, 4.0) = 0.70; // 坡度大于此值直接露岩
|
||||
uniform float band_soft : hint_range(0.02, 1.0) = 0.18; // 色阶过渡宽度(越小越硬)
|
||||
|
||||
// ===========================================================================
|
||||
// 噪声
|
||||
// ===========================================================================
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
float vnoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
float a = hash21(i);
|
||||
float b = hash21(i + vec2(1.0, 0.0));
|
||||
float c = hash21(i + vec2(0.0, 1.0));
|
||||
float d = hash21(i + vec2(1.0, 1.0));
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
// 两层就够。这里值噪声可以用 —— 它只塑造「高度」,不拿它求导当法线
|
||||
//(法线是另外用有限差分在「位移后」重算的,见 vertex)。水面着色器里不能这么偷懒。
|
||||
float fbm2(vec2 p) {
|
||||
return vnoise(p) * 0.65 + vnoise(p * 2.07 + 13.1) * 0.35;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 高度场
|
||||
// ===========================================================================
|
||||
|
||||
// 返回高于水面的米数(水面 = 0)
|
||||
float island_at(vec2 p) {
|
||||
float r = length(p) / max(island_radius, 0.001);
|
||||
|
||||
// 海岸线:按噪声推拉半径,否则是一个完美的圆
|
||||
r += (fbm2(p * coast_scale) - 0.5) * coast_warp;
|
||||
|
||||
// 径向剖面:plateau 以内是平顶,以外收到水线。
|
||||
// 注意海岸线噪声已经加进 r 了,所以平顶边缘也是弯的,不会是一个正圆
|
||||
float prof = 1.0 - smoothstep(plateau, 1.0, clamp(r, 0.0, 1.0));
|
||||
prof = pow(prof, max(profile_power, 0.001));
|
||||
float h = prof * island_height;
|
||||
|
||||
// 地表起伏:只作用在陆地上,海岸线附近淡出,否则海岸会碎成齿状。
|
||||
// 主场景用平顶当可走地面时,这个值要压小 —— 碰撞是平的,起伏大了人会陷进坡里
|
||||
h += (fbm2(p * detail_scale) - 0.5) * detail_strength * smoothstep(1.0, 0.35, r);
|
||||
|
||||
// 裙边:岛外主动扎到水下,免得平面边缘悬空
|
||||
h -= dip_depth * smoothstep(0.85, 1.30, r);
|
||||
return h;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
// 海拔从顶点传下来。**不要在片元里拿 MODEL_MATRIX 乘 VERTEX**(见文件头)
|
||||
varying float v_height;
|
||||
|
||||
void vertex() {
|
||||
vec2 p = VERTEX.xz;
|
||||
float h = island_at(p);
|
||||
VERTEX.y += h;
|
||||
v_height = h;
|
||||
|
||||
// 几何被位移过了,原网格法线不再正确 —— 用有限差分重算。
|
||||
// 四个额外采样换一个到处都对(包括噪声起伏)的法线,很划算。
|
||||
const float e = 0.35;
|
||||
float hx = island_at(p + vec2(e, 0.0)) - island_at(p - vec2(e, 0.0));
|
||||
float hz = island_at(p + vec2(0.0, e)) - island_at(p - vec2(0.0, e));
|
||||
NORMAL = normalize(vec3(-hx / (2.0 * e), 1.0, -hz / (2.0 * e)));
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
float h = v_height;
|
||||
|
||||
// 陡峭度:1 - n.y。与坐标系无关,不用管 NORMAL 在哪个空间
|
||||
float steep = 1.0 - clamp(NORMAL.y, 0.0, 1.0);
|
||||
|
||||
vec3 c = sand_color;
|
||||
c = mix(c, grass_color, smoothstep(sand_line - band_soft, sand_line + band_soft, h));
|
||||
c = mix(c, rock_color, max(
|
||||
smoothstep(grass_line - band_soft, grass_line + band_soft, h),
|
||||
smoothstep(rock_slope, rock_slope * 1.6, steep)
|
||||
));
|
||||
// 水下的部分压成湿沙色
|
||||
c = mix(c, wet_color, smoothstep(wet_line + band_soft, wet_line - 0.30, h));
|
||||
|
||||
ALBEDO = c;
|
||||
ROUGHNESS = 1.0;
|
||||
METALLIC = 0.0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1l20nbw5a5cv
|
||||
@@ -0,0 +1,20 @@
|
||||
[gd_resource type="ShaderMaterial" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Shader" path="res://world/ocean/island.gdshader" id="1_island"]
|
||||
|
||||
; 主场景用的「台地岛」材质:与 ocean_demo 那座锥形岛共用同一个着色器,只改形状参数。
|
||||
;
|
||||
; 关键的一组取值(相对水面 y=0,而节点本身摆在 y=-1.5,所以平顶落在世界 y=0):
|
||||
; island_height = 1.5 平顶高出水面 1.5 米 —— 正好等于主场景 Ground 碰撞盒的顶面高度
|
||||
; plateau = 0.72 平顶半径 = 40 * 0.72 ≈ 28.8 米。地面碰撞盒放大到 58×58(半宽 29)
|
||||
; 就是为了和这个平顶对齐,否则人会走到平顶外面掉下去
|
||||
; detail_strength = 0.25 起伏只有 ±12 厘米。碰撞是平板,起伏大了会看见脚陷进坡里
|
||||
; dip_depth = 14 岛外裙边扎到水下 14 米,从侧面看不到平面边缘
|
||||
[resource]
|
||||
shader = ExtResource("1_island")
|
||||
shader_parameter/island_radius = 40.0
|
||||
shader_parameter/island_height = 1.5
|
||||
shader_parameter/plateau = 0.72
|
||||
shader_parameter/profile_power = 1.60
|
||||
shader_parameter/dip_depth = 14.0
|
||||
shader_parameter/detail_strength = 0.25
|
||||
@@ -0,0 +1,143 @@
|
||||
extends Node3D
|
||||
## 海洋效果场景 · 只做视觉,不含任何玩法交互
|
||||
##
|
||||
## 四件事:
|
||||
## 1. 推进 world_time —— 波形相位统一由这里给,不用着色器的内置 TIME
|
||||
## 2. 把 Sun 节点方向推给水面着色器,让波光与场景光照同向
|
||||
## 3. 近处细网格跟着相机平移(远处那层大平面不动)
|
||||
## 4. 缓慢环绕相机,水面动起来才看得出来
|
||||
##
|
||||
## 直接用 F6(运行当前场景)即可,不依赖 Net / MultiplayerSpawner。
|
||||
##
|
||||
## 关于 world_time(这是本文件最重要的一个决定):
|
||||
## 着色器里的 TIME 是「各进程自己的启动计时」。联机时两端启动时刻差几秒,
|
||||
## 同一时刻看到的浪就不是同一片 —— 两个玩家站在一片海上,浪却不一样,一眼就看出来。
|
||||
## 所以波形相位必须来自一个「大家都同意的时钟」:
|
||||
## · 单机 / 效果场景:用 Time.get_ticks_msec() 自己减去启动时刻(本文件现在就是这样)
|
||||
## · 联机:把这个基准时刻换成主机下发的同步时间戳(一个 float,RPC 一次即可)
|
||||
## 注意用「绝对时钟差」而不是逐帧累加 delta —— 两端帧率不同,累加会越走越偏。
|
||||
|
||||
@export_group("水面")
|
||||
## 跟随相机的那层近水面(细网格)。留空则取名为 Water 的子节点
|
||||
@export var near_water: MeshInstance3D
|
||||
## 网格对齐步长(米)。应当 ≈ 顶点间距:波形是世界坐标锚定的,
|
||||
## 不对齐的话顶点会在波形上「滑动」,看上去像水面在抖
|
||||
@export var follow_snap: float = 2.5
|
||||
## 波形推进倍率。1.0 = 着色器里的默认速度
|
||||
@export var time_scale: float = 1.0
|
||||
|
||||
@export_group("相机")
|
||||
## 是否自动环绕(关掉就停在当前机位,方便截图)
|
||||
@export var orbit_enabled: bool = true
|
||||
## 环绕角速度(度/秒)
|
||||
@export var orbit_speed_deg: float = 5.0
|
||||
## 起始方位角(度)
|
||||
@export var orbit_start_deg: float = 35.0
|
||||
## 环绕半径(米)
|
||||
@export var orbit_radius: float = 46.0
|
||||
## 相机离水面高度(米)
|
||||
@export var orbit_height: float = 13.0
|
||||
## 注视点离水面高度(米)。略高于水面,构图不会被岛顶顶到
|
||||
@export var look_at_height: float = 2.0
|
||||
|
||||
@onready var sun: DirectionalLight3D = $Sun
|
||||
@onready var camera: Camera3D = $Camera3D
|
||||
|
||||
var _start_msec: int = 0
|
||||
var _orbit_angle: float = 0.0
|
||||
## 场景里所有水面材质(近平面 + 远平面)。
|
||||
## 自动收集,不靠手工把每个材质拖到导出版位上
|
||||
var _water_materials: Array[ShaderMaterial] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_start_msec = Time.get_ticks_msec()
|
||||
_orbit_angle = deg_to_rad(orbit_start_deg)
|
||||
_hide_net_ui()
|
||||
if near_water == null:
|
||||
near_water = get_node_or_null("Water") as MeshInstance3D
|
||||
_collect_water_materials()
|
||||
if _water_materials.is_empty():
|
||||
push_warning("没找到任何水面材质,水面不会动")
|
||||
_update_camera()
|
||||
_follow_camera()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# 绝对时钟差,不是逐帧累加(原因见文件头)
|
||||
var t := float(Time.get_ticks_msec() - _start_msec) / 1000.0 * time_scale
|
||||
var sun_dir := Vector3.UP
|
||||
if sun != null:
|
||||
# 平行光的照射方向是节点的 -Z,取反就是指向太阳
|
||||
# 「指向太阳」= 平行光节点的 +Z(沿 -Z 照射)。取反会让波光整片消失,且不报错
|
||||
sun_dir = sun.global_transform.basis.z
|
||||
for mat in _water_materials:
|
||||
mat.set_shader_parameter("world_time", t)
|
||||
mat.set_shader_parameter("sun_direction", sun_dir)
|
||||
|
||||
if orbit_enabled:
|
||||
_orbit_angle += deg_to_rad(orbit_speed_deg) * delta
|
||||
_update_camera()
|
||||
_follow_camera()
|
||||
|
||||
|
||||
## 把场景里每个「声明了 world_time 的水面材质」都收进来。
|
||||
## 这样再加几层水面(远平面、沼泽、水坑)都不用改代码,也不用手工接线。
|
||||
func _collect_water_materials() -> void:
|
||||
_water_materials.clear()
|
||||
var seen := {}
|
||||
for node in find_children("*", "MeshInstance3D", true, false):
|
||||
var mi := node as MeshInstance3D
|
||||
if mi == null:
|
||||
continue
|
||||
var mat := mi.material_override as ShaderMaterial
|
||||
if mat == null or mat.shader == null:
|
||||
continue
|
||||
var key := mat.get_instance_id()
|
||||
if seen.has(key):
|
||||
continue
|
||||
if not _declares_world_time(mat):
|
||||
continue
|
||||
seen[key] = true
|
||||
_water_materials.append(mat)
|
||||
|
||||
|
||||
## 用「有没有 world_time 这个 uniform」认水面,比按文件名/目录判断稳:
|
||||
## 改文件名、复制一份材质出来调参,都还能认出来
|
||||
func _declares_world_time(mat: ShaderMaterial) -> bool:
|
||||
for uniform in mat.shader.get_shader_uniform_list():
|
||||
if str(uniform.get("name")) == "world_time":
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _update_camera() -> void:
|
||||
if camera == null:
|
||||
return
|
||||
var center := global_position + Vector3(0.0, look_at_height, 0.0)
|
||||
camera.global_position = global_position + Vector3(
|
||||
cos(_orbit_angle) * orbit_radius,
|
||||
orbit_height,
|
||||
sin(_orbit_angle) * orbit_radius
|
||||
)
|
||||
camera.look_at(center, Vector3.UP)
|
||||
|
||||
|
||||
## 近平面跟着相机平移。均匀网格没法同时做到「近处够密」和「覆盖到地平线」,
|
||||
## 所以近处用跟着相机走的小密网,远处另有一层大平面(它的 swell_amount = 0,纯平)。
|
||||
func _follow_camera() -> void:
|
||||
if near_water == null or camera == null:
|
||||
return
|
||||
var step := maxf(follow_snap, 0.01)
|
||||
var c := camera.global_position
|
||||
near_water.global_position.x = roundf(c.x / step) * step
|
||||
near_water.global_position.z = roundf(c.z / step) * step
|
||||
|
||||
|
||||
## Net 是 autoload 的联机菜单,一 _ready 就把整个 UI 铺上来。
|
||||
## 本场景只是看效果,用不到它 —— 整层藏掉。不改它的逻辑,
|
||||
## 退出本场景后其它场景照常(_ready 重新跑一次)。
|
||||
func _hide_net_ui() -> void:
|
||||
var net := get_node_or_null("/root/Net")
|
||||
if net is CanvasLayer:
|
||||
(net as CanvasLayer).visible = false
|
||||
@@ -0,0 +1 @@
|
||||
uid://17aed15wn1yu
|
||||
@@ -0,0 +1,124 @@
|
||||
[gd_scene format=3 uid="uid://cmljgtibtt52v"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://17aed15wn1yu" path="res://world/ocean/ocean_demo.gd" id="1_yme6p"]
|
||||
[ext_resource type="Sky" uid="uid://bi632suyifms5" path="res://world/sky/cartoon_sky.tres" id="2_furff"]
|
||||
[ext_resource type="Shader" uid="uid://cqlv11372k13t" path="res://world/ocean/water.gdshader" id="3_t7i7y"]
|
||||
[ext_resource type="Shader" uid="uid://d1l20nbw5a5cv" path="res://world/ocean/island.gdshader" id="4_ihafm"]
|
||||
[ext_resource type="Material" path="res://world/ocean/water_far_material.tres" id="5_furff"]
|
||||
|
||||
[sub_resource type="Environment" id="Environment_nmrd5"]
|
||||
background_mode = 2
|
||||
sky = ExtResource("2_furff")
|
||||
ambient_light_source = 3
|
||||
reflected_light_source = 2
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_47m0w"]
|
||||
render_priority = 0
|
||||
shader = ExtResource("3_t7i7y")
|
||||
shader_parameter/world_time = 0.0
|
||||
shader_parameter/wind_direction = Vector2(1, 0.25)
|
||||
shader_parameter/swell_amount = 1.0
|
||||
shader_parameter/wave_height = 0.38
|
||||
shader_parameter/wave_length = 42.0
|
||||
shader_parameter/wave_speed = 1.0
|
||||
shader_parameter/choppiness = 0.25
|
||||
shader_parameter/ripple_scale = 1.4
|
||||
shader_parameter/ripple_strength = 0.16
|
||||
shader_parameter/ripple_speed = 0.7
|
||||
shader_parameter/ripple_fade_distance = 110.0
|
||||
shader_parameter/grain_scale = 0.45
|
||||
shader_parameter/grain_strength = 0.32
|
||||
shader_parameter/grain_speed = 0.22
|
||||
shader_parameter/shallow_color = Color(0.24, 0.8, 0.76, 1)
|
||||
shader_parameter/deep_color = Color(0.03, 0.2, 0.45, 1)
|
||||
shader_parameter/depth_fade = 7.0
|
||||
shader_parameter/opacity_shallow = 0.62
|
||||
shader_parameter/opacity_deep = 0.97
|
||||
shader_parameter/sky_color = Color(0.38, 0.66, 0.98, 1)
|
||||
shader_parameter/horizon_color = Color(0.78, 0.88, 1, 1)
|
||||
shader_parameter/fresnel_power = 3.2
|
||||
shader_parameter/fresnel_amount = 0.82
|
||||
shader_parameter/band_count = 3.0
|
||||
shader_parameter/band_sharpness = 0.7
|
||||
shader_parameter/sun_direction = Vector3(0.35, 0.55, 0.35)
|
||||
shader_parameter/sun_color = Color(1, 0.96, 0.85, 1)
|
||||
shader_parameter/glitter_power = 320.0
|
||||
shader_parameter/glitter_strength = 0.85
|
||||
shader_parameter/glitter_bands = 3.0
|
||||
shader_parameter/foam_color = Color(0.96, 0.99, 1, 1)
|
||||
shader_parameter/shore_depth = 1.05
|
||||
shader_parameter/shore_amount = 0.6
|
||||
shader_parameter/crest_amount = 0.18
|
||||
shader_parameter/crest_start = 0.92
|
||||
shader_parameter/crest_fade_distance = 200.0
|
||||
shader_parameter/foam_bands = 3.0
|
||||
shader_parameter/haze_color = Color(0.8, 0.89, 1, 1)
|
||||
shader_parameter/haze_strength = 0.95
|
||||
shader_parameter/haze_power = 5.0
|
||||
shader_parameter/haze_distance = 90.0
|
||||
|
||||
[sub_resource type="PlaneMesh" id="PlaneMesh_t7i7y"]
|
||||
size = Vector2(520, 520)
|
||||
subdivide_width = 208
|
||||
subdivide_depth = 208
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_w7758"]
|
||||
render_priority = 0
|
||||
shader = ExtResource("4_ihafm")
|
||||
shader_parameter/island_radius = 16.0
|
||||
shader_parameter/island_height = 5.5
|
||||
shader_parameter/profile_power = 1.7
|
||||
shader_parameter/dip_depth = 9.0
|
||||
shader_parameter/coast_scale = 0.055
|
||||
shader_parameter/coast_warp = 0.22
|
||||
shader_parameter/detail_scale = 0.22
|
||||
shader_parameter/detail_strength = 1.6
|
||||
shader_parameter/sand_color = Color(0.93, 0.87, 0.68, 1)
|
||||
shader_parameter/grass_color = Color(0.44, 0.72, 0.36, 1)
|
||||
shader_parameter/rock_color = Color(0.55, 0.56, 0.6, 1)
|
||||
shader_parameter/wet_color = Color(0.62, 0.6, 0.44, 1)
|
||||
shader_parameter/wet_line = 0.15
|
||||
shader_parameter/sand_line = 0.9
|
||||
shader_parameter/grass_line = 2.6
|
||||
shader_parameter/rock_slope = 0.85
|
||||
shader_parameter/band_soft = 0.18
|
||||
|
||||
[sub_resource type="PlaneMesh" id="PlaneMesh_marqi"]
|
||||
size = Vector2(72, 72)
|
||||
subdivide_width = 144
|
||||
subdivide_depth = 144
|
||||
|
||||
[sub_resource type="PlaneMesh" id="PlaneMesh_ihafm"]
|
||||
size = Vector2(6000, 6000)
|
||||
subdivide_width = 12
|
||||
subdivide_depth = 12
|
||||
|
||||
[node name="OceanDemo" type="Node3D" unique_id=941353547]
|
||||
script = ExtResource("1_yme6p")
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=776578237]
|
||||
environment = SubResource("Environment_nmrd5")
|
||||
|
||||
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=828158479]
|
||||
transform = Transform3D(0.8253356, -0.36375266, 0.4318624, 0, 0.7648422, 0.64421767, -0.5646425, -0.5316958, 0.6312515, 0, 0, 0)
|
||||
light_color = Color(1, 0.972549, 0.9019608, 1)
|
||||
light_energy = 1.1
|
||||
shadow_enabled = true
|
||||
directional_shadow_max_distance = 90.0
|
||||
|
||||
[node name="Water" type="MeshInstance3D" parent="." unique_id=92383993]
|
||||
material_override = SubResource("ShaderMaterial_47m0w")
|
||||
mesh = SubResource("PlaneMesh_t7i7y")
|
||||
|
||||
[node name="Island" type="MeshInstance3D" parent="." unique_id=1330851337]
|
||||
material_override = SubResource("ShaderMaterial_w7758")
|
||||
mesh = SubResource("PlaneMesh_marqi")
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="." unique_id=2033278712]
|
||||
transform = Transform3D(0.57352, -0.19042432, 0.79675174, 0, 0.9726074, 0.23245396, -0.8191916, -0.133317, 0.55780977, 37.7, 13, 26.4)
|
||||
fov = 60.0
|
||||
|
||||
[node name="WaterFar" type="MeshInstance3D" parent="." unique_id=1656990011]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.8, 0)
|
||||
material_override = ExtResource("5_furff")
|
||||
mesh = SubResource("PlaneMesh_ihafm")
|
||||
@@ -0,0 +1,120 @@
|
||||
extends Node3D
|
||||
## 可复用的海面驱动 · 只管「让海动起来」,不含任何玩法交互
|
||||
##
|
||||
## 三件事:
|
||||
## 1. 推进 world_time —— 波形相位统一由这里给,不用着色器的内置 TIME
|
||||
## 2. 把场景里的平行光方向推给水面着色器,让波光与实际光照同向
|
||||
## 3. 近处细网格跟着当前相机平移(远处那层大平面不动)
|
||||
##
|
||||
## 用法:把 sea.tscn 实例化到任意场景即可,不需要接线。
|
||||
## 相机是运行时从 viewport 取的(玩家的第三人称相机、编辑器视口相机都算),
|
||||
## 所以本文件不依赖任何具体玩家或相机节点。
|
||||
##
|
||||
## 关于 world_time(这是本文件最重要的一个决定):
|
||||
## 着色器里的 TIME 是「各进程自己的启动计时」。联机时两端启动时刻差几秒,
|
||||
## 同一时刻看到的浪就不是同一片 —— 两个玩家站在一片海上,浪却不一样,一眼就看出来。
|
||||
## 所以波形相位必须来自一个「大家都同意的时钟」:
|
||||
## · 现在:Time.get_ticks_msec() 减去本节点启动时刻
|
||||
## · 要联机同步时:把 _start_msec 换成主机下发的时间基准(一个 float,RPC 一次即可)
|
||||
## 注意用「绝对时钟差」而不是逐帧累加 delta —— 两端帧率不同,累加会越走越偏。
|
||||
##
|
||||
## 水面材质是**自动收集**的:场景里任何挂了带 world_time 的 ShaderMaterial 的
|
||||
## MeshInstance3D 都会被接管。之后再加几层水(远平面、沼泽、水坑)不用改代码。
|
||||
|
||||
@export_group("水面")
|
||||
## 跟随相机的那层近水面(细网格)。留空则取名为 Water 的子节点
|
||||
@export var near_water: MeshInstance3D
|
||||
## 网格对齐步长(米)。应当 ≈ 顶点间距:波形是世界坐标锚定的,
|
||||
## 不对齐的话顶点会在波形上「滑动」,看上去像水面在抖
|
||||
@export var follow_snap: float = 2.5
|
||||
## 波形推进倍率。1.0 = 着色器里的默认速度;0 = 冻结
|
||||
@export var time_scale: float = 1.0
|
||||
|
||||
@export_group("光照")
|
||||
## 提供太阳方向的光源。留空则自动找:父节点下的 Sun → 全场景第一个平行光
|
||||
@export var sun: DirectionalLight3D
|
||||
|
||||
var _start_msec: int = 0
|
||||
var _water_materials: Array[ShaderMaterial] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_start_msec = Time.get_ticks_msec()
|
||||
if near_water == null:
|
||||
near_water = get_node_or_null("Water") as MeshInstance3D
|
||||
if sun == null:
|
||||
sun = _find_sun()
|
||||
_collect_water_materials()
|
||||
if _water_materials.is_empty():
|
||||
push_warning("%s 没找到任何水面材质,海不会动" % get_path())
|
||||
_follow_camera()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# 绝对时钟差,不是逐帧累加(原因见文件头)
|
||||
var t := float(Time.get_ticks_msec() - _start_msec) / 1000.0 * time_scale
|
||||
# 「指向太阳」= 平行光节点的 +Z。
|
||||
# 平行光沿自己的 -Z 照射(与相机朝向同一套约定),所以 +Z 才是朝天;
|
||||
# 天空着色器里那个 LIGHT0_DIRECTION 也是这个方向 —— 它的 y 必须为正,否则天空会进夜色。
|
||||
# 取反是最容易犯的错:符号错了不会报错,只是波光整片消失(half-vector 朝下,
|
||||
# dot(N, H) 恒为负),看上去像「波光强度调太小了」。
|
||||
var sun_dir := Vector3.UP if sun == null else sun.global_transform.basis.z
|
||||
for mat in _water_materials:
|
||||
mat.set_shader_parameter("world_time", t)
|
||||
mat.set_shader_parameter("sun_direction", sun_dir)
|
||||
_follow_camera()
|
||||
|
||||
|
||||
## 把本子树里每个「声明了 world_time 的水面材质」都收进来。
|
||||
## 用 uniform 名字认水面,比按文件名或目录判断稳:改文件名、复制一份出来调参都还能认出。
|
||||
func _collect_water_materials() -> void:
|
||||
_water_materials.clear()
|
||||
var seen := {}
|
||||
for node in find_children("*", "MeshInstance3D", true, false):
|
||||
var mi := node as MeshInstance3D
|
||||
if mi == null:
|
||||
continue
|
||||
var mat := mi.material_override as ShaderMaterial
|
||||
if mat == null or mat.shader == null:
|
||||
continue
|
||||
var key := mat.get_instance_id()
|
||||
if seen.has(key):
|
||||
continue
|
||||
if not _declares_world_time(mat):
|
||||
continue
|
||||
seen[key] = true
|
||||
_water_materials.append(mat)
|
||||
|
||||
|
||||
func _declares_world_time(mat: ShaderMaterial) -> bool:
|
||||
for uniform in mat.shader.get_shader_uniform_list():
|
||||
if str(uniform.get("name")) == "world_time":
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _find_sun() -> DirectionalLight3D:
|
||||
var parent := get_parent()
|
||||
if parent != null:
|
||||
var named := parent.find_child("Sun", true, false) as DirectionalLight3D
|
||||
if named != null:
|
||||
return named
|
||||
var root := get_tree().current_scene if get_tree().current_scene != null else get_tree().root
|
||||
for node in root.find_children("*", "DirectionalLight3D", true, false):
|
||||
return node as DirectionalLight3D
|
||||
return null
|
||||
|
||||
|
||||
## 近平面跟着相机平移。均匀网格没法同时做到「近处够密」和「覆盖到地平线」,
|
||||
## 所以近处用跟着相机走的小密网,远处另有一层大平面(它的 swell_amount = 0,纯平)。
|
||||
## 波形在世界空间锚定,所以平面平移不会让浪跟着滑。
|
||||
func _follow_camera() -> void:
|
||||
if near_water == null:
|
||||
return
|
||||
var cam := get_viewport().get_camera_3d() if get_viewport() != null else null
|
||||
if cam == null:
|
||||
return
|
||||
var step := maxf(follow_snap, 0.01)
|
||||
var c := cam.global_position
|
||||
near_water.global_position.x = roundf(c.x / step) * step
|
||||
near_water.global_position.z = roundf(c.z / step) * step
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpb6yt6qbcq8a
|
||||
@@ -0,0 +1,30 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://world/ocean/sea.gd" id="1_sea"]
|
||||
[ext_resource type="Shader" path="res://world/ocean/water.gdshader" id="2_water"]
|
||||
[ext_resource type="ShaderMaterial" path="res://world/ocean/water_far_material.tres" id="3_far"]
|
||||
|
||||
[sub_resource type="PlaneMesh" id="PlaneMesh_near"]
|
||||
size = Vector2(300, 300)
|
||||
subdivide_width = 120
|
||||
subdivide_depth = 120
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_near"]
|
||||
shader = ExtResource("2_water")
|
||||
|
||||
[sub_resource type="PlaneMesh" id="PlaneMesh_far"]
|
||||
size = Vector2(6000, 6000)
|
||||
subdivide_width = 12
|
||||
subdivide_depth = 12
|
||||
|
||||
[node name="Sea" type="Node3D"]
|
||||
script = ExtResource("1_sea")
|
||||
|
||||
[node name="Water" type="MeshInstance3D" parent="."]
|
||||
mesh = SubResource("PlaneMesh_near")
|
||||
material_override = SubResource("ShaderMaterial_near")
|
||||
|
||||
[node name="WaterFar" type="MeshInstance3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.8, 0)
|
||||
mesh = SubResource("PlaneMesh_far")
|
||||
material_override = ExtResource("3_far")
|
||||
@@ -0,0 +1,364 @@
|
||||
shader_type spatial;
|
||||
|
||||
/* ===========================================================================
|
||||
卡通海面 · 纯程序化,零贴图
|
||||
---------------------------------------------------------------------------
|
||||
渲染顺序: 水体底色 → 菲涅尔天空反射 → 太阳波光 → 泡沫 → 地平线雾霭
|
||||
|
||||
四层各管一件事,缺一层就露馅:
|
||||
1 底色 水越深越暗越蓝(靠 DEPTH_TEXTURE 拿到真实水深)
|
||||
2 菲涅尔 掠射角反射天空色、垂直看透出底色 —— 水面的「亮边」全靠它,最容易漏
|
||||
3 波光 太阳的镜面反射,压成硬边色块
|
||||
4 泡沫 岸线(水浅)+ 浪尖(波峰)两处,硬边
|
||||
|
||||
波形分工(这是本着色器最重要的一个决定):
|
||||
· 位移只用 3 组低频正弦 —— 决定轮廓。幅度刻意压得很小,
|
||||
大浪交给「明暗」而不是「几何」,否则顶点一密就是碾衣板
|
||||
· 细纹 + 噪点只扰动法线、不给位移 —— 决定波光,且按距离淡出
|
||||
|
||||
**波形时间来自哪里(world_time 与 TIME 的关系):**
|
||||
world_time >= 0 用传入值。运行时由 ocean_demo.gd 每帧推进(联机要同步的时钟)
|
||||
world_time < 0 用引擎内置 TIME。**编辑器预览就是这种情况** ——
|
||||
脚本在编辑器里不跑,所以没人推 world_time;
|
||||
没这个兼底的话,编辑器里看到的海是**完全冻结**的,
|
||||
只有按 F6 跑起来才动。
|
||||
两者默认值就是 -1,所以场景一打开(未运行)海就在动,不用做任何配置。
|
||||
|
||||
**五个坑,都是实测踩出来的:**
|
||||
① 纯正弦场只要两个方向接近平行,叠加后就会退化成一道规则条纹。
|
||||
方向必须显式排开(夹角 90° 以上),别拿「看起来不一样」的坐标去拼。
|
||||
② 只有正弦、没有噪声的法线,会让**太阳波光变成一张规则网格** ——
|
||||
高光落在「法线刚好平分视线与阳光」的位置,周期性法线场的高光自然周期性。
|
||||
③ 打散它的噪声**必须是梯度噪声,不能用值噪声**。值噪声的方形格子在
|
||||
当『高度』用的时候看不出来,但拿来求导当法线,就是一块块方格。
|
||||
④ TAU / PI / E 是着色器语言的内置常量,自己再 const 一遍会直接编译失败。
|
||||
而**编译失败的外观是「没着色器的一团白」**,很容易当成「参数没调好」去瞎调。
|
||||
⑤ 片元阶段的 VERTEX 是**视图空间**,不是世界空间(小岛着色器在那里把
|
||||
MODEL_MATRIX 乘上去当海拔用,结果整座岛被染成一大块沙滩)。
|
||||
|
||||
两处刻意与 cartoon_sky.gdshader 对齐:
|
||||
· 反射色 / 雾霭色直接取那条天空的色板,海天交界才能连成一条线
|
||||
· 时间不用内置 TIME(运行时)—— 联机两端启动时刻差几秒,看到的就不是同一片海
|
||||
|
||||
关于「近处细平面 + 远处大平面」两层水面:
|
||||
均匀网格做不了「近处要密、远处要远」这两件事。天空在地平线以下
|
||||
是一片深色半球,有限大的水面边缘与真地平线之间会露出那一条深色带
|
||||
(相机高 h、平面半宽 d 时,带宽 ≈ atan(h/d))。所以:
|
||||
· 近平面小而密(2.5 m/格),跟随相机走 —— 波形是世界坐标锚定的,不会滑
|
||||
· 远平面大而疏(约 500 m/格),把边缘推到 0.25° 以内,带宽只剩两三个像素
|
||||
两者用同一个着色器,靠 swell_amount 区分(远平面 = 0,纯平)。
|
||||
|
||||
前提:本节点不能旋转(平移、缩放都可以)。波形与法线都在模型空间算。
|
||||
调好的美术取值直接写在下面的 uniform 默认值里 —— 即使 .tres 里的
|
||||
shader_parameter 被清掉,画面也不会跑偏。
|
||||
=========================================================================== */
|
||||
|
||||
render_mode blend_mix, cull_disabled, unshaded, fog_disabled;
|
||||
|
||||
// --------------------------------------------------------------- 时间 -----
|
||||
group_uniforms Time;
|
||||
// -1 = 没人接管,用引擎 TIME(编辑器预览);>= 0 = 用传入值(运行时的同步时钟)
|
||||
uniform float world_time : hint_range(-1.0, 3600.0) = -1.0;
|
||||
// 风向。大浪与细纹都沿着它及其旋转方向铺开
|
||||
uniform vec2 wind_direction = vec2(1.0, 0.25);
|
||||
|
||||
// ------------------------------------------------------- 大浪(真实位移) ---
|
||||
group_uniforms Swell;
|
||||
// 1 = 正常水面;0 = 纯平(远平面用,不参与几何)
|
||||
uniform float swell_amount : hint_range(0.0, 1.0) = 1.0;
|
||||
uniform float wave_height : hint_range(0.0, 3.0) = 0.38; // 米:最低频那组的振幅
|
||||
uniform float wave_length : hint_range(2.0, 120.0) = 42.0; // 米:最低频那组的波长
|
||||
uniform float wave_speed : hint_range(0.0, 4.0) = 1.00; // 传播速度倍率
|
||||
uniform float choppiness : hint_range(0.0, 1.0) = 0.25; // 波峰尖锐度
|
||||
|
||||
// ------------------------------------------------- 细纹(只动法线,不动位移)---
|
||||
group_uniforms Ripple;
|
||||
uniform float ripple_scale : hint_range(0.05, 6.0) = 1.40; // 1/米:细纹密度
|
||||
uniform float ripple_strength : hint_range(0.0, 1.5) = 0.16; // 坡度大小(不是高度)
|
||||
uniform float ripple_speed : hint_range(0.0, 4.0) = 0.70;
|
||||
// 米:细纹与噪点在这么远以后淡出。**不做这个远处会满屏闪烁** ——
|
||||
// 空间频率一旦落到像素频率以下就是噪点,掠射角还会把它放大
|
||||
uniform float ripple_fade_distance : hint_range(10.0, 600.0) = 110.0;
|
||||
|
||||
// --------------------------------- 噪点(打散正弦场的规则性,见文件头坑②③)---
|
||||
group_uniforms Grain;
|
||||
uniform float grain_scale : hint_range(0.02, 3.0) = 0.45; // 1/米:噪点密度
|
||||
uniform float grain_strength : hint_range(0.0, 2.0) = 0.32; // 坡度大小
|
||||
uniform float grain_speed : hint_range(0.0, 4.0) = 0.22;
|
||||
|
||||
// ----------------------------------------------------------------- 水体 -----
|
||||
group_uniforms Water;
|
||||
uniform vec3 shallow_color : source_color = vec3(0.240, 0.800, 0.760); // 浅滩
|
||||
uniform vec3 deep_color : source_color = vec3(0.030, 0.200, 0.450); // 深海
|
||||
uniform float depth_fade : hint_range(0.5, 40.0) = 7.0; // 米:多深算「全深色」
|
||||
uniform float opacity_shallow : hint_range(0.0, 1.0) = 0.62;
|
||||
uniform float opacity_deep : hint_range(0.0, 1.0) = 0.97;
|
||||
|
||||
// ------------------------------------------------------------- 天空反射 -----
|
||||
group_uniforms Reflection;
|
||||
uniform vec3 sky_color : source_color = vec3(0.380, 0.660, 0.980); // 抬头看到的天顶
|
||||
uniform vec3 horizon_color : source_color = vec3(0.780, 0.880, 1.000); // 掠射看到的地平线
|
||||
uniform float fresnel_power : hint_range(0.5, 12.0) = 3.2;
|
||||
uniform float fresnel_amount : hint_range(0.0, 1.0) = 0.82;
|
||||
// 0=完全平滑。段数越多越「卡通」,但也越容易把法线场切成条纹 ——
|
||||
// 3 是实测下来既有色块感、又不会变成斑马线的值
|
||||
uniform float band_count : hint_range(0.0, 12.0) = 3.0;
|
||||
uniform float band_sharpness : hint_range(0.0, 1.0) = 0.70;
|
||||
|
||||
// --------------------------------------------------------------- 太阳 -----
|
||||
group_uniforms Sun;
|
||||
// 方向由脚本从 Sun 节点推过来,保证与场景光照一致;未接管时用默认值
|
||||
uniform vec3 sun_direction = vec3(0.35, 0.55, 0.35);
|
||||
uniform vec3 sun_color : source_color = vec3(1.000, 0.960, 0.850);
|
||||
uniform float glitter_power : hint_range(10.0, 800.0) = 320.0; // 越大越碎
|
||||
uniform float glitter_strength : hint_range(0.0, 4.0) = 0.85;
|
||||
uniform float glitter_bands : hint_range(0.0, 8.0) = 3.0; // 波光分段
|
||||
|
||||
// --------------------------------------------------------------- 泡沫 -----
|
||||
group_uniforms Foam;
|
||||
uniform vec3 foam_color : source_color = vec3(0.960, 0.990, 1.000);
|
||||
uniform float shore_depth : hint_range(0.0, 8.0) = 1.05; // 米:多浅开始出岸线泡沫
|
||||
// 岸线泡沫很容易做过头 —— 调太满就是一圈白色描边,像贴纸不像浪。0.6 左右刚好
|
||||
uniform float shore_amount : hint_range(0.0, 2.0) = 0.60;
|
||||
uniform float crest_amount : hint_range(0.0, 2.0) = 0.18; // 浪尖泡沫
|
||||
uniform float crest_start : hint_range(0.0, 1.5) = 0.92; // 浪尖阈值(越高越稀)
|
||||
// 米:浪尖泡沫的可见距离。浪花是近景细节,让它铺到地平线只会糊成一片白网
|
||||
uniform float crest_fade_distance : hint_range(20.0, 800.0) = 200.0;
|
||||
uniform float foam_bands : hint_range(1.0, 5.0) = 3.0; // 硬边分段
|
||||
|
||||
// --------------------------------------------------------- 地平线雾霭 -----
|
||||
group_uniforms Haze;
|
||||
// 与 cartoon_sky.gdshader 的 Haze 组保持一致,海面才能在远处融进天空
|
||||
uniform vec3 haze_color : source_color = vec3(0.800, 0.890, 1.000);
|
||||
uniform float haze_strength : hint_range(0.0, 1.0) = 0.95;
|
||||
uniform float haze_power : hint_range(1.0, 20.0) = 5.0; // 越大,雾霭只集中在最贴地平线的一条
|
||||
uniform float haze_distance : hint_range(5.0, 400.0) = 90.0;
|
||||
|
||||
// ------------------------------------------- 场景深度(量「水有多深」)---
|
||||
// 在 Forward+ 下可用
|
||||
uniform sampler2D depth_texture : hint_depth_texture, repeat_disable, filter_nearest;
|
||||
|
||||
// ===========================================================================
|
||||
// 工具
|
||||
// ===========================================================================
|
||||
|
||||
// 波形时间:有人接管就用传入值,没人接管就用引擎 TIME。
|
||||
// 这是为了让场景「一打开就在动」—— 否则编辑器里看到的是冻结的海。
|
||||
float wave_time() {
|
||||
return world_time >= 0.0 ? world_time : TIME;
|
||||
}
|
||||
|
||||
// 二维旋转。写成显式形式而不是 mat2,避免行列顺序搞混
|
||||
vec2 rot2(vec2 v, float a) {
|
||||
float s = sin(a);
|
||||
float c = cos(a);
|
||||
return vec2(v.x * c - v.y * s, v.x * s + v.y * c);
|
||||
}
|
||||
|
||||
// 卡通色阶:把 0..1 压成 n 段硬边色块(n<0.5 时原样返回 = 平滑)
|
||||
float posterize(float t, float n, float sharp) {
|
||||
if (n < 0.5) {
|
||||
return t;
|
||||
}
|
||||
float s = t * n;
|
||||
float base = floor(s);
|
||||
float fr = s - base;
|
||||
float e = mix(0.5, 0.015, clamp(sharp, 0.0, 1.0));
|
||||
return (base + smoothstep(0.5 - e, 0.5 + e, fr)) / n;
|
||||
}
|
||||
|
||||
// 伪随机梯度方向(每个分量 -1..1)。用 fract 混合而不是 sin,大坐标下也不掉精度。
|
||||
vec2 hash22(vec2 p) {
|
||||
vec3 p3 = fract(vec3(p.x, p.y, p.x) * vec3(0.1031, 0.1030, 0.0973));
|
||||
p3 += dot(p3, vec3(p3.y, p3.z, p3.x) + 33.33);
|
||||
return fract(vec2((p3.x + p3.y) * p3.z, (p3.x + p3.z) * p3.y)) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
// 梯度噪声(Perlin)。比值噪声多几次乘法,但这个场合不能省:
|
||||
// 值噪声的格点是「梯度为零」的平台,求导当法线时就是一块块方格。
|
||||
float gradient_noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
// 五次插值:二阶导连续
|
||||
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
|
||||
float a = dot(hash22(i), f);
|
||||
float b = dot(hash22(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));
|
||||
float c = dot(hash22(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));
|
||||
float d = dot(hash22(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
// 两层。第二层旋转约 37° 并换频率 —— 两层同向的话网格会重新变得可辨。
|
||||
// 再多一层在掠射角下只会变成噪点,没有收益。
|
||||
float grain(vec2 p) {
|
||||
return gradient_noise(p) * 0.65 + gradient_noise(rot2(p, 0.65) * 2.07 + 17.3) * 0.35;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 高度场 —— 顶点位移和法线用的是同一个函数
|
||||
// ===========================================================================
|
||||
|
||||
// 三组正弦的振幅和。浪尖泡沫要拿它当分母,
|
||||
// 拿 wave_height 当分母会永远溢出到 1(泡沫铺满整个海面)
|
||||
float swell_amplitude_sum() {
|
||||
return wave_height * 1.79; // 1 + 0.52 + 0.27
|
||||
}
|
||||
|
||||
// 返回 (高度, dh/dx, dh/dz)
|
||||
//
|
||||
// 三组正弦的频率比取 1 : 1.73 : 2.61(≈ √3、√7),互不成整数倍,
|
||||
// 叠加后不会每过一段就重复出同样的花样。方向两两相差至少 60°。
|
||||
vec3 swell_field(vec2 p, float t) {
|
||||
vec2 d0 = normalize(wind_direction); // 随风向
|
||||
vec2 d1 = rot2(d0, 2.30); // +131.8°
|
||||
vec2 d2 = rot2(d0, -1.15); // -65.9°
|
||||
|
||||
float k = TAU / max(wave_length, 0.001);
|
||||
float k0 = k;
|
||||
float k1 = k * 1.73;
|
||||
float k2 = k * 2.61;
|
||||
|
||||
float a0 = wave_height;
|
||||
float a1 = wave_height * 0.52;
|
||||
float a2 = wave_height * 0.27;
|
||||
|
||||
float sp = t * wave_speed;
|
||||
float p0 = dot(d0, p) * k0 + sp;
|
||||
float p1 = dot(d1, p) * k1 + sp * 1.35;
|
||||
float p2 = dot(d2, p) * k2 + sp * 1.80;
|
||||
|
||||
float h = a0 * sin(p0) + a1 * sin(p1) + a2 * sin(p2);
|
||||
vec2 g = a0 * k0 * cos(p0) * d0
|
||||
+ a1 * k1 * cos(p1) * d1
|
||||
+ a2 * k2 * cos(p2) * d2;
|
||||
return vec3(h, g.x, g.y);
|
||||
}
|
||||
|
||||
// 细纹的坡度(只有坡度,没有高度)。直接返回「坡多陡」而不是「水多高」。
|
||||
//
|
||||
// 方向是**写死的固定角度**,只让第一组跟着风向走。
|
||||
// 教训:曾经写成 normalize(vec2(1.0, wind_direction.y)) 与 normalize(vec2(-2.0, 9.0)),
|
||||
// 而 wind_direction 是 (1, 0.25) —— normalize(0.25, -1) 与 normalize(-2, 9) 恰好反向平行,
|
||||
// 两组余弦相消,整层退化成一道 1D 驻波。三组夹角依次约 96° / 90°,不会再退化。
|
||||
vec2 ripple_grad(vec2 p, float t) {
|
||||
vec2 d0 = normalize(vec2(wind_direction.y, -wind_direction.x)); // 与风向垂直
|
||||
vec2 d1 = normalize(vec2(7.0, 3.0)); // 23.2°
|
||||
vec2 d2 = normalize(vec2(-3.0, 7.0)); // 113.2°
|
||||
float k = ripple_scale;
|
||||
float sp = t * ripple_speed;
|
||||
float p0 = dot(d0, p) * k + sp;
|
||||
float p1 = dot(d1, p) * k * 1.73 + sp * 1.6;
|
||||
float p2 = dot(d2, p) * k * 2.41 + sp * 0.9;
|
||||
return ripple_strength * (cos(p0) * d0 + 0.7 * cos(p1) * d1 + 0.5 * cos(p2) * d2);
|
||||
}
|
||||
|
||||
// 噪点的坡度:有限差分。
|
||||
// **它就是「波光不规则」的全部来源**(见文件头坑②)—— 只有正弦的话高光会连成网格。
|
||||
// 噪点本来就没有解析导数,而三次采样只贵一点点。
|
||||
vec2 grain_grad(vec2 p, float t) {
|
||||
float s = grain_scale;
|
||||
vec2 q = p * s + vec2(t * grain_speed, -t * grain_speed * 0.7);
|
||||
float e = 0.30;
|
||||
float g0 = grain(q);
|
||||
float gx = grain(q + vec2(e, 0.0));
|
||||
float gz = grain(q + vec2(0.0, e));
|
||||
return grain_strength * vec2(gx - g0, gz - g0) / e;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 顶点
|
||||
// ===========================================================================
|
||||
|
||||
varying vec3 v_world_pos;
|
||||
|
||||
void vertex() {
|
||||
// 波形用**世界坐标**锚定,不是模型坐标 —— 否则近平面跟着相机走的时候,
|
||||
// 海浪会跟着一起滑,看上去像船在推着水跑
|
||||
vec3 world = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||
vec3 w = swell_field(world.xz, wave_time());
|
||||
|
||||
// 向波峰挤压:把波谷压平、波峰收尖,剪影才像「浪」而不是「正弦」
|
||||
float h = w.x * (1.0 + choppiness * 0.35 * sign(w.x));
|
||||
VERTEX.y += h * swell_amount;
|
||||
|
||||
NORMAL = normalize(vec3(-w.y * swell_amount, 1.0, -w.z * swell_amount));
|
||||
|
||||
v_world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 片元
|
||||
// ===========================================================================
|
||||
|
||||
void fragment() {
|
||||
vec2 p = v_world_pos.xz;
|
||||
float t = wave_time();
|
||||
|
||||
vec3 to_cam = CAMERA_POSITION_WORLD - v_world_pos;
|
||||
float view_dist = length(to_cam);
|
||||
vec3 view_dir = to_cam / max(view_dist, 0.001);
|
||||
vec3 sun_dir = normalize(sun_direction);
|
||||
|
||||
// ------------------------------------------------------------ 法线 ----
|
||||
vec3 swell = swell_field(p, t);
|
||||
// 细纹与噪点按距离淡出
|
||||
float ripple_fade = exp(-view_dist / max(ripple_fade_distance, 0.001));
|
||||
vec2 grad = swell.yz * swell_amount
|
||||
+ (ripple_grad(p, t) + grain_grad(p, t)) * ripple_fade;
|
||||
vec3 n = normalize(vec3(-grad.x, 1.0, -grad.y));
|
||||
|
||||
// 反射方向:既决定反射到天空的哪一段,也决定地平线雾霭的强弱
|
||||
vec3 refl = reflect(-view_dir, n);
|
||||
|
||||
// ------------------------------------------------ 场景深度 → 水深 ----
|
||||
// 水面是半透明的(下面会写 ALPHA),所以走透明队列、在不透明物之后绘制,
|
||||
// 此刻深度图里已经有岛和地形。没有这一步就没有浅滩渐变,也没有岸线泡沫。
|
||||
float raw_depth = texture(depth_texture, SCREEN_UV).r;
|
||||
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, raw_depth);
|
||||
vec4 scene_view = INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
|
||||
scene_view.xyz /= scene_view.w;
|
||||
float scene_dist = -scene_view.z;
|
||||
float water_dist = -VERTEX.z; // 片元里的 VERTEX 是视图空间,-z 就是沿视线的距离
|
||||
float depth = max(scene_dist - water_dist, 0.0);
|
||||
|
||||
// ------------------------------------------------------------ 1 底色 ---
|
||||
float d = clamp(depth / max(depth_fade, 0.001), 0.0, 1.0);
|
||||
vec3 body = mix(shallow_color, deep_color, d);
|
||||
|
||||
// -------------------------------------------------------- 2 菲涅尔 ---
|
||||
float fres = pow(1.0 - clamp(dot(n, view_dir), 0.0, 1.0), fresnel_power);
|
||||
fres = clamp(fres * fresnel_amount, 0.0, 1.0);
|
||||
// 压成硬边色阶 —— 水体到天空反射之间是几段色块,不是一条渐变
|
||||
fres = posterize(fres, band_count, band_sharpness);
|
||||
|
||||
vec3 sky = mix(horizon_color, sky_color, clamp(refl.y * 1.6, 0.0, 1.0));
|
||||
vec3 col = mix(body, sky, fres);
|
||||
|
||||
// ---------------------------------------------------------- 3 波光 ---
|
||||
vec3 half_v = normalize(view_dir + sun_dir);
|
||||
float glitter = pow(max(dot(n, half_v), 0.0), glitter_power) * glitter_strength;
|
||||
// 再压一次色阶:把连续的高光切成几块碎点,才是卡通水面的「闪光」
|
||||
glitter = posterize(clamp(glitter, 0.0, 1.0), glitter_bands, 0.9);
|
||||
col += sun_color * glitter;
|
||||
|
||||
// ---------------------------------------------------------- 4 泡沫 ---
|
||||
// 岸线:水浅的地方
|
||||
float shore = 1.0 - smoothstep(0.0, max(shore_depth, 0.001), depth);
|
||||
// 浪尖:拿振幅之和当分母(见 swell_amplitude_sum 的注释),并按距离淡出
|
||||
float crest = smoothstep(crest_start, 1.0, swell.x / max(swell_amplitude_sum(), 0.001));
|
||||
crest *= exp(-view_dist / max(crest_fade_distance, 0.001));
|
||||
float foam = clamp(shore * shore_amount + crest * crest_amount * swell_amount, 0.0, 1.0);
|
||||
foam = posterize(foam, foam_bands, 0.95);
|
||||
col = mix(col, foam_color, foam);
|
||||
|
||||
// ------------------------------------------------------ 地平线雾霭 ---
|
||||
// 视线越平,反射到的天空越靠近地平线 —— 而那里本就是雾霭色。
|
||||
// 于是海天交界不用画线就自然连上了(与 cartoon_sky 的 Haze 组同色)
|
||||
float far = 1.0 - exp(-view_dist / max(haze_distance, 0.001));
|
||||
float glare = pow(1.0 - clamp(refl.y, 0.0, 1.0), haze_power);
|
||||
col = mix(col, haze_color, clamp(glare * haze_strength * (0.25 + 0.75 * far), 0.0, 1.0));
|
||||
|
||||
ALBEDO = col;
|
||||
// 泡沫处不透明,否则浪花会透出底下的水,看着像一层贴纸
|
||||
ALPHA = max(mix(opacity_shallow, opacity_deep, d), foam);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cqlv11372k13t
|
||||
@@ -0,0 +1,10 @@
|
||||
[gd_resource type="ShaderMaterial" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Shader" path="res://world/ocean/water.gdshader" id="1_water"]
|
||||
|
||||
[resource]
|
||||
shader = ExtResource("1_water")
|
||||
shader_parameter/swell_amount = 0.0
|
||||
shader_parameter/crest_amount = 0.0
|
||||
shader_parameter/opacity_shallow = 1.0
|
||||
shader_parameter/opacity_deep = 1.0
|
||||
Reference in New Issue
Block a user