Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fb16e45c5 | ||
|
|
d2b0a47850 | ||
|
|
e2491bc4e1 | ||
|
|
5c0adb3a96 | ||
|
|
b8bbe21c62 | ||
|
|
1822a2211e | ||
|
|
a76c4aebef | ||
|
|
18e7b2daa8 | ||
|
|
ecebf76f47 | ||
|
|
877d3bbad0 | ||
|
|
f2e06e9dee | ||
|
|
a1d7007241 | ||
|
|
ed1a9949c8 | ||
|
|
f296b643d4 | ||
|
|
c81571ec1e | ||
|
|
850d657404 | ||
|
|
322c1c22eb | ||
|
|
020c2a50ce | ||
|
|
9d60807642 | ||
|
|
fc89790363 | ||
|
|
02c8d36f8d | ||
|
|
77fefd2505 | ||
|
|
4ef1a30767 | ||
|
|
7934537793 | ||
|
|
f381769a7a | ||
|
|
ab24c39d0e | ||
|
|
93783b1bd9 | ||
|
|
a0dfe7b483 | ||
|
|
87b8b110a5 | ||
|
|
3dd9d2ec71 | ||
|
|
e06818bc5a | ||
|
|
7de2090b6f | ||
|
|
96e21757c3 | ||
|
|
d20d45fffc | ||
|
|
bc88fef43a | ||
|
|
bbb0c5db14 | ||
|
|
3920b88041 | ||
|
|
66d87d48d6 | ||
|
|
7fd1d97099 | ||
|
|
4950ee3b7c | ||
|
|
e07ba7fc46 | ||
|
|
27b9788a04 | ||
|
|
4765dfaa02 | ||
|
|
475a026604 | ||
|
|
8e463d9247 | ||
|
|
1bd447a56e | ||
|
|
9b1d30339f |
@@ -0,0 +1,179 @@
|
||||
# 2026-10-09
|
||||
|
||||
## 简易准心(Crosshair)
|
||||
- 新增 `ui/crosshair.gd`:`@tool extends Control`,用 `_draw()` 画十字(gap/length/thickness/dot 可导出调参),`mouse_filter = IGNORE`,监听 `NOTIFICATION_RESIZED` 重绘。
|
||||
- 挂载约定:`CanvasLayer > Crosshair(Control)`,Control 锚点为 Full Rect,中心即屏幕中心;推荐挂在 `CameraRig` 之下 —— 因为 `player.gd _ready()` 对非 authority 会 `camera_rig.queue_free()`,远端玩家准心随之一并释放,无需额外代码。
|
||||
- 项目 stretch 模式为 `canvas_items` + `expand`,CanvasLayer 会自动跟随缩放。
|
||||
|
||||
### 后续改动:只保留「点」
|
||||
- 最终形态:准心 = 屏幕中心一个点,无十字、无样式开关(`enum Style` 已移除)。
|
||||
- 导出参数:`dot_color` / `dot_radius`(默认 2.5) / `outline` / `outline_color` / `outline_width`。
|
||||
- `_draw()` 先画描边圆再画主体圆,保证浅背景可见。
|
||||
|
||||
## player.gd 拆解方案(待执行)
|
||||
- 现状:`character/player/player.gd` 155 行,混了视角 / 移动跳跃 / 拾取 / 多人权限四类职责。
|
||||
- 方案:**子节点组件化**(与既有 `PlayerAnimator` 同模式)。根节点降为协调器(~40 行),新增 `Look` / `Motor` / `Pickup` 三个 Node 子节点,各自挂 `player_look.gd` / `player_motor.gd` / `player_pickup.gd`。
|
||||
- 三条硬约束:① `move_and_slide()` 留在根节点,Motor 只写 `body.velocity`;② 物理顺序由根节点显式编排(motor.step → move_and_slide → animator → pickup),不让子节点各写 `_physics_process`;③ 非 authority 时三个组件的输入/物理必须一并关闭。
|
||||
- 范式定位:组合优于继承 + 组件模式(Component Pattern);协调器部分带 Facade/Mediator 色彩;`setup()` 注入属手工依赖注入。**不是 ECS**(Godot 节点是「带行为的对象」,非纯数据组件)。
|
||||
- 状态:**已落地**(2026-10-09)。新增 `player_look.gd` / `player_motor.gd` / `player_pickup.gd`(均带 `class_name`:PlayerLook / PlayerMotor / PlayerPickup);`player.gd` 重写为协调器(~65 行);`player.tscn` 追加 Look / Motor / Pickup 三个 Node 子节点(挂 FaceRig 之后,ext_resource id 13_look / 14_motor / 15_pickup,未写 uid 由 Godot 补)。
|
||||
- 场景格式注意:本项目的 .tscn 节点带非标准 `unique_id=`(Godot 4.7 特性),`unique_id` 与 `load_steps` 均可省略;`paint_panel.tscn` 就完全没写,可作参照。
|
||||
- 已确认无外部脚本引用被搬走的属性(仅 `world/main.tscn` 通过场景 uid 引用玩家场景)。
|
||||
- 顺手去掉了原 `_pickObject` 里的两行 `print()` 调试输出。
|
||||
|
||||
### 拾取改为长按拖拽
|
||||
- `player_pickup.gd`:由「按一下切换」改为「按下抓起、松开放下」——`is_action_pressed("carry")` → `_grab()`,`is_action_released("carry")` → `_drop()`。
|
||||
- `carry` 动作确认绑定鼠标左键(`button_index: 1`);项目里另有未被任何脚本使用的 `throw_carry`(绑 Q 键),可作投掷功能的预留。
|
||||
- 加了 `Input.mouse_mode != MOUSE_MODE_CAPTURED` 早退,并在 `player.gd` 重新锁定鼠标那一支补 `get_viewport().set_input_as_handled()`,避免「点一下重新锁定鼠标」顺带抓起物体(两种分支执行顺序下都成立)。
|
||||
- `physics_step()` 与 `has_object()` 改用 `is_instance_valid()` —— 防止拖拽途中物体被 destroy 时取属性报错。
|
||||
- `_drop()` 保留线速度(松手有惯性 / 可甩出);想要「就地放下」需补 `set_linear_velocity(Vector3.ZERO)`。
|
||||
|
||||
### 按 Q 投掷
|
||||
- `player_pickup.gd` 接入 `throw_carry`(Q 键)→ `_throw()`:取 `-_hold_pos.global_transform.basis.z` 作投掷方向(持握点挂在摄像机下且仅有平移,其 basis 即视线朝向,故无需再注入摄像机),`set_linear_velocity(dir * throw_speed)` 后置 `_current = null`。
|
||||
- 新增导出参数 `throw_speed: float = 12.0`。
|
||||
- 备选手感(注释里留了行):改用 `apply_central_impulse` 则重物飞得慢。
|
||||
|
||||
### 可拖拽时准心变手型
|
||||
- 新增素材 `character/assets/hand_cursor.svg`(新建了 `character/assets/` 目录):简笔手(4 指 + 手掌 + 拇指的圆角矩形),双层 —— 底层深色 `#1a1a1a` stroke-width 4 当描边,上层白色 stroke-width 1.5 覆盖内部接缝。32×32,**未写 `<title>`**(避免 ThorVG 解析风险)。
|
||||
- `ui/crosshair.gd`:加 `class_name Crosshair`;新增 `hand_texture`(自动加载上面的 svg) / `hand_size`(=26)、`set_grabbable(bool)`;`_draw()` 在 `_grabbable && hand_texture != null` 时走 `_draw_hand()`,否则走 `_draw_dot()`。
|
||||
- `player_pickup.gd`:新增 `signal hover_changed(grabbable: bool)`,`physics_step()` 里先调 `_update_hover()`——用 `_ray.force_raycast_update()` 强制刷新(否则结果滞后于本帧 move_and_slide,跑动中瞄到物体慢一帧),**只在状态变化时**发信号。
|
||||
- `player.gd`:`@onready var crosshair: Crosshair = $CameraRig/HUD/Crosshair`;`_ready()` 权威分支里 `pickup.hover_changed.connect(_on_pickup_hover_changed)`;handler 里既切准心,也 `Input.set_custom_mouse_cursor()` 换系统指针(含指尖热点),鼠标未捕获时该行无副作用。
|
||||
- **注意**:用户侧已把 `AnimationPlayer` 从 `Body/AnimationPlayer` 提到 Player 根节点(`$AnimationPlayer`),并把 `animator` 变量改名为 `animation_player`;相机 scale 改为 5。
|
||||
|
||||
### 换用真实光标素材(grab / grab_hover)
|
||||
- 素材来源:`C:\Users\yvmou\Downloads\cursors\light\`(light 主题 = 深色手型,32×32 RGBA)。
|
||||
- 已复制到 `character/assets/grab.png`(指着可拖拽物)与 `character/assets/grab_hover.png`(正抓着),Godot 已生成 `.import`。
|
||||
- **删除了临时的 `character/assets/hand_cursor.svg` 及其 `.import`**(已被真实素材取代)。
|
||||
- `ui/crosshair.gd` 改为三态:`enum GrabState { NONE, HOVER, HOLDING }`;导出 `hover_texture` / `hold_texture` / `hand_size`(=32,1:1 不缩放) / `icon_modulate` / `sync_os_cursor`;公开 `texture_for_state(state)` 供系统指针复用素材;`_sync_os_cursor()` 内部完成 `Input.set_custom_mouse_cursor`(`Engine.is_editor_hint()` 下跳过),热点在图标高度 12% 处 —— **系统指针逻辑已从 player.gd 移入准心**。
|
||||
- `player_pickup.gd`:信号由 `hover_changed(bool)` 改为 `interact_state_changed(hovering, holding)`,`_update_hover()` 改名 `_update_interact_state()` 并同时跟踪 `_holding`。
|
||||
- `player.gd`:handler 改名 `_on_pickup_interact_state_changed(false)`,只负责把两个布尔量收敛成 `Crosshair.GrabState`(优先级 抓着 > 指着 > 没有)。
|
||||
- 待用户确认:像素手在非整数倍分辨率缩放下发糊的话,需把 Crosshair 的 `texture_filter` 设为 Nearest。
|
||||
|
||||
### 拖拽时物体一卡一卡(已修)
|
||||
- **根因(几何 + 控制律)**:`physics_step()` 用 `v = to_hold * pick_power` 是一阶跟随,人匀速前进时物体必有稳态滞后,滞后距离 = 移动速度 / pick_power。当时为 `walk_speed 5 ÷ pick_power 10 = 0.5 m`,而持握点离身体仅 `0.44 m`(InteractionPos local z = -0.4439,Player 缩放 0.2 × Camera 缩放 5 = 净 1.0,故世界距离就是 0.444 m)→ 物体退进玩家胶囊体 → 物理推出 / 拖拽拉回互相打架 → 高频抖动。
|
||||
- **修复 1(前馈)**:`desired = to_hold * pick_power + _body.velocity` —— 匀速行走时滞后归零。这是根治。
|
||||
- **修复 2(碰撞例外)**:`_grab()` 里 `_current.add_collision_exception_with(_body)`,`_drop()` / `_throw()` 经 `_forget()` 用 `remove_collision_exception_with` 解除。只解除「物体 ↔ 玩家」这一对,物体与场景其他碰撞体照常交互。
|
||||
- 另加 `@export var max_drag_speed: float = 30.0` 速度上限,物体离得远时不会被一股脑拽飞。
|
||||
- **接口变更**:`PlayerPickup.setup(ray, hold_pos, body)` 多了第三个参数(玩家本体),`player.gd` 里传 `self`。`_grab()` 加了 `is_instance_valid(_current)` 早退,避免重复抓取时漏掉碰撞例外。
|
||||
|
||||
### 玩家缩放层级(查明,供后续改尺寸参考)
|
||||
- `character/player/player.tscn` 的缩放链:`Player ×0.2` → 子节点 Collision(胶囊 r0.545/h1.406,本地 y0.703) / CameraRig(眼高 y1.6) / Body/mouse(×0.1824) 累计 ×0.2。
|
||||
- `Camera ×5` **是补偿值**,用来把相机子树抵消回世界缩放 1.0(0.2×5=1.0),因此 InteractionRay 世界长度 0.5 m、InteractionPos 世界距离 0.444 m。
|
||||
- 推论:**改 `Player.scale` 必须同步 `Camera.scale = 1 / Player.scale`**,否则交互距离/持握距离会跟着缩。另外 PlayerMotor 的 walk/sprint/jump/accl 是世界单位,不会自动缩放,改尺寸时需手动同倍调整。
|
||||
- 世界尺寸现值:碰撞胶囊半径 0.109 / 高 0.281,眼高 0.32 m。
|
||||
|
||||
### 整体人物缩到 0.6 倍(已实施)
|
||||
- `player.tscn`:`Player` 缩放 0.2 → **0.12**;`Camera` 缩放 5 → **8.333333**(= 1/0.12,保持相机子树净缩放 1.0,**交互距离/持握距离不变**)。Camera 的位置偏移与 CameraRig 的 y=1.6 都自动随父级缩放,未改。
|
||||
- `player_motor.gd`:walk 5.0→**3.0**、sprint 8.5→**5.1**、acceleration 45→**27**、air_acceleration 12→**7.2**,均为 ×0.6;jump_velocity 5.0→**3.87**(×√0.6,因跳跃高度 = v²/2g,直接乘 0.6 会跳得偏矮)。
|
||||
- `player_animator.gd`:**基准速度也必须 ×0.6** —— walk 5.0→3.0、run 8.5→5.1、crouch 2.5→1.5、sneak 1.6→0.96。原因:模型缩到 0.6 倍后,同一片段一个循环跨出的世界距离也缩到 0.6 倍,不改基准速度脚步会明显打滑。同时修正了 `_resolve_state()` 里那句「jump 片段 0.5s ≈ 5/9.8 上升时间」的过时注释。
|
||||
- 未改动:`pick_power` 10(新滞后 3.0/10 = 0.3 m < 0.444 m 持握距离,且已有速度前馈,安全)、`throw_speed` 12(被扔的物体本身没缩,投掷距离不该变)、`mouse_sensitivity`(纯角度)。
|
||||
- 缩放后世界尺寸:胶囊半径 0.0654 / 高 0.169,眼高 0.192 m;跳跃绝对高度 1.276 → 0.766 m(相对身高观感不变)。
|
||||
|
||||
### 再缩到「视觉高度 0.15 m」(已实施)
|
||||
- **口径确认(重要)**:用户选的是**视觉模型高度**,不是碰撞胶囊。两者不等:
|
||||
- 模型:glb 顶点原始高 `8.557729`(min y -4.7746 / max y 3.7832)× mouse 节点缩放 `0.18240134` = 本地 1.560941;世界 = ×0.12 = **0.187313 m**(当时)
|
||||
- 胶囊:本地 1.4063476 × 0.12 = **0.1687617 m**
|
||||
- 模型底部在本地 y ≈ -0.0007(脚踩原点,与胶囊底部对齐),故测量可信。模型比胶囊高 11%(多出耳朵/尾巴)。
|
||||
- 缩放系数 `k = 0.15 / 0.187313 = 0.800799`。
|
||||
- `player.tscn`:`Player.scale` 0.12 → **0.096096**;`Camera.scale` 8.333333 → **10.406274**(= 1/0.096096)。
|
||||
- `player_motor.gd`:walk 3.0→**2.4024**、sprint 5.1→**4.0841**、accel 27→**21.6216**、air_accel 7.2→**5.7658**、jump 3.87→**3.4632**(×√k)。
|
||||
- `player_animator.gd`:walk_ref 3.0→**2.4024**、run_ref 5.1→**4.0841**、crouch_ref 1.5→**1.2012**、sneak_ref 0.96→**0.7688**。
|
||||
- 注释已改成「按同一系数同步缩放」的通用表述,不再写死 0.6,避免下次再失效。
|
||||
- 结果:胶囊世界 半径 0.05241 / 高 0.13514,眼高 0.08729 m,跳跃绝对高度 ≈ 0.612 m;拖拽滞后 = 2.4024/10 = 0.24 m < 0.444 m 持握距离,安全。
|
||||
- **可复用公式**:`Player.scale = 目标世界高 / (模型本地总高)`,`Camera.scale = 1 / Player.scale`,速度与动画基准速度均 ×k(jump 用 ×√k)。
|
||||
|
||||
### 按 0.15 m 身高重新设计移动参数(已实施)
|
||||
- 用户明确:「不用管之前,按 0.15 的身高重新设计」→ 采取**以人类为参照的等比设计**,不再依赖旧基准。
|
||||
- 设计基准:人 1.75 m,游戏惯例 walk 5.0 / sprint 8.5 / accel 45 / air_accel 12 / jump 5.0(跳跃高度 1.28 m ≈ 0.73 身高)。系数 `k = 0.15 / 1.75 ≈ 0.0857143`。
|
||||
- `player_motor.gd`:walk 2.4024→**0.4286**、sprint 4.0841→**0.7286**、accel 21.6216→**3.8571**、air_accel 5.7658→**1.0286**、jump 3.4632→**1.4639**(×√k)。
|
||||
- `player_animator.gd`:walk_ref 2.4024→**0.4286**、run_ref 4.0841→**0.7286**、crouch_ref 1.2012→**0.2143**、sneak_ref 0.7688→**0.1371**。
|
||||
- 校验:两边都是「走路 = 2.86 身长/秒」「跳跃 = 0.73 身高」,比例一致。加速度给出约 0.11 秒加到全速;跳跃高度 0.109 m。
|
||||
- 拖拽滞后 = 0.4286/10 = 0.043 m ≪ 0.444 m 持握距离,安全。
|
||||
- **未改动**(有意保持):交互距离/持握距离(射线 0.5 m、持握 0.444 m —— 若按 k 缩到 0.038 m 会够不到任何东西)、`pick_power` 10、`throw_speed` 12(被扔物体是世界尺寸,未缩)、`mouse_sensitivity`(纯角度)。
|
||||
- **世界尺度查不到可靠线索**:`world/main2.tscn` 有 295 个 `Transform3D`,但混入了嵌套实例的局部变换,位置跨度(X33/Z57)不可信;198 个实例缩放为 1.0,资产疑似米制。若实际关卡跨度大、用户觉得慢,把 walk/sprint 等线性量乘同一倍数即可。
|
||||
|
||||
### 蹲伏真正生效(已实施)
|
||||
- 背景:**蹲下改速度、改摄像机高度这两件事从来没实现过**(原 `player.gd` 也只把 `crouching` 传给动画器切 CROUCH/SNEAK 片段),不是回归。
|
||||
- `player_motor.gd`:新增 `@export var crouch_speed: float = 0.2143`(= walk 的一半 = 2.5×k)。`step()` 里改为**蹲伏优先于疾跑**:`sprinting = wants_sprint and not crouching`,速度按 `crouch_speed > sprint_speed` 优先级取。
|
||||
- `player_look.gd`:新增蹲伏组 `crouch_height_ratio`(=0.6) / `crouch_transition_speed`(=10);`setup()` 从场景读 `_stand_height = rig.position.y`(不写死 1.6);新增 `set_crouching(bool)` 设目标高度;`_process()` 用 `1 - exp(-k·delta)` 指数收敛平滑升降(**渲染帧**,物理帧会一顿一顿);`_ready()` 里 `set_process(false)`,`setup()` 才开 —— 远端玩家不调 setup 就不会跑。
|
||||
- `player.gd`:`_physics_process` 里 `motor.step(delta)` 之后加 `look.set_crouching(motor.crouching)` —— **蹲伏状态由 Motor 单点判定**,不让 Look 自己读蹲键,避免两处各判一次不同步。
|
||||
- `player_animator.gd`:`sneak_ref_speed` 0.1371 → **0.2143**,与 `crouch_speed` 对齐 —— 蹲着走播的就是 SNEAK 片段,两边不等会打滑(原来会以 1.56× 播放,逼近 1.6 的 clamp 上限)。注释里写明了这条配对关系。
|
||||
- 世界高度:站立 `1.6 × 0.096096 = 0.1538 m` → 蹲下 `0.0923 m`。
|
||||
- **未做(可以后续补)**:蹲下时没有缩小碰撞胶囊 —— 所以现在还钻不过低矮缝隙。要做需要额外处理「头顶有障碍时不许起身」的检测。
|
||||
|
||||
### 关于「重力是否该随角色缩小」(已分析,待用户定夺)
|
||||
- **物理结论**:不该变。重力加速度是行星属性,与物体大小无关。
|
||||
- **但设计上是真问题**:我们的速度按 k=0.0857 等比缩了,重力却没缩,导致**落体相对过快**。
|
||||
- 落「自身身高」用时:人类 (1.75 m, g=9.8) = 0.598 s;本角色 (0.15 m, g=9.8) = 0.175 s,**快 3.4 倍**。
|
||||
- 若 `g = 9.8 × k = 0.84`:本角色也是 0.598 s,与人类完全一致(缩小版人类)。
|
||||
- **顺带暴露的问题**:`jump` 片段长 0.5 s。现在 g=9.8、jump_velocity=1.4639 → 上升仅 **0.149 s**,只播得完片段的 30%。若 `g=0.84` 且 `jump_velocity = walk × 1.0 = 0.4286`,上升 = **0.51 s**,正好等于片段长度 —— 回到原始设计(原来 jump 5.0 / g 9.8 = 0.51 s)里那种吻合。
|
||||
- **实现方式(已落地为旋钮)**:`PlayerMotor` 新增 `@export_range(0.0, 2.0, 0.01) var gravity_scale: float = 1.0`,`step()` 里 `_body.velocity.y -= _gravity * gravity_scale * delta`(**每帧现读**,方便运行中拖 Inspector 试手感)。默认 1.0 = 行为完全不变。**绝不要改 `ProjectSettings physics/3d/default_gravity`** —— 那是全局的,会让场景里所有 RigidBody 一起变飘,包括拖拽/投掷的物体。
|
||||
- 三个候选:`9.8`(物理真实,落体快、跳跃动画对不上)/ `2.87`(×√k,折中)/ `0.84`(×k,缩小版人类,跳跃动画吻合,但比月球 1.62 还飘)。
|
||||
- **⚠ 配对规律**:重力 × s 时 `jump_velocity` 也要 × s,否则跳跃高度按 1/s 暴涨。现在是 `g=9.8 + v=1.4639 → 高 0.109 m`;若 `g=0.84` 而 v 不变,跳跃高会变成 **1.28 m(8.5 个身高)**;正确配对是 `g=0.84 + v=0.4286`。这条已写进 `gravity_scale` 的代码注释。
|
||||
- 用户尚未选定最终值。
|
||||
|
||||
### 跳跃「高度过低、落地太快」的调法(已给方案,待用户选)
|
||||
- 问题:现状 g=9.8 + jump_velocity=1.4639 → 跳高 **0.109 m**(0.73 身高)、**滞空仅 0.30 s**,跳一下几乎立刻落地。
|
||||
- **核心关系**:`h = v²/2g`、`T = 2v/g` → 合起来 `h = g·T²/8`。
|
||||
→ **固定重力下,滞空翻倍 = 高度涨 4 倍**。所以「既要跳得久、又不跳太高」只能靠**降重力**,光提 `jump_velocity` 做不到。
|
||||
- **反解公式**(定目标高度 h 与滞空 T):`g = 8h/T²`(→ gravity_scale = g/9.8),`jump_velocity = 4h/T`。
|
||||
- 三个候选(角色 0.15 m,1 身高 = 0.15 m):
|
||||
|
||||
| 方案 | gravity_scale | jump_velocity | 跳高 | 身高倍数 | 滞空 | 落地时长 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 现状 | 1.0 | 1.4639 | 0.109 m | 0.73 | 0.30 s | — |
|
||||
| A 只提跳跃 | 1.0 | 2.10 | 0.225 m | 1.50 | 0.43 s | 不变 |
|
||||
| B 只降重力 | 0.5 | 1.4639 | 0.219 m | 1.46 | 0.60 s | ×1.41 |
|
||||
| **C 组合(推荐)** | 0.5 | 1.50 | 0.230 m | 1.53 | 0.61 s | ×1.41 |
|
||||
|
||||
→ A 和 B 高度几乎一样,但 B 的滞空长 40% —— 这正是「降重力」的价值。
|
||||
- 副作用:g=4.9 时从 1 m 高落下要 0.639 s(原 0.452 s)。仍远好于等比 g=0.84(比月球还飘)。
|
||||
- jump 动画片段 0.5 s:现状只播得完 30%,C 方案升到 61%。要让上升时间完全等于 0.5 s 需 T=1.0 s → g 低到 1.84(接近月球),不建议。
|
||||
- **可选改进(未实施)**:把 `jump_velocity` 换成「跳跃高度 `jump_height`(米)」—— 设计上更直观,且改重力时高度自动保持,不会出现「改重力后跳高暴涨」的坑。
|
||||
|
||||
### 跳跃参数定案:方案 C(已实施)
|
||||
- 用户问「推荐哪个方案」→ 定 **C**:`gravity_scale = 0.5`(有效 g = 4.9)、`jump_velocity = 1.5`。
|
||||
- 结果:跳高 **0.230 m(1.53 身高)**、滞空 **0.612 s**、上升 0.306 s;从 1 m 高落下 0.639 s(原 0.452 s,×1.41)。
|
||||
- 选 C 不选 A(只提 jump_velocity、保持 g=9.8)的理由:同样 0.23 m 的高度,A 只有 0.43 s 滞空,落地仍嫌仓促;C 多出 0.18 s。而且 C 让 jump 片段能播完约六成(A 只有四成)。
|
||||
- 没选等比方案(`gravity_scale = 0.0857`):那比月球(1.62)还飘。
|
||||
- 代码注释已整理:`jump_velocity` 与 `gravity_scale` 标注为**配对参数**,并写入反解公式 `g = 8h/T²`、`v = 4h/T`;`player_animator.gd` 里那句引用旧值的上升时间注释也改准了(1.5 / 4.9 ≈ 0.31 s)。
|
||||
|
||||
### 贴墙时摄像机穿模(已修)
|
||||
- **根因(纯几何,差 8 毫米)**:
|
||||
| 项 | 值 | 来源 |
|
||||
|---|---|---|
|
||||
| 摄像机 `near` | **0.05 m** | Godot 默认,`player.tscn` 未覆盖 |
|
||||
| 摄像机前移量 | **0.01074 m** | `Camera` 本地 Z = −0.1118 × Player 缩放 0.096096 |
|
||||
| 贴墙时身体离墙 | **0.05241 m** | 胶囊局部半径 0.5454 × 0.096096 |
|
||||
| **摄像机到墙** | **0.04167 m** | 0.05241 − 0.01074 |
|
||||
→ `0.0417 < 0.05`,墙面落在近裁剪面**内部**被整个裁掉,于是看到墙的背面/内部。
|
||||
- **修复 1**:`player.tscn` 的 `Camera` 加 `near = 0.01`(留 4 倍余量)。项目是 Forward+(反向 Z 深度),near 缩小对深度精度影响很小。`far` 保持默认未动。
|
||||
- **修复 2**:`player.gd` 新增 `@onready var model: Node3D = get_node_or_null("Body/mouse") as Node3D`,在权威分支里 `model.visible = false` —— 原来正是靠 near=0.05 把自己的身体裁掉才看不见,near 调小后身体会露出来。只影响本地玩家,远端玩家各自的模型实例不受影响。
|
||||
- 代价:自己也看不到自己的影子(`visible=false` 会关掉阴影)。想保留影子可改用「本地玩家的 MeshInstance3D 放到独立视觉层 + 本地摄像机 cull_mask 排除该层」,但要注意该层不能和远端玩家共用,否则本地相机也看不到别人。
|
||||
- **备选方案(未采用)**:把胶囊半径加大到 > 0.06 m 让摄像机永远离墙更远 —— 会改变玩法(体型变胖);或把 `Camera` 的前移量改小 —— 会动视角位置。
|
||||
|
||||
### 附带发现的 bug:复制配置里的节点路径过期(已修)
|
||||
- 用户此前把 `AnimationPlayer` 从 `Body/AnimationPlayer` 移到 Player 根节点(`$AnimationPlayer`),但 `player_sync.tres` 里两条路径仍写着 `Body/AnimationPlayer:anim_state` / `Body/AnimationPlayer:anim_speed` → 这两个属性根本没被复制,**远端玩家的动画状态不会同步**。
|
||||
- 已改为 `AnimationPlayer:anim_state` / `AnimationPlayer:anim_speed`。
|
||||
|
||||
## ⚠ 项目约定(用户明确要求)
|
||||
**改 Godot 的 .tscn / .tres / .gd,一律走 godot-ai MCP,不要直接写文件。** 原因见下:外部直接写 .tscn 会和编辑器的文件监视重载(`_reload_modified_scenes`)竞态,导致编辑器读到中间状态、报出**假的解析错误**。
|
||||
- 可用工具:`scene_open` / `scene_save` / `node_set_property` / `node_get_properties` / `node_create` / `node_manage` / `script_manage(find_symbols 可当解析校验)` / `script_create`(带诊断) / `filesystem_manage(read_text/write_text/scan/reimport)` / `resource_manage` / `logs_read` / `editor_manage(state)` / `project_run`。
|
||||
- 调用形式:多数工具是「扁平参数」(如 `scene_open {path}`),管理类工具是 `{op, params}` 双层(如 `editor_manage {op:"state"}`)。`node_*` 的 path 是**相对当前编辑场景根**(如 `/Player/CameraRig/Camera`),不是 `/root/...`。
|
||||
|
||||
## player.tscn 解析错误事件(已解决)
|
||||
- 现象:`Parse Error ... player.tscn:38` in `_parse_node_tag`,场景彻底打不开。
|
||||
- **真实原因:不是文件语法错误。** 我用 MCP `node_set_property` 设 `Camera.near = 0.01` 后 `scene_save`,编辑器写出的内容与我手写的**字节完全一致**(同为 5106 字节),且 `scene_open(force_reload=true)` 立即 `reloaded_from_disk: true` 加载成功。
|
||||
→ 根因是**外部直接写 .tscn 与编辑器文件监视及重载的竞态**。
|
||||
- 诊断过程中的有效手段(可复用):
|
||||
1. `editor_manage(op="state")` 看当前场景与就绪状态
|
||||
2. `scene_open` 做**对照实验**(打开一个已知正常场景,若 `switched:true` 则说明工具可靠、目标文件真的坏了)
|
||||
3. `logs_read(source="editor", include_details=true)` 拿准确报错函数与行号
|
||||
4. 用 `node_create` 在**内存里**加临时节点 + `node_get_properties` 探测属性真名(**不保存、不落盘**,随后 `node_manage(op="delete")` 清掉)—— 这次用它确认了 `Camera3D.near` 是合法 float 属性(默认 0.05,`far` 默认 4000)
|
||||
5. `script_manage(op="find_symbols")` 可当脚本**解析校验**器;`filesystem_manage(op="scan")` 重建 `class_name` 全局表
|
||||
- 修复结果:`player.tscn` 的 `Camera` 现有 `near = 0.01`(MCP 设置并保存),场景可正常加载。
|
||||
|
||||
### 日志里发现的其它线索(多为历史记录,非当前问题)
|
||||
- `Could not find type "PlayerLook/PlayerMotor/PlayerPickup/Crosshair"`、`Identifier "animator" not declared` —— 都是**旧状态的历史日志**;`find_symbols` 证明 player.gd 现已正常,且 `filesystem_manage(scan)` 返回 `global_classes_registered_delta: 0`(类名表本是全的,共 75 个)。
|
||||
- `crosshair.gd:133 _draw 里 texture_for_state 收到 Nil` —— `@tool` 脚本热重载时旧实例被新代码调用的一次性产物,非当前 bug。
|
||||
- `player.tscn` 的 **Crosshair 纹理疑似接反**:`hover_texture = grab_hover.png`、`hold_texture = grab.png`。按我的语义(hover=指着可拖拽物 / hold=正抓着)应互换;但若按光标包命名(`_hover` 后缀表示悬停态)则现状正确。**未擅自改动,已向用户指出。**
|
||||
- 遗留警告(非本次引入):`face_painter.gd:153` 整数除法、`face_rig.gd:190` 局部变量 `name` 遮蔽 Node.name、`paint_panel.gd:69` 局部变量 `scale` 遮蔽 Control.scale、`Cannot load shader: res://shader/stylized.gdshader`。
|
||||
@@ -0,0 +1,220 @@
|
||||
# 2026-10-10
|
||||
|
||||
## 新增右上角调试面板(DebugPanel)
|
||||
|
||||
**需求**:右上角显示玩家移动速度、相机高度等信息。
|
||||
|
||||
**新增文件**
|
||||
- `ui/debug_panel.gd`(`class_name DebugPanel extends Control`)—— 单节点 `_draw()` 实现,
|
||||
不用 Label 节点。理由:要自己控制两列对齐(标签左对齐、数值左对齐、列间距固定),
|
||||
不必去猜字体度量。总刷屏用 `draw_rect` 画底板。
|
||||
|
||||
**显示内容**(10 Hz 刷新,`refresh_interval` 可调)
|
||||
| 行 | 内容 |
|
||||
|---|---|
|
||||
| FPS | `Engine.get_frames_per_second()` |
|
||||
| 移动速度 | 水平速度 m/s + 身长/秒 |
|
||||
| 垂直速度 | `velocity.y` |
|
||||
| 相机高度 | 相机相对脚底的世界高度 m + 倍身高 |
|
||||
| 状态 | 站立/蹲下/疾跑 · 地面/空中 |
|
||||
| 位置 | 世界 X, Z |
|
||||
| 手持 | 拖拽中 / 可抓取 / — |
|
||||
|
||||
**关键设计点**
|
||||
- 相机高度用 `_camera.global_position.y - _player.global_position.y`(世界 y 相减),
|
||||
**不是**读 `CameraRig.position.y` —— 后者是本地坐标还要乘玩家缩放,且相减法对
|
||||
「蹲伏过渡中途」这种非稳态也直接成立。
|
||||
- `body_height`(默认 0.15)只用于换算「身长/秒」和「倍身高」,**和 `player.tscn` 的
|
||||
`Player.scale` 绑定,改角色尺寸时要同步改**。
|
||||
- `mouse_filter = IGNORE` 在 `_ready()` 里显式设置(见下方 MCP 限制),
|
||||
否则这个全屏 Control 会吃掉所有鼠标事件,鼠标视角和拖拽全会废掉。
|
||||
- `visible` 默认 false,`setup()` 里才置 true —— 避免远端玩家的「回放身体」
|
||||
闪出一块「未接线」面板。
|
||||
- F3 切换显隐(`toggle_key`)。`Key` 类型可以直接 `@export`。
|
||||
- 挂在 `CameraRig/HUD` 下(和准心同级)→ 远端玩家的 HUD 随 `camera_rig.queue_free()` 一起释放,
|
||||
天然只有本地玩家显示。
|
||||
|
||||
**改动文件**
|
||||
- `character/player/player.tscn`:`HUD` 下新增 `DebugPanel (Control)`,
|
||||
锚点 Full Rect(`anchor_right`/`anchor_bottom` = 1.0),挂 `ui/debug_panel.gd`。
|
||||
- `character/player/player.gd`:加 `@onready var debug_panel: DebugPanel = $CameraRig/HUD/DebugPanel`
|
||||
和 `_ready()` 里 `debug_panel.setup(self, motor, pickup, camera)`。
|
||||
- `character/player/player_pickup.gd`:新增公开 getter `is_hovering()`(配 `has_object()`)。
|
||||
|
||||
---
|
||||
|
||||
## ⚠ 本次踩到的 Godot MCP 限制(重要,可复用)
|
||||
|
||||
### 1. 编辑器在 play 模式时 MCP 拒绝一切写入
|
||||
`EDITOR_NOT_READY / EDITOR_PLAYING`。必须先 `project_manage(op="stop")`。
|
||||
(本次用户选择「我来停」,停完 `editor_manage(op="state")` 确认 `readiness: ready`。)
|
||||
|
||||
### 2. MCP 写不了整型属性
|
||||
`node_set_property` 传整数会变成 String → `WRONG_TYPE: Cannot write String to a int property`。
|
||||
**浮点/字符串正常**。受影响的:`mouse_filter`、`grow_horizontal`、`anchors_preset`。
|
||||
→ 绕过办法:
|
||||
- 锚点:改设 `anchor_right` / `anchor_bottom` = **1.0**(浮点可写);编辑器保存时会自动补
|
||||
`anchors_preset = 15` + `layout_mode = 3`,效果与准心节点完全一致。
|
||||
- 整型语义(如 `mouse_filter`):**写进脚本里**(`_ready()` 里设),别依赖场景属性。
|
||||
|
||||
### 3. 假 parse error(本项目的顽固问题,第二次遇到)
|
||||
`script_create` / `script_patch` / `filesystem_manage(write_text)` 的 `diagnostics` 会对
|
||||
**完全合法的文件**报 `gdscript_reload_failed, error_code 43 (ERR_PARSE_ERROR)`,
|
||||
`line` 是 `fallback_line: true`(≈ 文件总行数,不可信)。
|
||||
本次 `ui/debug_panel.gd`、`player_pickup.gd` 都被这样误报过,而真实项目上
|
||||
`--check-only` 全部通过。**判定:这是编辑器热重载产物,不是语法错误。**
|
||||
教训:**MCP 的 diagnostics 只能当提示,不能用它判定脚本坏了。**
|
||||
|
||||
### 4. 可靠的脚本语法校验方法(离线,不污染项目)
|
||||
Godot 可执行文件路径:
|
||||
`D:\_yvmou\Godot\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe`
|
||||
(从 `Get-Process | Where ProcessName -like "*godot*" | Select Path` 拿到)
|
||||
|
||||
直接对真实项目跑(安全,`--check-only` 解析完即退):
|
||||
```bash
|
||||
"$GODOT" --headless --path "E:/Godot/agame" --check-only --script "res://ui/debug_panel.gd"
|
||||
```
|
||||
**输出为空 = 通过**。
|
||||
|
||||
沙箱法(想彻底不碰项目时):临时目录建最小 `project.godot`,拷入脚本及其依赖,
|
||||
先 `--editor --quit` 生成 `.godot/global_script_class_cache.cfg`(否则会误报
|
||||
「Could not find type」——注意**类型解析失败也是报 Parse Error**),再 `--check-only`。
|
||||
|
||||
### 5. 字体(中文能否渲染)
|
||||
实测:`ThemeDB.fallback_font` **就是**默认主题字体(`FontFile` / "Open Sans SemiBold"),
|
||||
`allow_system_fallback = true`,系统有 Microsoft YaHei/SimSun。
|
||||
注意 `has_char("点")` 返回 **false**(`has_char` 不查系统回退链),但排版时回退生效:
|
||||
「移动速度相机高度」8 字量出 128px = 16px/字,字形确实取到了。
|
||||
→ **结论:`_draw()` + `ThemeDB.fallback_font` 画中文是可行的,不必额外挂字体资源。**
|
||||
|
||||
### 6. 其他
|
||||
- `custom_manage(op="list")` 可列出第三方 addon 注册的自定义工具。
|
||||
- `editor_manage(op="state")` 能看 play 状态;`logs_read(source="editor")` 只看得到
|
||||
脚本 reload 的 warning,拿不到 GDScript 的详细 parse 报错文本(stderr 到编辑器控制台)。
|
||||
- 用户 `world/main2.tscn` 是编辑器常驻场景,用完要**还原回去**(本次已还原)。
|
||||
|
||||
---
|
||||
|
||||
## `player_sync.tres` 为什么在 Godot 里「不可编辑」
|
||||
|
||||
- `SceneReplicationConfig` **没有任何 `@export` 属性**,类上只有 `add_property` / `remove_property` /
|
||||
`property_set_*` 这些**方法** → 通用 Inspector 渲染出来是空的,所以双击 `.tres` 看起来「不能编辑」。
|
||||
- 这不是错觉:Godot 4.7 二进制里有 `ReplicationEditor` / `Replication Dock` / `Remove Property` /
|
||||
`Replication Mode`(下拉枚举 `Never,Always,On Change`)/ `ReplicationInterface`,
|
||||
但**没有** `SceneReplicationConfigEditor` —— 即不存在独立的资源编辑器。
|
||||
- **正确改法**:选中场景里的 `Sync` 节点(`MultiplayerSynchronizer`)→ Inspector 的 Replication 区
|
||||
→ 添加/删除属性、勾 Spawn、选 Replication Mode → 保存场景(外部 .tres 会被一并写盘)。
|
||||
- 运行时可在调试器里看同步情况(`ReplicationInterface` / `ReplicationProfiler`)。
|
||||
|
||||
### 顺带发现:`.:health` 是失效路径
|
||||
`character/player/player.gd` 上**没有** `health`(`health` 只存在于 `demo/` 那套
|
||||
`agent_base.gd` + `Health` 节点)。同类「路径过期」问题已第三次出现
|
||||
(前两次是 `Body/AnimationPlayer:anim_state` / `anim_speed`)。
|
||||
当前 5 条里 4 条有效:`.:position`、`Body:rotation`、`AnimationPlayer:anim_state`、`AnimationPlayer:anim_speed`。
|
||||
|
||||
原 5 条全是 `replication_mode = 1`(Always,每处理帧持续发)。`anim_state` / `anim_speed`
|
||||
是低频变化量,改用 `On Change`(2,可靠传输、变化才发)更省带宽。
|
||||
|
||||
### 已处理(用户确认后动手)
|
||||
`player_sync.tres` 现为 4 条:
|
||||
|
||||
| # | 路径 | spawn | mode |
|
||||
|---|---|---|---|
|
||||
| 0 | `.:position` | true | 1 Always |
|
||||
| 1 | `Body:rotation` | true | 1 Always |
|
||||
| 2 | `AnimationPlayer:anim_state` | true | 2 On Change |
|
||||
| 3 | `AnimationPlayer:anim_speed` | true | 2 On Change |
|
||||
|
||||
- **改法**:MCP `filesystem_manage(write_text)` 直接写 .tres 文本 → `scan()` → `reimport()`。
|
||||
这个资源**没有编辑器 UI,也不存在「编辑器打开着」的竞态**,所以写文本是安全的
|
||||
(与 .tscn 的情况不同)。`reimport` 对 .tres 只会刷新文件条目(`skipped_non_imported`)。
|
||||
- **验证手段(值得复用)**:把手写的 `.tres` 拷进最小沙箱工程,用
|
||||
`--headless --path <沙箱> --script res://check.gd` 跑一段 `extends SceneTree` 的脚本,
|
||||
`load()` 它并打印 `get_properties()` / `property_get_spawn()` /
|
||||
`property_get_replication_mode()` —— 这是**唯一能验证手写 .tres 真能解析**的办法。
|
||||
实测输出:4 条,mode = 1,1,2,2 ✓
|
||||
- `resource_manage(op="load")` 对 `SceneReplicationConfig` 只返回 3 个属性
|
||||
(`resource_local_to_scene` / `resource_path` / `resource_name`),
|
||||
`op="inspect"` 直接拒绝(不在它支持的原生资源族里)。
|
||||
- `Always`(1) 用不可靠传输、每帧持续发;`On Change`(2) 用可靠传输、值变了才发。
|
||||
连续变化的量(position / rotation)用 `Always` 是对的,低频量才换 `On Change`。
|
||||
|
||||
### 决定:保持外部 .tres,不内嵌(用户明确选择「不改」)
|
||||
调研过三条替代路,全部放弃,**以后不必再提**:
|
||||
- **A 内嵌 `sub_resource`**:功能零损失、可少一个文件,但给配置加条目要调 `add_property()`
|
||||
—— 那是**方法不是属性**,MCP 只能读写属性(`custom_manage` 也返回 0 个自定义工具),
|
||||
最后 4 行只能用户在 Replication 面板手点。不值得为省一个文件付这个手工成本。
|
||||
- **B 脚本里构造**(`MultiplayerSynchronizer` 上挂脚本,`_ready()` 里 new 一个 config 再赋给
|
||||
`replication_config`):会让编辑器 Replication 面板与实际同步内容**脱节**,
|
||||
且运行时赋值能否赶在同步器开始工作前生效**未实测**。放弃。
|
||||
- **C 弃用 Synchronizer 改 `@rpc`**:`world/main.tscn` 用 `MultiplayerSpawner` + `main.gd` 的
|
||||
`spawn_function` 生成玩家,「新 peer 拿到当前动画状态」是 `spawn = true` 白送的;
|
||||
换 RPC 要自己补初始状态 / 节流 / 丢包策略。为 4 个值不划算。
|
||||
- 根本原因:`.tres` 的"缺点"只是不能双击打开,而**编辑入口一直是 Sync 节点的 Replication
|
||||
面板**(换成 A 也是同一个面板)。
|
||||
|
||||
### `/Player/Sync` 节点实测值
|
||||
`root_path = ".."`(默认值 = 父节点 Player,这就是 `.:position` 指 Player 的原因)、
|
||||
`delta_interval = 0.0`、`public_visibility = true`、`visibility_update_mode = 0`。
|
||||
`replication_config` **不在** `node_get_properties` 可读属性里(被 ReplicationEditor 面板接管)。
|
||||
|
||||
### 补充:外部 .tres 改动的落盘机制(用户追问「tres 不是不可编辑吗」)
|
||||
- 准确说法:`player_sync.tres` **没有自己的编辑界面**,但**内容可以改**,入口是 Sync 节点的
|
||||
Replication 面板。这是两件事,别混。
|
||||
- 根因链:`SceneReplicationConfig` 没有任何 `@export` 属性(只有方法)→ 通用 Inspector 渲染不出
|
||||
可编辑项 → Godot 只好把专用 UI(`ReplicationEditor`)挂在**使用它的节点**
|
||||
(`MultiplayerSynchronizer`)上。实测证据:`resource_manage(op="load")` 只回 3 个元数据属性
|
||||
(`resource_local_to_scene` / `resource_path` / `resource_name`)—— 这就是你双击它看到的全部内容。
|
||||
- **编辑器加载的那份 .tres 实例就是项目文件本身**,所以面板里改它会**写回 `.tres`**,重启后仍在
|
||||
(与 `duplicate()` 出来的副本不同 —— 这既是它"生效"的原因,也是 Godot 著名的「资源被意外共享
|
||||
修改」陷阱的同一机制)。改完记得保存场景。
|
||||
- 真正做不到的只有一件事:**手改文本时没有校验**。NodePath 写错编辑器不会拦 ——
|
||||
`.:health`、`Body/AnimationPlayer:anim_state` 两次事故都源于此。要改路径请走面板下拉选,
|
||||
别手打。
|
||||
|
||||
### 项目事实:资源类的三种来源(排查中确认)
|
||||
- `demo/ai/trees/*.tres` 的类型是 `BehaviorTree` / `BlackboardPlan`,**但这两个类不在任何 .gd 里** ——
|
||||
`addons/limboai/` 只有 `bin/*.so`(GDExtension,C++ 编译产物),一个 `.gd` 都没有。
|
||||
所以它们是**插件用 C++ 注册的 Resource 类**,用起来和引擎内置类没区别(LimboAI 的 C++ 版)。
|
||||
- 项目里目前**没有任何自己写的 `extends Resource` 脚本**(排除 addons)。
|
||||
已存在的 `.tres` 类型:`StandardMaterial3D`(素材包材质)、`SceneReplicationConfig`(player_sync)、
|
||||
`BehaviorTree`(limboai)。
|
||||
- 没有二进制 `.res`。
|
||||
- 判据:**`@export` 就是「能不能在 Inspector 里编辑」的分水岭**。`SceneReplicationConfig` 一个
|
||||
`@export` 都没有 → 打开是空的;自定义 Resource 有 → 双击就能编辑。
|
||||
|
||||
### 调试器面板截图排查(15:58 用户贴图)
|
||||
**三条警告**都是历史遗留、与近期改动无关,来自项目加载时的 `GDScript::reload` 全量重载:
|
||||
- `face_painter.gd:153` 整数除法 —— **不是 bug**。`_stamp_d` 恒为偶数(第 138 行 `d = r * 2`),
|
||||
整除不丢精度。要消警告用 `@warning_ignore("integer_division")` + 注释说明,**不要**改成浮点。
|
||||
- `face_rig.gd:190` 局部变量 `name` 遮蔽 `Node.name` → 改名(如 `entry_name`)即可。
|
||||
- `paint_panel.gd:69` 局部变量 `scale` 遮蔽 `Control.scale` → 改名(如 `factor`)即可。
|
||||
|
||||
**那条错误 `net.gd:66 host_game(): Couldn't create an ENet host.` 也不是 bug**:
|
||||
日志证据显示当时**两个实例同时在跑** —— 主机(peer 1)已占住 7000 端口,第二个实例点「创建主机」
|
||||
必然失败(这是预期路径),随后它点「加入」**成功连上**(日志里有完整握手 + 双方各自生成角色)。
|
||||
功能正常,唯一可改进的是错误消息不够人话。
|
||||
|
||||
排查要点:**`user://logs/godot.log` 是「按项目」共享的,多实例会并发写同一个文件**,
|
||||
所以日志行会交错(截图里 `enet_connection.cp` 被从中间截断就是并发写的痕迹)。
|
||||
判据:`[Net] 本端 1 …` 和 `[Net] 本端 1484785418 …` 同时出现 = 两个进程。
|
||||
|
||||
### ⚠ 两条重要的方法论修正(都靠实测证伪)
|
||||
|
||||
**1. 之前说的「假 parse error」成因找到了 —— 不是假错,是热重载场景内的脚本。**
|
||||
`script_patch`/`script_create` 返回 `gdscript_reload_failed (error_code 43)` 的文件,
|
||||
全部都是**挂在「当前编辑器打开的那个场景」里的节点上的脚本**:
|
||||
`debug_panel.gd`(HUD/DebugPanel)、`player_pickup.gd`(/Player/Pickup)、
|
||||
`face_painter.gd`、`face_rig.gd`(FaceRig) —— 当时开着的正是 `player.tscn`。
|
||||
而 `paint_panel.gd`(在 paint_panel.tscn 里)、`net.gd`(autoload,net.tscn 没开)
|
||||
**都返回 `diagnostics: []` 干净结果**。
|
||||
结论:场景里有活实例时热重载会被判定失败,**文件本身没问题**。
|
||||
想拿干净诊断:先把编辑器切到一个**不含该脚本实例**的场景,或直接用下面的离线校验。
|
||||
|
||||
**2. `--check-only` 只报错、不报警告!**
|
||||
实测探针(`var name := "hello"` + `5 / 2`,两条确定会触发的警告)→ `--check-only` **零输出**。
|
||||
所以「无输出」只能证明**没有语法/分析错误**,**不能**作为「警告已消除」的证据。
|
||||
要确认警告消掉了,必须看编辑器的 Errors 面板(重启编辑器或重新加载项目)。
|
||||
本次三条警告的处理依据是「遮蔽变量已改名 + 所有引用点都改到」(`face_rig.gd` 的 `name`
|
||||
在 190/191/192/202/208 共 5 处,**漏改后两处会静默解析成 `Node.name`、不报错但值全错**,
|
||||
已全部改名 `entry_name`)。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 2026-10-12
|
||||
|
||||
## 可拖拽判断(PickupItem 标记)
|
||||
|
||||
**需求**:`scripts/item.gd` 提供 `can_pickup`,只有为 true 时才能拖拽。
|
||||
|
||||
**item.gd 本次改动**:加了 `class_name PickupItem` 和说明注释,功能不变。
|
||||
```gdscript
|
||||
class_name PickupItem
|
||||
extends Node
|
||||
@export var can_pickup: bool = true
|
||||
```
|
||||
|
||||
### ⚠ 关键发现:脚本挂载位置 ≠ 射线打中的节点
|
||||
|
||||
用户在 `world/main.tscn` 第 155 行把 item.gd 挂在了 `SM_Bottle_12` 上,但那个节点**不是刚体**。
|
||||
实例来源 `res://assets/Furniture_FREE-af040129/SM_Bottle_1.glb`,实际结构是:
|
||||
|
||||
```
|
||||
SM_Bottle_12 (Node3D) ← item.gd 挂在这一层(glb 实例的根)
|
||||
└── SM_Bottle_1 (RigidBody3D) ← 射线实际打中的是这一层
|
||||
├── SM_Bottle_1 (MeshInstance3D)
|
||||
└── CollisionShape3D
|
||||
```
|
||||
|
||||
**实测确认**:glb 内部节点的 `owner` 指向实例根(`SM_Bottle_1.owner == SM_Bottle_12`),
|
||||
再往上为 null。所以「沿 owner 链向上找标记」正好一步命中。
|
||||
|
||||
### 实现(`player_pickup.gd`)
|
||||
|
||||
- 新增 `@export var allow_unmarked: bool = false`
|
||||
- **false(默认)= 白名单**:只有挂了 `PickupItem` 且 `can_pickup=true` 的才拖得动
|
||||
- true = 黑名单:没挂标记的默认可拖,只有显式设 false 的不能拖
|
||||
- 新增 `can_grab(collider) -> bool`:先判 `is RigidBody3D`,再查标记
|
||||
- 新增 `_find_item_marker(body)`:顺序 **刚体自身 → 直接子节点 → owner 链向上**
|
||||
- 向上时**只查节点自身、不查子节点** —— 否则走到 World 层会遍历几百个子节点,而这是每物理帧都要跑的
|
||||
- `_update_interact_state()` 的 hovering 判定改用 `can_grab()`
|
||||
- `_grab()` 里再判一次(悬停状态来自上一物理帧,其间 can_pickup 可能已被改掉)
|
||||
|
||||
### 实测验证(headless,全部符合预期)
|
||||
```
|
||||
can_grab(SM_Bottle_1 RigidBody3D) = true ✓
|
||||
can_grab(SM_Bottle_12 Node3D) = false ✓(不是刚体)
|
||||
can_grab(Can2 内部刚体) = false ✓(严格/白名单模式)
|
||||
allow_unmarked=true 后 Can2 = true ✓
|
||||
```
|
||||
|
||||
### ⚠ 副作用(需告知用户)
|
||||
默认白名单模式下,场景里**所有没挂 item.gd 的刚体都拖不动了**
|
||||
(`Can2`、饭盒、水果等)。要么逐个挂标记,要么把 `allow_unmarked` 勾上。
|
||||
|
||||
### MCP 备注
|
||||
4 次 `script_patch` 均返回 `replacements: 1` 但伴随 `gdscript_reload_failed (code 43)`
|
||||
假报错(`fallback_line: true`)—— 与 10-10 记录的是同一现象。
|
||||
离线 `--check-only` 两个脚本均通过;`PickupItem` 已进 `global_script_class_cache.cfg`。
|
||||
@@ -0,0 +1,25 @@
|
||||
# 2026-10-13 工作日志
|
||||
|
||||
## 修复:toon 材质所有模型联动、无法单独设基础纹理
|
||||
|
||||
**问题**:`shader/toon_apply.gd` 用 `material_override = toon.tres` 把同一个共享 `ShaderMaterial` 实例挂给所有 `MeshInstance3D`,导致
|
||||
1. 改 `toon.tres` 影响全部模型;
|
||||
2. `material_override` 整包覆盖,丢失模型自带贴图,无法给单个模型设 `base_texture`。
|
||||
|
||||
**方案**:重写 `shader/toon_apply.gd`
|
||||
- 为每个 `MeshInstance3D` 的每个 surface **复制独立材质实例**(`cartoon_material.duplicate()`,含 `next_pass` 描边的复制),通过 `set_surface_override_material(i, mat)` 逐面挂载。
|
||||
- 自动从 `mesh.surface_get_material(i)` 提取原始贴图(`BaseMaterial3D.albedo_texture` 或已有 ShaderMaterial 的 `base_texture`)写入各自的 `base_texture`。
|
||||
- 新增导出项:`base_texture_override`(手动指定单模型贴图,优先级最高)、`use_original_texture`、`use_material_override`(兼容旧整包模式,默认关)。
|
||||
- `toon.tres` 降级为"模板":脚本每次应用时拷贝其非贴图参数作为初值,之后各模型独立。
|
||||
|
||||
**关键文件**:`shader/toon_apply.gd`(uid 未变,场景引用 `assets/test/test.tscn` 仍有效)
|
||||
**相关**:`shader/toon.tres`(基础 cel-shader + outline next_pass)、`shader/cel-shader-base.gdshader`、`shader/outline.gdshader`(outline 用 global uniform,全局限定)
|
||||
|
||||
## 修复报错:`get_shader_uniform_list` 不存在
|
||||
|
||||
- 报错:`Nonexistent function 'get_shader_uniform_list' in base 'ShaderMaterial'`。
|
||||
- 原因:该方法属于 **`Shader`**,不属于 `ShaderMaterial`。
|
||||
- 修复:改为 `cartoon_material.shader.get_shader_uniform_list()`(先判空)。
|
||||
- 同时把 `get_surface_override_material(mi.material_override)` 返回的 `Material` 用显式 `is ShaderMaterial` 判断 + 声明式 `var x: ShaderMaterial` 赋值,避免隐式向下转换告警。
|
||||
- 环境:Godot 4.7,Forward Plus,config_version=5。本机未找到 Godot CLI,无法自动语法校验。
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# 2026-10-14
|
||||
|
||||
## shader/toon_apply.gd 自动刷新可行性咨询(未改动任何文件)
|
||||
|
||||
- 项目 Godot 4.7 Forward+。
|
||||
- 现状:`toon_apply.gd` 是 @tool Node3D,`_apply_materials()` 对每个子 MeshInstance3D `duplicate()` 一份 ShaderMaterial 并 `material_override`。
|
||||
- 结论:
|
||||
1. 保存/编辑 `toon_apply.gd` 本身 → 编辑器热重载 @tool 脚本会重跑 `_ready()`,已能自动刷新(4.3 有热重载失效 bug,4.4+ 已修,本项目 4.7 无此问题)。
|
||||
2. 编辑 `shader/toon.tres` 的 shader 参数(color / uv_scale 等)→ **不会**自动刷新。原因:ShaderMaterial 的 `Resource.changed` 仅在 resource_name 变化或 shader 本身变化时发出,参数改动不发出(官方文档明确);且每网格是 duplicate 的副本,与模板已无关联。
|
||||
3. 若要 2 也自动刷新,需 EditorPlugin 监听 `EditorInterface.get_inspector().property_edited`(或 EditorFileSystem),回调里调用 `_apply_materials()`。
|
||||
- 备选架构:改用 `GeometryInstance3D.set_instance_shader_parameter()` 的 per-instance uniform,可不 duplicate、直接共享 toon.tres,改参数即实时生效;但限制是**不支持贴图/数组**、每 shader 上限 16 个。本项目需要 per-mesh 的 `base_texture`,因此该方案只能覆盖 color 之类的标量/向量参数,贴图仍需 duplicate 或走 Texture2DArray + index。
|
||||
|
||||
## 追加:评估 per-instance uniform + Texture2DArray 方案(结论:不适合本项目)
|
||||
|
||||
- 实测贴图尺寸(脚本扫 PNG 头):全项目 289 张 PNG / 24 种尺寸。
|
||||
- `assets` 180 张:1024×1024 ×108、512×512 ×40、4096×4096 ×25、2048×2048 ×5
|
||||
- `assets/自建资产` 18 张:4096×4096 ×13(地板、地毯 PBR 套图)
|
||||
- `character` 81 张:32×32 ×70、512×512 ×4、1024×1024 ×1
|
||||
- `demo` 28 张:15 种尺寸混杂
|
||||
- 结论:Texture2DArray 要求所有层同尺寸/同格式/同 mipmap,只能统一到 4096(单层带 mipmap 约 85 MiB 无损 / 21 MiB VRAM 压缩),几百张即 GB 级显存,且需额外打包流水线 → **per-instance uniform 路线不划算**,除非贴图少且尺寸统一。
|
||||
- 推荐路线(未实施):
|
||||
- A:保留 duplicate,加 EditorPlugin 监听 `EditorInspector.property_edited`,命中 toon.tres 时重刷 `_apply_materials()`(可加防抖)。
|
||||
- B:把公共外观参数(color/specular/fresnel/normal_scale/uv_scale/uv_offset)提升为 `global uniform`(项目设置 → Shader Globals 定义),调参全局实时生效、无需重刷;贴图与 per-model 覆盖仍留在 duplicate 材质上。代价:需改 cel-shader-base.gdshader / outline.gdshader 并清掉 toon.tres 里的同名参数;全局值会写入 project.godot,需团队一起提交。
|
||||
|
||||
## 已实施:路线 A(新增 addons/toon_refresh 编辑器插件)
|
||||
|
||||
用户选择 A。改动:
|
||||
- 新增 `addons/toon_refresh/plugin.cfg` + `plugin.gd`(EditorPlugin)。
|
||||
- `project.godot` 的 `[editor_plugins] enabled` 追加 `res://addons/toon_refresh/plugin.cfg`。
|
||||
|
||||
实现要点:
|
||||
- 监听 `EditorInterface.get_inspector().property_edited`,命中 `res://shader/toon.tres` 或其内联子资源时,0.2s 防抖后遍历当前编辑场景,对脚本文件名为 `toon_apply.gd` 且有 `_apply_materials` 的节点调用该方法。
|
||||
- 另加 项目 > 工具 菜单项「刷新 toon 材质(当前场景)」作手动兜底。
|
||||
- 只影响当前编辑场景;不覆盖手动改过的单个材质副本。
|
||||
|
||||
已实测确认的事实(重要,别再靠猜):
|
||||
- **`toon.tres` 的 `next_pass` 内联子资源,`resource_path` 不是空串**,而是 `res://shader/toon.tres::ShaderMaterial_m5gih` 这种带 `::` 后缀的形式 → 匹配逻辑用 `begins_with(TARGET + "::")`。
|
||||
- `ShaderMaterial.changed` **不会**因 `set_shader_parameter()` 触发;因改 `resource_name` 触发(与官方文档一致)。
|
||||
- 编辑器路径:`D:\_yvmou\Godot\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64.exe`(另有 mono 版)。可用 `--headless --editor --path E:/Godot/agame --quit` 做插件加载冒烟测试;用 `--check-only --script res://xxx.gd` 做语法校验。
|
||||
- 该项目 headless 启动时 godotsteam / limboai 的 GDExtension DLL 会报加载失败(既有问题,与本次改动无关)。
|
||||
|
||||
项目相关:`[shader_globals]` 里已有 diffuse_curve、specular_smoothness、fresnel_smoothness、outline_width、outline_color —— 描边与光照曲线本来就是全局 uniform,调参实时生效;真正需要重刷的是 toon.tres 上的材质级参数。
|
||||
|
||||
## 修复:USE_NORMAL_MAP=1 时"平视全场景变暗、俯瞰才亮"
|
||||
|
||||
用户报告的症状。根因是 `cel-shader-base.gdshader` 里的一行:
|
||||
`NORMAL = normal.vector;`(原第 121 行)——`normal.vector` 是**切线空间**的法线贴图值,
|
||||
而 Godot 4 的 `fragment()` 里 `NORMAL` 是**视图空间**(官方文档明确写
|
||||
"inout vec3 NORMAL ... in view space")。把切线空间值写进视图空间的 NORMAL,
|
||||
等于让所有像素的法线都指向"视图空间 +Z"≈ 朝向相机的一个常量 →
|
||||
整个场景明暗只由相机朝向决定:平视时与光方向近乎垂直/反向 → 全黑且无明暗起伏;
|
||||
俯瞰时与向下打的光对齐 → 恢复正常。`normal_map_fragment()` 里算好的正确视图空间法线
|
||||
`ref_normal` 被算出来后就丢掉了(死代码)。
|
||||
|
||||
连带问题:`normal_map` 这个 uniform 从来没被赋过值(toon_apply.gd 只抓 base_texture),
|
||||
采样到引擎默认纹理后算出的法线同样是垃圾。main2.tscn 里 413 个 toon_apply 节点刷出的
|
||||
434 个网格中,430 个都没有法线贴图。
|
||||
|
||||
改动:
|
||||
- `shader/includes/normal-map.gdshaderinc`:删掉 `ref_normal` / `vector.xy*2-1` / `vector.z`
|
||||
重建等死代码,改为返回 `NormalData(map, normal_scale)`,map 就是原始纹理值(切线空间),
|
||||
蓝通道交给引擎重建;补上坐标空间说明。
|
||||
- `shader/cel-shader-base.gdshader`:`USE_NORMAL_MAP` 分支只写
|
||||
`NORMAL_MAP = normal.map; NORMAL_MAP_DEPTH = normal.depth;`,不再碰 `NORMAL`。
|
||||
- `shader/toon_apply.gd`:抓 `normal_texture`(StandardMaterial3D)或 `normal_map`
|
||||
(ShaderMaterial)→ 有就赋给副本;**没有就显式把 `normal_scale` 设为 0.0**,否则会采样默认纹理。
|
||||
|
||||
验证(真实场景跑法,见 MEMORY.md):
|
||||
- 对照实验确认 dummy 渲染后端仍会报 `SHADER ERROR`,所以无头运行可校验 shader 语法。
|
||||
- 加载 `world/main2.tscn` 零 shader 错误、零脚本错误。
|
||||
- 功能断言:toon_apply 子树 434 个网格全部应用成功;430 个 `normal_scale=0.0`(无贴图),
|
||||
4 个 `normal_scale=1.0` 且 `normal_map` 已设置(源材质确实带法线贴图)。
|
||||
|
||||
遗留待用户确认(未改动):
|
||||
- `world/main2.tscn` 的 `model/floor1`(不归 toon_apply 管,用自建 `surface_material_override/0`)
|
||||
上 `normal_scale = -1.7749993243124995`,在新语义下等于 `NORMAL_MAP_DEPTH = -1.775`(反向外插),
|
||||
几乎不可能是想要的,建议改成 1.0。
|
||||
- `toon_apply.gd` 只读 `mesh.surface_get_material(0)`,忽略了 `surface_material_override`,
|
||||
对把材质放在节点覆盖槽上的模型会抓不到贴图(本次未改,属潜在问题)。
|
||||
|
||||
## 排查:亮色模型"像自发光"(未改动任何项目文件)
|
||||
|
||||
用户问"没开自发光,为什么颜色较亮时有自发光感"。用 Godot 无头脚本采样截图
|
||||
(`Image.load_from_file` + 逐像素统计,临时脚本已删):
|
||||
- 红篮区域(25988 样本):**37.1% 的像素有通道被钉在 255**,均值 (164,57,56)
|
||||
- 抽屉顶面(7616 样本):**63.0% 触顶,4758 个纯白像素**,均值 (212,212,205)
|
||||
- 深色柜体正面(10048 样本):仅 **3.3% 触顶**,均值 (108,89,74)
|
||||
- 背景:66452 样本 min==max==(54,72,87) → **纯色平涂,不是天空盒**
|
||||
|
||||
结论:是**通道削顶(clipping)**,不是自发光。shader 里 `USE_EMISSION 0`,且 toon.tres
|
||||
的 specular/fresnel 的 rgb 都是 0 → 唯一光照项就是 `ALBEDO × LIGHT_COLOR × 阶梯`。
|
||||
亮色/高饱和色在线性空间里本来就有通道接近 1.0,光照只要 >1 就把它钉死 →
|
||||
整片变成没有渐变的满色 → 观感就像发光;中等亮度色需要 3 倍以上才触顶,所以正常。
|
||||
阶梯曲线本身是平的(`new_gradient_texture_1d.tres`:u<0.127→0,0.14~0.50→0.198,
|
||||
u>0.523→1.0),削顶后连最后一点层次也没了。
|
||||
|
||||
项目里的过曝来源候选:
|
||||
- `world/main2.tscn` 的 SpotLight3D `sconce`:`light_energy = 2.655`、`light_indirect_energy = 2.41`
|
||||
- `world/main.tscn` 的 Environment:`ambient_light_source = 3`(天空)+ 自定义天空
|
||||
`atm_sun_intensity = 18`、`sun_disk_intensity = 30` → 环境光辐照度可远超 1
|
||||
- **矛盾点(可用来定位)**:main.tscn 的 Environment 是 `tonemap_mode = 3`(ACES) + `tonemap_white = 6.0`,
|
||||
ACES 会把高光滚降,理论上几乎不可能出现纯 255(输入 4.0 也只到 ~253)。但截图里大面积纯 255
|
||||
→ 说明被观察的视口**没有套用那套 ACES 环境**:要么看的是 main2.tscn(自身无 WorldEnvironment、
|
||||
无 tonemap → 硬削顶),要么编辑器 3D 视口没启用场景环境。
|
||||
- 另一个坑:toon.tres 的 `color = 0.549` 带 `source_color`,线性空间里只有 **0.26**,
|
||||
toon 管线把反照率整体压掉约 4 倍 → 亮暗反差被拉得很大。
|
||||
- `world/main2.tscn` 里没有任何 `use_custom_color = true`,所以不存在自定义颜色覆盖。
|
||||
- `world/main.tscn` 通过 ext_resource 把 `main2.tscn` 作为子场景实例化。
|
||||
|
||||
待用户确认:具体是哪个场景/哪个物体,以便定位"材质来源 + 光源 + 环境"这条链路。
|
||||
|
||||
## 重要:上游 godot4-cel-shader 的法线贴图路径是"从未运行过的死代码"
|
||||
|
||||
用户质疑"着色器源自 https://github.com/eldskald/godot4-cel-shader,理应无需修改"。
|
||||
本地有干净的上游副本 `E:/Godot/godot4-cel-shader-main/godot4-cel-shader-main/`,比对结果:
|
||||
|
||||
- 本项目改动前的 `cel-shader-base.gdshader` / `includes/normal-map.gdshaderinc`
|
||||
**与上游逐字一致**(包括 `NORMAL = normal.vector;` 和 `ref_normal` 算完即丢)。→ 当时不是本项目改坏的。
|
||||
- 但上游 5 个 shader 变体**全部是 `#define USE_NORMAL_MAP 0`**,全仓库没有任何一处写成 1,
|
||||
README 里 "normal" 出现 0 次 → **这条路径上游从未启用、从未测试过**。
|
||||
- 本项目把它开成 1,才第一次走进这段代码,于是坐标空间错误暴露。
|
||||
- 结论:**只要 `USE_NORMAL_MAP 1`,那行就必须改**;想严格保持上游 1:1,唯一办法是把开关保持 0。
|
||||
- Godot 侧证据:引擎自己也会再做一次 NORMAL_MAP 合成
|
||||
(Godot 源码 clearcoat 等价实现:`normalize(tangent*map.x + binormal*map.y + normal*map.z)`),
|
||||
公式里的 `normal` 就是被污染过的 NORMAL → 污染会传播;且 depth=0 也无法"中和",
|
||||
因为 mix 的基值本身就是被写坏的 NORMAL。
|
||||
|
||||
给用户的三个选项(待其选择):
|
||||
A. `USE_NORMAL_MAP` 改回 0,并把两个 shader 文件完全还原成上游(代价:实测 434 个网格里那 4 个带法线贴图的会失去效果)
|
||||
B. 只保留 `cel-shader-base.gdshader` 删掉 `NORMAL = normal.vector;` 这一行的差异,include 还原上游(1 行差异)
|
||||
C. 保留当前版本(include 里的死代码也清掉,与上游差异最大但最干净)
|
||||
|
||||
### 已实施:用户选择 A
|
||||
|
||||
- 用 git 精确还原:`git checkout -- shader/cel-shader-base.gdshader shader/includes/normal-map.gdshaderinc shader/toon_apply.gd`
|
||||
(先 `git diff` 逐 hunk 确认过全部改动都是本次会话产生的,没有混入用户未提交的修改)。
|
||||
- 再把 `#define USE_NORMAL_MAP 1` 改成 `0`(HEAD 里本来就是 1,是用户自己开的)。
|
||||
- 结果:`normal-map.gdshaderinc` 与上游**逐字一致**;`cel-shader-base.gdshader` 与上游的差异
|
||||
只剩项目自己加的中文注释行(值已与上游相同);`toon_apply.gd` 无改动。
|
||||
- 验证:`--headless res://world/main2.tscn --quit-after 60` 无 shader / 脚本 / 解析错误。
|
||||
- 注意:`toon.tres` 里仍有 `shader_parameter/normal_scale = 1.0`,宏关闭时被忽略,无副作用;
|
||||
`world/main2.tscn` 里 `model/floor1` 那份自建材质的 `normal_scale = -1.775` 同样被忽略。
|
||||
@@ -0,0 +1,205 @@
|
||||
# 2026-10-15
|
||||
|
||||
## 玩家状态管理迁移到 LimboAI LimboHSM
|
||||
|
||||
- 需求:玩家场景的状态管理完全用 LimboHSM 实现(addons/limboai v1.8.1,GDExtension 版,
|
||||
**无需启用 editor plugin**,ClassDB 里没有 HSMPlayer —— LimboHSM/LimboState 本身就是 Node)。
|
||||
- 方案讨论:BT vs HSM 选型 → 玩家是人操作的(决策已由人完成),用 HSM 建模身体状态;
|
||||
BT 留给将来的 NPC/autopilot。
|
||||
- 新增 `character/player/hsm/`(11 个脚本):
|
||||
- `player_hsm.gd`(根,转换表 Grounded⇄Airborne:`airborne`/`landed` 事件)
|
||||
- `grounded.gd`(Grounded 子机:12 条转换 + `_enter` 按当下输入挑初始子状态)
|
||||
- `airborne.gd`(Jump→Fall on `falling`;`_enter` 按 vy>0 挑 Jump 否则 Fall)
|
||||
- `ground_state.gd`(地面叶子基类:统一判 try_jump/踏空 → `airborne`,再走 `_think()`)
|
||||
- idle/walk/sprint/crouch/sneak.gd(写 motor.target_speed/crouching/sprinting + 动画)
|
||||
- jump.gd/fall.gd(落地判定 `is_on_floor and vy<=0`,带 vy 门槛防起跳帧弹回)
|
||||
- 改动:
|
||||
- `player.gd`:加 `class_name Player`;挂 HSM;`_ready` 末尾 `hsm.initialize(self)` +
|
||||
`hsm.set_active(true)`(仅权威端);管线第一位 `hsm.update(delta)`;animator 改调 `set_gait`。
|
||||
- `player_motor.gd`:删掉从输入推断 sprint/crouch/target 的逻辑,暴露
|
||||
`target_speed`(状态写)、`has_move_input()`、`try_jump()`(起跳在状态判定处就地执行,
|
||||
同帧转 Jump);crouching/sprinting 变成状态写入的事实镜像(Look/DebugPanel 继续用)。
|
||||
- `player_animator.gd`:`set_motion` 拆成 `play_state(state)`(状态 _enter 调)+
|
||||
`set_gait(velocity)`(每帧刷倍率);删 `_resolve_state` 和 `idle_threshold`。
|
||||
anim_state/anim_speed 同步属性保持不变(远端靠 Sync 复制驱动)。
|
||||
- `player.tscn`:HSM 节点子树(LimboHSM root + Grounded{Idle,Walk,Sprint,Crouch,Sneak} +
|
||||
Airborne{Jump,Fall}),根机器 `update_mode = 2`(MANUAL,编辑器里写入 int 失败,
|
||||
手改 tscn 后 force_reload 场景生效)。
|
||||
- 行为变化(有意为之):行走/站立切换改按**输入**判定(旧版按速度阈值 0.2 m/s),
|
||||
顶墙时播 walk 而非 idle;其余(蹲伏优先于疾跑、跳跃缓冲、落地按输入挑状态)与旧版一致。
|
||||
- 验证:全部脚本 `--check-only` 通过;无头探针场景模拟输入跑完整状态链
|
||||
(Idle→Walk→Sprint→Sneak→Crouch→Idle→Walk→Jump→落地Walk→Idle),数值与理论吻合。
|
||||
|
||||
## 二次重写:小人国定标(同日)
|
||||
|
||||
- 用户明确:不保旧手感、可重写;**小人国玩法,角色身高约 0.2 m**;旧代码可删。
|
||||
- 定标(全部固化进 player_motor.gd 默认值,删掉了 tscn 里的 walk/sprint 覆盖):
|
||||
walk 2.419(≈12 身长/s)、sprint 3.624(≈18 身长/s)、crouch 1.21(走速一半)、
|
||||
accel 22(0.11s 到全速)、air_accel 5.9(地面 1/4)、jump 1.5 / gravity_scale 0.5
|
||||
(跳高 0.23 m ≈ 1.15 身高、滞空 0.61 s)不变。DebugPanel body_height 0.15 → 0.2。
|
||||
- **修掉一个真 bug**:场景里 walk 被调到 2.419 后,动画器基准速度还是 0.4286,
|
||||
walk/run 动画一直被 clamp 在 1.6× 上限。重构:删掉 animator 的 4 个 *_ref_speed 导出,
|
||||
`play_state(state, ref_speed)` 由 HSM 状态传入基准速度(状态知道目标速度是多少),
|
||||
`set_gait` 用存下的 `_ref_speed` 算倍率 —— 两处对表的坑没了。
|
||||
- 探针复测:三个位移动画全速倍率均 1.00,转换链不变。
|
||||
|
||||
### 踩坑(复用注意)
|
||||
|
||||
1. **无头 `--check-only` 对新 class_name 报「Could not find type」**:
|
||||
全局类缓存 `.godot/global_script_class_cache.cfg` 没刷新。让编辑器跑一次
|
||||
`EditorFileSystem.scan()`(godot_ai MCP 的 filesystem scan)即解;批量循环检查时
|
||||
同一批里前后结果不一致也是这个原因。
|
||||
2. **godot-ai MCP `node_set_property` 写 enum int 属性失败**
|
||||
(WRONG_TYPE: Cannot write String to a int property,value 传 JSON number 也一样)。
|
||||
变通:MCP scene_save 后直接手改 .tscn 补属性行,再 `scene_open force_reload=true`。
|
||||
3. **MCP 场景节点路径**要含场景根:`/Player/HSM`,不是 `/HSM`。
|
||||
4. **LimboHSM 嵌套用法**:子状态机的 `_enter`(脚本 override)先于其 initial_state 激活执行,
|
||||
所以可以在 `_enter` 里 `set_initial_state()` 动态挑入口子状态(落地按输入挑、空中按 vy 挑)。
|
||||
5. **转换触发的环境判定要放叶子状态**,不要放父机 `_update`:父机先于子状态执行,
|
||||
在父机里 dispatch 转换后,本轮级联还可能调到已退出的旧子状态(叶子自己判、dispatch 后
|
||||
立即 return,没有这个窗口)。
|
||||
6. 无头模拟按键:`Input.action_press` 只改动作状态、**不发输入事件**(Motor 的跳跃缓冲在
|
||||
`_unhandled_input` 收事件,探不到);要用 `Input.parse_input_event(InputEventAction)`。
|
||||
|
||||
---
|
||||
|
||||
## main2 卡通渲染 / 世界环境 / 光照 调优(达到可交付观感)
|
||||
|
||||
目标:把 `world/main2.tscn` 的卡通着色器参数 + WorldEnvironment + 光照调到"商业化"水平。
|
||||
改了 5 个文件:`project.godot`、`world/main2.tscn`、`shader/toon.tres`、
|
||||
新增 `shader/toon_ramp_1d.tres`、删除 `shader/new_gradient_texture_1d.tres`。
|
||||
|
||||
### 动画诊断出的三个致命问题
|
||||
|
||||
1. **描边等于不存在**。`cel-shader-base` 的 `next_pass` 链、`outline.gdshader` 都正常,
|
||||
但 `outline_width = 2.0` 在这个 Godot 版本里只对应约 **0.1px**,肉眼不可能看见。
|
||||
实测标定:`像素宽度 ≈ outline_width × 0.057`(1364px 视口)。已改为 **30**(≈1.7px)。
|
||||
2. **`toon.tres` 的 `specular` / `fresnel` RGB 都是纯黑** —— 两个模块虽然连着,贡献恒为 0。
|
||||
3. **漫反射渐变是"黑→硬边→白"的二值 ramp**,导致场景只有一层死板明暗。
|
||||
|
||||
### 最关键的一条坑(务必记住)
|
||||
|
||||
**编辑器只在启动时注册 `[shader_globals]`。** 外部(含 MCP `settings_set`)改
|
||||
project.godot 的全局着色器参数,正在跑的编辑器视口**完全不变**。
|
||||
我就是因为这个,一开始把 `outline_width` 从 2 调到 10 再到 60,截图却毫无变化,
|
||||
最后才定位到根因。验证手段:写一个 `res://tests/test_*.gd`(必须 `extends McpTestSuite`),
|
||||
在 `test_*` 方法里直接 `RenderingServer.global_shader_parameter_set(...)`,
|
||||
再用 MCP `test_run` 执行 —— 能在编辑器进程内把值推进渲染器。
|
||||
|
||||
同时用这个办法做了隔离实验,排除了 4 个错误猜想:雾、SSAO、泛光、菲涅尔**都不是**
|
||||
墙面噪点的来源;真正的噪点是**截图传输压缩**(480px 截图墙面完全干净,1364px 才有颗粒)。
|
||||
另外顺手证实 `POSITION` 顶点覆盖是生效的(写 `p.y += p.w * 0.8` 面板会整体下移),
|
||||
所以描边 pass 本身没问题,别去改上游 `outline.gdshader`。
|
||||
|
||||
### 最终取值
|
||||
|
||||
- **着色器全局**(`project.godot [shader_globals]`):
|
||||
`diffuse_curve = toon_ramp_1d.tres`、`specular_smoothness = 0.08`、
|
||||
`fresnel_smoothness = 0.12`、`outline_width = 30`、
|
||||
`outline_color = Color(0.08, 0.07, 0.1, 1)`。
|
||||
- **新 ramp**(`shader/toon_ramp_1d.tres`,GradientTexture1D/256):
|
||||
0.00–0.30 → 0.24;0.38–0.62 → 0.60;0.70–1.00 → 1.00,两级转场各 0.08 软边。
|
||||
- **材质**(`shader/toon.tres`,以及 main2 里地板的独立 ShaderMaterial):
|
||||
`specular = (0.12,0.12,0.12,0.45)`、`fresnel = (0.4,0.55,0.8,0.18)`。
|
||||
- **Environment**:天空换成项目现成的 `world/sky/cartoon_sky.tres`(与 character_select /
|
||||
ocean_demo 一致);`ambient_light_source = 3`(Sky) + 冷色补光
|
||||
`(0.3,0.38,0.55)`、sky_contribution 0.45、energy 0.5;ACES 色调映射(`tonemap_mode=3`,
|
||||
这个版本枚举确实是 LINEAR0/REINHARDT1/FILMIC2/ACES3/AGX4);SSAO 轻度(intensity 1.0、
|
||||
detail 0.3、light_affect 0);Glow 阈值抬到 2.2 防止白面爆光;雾 0.004 + aerial 0.3;
|
||||
adjustment contrast 1.12 / saturation 1.08。
|
||||
- **光照**:平行光改暖色 `(1,0.925,0.83)`、energy 0.95、`light_angular_distance 0.8` 软阴影、
|
||||
bias 0.05 / n_bias 1.5、max_distance 60;sconce 聚光灯 energy 3.2 / range 12 /
|
||||
angle 55 / 开启阴影。顺手开了 `anti_aliasing/quality/msaa_3d = 2`(4x,描边抗锯齿)。
|
||||
|
||||
### 排查时踩的 MCP 细节
|
||||
|
||||
- MCP `settings_set` **写不了 Color**:传 `{"r":..,"g":..}` 会落成嵌套字典、
|
||||
传 `"#141219"` 会落成 String,两者都不是引擎要的 Color。
|
||||
正解是手改 project.godot 写成 `Color(r,g,b,a)`。改完用无头探针脚本
|
||||
(`extends SceneTree` + `ProjectSettings.get_setting()` 打印 `typeof(inner)`)复验。
|
||||
- `settings_set` 会把**整个** ProjectSettings 从内存写回磁盘,
|
||||
所以它写完之后我手改过的 `Color(...)` 会被覆盖成 String —— **必须最后一步再手改**。
|
||||
- `editor_screenshot` 的 `view_target` 会**移动用户的编辑器相机**;`max_resolution=0`
|
||||
才是原生分辨率。
|
||||
|
||||
### 追加:远处地面"发白"的根因 = fresnel 掠射角饱和(务必记住)
|
||||
|
||||
用户反馈:main2 里走廊/餐厅方向的地面(z≈15~25)呈一片**灰白偏冷的平面**,近处木地板正常。
|
||||
|
||||
- 排查路径:`floor1.glb` 只有 1 个 mesh / 1 个材质(用 Python 解 GLB 的 JSON chunk 确认),
|
||||
AABB 覆盖 x −18.2~16.6、z −24.9~32.9 —— **餐厅地面就是这块地板**,不是缺几何、
|
||||
也不是贴图问题(BaseColor 是纯木纹)。地毯不泛白是因为它的固有色深,把叠加量盖住了。
|
||||
- **根因在 `shader/includes/base-cel-shader.gdshaderinc` 的 `fresnel_light()`**:
|
||||
```
|
||||
fresnel_dot = 1.0 - dot(normal, view); // 水平面越远越掠射 → 越接近 1
|
||||
fresnel_threshold = pow(1.0 - fresnel_strength, dot(light, normal)); // ≈0.84
|
||||
... * smoothstep(threshold ± fresnel_smoothness/2, fresnel_dot)
|
||||
```
|
||||
实测:5 m 处 fresnel_dot≈0.69(=0,无贡献),20 m 处≈0.92(=1,满强度)。
|
||||
于是**远处地面被整片点亮**,叠加的是 `fresnel_color = (0.4,0.55,0.8)` 蓝白,
|
||||
木地板 albedo(≈0.34,0.24,0.2) + 叠光(≈0.40,0.51,0.66) → 就是用户看到的灰白。
|
||||
- **是上一轮调优引入的**:`fresnel` 原来 RGB=(0,0,0)(贡献恒 0,所以从没显形),
|
||||
上一轮改成 (0.4,0.55,0.8) 后才出现。**大平面(地板/天花板)上放菲涅尔轮廓光必然有这副作用。**
|
||||
- 修法(择一):`fresnel.a` 调到 0.05 以下;或把 `fresnel_color` 压暗到接近物色;
|
||||
或把 `fresnel_smoothness`(全局 uniform)调小收紧过渡带。当前已按用户要求
|
||||
**恢复为 (0.4,0.55,0.8,0.18)**,泛白会重现,等用户定夺。
|
||||
|
||||
### 追加:MCP `material_manage(apply_to_node)` 会写盘并**删除孤儿资源**
|
||||
|
||||
- 用 MCP `apply_to_node` 给 `model/floor1/pPlane1` 临时套一个纯红 unshaded 材质做隔离实验,
|
||||
之后 `scene_open force_reload` **材质没有恢复** —— 因为该操作**直接改了磁盘上的 .tscn**
|
||||
(返回值里的 `saved_to: ""` 不代表没落盘)。
|
||||
- 更严重:Godot 重新序列化时把**失去引用的资源全删了** —— 地板配套的
|
||||
`ShaderMaterial_mmyc1`(cel + outline)、`ShaderMaterial_tyxef`,
|
||||
以及 3 个 `ext_resource`(`outline.gdshader`/`cel-shader-base.gdshader`/地板 BaseColor 贴图)
|
||||
一并消失,同时把等于默认值的属性行省略(如 `glow_blend_mode = 1`)。
|
||||
- **教训:拿 MCP 改材质做实验前先 `git stash` 或备份 .tscn**;事后用 `git diff` 对照逐行恢复。
|
||||
恢复时注意用**调优后的值**(不是 HEAD 的原值)。
|
||||
|
||||
## 阴影锯齿专项(main2 沙发/盘子)
|
||||
|
||||
- 现象:沙发扶手上大片「梳齿/蕨叶」状黑纹 + 盘子影子边缘阶梯状锯齿。
|
||||
- 逐个隔离(改 `shadow_enabled` / 单变量逐项)后确认:**都来自 `DirectionalLight3D`**。
|
||||
- **根因 = `light_angular_distance = 0.8`**。它开启 PCSS 软阴影,半影半径随距离放大,
|
||||
但 Godot 的采样数有限 → 出现稀疏采样尖刺(就是那片蕨叶纹)。
|
||||
归零后(`0.0`)两个 artifact **同时消失**。
|
||||
- 次要项:工程设置 `soft_shadow_filter_quality` 默认是 **2(Soft Low)**,
|
||||
提到 **4(Soft High)** 后高频噪点能量 1.149 → 0.687(-40%),强跳变 756 → 630。
|
||||
- `shadow_blur`(1.0/1.5/2.5/4.0)实测**几乎无影响**(0.669~0.687),已删掉该覆盖行。
|
||||
- `shadow_bias`/`shadow_normal_bias` 归零会导致**全身自阴影爆发**(阴影面积 18484 → 114706),
|
||||
必须用引擎默认 0.1 / 2.0(原场景的 0.05 / 1.5 偏低)。
|
||||
- 最终:`light_angular_distance = 0.0`、删掉 bias/blur 覆盖行、
|
||||
`directional_shadow_max_distance 60 → 45`、`project.godot` 加
|
||||
`lights_and_shadows/{directional_shadow,positional_shadow}/soft_shadow_filter_quality=4`。
|
||||
- 前后对比图存在 `docs/shadow_aliasing_fix.png`。
|
||||
|
||||
### 追加:用户把 blur 归零后「全画面三角形颗粒」
|
||||
|
||||
用户手动把 `shadow_blur = 0`,**同时把 `shadow_normal_bias` 也设成了 0**,
|
||||
结果整幅画面(沙发、盘子、地板全部)盖满斜向的三角颗粒 / 摩尔纹。
|
||||
|
||||
在独立进程里做了 2×2 对照(`soft_shadow_filter_quality = 4`,
|
||||
指标 = 区域内相邻像素亮度差的平均,越低越干净):
|
||||
|
||||
| 组合 | 4096 贴图 | 8192 贴图 |
|
||||
|---|---|---|
|
||||
| blur 0 + normal_bias 0(**用户当时**) | **12.730** | **17.923** |
|
||||
| blur 0 + normal_bias 2 | 0.638 | 0.611 |
|
||||
| blur 1 + normal_bias 0 | 0.566 | 0.522 |
|
||||
| blur 1 + normal_bias 2(推荐) | 0.577 | 0.532 |
|
||||
| blur 0.6 + normal_bias 2 + max_dist 25 | 0.546 | 0.537 |
|
||||
|
||||
结论:
|
||||
|
||||
- **必须「blur=0 且 normal_bias=0」同时成立才会爆**(12.7)。任意改回一个就回到 ~0.6。
|
||||
机制:`normal_bias = 0` 会在**每个阴影贴图纹素**上产生自阴影判定抖动,
|
||||
而 `shadow_blur` 正是那个 PCF 半径 —— 它被归零后抖动不再被平滑,
|
||||
于是以贴图纹素的频率原样显现成三角颗粒(贴图纹素斜投影到受光面就是四边形/三角)。
|
||||
- **加大阴影贴图分辨率没用,反而更糟**(12.730 → 17.923)。
|
||||
分辨率越高的贴图纹素越细密 → 抖动频率越高 → 颗粒越密越明显。
|
||||
**所以"锯齿是阴影贴图不够大"是错的直觉。**
|
||||
- 想要更锐的阴影:`shadow_blur` 可以取 0(normal_bias 保持 2.0 就干净,0.638)。
|
||||
- 已把编辑器的 `shadow_normal_bias` 恢复到 2.0、`shadow_blur` 恢复到 1.0
|
||||
(用 MCP `node_set_property`,**实测它只改编辑器内存、不写盘**)。
|
||||
- 对比图存在 `docs/shadow_normal_bias_fix.png`。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# 2026-10-16
|
||||
|
||||
## 修复:第一人称相机高达 1.16 m(玩家缩放被静默抹掉)
|
||||
|
||||
- 现象:进游戏后第一人称视角像一米多高看得见 woman 的胸口;DebugPanel 显示
|
||||
「相机高度 1.161 m / 5.80 身高」。
|
||||
- 根因:`world/main.gd` 的 `_create_player()` 里写 `player.global_position = ...`,而该节点
|
||||
**此时还没入树**(MultiplayerSpawner 之后才 add_child)。`Node3D.get_global_transform()`
|
||||
在未入树时 `ERR_FAIL` 返回**单位矩阵**,`set_global_transform()` 顺势把 Player 根节点的
|
||||
basis 换成单位阵 → **player.tscn 那层 0.096096 缩放被抹掉**。
|
||||
- 无头探针对照(复现两种写法,同一份 player.tscn):
|
||||
|
||||
| 写法 | Player.global_scale | 模型世界高 | 相机相对脚底 |
|
||||
|---|---|---|---|
|
||||
| 旧 `global_position` | 1.0 | 1.561 m | **1.1610 m** |
|
||||
| 新 `position` | 0.096096 | 0.150 m | **0.1116 m** |
|
||||
|
||||
旧写法的 1.1610 m 与游戏内面板的 1.161 m 逐位吻合;控制台每个玩家还会刷一条
|
||||
`ERROR: Condition "!is_inside_tree()" is true.`。
|
||||
- 修复:`player.position = player_spawn_pos.position`(Main 与 PlayerSpawnPos 同级、Main 无变换,
|
||||
局部坐标即世界坐标)。`--check-only` 通过。
|
||||
- 离屏渲染三张对照图(woman 1.703 m 站位旁):`probe_fix_A.png`(同框,旧写法巨鼠几乎挡住
|
||||
woman)、`probe_fix_B.png`(woman 脚边,修复后 15 cm 小鼠)、`probe_fix_C.png`(修复后
|
||||
第一人称,面板读数 0.112 m / 0.56 身高)。
|
||||
- 附带确认:`InteractionRay` 世界长度在两种状态下都是 0.25 m(`Camera3D` 全局缩放恒为 1.0),
|
||||
所以修缩放不影响拾取距离。
|
||||
@@ -0,0 +1,212 @@
|
||||
# agame 项目长期笔记
|
||||
|
||||
Godot 4.7.2(Forward+,Jolt Physics,D3D12)。**可用编辑器(2026-10-16 实测):
|
||||
`E:\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe`
|
||||
(mono 版;同目录另有 `..._console.exe`)。**
|
||||
⚠️ 旧记录里的 `D:\_yvmou\Godot\...` **已失效 —— D 盘根本没有 `_yvmou` 目录**,别再照抄。
|
||||
|
||||
## 代码约定
|
||||
|
||||
- **GDScript 警告被当作错误**(至少 `inference_on_variant`)。不要写成
|
||||
`var x := some_variant_returning_call()`;要显式写类型,例如
|
||||
`var s: Script = node.get_script() as Script`。改完脚本务必用
|
||||
`--check-only --script res://xxx.gd` 自查。
|
||||
- 缩进用 tab,注释用中文(项目既有风格)。
|
||||
|
||||
## 无头验证的正确姿势(踩过坑)
|
||||
|
||||
- `--check-only --script res://x.gd`:GDScript 语法校验,有效。
|
||||
- `godot --headless --editor --path <proj> --quit`:验证编辑器插件能否加载,有效。
|
||||
- **不要**用 `extends SceneTree` 的 `--script` 脚本去测 `_ready()` 相关行为:
|
||||
这种模式下节点不会真正进入场景树(`is_inside_tree()` 为 false),`_ready` 不执行,
|
||||
会得出完全错误的结论。正确做法是造一个临时场景,用位置参数当主场景跑:
|
||||
`godot --headless --path <proj> res://tools/_tmp_probe.tscn`。
|
||||
- 即使是 `--headless`(dummy 渲染后端),**shader 编译错误仍会被报告**
|
||||
(`SHADER ERROR: ...`),所以可以用无头运行来校验 shader 语法。
|
||||
|
||||
## 玩家 = LimboAI LimboHSM(2026-10-15 起)
|
||||
|
||||
- LimboAI v1.8.1 是 **GDExtension 版**(addons/limboai 只有 bin/,无 plugin.cfg),
|
||||
**不需要启用 editor plugin**;ClassDB 里没有 HSMPlayer —— `LimboHSM`/`LimboState` 本身就是 Node。
|
||||
- 玩家状态机在 `character/player/hsm/`:根 `player_hsm.gd`(Grounded⇄Airborne)+
|
||||
Grounded{Idle,Walk,Sprint,Crouch,Sneak} + Airborne{Jump,Fall},全是场景节点、脚本挂状态上。
|
||||
- 分工:**HSM 管「是什么状态」**(写 motor.target_speed/crouching/sprinting + 调
|
||||
animator.play_state),**motor 只管物理**(重力/加速度/跳跃缓冲 + try_jump),
|
||||
animator 只管「怎么播」(play_state + set_gait 刷倍率)。player.gd 管线第一位
|
||||
`hsm.update(delta)`(MANUAL 模式,仅权威端 initialize+set_active;远端靠 Sync 复制)。
|
||||
- 走/站立转换按**输入**判定(非旧版的速度阈值)——顶墙时播 walk 不播 idle,是预期行为。
|
||||
- 用法要点:子机 `_enter` 里可 `set_initial_state()` 动态挑入口子状态;环境判定
|
||||
(起跳/踏空/落地)放**叶子**状态的 _update,别放父机;落地判定要带 `vy<=0` 门槛。
|
||||
- 踩坑:新 class_name 后无头 `--check-only` 报「Could not find type」= 全局类缓存没刷新,
|
||||
让编辑器 scan 一次即解;MCP node_set_property 写 enum int 失败 → 手改 tscn + 场景 force_reload。
|
||||
|
||||
## toon 卡通渲染管线
|
||||
|
||||
- `shader/cel-shader-base.gdshader`(`USE_*` 宏开关各模块)+ `shader/includes/*.gdshaderinc`,
|
||||
上游是 godot4-cel-shader 项目。
|
||||
- `shader/toon.tres` 是材质模板;`shader/outline.gdshader` 作为 `next_pass` 描边。
|
||||
- `shader/toon_apply.gd`(@tool Node3D)给每个 MeshInstance3D 复制一份独立材质,
|
||||
因为**每个模型有各自的贴图**,所以必须独立(不能共享一份材质)。
|
||||
- 已在项目设置里用了 **global uniform**:`diffuse_curve`、`specular_smoothness`、
|
||||
`fresnel_smoothness`、`outline_width`、`outline_color`。
|
||||
⚠️ **但"全局实时生效"是错的**(2026-10-15 实测纠正):编辑器只在**启动时**
|
||||
把 `[shader_globals]` 注册进 RenderingServer,之后外部改 project.godot(或
|
||||
MCP 的 `project_manage settings_set`)**不会**影响正在运行的编辑器视口。
|
||||
手动改完全局参数必须**重启编辑器**(或临时用 `test_run` 跑一段
|
||||
`RenderingServer.global_shader_parameter_set()` 把值推进去)才能看到效果。
|
||||
这正是当初"改了 outline_width 却毫无变化"的原因。
|
||||
- `outline_width` 的实际像素宽度约为 `width × 0.057`(1364px 视口实测校准),
|
||||
所以**原来的 2.0 实际只有约 0.1px —— 等于没描边**。要看到 2px 描边需要 width ≈ 30。
|
||||
- 贴图尺寸极不统一(289 张 PNG / 24 种尺寸,1024²、512²、4096²、32² 混杂),
|
||||
因此**不适合** Texture2DArray + per-instance uniform 方案(显存会爆且要额外打包流水线)。
|
||||
- 编辑器插件 `addons/toon_refresh/`:改 toon.tres 时自动重刷 toon_apply 材质。
|
||||
**只覆盖材质级参数**(color/specular/fresnel/uv),管不到 global uniform。
|
||||
- ⚠️ **`fresnel`(轮廓光)不能给大平面用**:`fresnel_light()` 以
|
||||
`1 - dot(normal, view)` 为激活量,水平地面/天花板稍远就是掠射角 → 直接饱和成满强度,
|
||||
整片平面被 `fresnel_color` 点亮。**"远处地面发白"就是这么来的**
|
||||
(原来是 (0,0,0) 贡献恒 0 所以没暴露,2026-10-15 改成 (0.4,0.55,0.8) 后显形)。
|
||||
真要留轮廓光就把 `a` 压到 ≤0.05,或把颜色调暗到接近物色。
|
||||
- ⚠️ **MCP `material_manage(op="apply_to_node")` 直接改磁盘 .tscn**,
|
||||
且重新序列化时会**删除失去引用的子资源与 ext_resource**
|
||||
(实测:地板实验后 `ShaderMaterial_mmyc1`/`tyxef` + 3 个 ext_resource 全被清掉,
|
||||
`saved_to:""` 也照样落盘)。拿 MCP 做材质实验前先备份,事后用 `git diff` 逐行核对恢复。
|
||||
|
||||
## 卡通渲染调参(2026-10-15 定的基线取值)
|
||||
|
||||
- `shader/toon_ramp_1d.tres`(新,替代已删除的 `new_gradient_texture_1d.tres`):
|
||||
三级 cel 明暗带,灰阶 0.24 / 0.60 / 1.00,转场各留 0.08 软边。
|
||||
注意 `diffuse_sample()` **只取红通道**,所以渐变做成彩色也没用。
|
||||
- `specular_smoothness = 0.08`、`fresnel_smoothness = 0.12`;
|
||||
原值 10.0 会把高光压到几乎不可见(`smoothstep(0.05, 10.05, x)`)。
|
||||
- `outline_width = 30`、`outline_color = Color(0.08, 0.07, 0.1, 1)`。
|
||||
- `toon.tres` 的 `specular` / `fresnel` **RGB 原来都是纯黑 = 两个模块完全失效**,
|
||||
现已改为 `specular=(0.12,0.12,0.12,α0.45)`、`fresnel=(0.4,0.55,0.8,α0.18)`。
|
||||
注意 specular 的 α 只用来推 gloss 指数,**亮度只能靠 rgb 调**。
|
||||
- MCP `settings_set` 写不了 Color:传 dict 会落成 `{"r":..}` 嵌套字典(引擎无法绑定),
|
||||
传 `"#rrggbb"` 会落成 String。**正确做法是手改 project.godot 写成 `Color(r,g,b,a)`。**
|
||||
int 型也一样:会落成 `={"type": "int","value": 4.0}` 的字典块,**用完记得手改回 `=4`**。
|
||||
|
||||
## 阴影质量(2026-10-15 定位完毕)
|
||||
|
||||
- **`DirectionalLight3D.light_angular_distance` 是本项目阴影锯齿的元凶。**
|
||||
它开启 PCSS 软阴影:半影半径 = 遮挡距离 × tan(角径),但 Godot 的 PCSS
|
||||
采样数固定 → 半径一大就变成**稀疏采样尖刺**,表现为:
|
||||
1) 大面积表面(沙发扶手内侧面)上出现**成片的「梳齿/蕨叶」黑纹**;
|
||||
2) 物体投影(白盘子)边缘出现**阶梯 + 毛刺**。
|
||||
实测 `0.8°` 时 artifact 明显,**改成 `0.0` 后两者同时消失**。
|
||||
⚠️ 别以为角径越大越"柔软自然"——在这个引擎里它只会换来尖刺。
|
||||
- 工程设置 `rendering/lights_and_shadows/{directional_shadow,positional_shadow}/soft_shadow_filter_quality`
|
||||
**默认只有 2(Soft Low)**,提到 **4(Soft High)** 实测高频噪点能量降 40%。枚举顺序:
|
||||
`0 Hard, 1 Soft Very Low, 2 Soft Low, 3 Soft Medium, 4 Soft High, 5 Soft Ultra`。
|
||||
- `shadow_blur`(0~10,默认 1.0)在 angular=0 时**几乎测不出影响**,不用调。
|
||||
- `shadow_bias` / `shadow_normal_bias` **别低于引擎默认 0.1 / 2.0**:
|
||||
归零会让全身自阴影爆发(阴影面积 18484px → 114706px)。原场景的 0.05 / 1.5 偏低。
|
||||
- ⚠️ **`shadow_normal_bias = 0` + `shadow_blur = 0` 同时出现 = 整幅画面三角颗粒/摩尔纹**
|
||||
(2026-10-15 实测,相邻像素亮度差 0.58 → 12.73,20 倍):
|
||||
`normal_bias = 0` 让**每个阴影贴图纹素**都自阴影抖动,而 `shadow_blur` 就是 PCF 半径,
|
||||
归零后抖动不再被平滑,于是按贴图纹素频率原样显现(纹素斜投影 → 三角/四边颗粒)。
|
||||
任意恢复其中一个即可(0.638 / 0.566)。只想锐化就把 blur 取 0、**normal_bias 留 2.0**。
|
||||
- ⚠️ **提高阴影贴图分辨率救不了锯齿**:`directional_shadow/size 4096 → 8192`
|
||||
在正常配置下只改善 ~8%,在坏配置下**反而更糟**(12.73 → 17.92),
|
||||
因为纹素越细抖动频率越高。"锯齿 = 贴图不够大"是错误直觉。
|
||||
- 室内场景可把 `directional_shadow_max_distance` 收到 45(原来 60),近场纹素密度提高 1.3×。
|
||||
|
||||
## 验证"启动时固化"的设置(可复用套路)
|
||||
|
||||
`[shader_globals]`、`rendering/lights_and_shadows/*`(阴影贴图尺寸、软阴影滤波质量)、
|
||||
MSAA 等**只在引擎启动时读一次**,改 project.godot 对运行中的编辑器视口无效。
|
||||
不要靠编辑器截图验证这类改动,走这条独立进程路线:
|
||||
|
||||
1. 造一个探针场景 `tools/_probe.tscn` + `tools/_probe.gd`:`extends Node3D`,
|
||||
在 `_ready()` 里 `load("res://world/xxx.tscn").instantiate()`、加一台 Camera3D、
|
||||
等 ~60 帧、`await RenderingServer.frame_post_draw` 后
|
||||
`get_viewport().get_texture().get_image().save_png(...)`、`quit()`。
|
||||
2. `DisplayServer.window_set_position(Vector2i(-6000,-6000))` 把窗口挪到屏幕外,不打扰用户。
|
||||
3. 用 `--resolution 1364x772` + 位置参数指定场景跑:
|
||||
`godot --path <proj> --resolution 1364x772 res://tools/_probe.tscn`(**别加 `--headless`**,
|
||||
dummy 渲染后端出不了图)。
|
||||
4. 想在探针里改灯的参数做单变量对照,直接在脚本里改 `DirectionalLight3D` 的属性再截图,
|
||||
一次进程就能出多张对照图。
|
||||
5. **要像素级对齐编辑器视口**:`res://tests/test_xxx.gd`(`@tool` + `extends McpTestSuite`,
|
||||
`print` 抓不到,得用 `FileAccess` 写文件)里读
|
||||
`EditorInterface.get_editor_viewport_3d(0).get_camera_3d().global_transform`,
|
||||
再 `test_run` 执行,就能拿到确切机位/ FOV。本项目的 sofa 机位是
|
||||
`origin(-12.070005, 4.639197, -2.615989)`,`fov 70.01`,视口 1364×772。
|
||||
6. 注意 main2 的「联机测试」UI 是 **autoload(`autoload/net.tscn`)**,不在场景树里,
|
||||
探针要递归隐藏 `get_tree().root` 下的 `CanvasLayer`/`Control` 才遮不掉画面。
|
||||
|
||||
## 坐标系陷阱 + 上游着色器的边界(重要)
|
||||
|
||||
Godot 4 的 `fragment()` 里 `NORMAL` / `TANGENT` / `BINORMAL` 都处于**视图空间**,
|
||||
而 `NORMAL_MAP` 要的是**切线空间**值、由引擎用 TANGENT/BINORMAL 还原。
|
||||
把切线空间值直接赋给 `NORMAL` 会让整个场景的法线变成一个跟着相机转的常量 ——
|
||||
表现为平视全黑、俯瞰才亮。
|
||||
|
||||
但**不要**因此去改上游文件:`cel-shader-base.gdshader` 与
|
||||
`includes/normal-map.gdshaderinc` 来自 <https://github.com/eldskald/godot4-cel-shader>,
|
||||
本项目的策略是**逐字跟随上游**。上游的法线贴图路径 `NORMAL = normal.vector;`
|
||||
确实有上述坐标空间错误,但上游所有变体都是 `#define USE_NORMAL_MAP 0`,
|
||||
全仓库从未启用过这条路径(README 也没提),所以是"能编译但结果错"的死代码。
|
||||
本项目保持 `USE_NORMAL_MAP 0`,与上游一致;`toon.tres` 里残留的
|
||||
`shader_parameter/normal_scale` 在宏关闭时会被忽略,无副作用。
|
||||
|
||||
**结论:要动上游 shader 之前,先用 `E:/Godot/godot4-cel-shader-main/` 里的干净副本 diff。**
|
||||
|
||||
正确做法(若将来真要开法线贴图):只写 `NORMAL_MAP = 原始切线空间值` +
|
||||
`NORMAL_MAP_DEPTH`,让引擎合成;不要自己写 `NORMAL`,也不要预先做 `*2-1` 重映射
|
||||
(引擎自己做,蓝通道由引擎重建)。
|
||||
|
||||
## 玩家尺寸 / 相机高度(2026-10-16 查证)
|
||||
|
||||
全在 `character/player/player.tscn`,「大小」是三层缩放叠加:
|
||||
|
||||
1. **`Player` 根节点整体 scale = 0.096096**(第 38 行)—— 主开关,模型+碰撞+相机一起缩放。
|
||||
2. **`Body/mouse` 模型自身 scale = 0.18240134**(第 87 行,X 为负 = 180° 翻转朝向);
|
||||
实际模型缩放 = 0.096096 × 0.1824 ≈ 0.0175。只想改模型不动碰撞就改这里。
|
||||
3. **碰撞胶囊(局部)** `CapsuleShape3D: radius 0.54541016 / height 1.4063476`(第 33-35 行),
|
||||
`Collision` 节点 y = 0.7031738(= height/2,贴地)。
|
||||
|
||||
- ⚠️ **相机高度与缩放联动,改一个必须同步改另一个**:站立视线高 = `CameraRig.position.y`
|
||||
(当前 1.6,第 46 行),世界高度 = 1.6 × 0.096096 ≈ **0.154 m**;而 `Camera` 节点自身
|
||||
scale = **10.4062605 = 1/0.096096**(第 49 行),唯一作用就是**抵消父级的 0.096 缩放**,
|
||||
让相机在世界空间回到 1:1(否则 near/FOV 异常)。动了 `Player` 的 scale,这个倒数必须一起改。
|
||||
- **无头探针实测(2026-10-16,真实实例化 `player.tscn`)**:`Player.global_scale = 0.096096`;
|
||||
模型 `Body/mouse.global_scale = 0.017528`;**视觉包围盒 = 0.158 × 0.150 × 0.135 m(约 15cm)**;
|
||||
胶囊世界尺寸 = 半径 5.24cm / 总高 13.5cm;**相机在脚底上方 0.1116 m**。
|
||||
⚠️ 相机净高度 ≠ 1.6 × 0.096 = 0.154 —— `Camera` 自身局部还带 `y = −0.439`,
|
||||
实际 = (1.6 − 0.439) × 0.096096 ≈ **0.1116 m**(`DebugPanel._camera_height()` 用
|
||||
`camera.global_position.y − player.global_position.y` 读数就是这个值)。
|
||||
- ⚠️⚠️ **2026-10-16 定位并修复的重大 bug:`world/main.gd` 的玩家生成会把 Player 根节点的缩放静默抹掉。**
|
||||
旧代码在 `spawn_function` 里写 `player.global_position = player_spawn_pos.global_position`,
|
||||
但该节点**此刻还没入树**(spawner 之后才 add_child)。`Node3D.get_global_transform()` 未入树时
|
||||
直接 `ERR_FAIL_COND_V(!is_inside_tree(), Transform3D())` 返回**单位矩阵**,于是
|
||||
`set_global_transform()` 把 Player 的 basis 整体换成单位阵 —— **0.096096 那层缩放没了**。
|
||||
实测后果(与游戏内 DebugPanel 逐位吻合):玩家模型 **1.561 m**、第一人称相机 **1.1610 m**,
|
||||
面板显示「相机高度 1.161 m / 5.80 身高」;控制台每生成一个玩家刷一条
|
||||
`ERROR: Condition "!is_inside_tree()" is true.`。
|
||||
修法:`player.position = player_spawn_pos.position`(Main 与 PlayerSpawnPos 同级、Main 无变换,
|
||||
局部坐标即世界坐标)。**通用规则:未入树的节点只能写局部 position/transform。**
|
||||
- 全项目**只有** `player.tscn` 一个 CharacterBody3D;`main2.tscn` 的 `World` 根节点无缩放(1:1),
|
||||
所以 15cm 就是游戏内真实绝对尺寸。玩家是**约 15cm 高的小老鼠**,有意为之(见
|
||||
`player_motor.gd` / `debug_panel.gd` 注释里的「小人国玩法,身高约 0.2 m」基准;
|
||||
走速 2.419 m/s ≈ 12 身长/秒就是按 0.2 m 身高定标的 —— 缩放被抹掉时手感同样全错)。
|
||||
- 相机自身 `scale = 10.4062605 = 1/0.096096`,作者意图是**抵消父级的 0.096**;实测
|
||||
`Camera3D` 的全局缩放两种玩家缩放下都是 1.0,所以 `InteractionRay.target_position = (0,0.25,0)`
|
||||
在世界里恒为 0.25 m —— **不随玩家缩放变化,改缩放时不用动它**。
|
||||
- `ui/character_select.tscn`(选人界面)的预览模型只挂了 `0.18240133` **一层**缩放
|
||||
(没有 Player 那层 0.096),模型 ≈ 1.56 m,机位 `PreviewCamera` 在 `(0,0.62,1.5)` 看向 `(0,0.45,0)`
|
||||
也是按这个尺度配的。它是**进游戏前的展示界面**,与游戏内 15cm 天然不同尺(要一致就再乘 0.096
|
||||
并把机位挪到 ~0.15 m 高)。
|
||||
- **渲染/骨骼实测(2026-10-16)**:`main2.tscn` 的 `woman` AABB 高 **1.703 m**(世界是 1:1 真人尺度);
|
||||
绕开 `main.gd` 直接实例化 `player.tscn` 时,玩家渲染出来约 15 cm、`Skeleton3D` 骨骼竖直跨度
|
||||
0.099 m。⚠️ 这一组数据只对「直接实例化」成立 —— 走真实生成路径时缩放会被上面那条 bug 抹掉。
|
||||
- ⚠️ **探针技巧**:`player.gd` 的 `_ready()` 会把**整只模型** `Body/mouse` 设为 `visible = false`,
|
||||
探针里只把下面的 `MeshInstance3D` 设成 `true` **没用**(父节点还藏着),
|
||||
必须显式 `player.get_node("Body/mouse").visible = true`。这个坑踩了三轮才排掉。
|
||||
- ⚠️ 量 skinned mesh 时不能只看 `mi.global_transform * mi.get_aabb()`:那是 **mesh 资源**的
|
||||
包围盒,**不含骨骼变换**,位置/尺寸都可能偏(实测玩家 mesh AABB 落在 Player 原点**下方**,
|
||||
而真实骨骼在原点**上方**)。要真实数据就查 `Skeleton3D.get_bone_global_pose()`,或者直接渲染截图量像素。
|
||||
- 蹲伏高度由 `player_look.gd` 的 `crouch_height_ratio = 0.6` 从 `CameraRig.position.y` 推导,
|
||||
改 rig 的 Y 会自动跟随,不用改脚本。
|
||||
- `world/main.tscn` 根节点 `Main`(Node3D)无缩放,`MultiplayerSpawner.spawn_path = ".."`,
|
||||
所以 Player 的 0.096 即最终世界缩放。(`PlayerSpawnPos` 在 y=4.8,出生会先坠落。)
|
||||
@@ -613,6 +613,12 @@ static func configure(id: String, url: String = "", launch_context: Dictionary =
|
||||
if not client.automatic_config_edits:
|
||||
return _manual_edit_result(client, "configure")
|
||||
var path_error := _config_path_resolution_error(client)
|
||||
if path_error.is_empty():
|
||||
path_error = _config_write_error(client, "configure")
|
||||
if path_error.is_empty():
|
||||
path_error = _config_create_error(client)
|
||||
if path_error.is_empty():
|
||||
path_error = _credentials_error(client)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
## Capture `url` once so a port flip in EditorSettings between write and
|
||||
@@ -857,6 +863,8 @@ static func remove(id: String, url: String = "", launch_context: Dictionary = {}
|
||||
if not client.automatic_config_edits:
|
||||
return _manual_edit_result(client, "remove")
|
||||
var path_error := _config_path_resolution_error(client)
|
||||
if path_error.is_empty():
|
||||
path_error = _config_write_error(client, "remove")
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if url.is_empty():
|
||||
@@ -897,6 +905,49 @@ static func _config_path_resolution_error(client: Client) -> String:
|
||||
return str(client.resolved_config_path_details().get("error", ""))
|
||||
|
||||
|
||||
## Why Configure must not create this client's file although status may still
|
||||
## read the path: `create_error` in `McpClient`'s candidate rules. Kept apart
|
||||
## from the function above, whose signature `script/local-self-update-smoke`
|
||||
## patches by exact match, in release trees as well as this one.
|
||||
static func _config_create_error(client: Client) -> String:
|
||||
if client.config_type == "cli":
|
||||
return ""
|
||||
return str(client.resolved_config_path_details().get("create_error", ""))
|
||||
|
||||
|
||||
## Why neither Configure nor Remove may touch this client's file: this editor's
|
||||
## Flatpak sandbox does not share it with the host (`McpClient.config_write_error`,
|
||||
## or the CLI strategy's own rule for a client that writes through its CLI, where
|
||||
## Configure and Remove differ). Every strategy refuses on it too; checking here
|
||||
## puts the refusal ahead of launcher discovery and the lock.
|
||||
static func _config_write_error(client: Client, action: String) -> String:
|
||||
if client.config_type == "cli":
|
||||
if action == "configure":
|
||||
return CliStrategy.configure_write_error(client)
|
||||
return CliStrategy.remove_write_error(client)
|
||||
return client.config_write_error()
|
||||
|
||||
|
||||
## Why Configure must not write an attach entry although the client's file is
|
||||
## writable: this editor's Flatpak sandbox keeps Godot AI's credentials where
|
||||
## the bridge that entry launches does not look. A missing entry is better
|
||||
## than one known to be broken. Remove is not held to it.
|
||||
static func _credentials_error(client: Client) -> String:
|
||||
if client.command_shape == Client.CommandShape.NONE:
|
||||
return ""
|
||||
if not McpPathTemplate.flatpak_hides_credentials():
|
||||
return ""
|
||||
var app_id := McpPathTemplate.flatpak_app_id()
|
||||
return (
|
||||
"Godot runs in a Flatpak sandbox that keeps Godot AI's credentials to "
|
||||
+ "itself, so the bridge %s starts could not connect and nothing was "
|
||||
+ "changed. Run `flatpak override --user "
|
||||
+ "--filesystem=xdg-config/godot-ai:create %s`, restart Godot, and try "
|
||||
+ "again. docs/steam-capability-directory.md covers the other ways to "
|
||||
+ "share them."
|
||||
) % [client.display_name, app_id if not app_id.is_empty() else "<Godot's Flatpak ID>"]
|
||||
|
||||
|
||||
# --- Strategy dispatch + verify (testable seam) --------------------------
|
||||
|
||||
static func _manual_edit_result(client: Client, action: String) -> Dictionary:
|
||||
@@ -1240,6 +1291,16 @@ static func manual_command(id: String) -> String:
|
||||
)
|
||||
if cmd.is_empty():
|
||||
return cmd
|
||||
## The path above is the one outside Flatpak. When this sandboxed editor
|
||||
## cannot see where a Flatpak build keeps the same file, that may be the
|
||||
## wrong one for this user, and this text is all a refused Configure (or a
|
||||
## manual-only client) leaves them to go on.
|
||||
var hidden_paths: Variant = path_resolution.get("hidden_paths", null)
|
||||
if hidden_paths is PackedStringArray and not hidden_paths.is_empty():
|
||||
cmd += (
|
||||
"\n\nA Flatpak build keeps this file at %s instead. Godot's own Flatpak "
|
||||
+ "sandbox cannot see it, so edit that one if it is the build you use."
|
||||
) % " or ".join(hidden_paths)
|
||||
## #507: when the allow-host opt-in names a non-loopback range, also
|
||||
## surface the LAN URL so the user can copy-paste the right address into
|
||||
## a remote agent. Informational only — configure/remove still WRITE the
|
||||
|
||||
@@ -79,10 +79,21 @@ var path_template: Dictionary = {}
|
||||
## a fallback path that may become invisible after copy-on-write. When
|
||||
## that private leaf is new, Configure seeds it from the first later
|
||||
## existing candidate so read-through content is not shadowed.
|
||||
## 2. If no file or wildcard package match exists, the first non-wildcard
|
||||
## template is the deterministic create target.
|
||||
## 2. If no file or wildcard package match exists, the create target is the
|
||||
## non-wildcard template whose own directories already exist, because the
|
||||
## client has run from there (`_evident_create_path` gives the order),
|
||||
## and otherwise the first non-wildcard template. This is what sends a
|
||||
## Flatpak-only IDE's entry to `~/.var/app/<id>/config/...` instead of a
|
||||
## `~/.config` it never reads.
|
||||
## 3. Multiple matches within any wildcard group are ambiguous and fail
|
||||
## closed instead of choosing an arbitrary package.
|
||||
## 4. A template inside another Flatpak app's `~/.var/app/<id>` is never a
|
||||
## create target while this editor's own Flatpak sandbox hides that
|
||||
## directory (`McpPathTemplate.hidden_flatpak_app_dir`). When rule 2 then
|
||||
## finds no existing directory among the rest, the resolution carries
|
||||
## `create_error` and the unseen `hidden_paths`: status still reads the
|
||||
## first template, and Configure refuses rather than create a file the
|
||||
## client may never read.
|
||||
##
|
||||
## Exact-file and config-home environment overrides still have higher
|
||||
## priority. When this map has no entry for the current platform,
|
||||
@@ -331,6 +342,57 @@ func resolved_config_path_details() -> Dictionary:
|
||||
return {"path": "", "error": unresolved_config_path_error(display_name, path)}
|
||||
|
||||
|
||||
## Why Configure and Remove must leave this client's resolved file alone; ""
|
||||
## when they may write it. See `unshared_flatpak_config_error`.
|
||||
func config_write_error() -> String:
|
||||
var path := resolved_config_path()
|
||||
return "" if path.is_empty() else unshared_flatpak_config_error(display_name, path)
|
||||
|
||||
|
||||
## Why Configure and Remove must leave the file at `path` alone when this
|
||||
## editor runs in a Flatpak sandbox that does not share it read-write with the
|
||||
## host; "" when it does, and outside Flatpak. A file written there is read
|
||||
## back and verified by this editor and never seen by the client.
|
||||
##
|
||||
## Every strategy asks this before it writes, ahead of `create_error`: where a
|
||||
## Flatpak build of the client keeps its settings is beside the point until
|
||||
## the sandbox can write the ordinary location at all. Status does not ask.
|
||||
## It keeps reading the path, and under a read-only grant that is the
|
||||
## client's real file.
|
||||
static func unshared_flatpak_config_error(client_name: String, path: String) -> String:
|
||||
var block := McpPathTemplate.flatpak_write_block(path)
|
||||
if block.is_empty():
|
||||
return ""
|
||||
var refused := (
|
||||
"Godot runs in a Flatpak sandbox that cannot write %s where %s reads "
|
||||
+ "it, so nothing was changed."
|
||||
) % [path, client_name]
|
||||
var by_hand := "or edit that file by hand (docs/steam-capability-directory.md)."
|
||||
if block.has("unmounted"):
|
||||
return refused + (
|
||||
" Flatpak was asked to share %s but mounted nothing there, which "
|
||||
+ "is what it does when the directory does not exist as Godot "
|
||||
+ "starts. Create it, restart Godot, and try again, %s"
|
||||
) % [block["unmounted"], by_hand]
|
||||
if block.has("hidden"):
|
||||
return refused + (
|
||||
" Flatpak hides %s from this sandbox, as a --nofilesystem rule "
|
||||
+ "does. Remove that rule, restart Godot, and try again, %s"
|
||||
) % [block["hidden"], by_hand]
|
||||
if block.has("read_only"):
|
||||
return refused + (
|
||||
" %s is mounted read-only. Make it writable, restart Godot, and "
|
||||
+ "try again, %s"
|
||||
) % [block["read_only"], by_hand]
|
||||
var app_id := McpPathTemplate.flatpak_app_id()
|
||||
return refused + (
|
||||
" Run `flatpak override --user --filesystem=%s %s`, restart Godot, "
|
||||
+ "and try again, or edit that file by hand: "
|
||||
+ "docs/steam-capability-directory.md covers a sandbox without that "
|
||||
+ "grant."
|
||||
) % [block["needs"], app_id if not app_id.is_empty() else "<Godot's Flatpak ID>"]
|
||||
|
||||
|
||||
## Shared wording for a path template `McpPathTemplate.expand` could not fully
|
||||
## resolve. Used by the guard above and by the merge-tier loader in
|
||||
## `_json_strategy.gd`, which resolves its own templates and never passes
|
||||
@@ -404,7 +466,11 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
|
||||
var ordered_templates: Array = []
|
||||
for template_variant in templates:
|
||||
ordered_templates.append(str(template_variant))
|
||||
var fallback_create_path := ""
|
||||
## Candidates whose file does not exist yet and that Configure could create.
|
||||
var create_templates := PackedStringArray()
|
||||
var create_paths := PackedStringArray()
|
||||
var hidden_app_dirs := PackedStringArray()
|
||||
var hidden_paths := PackedStringArray()
|
||||
for index in range(ordered_templates.size()):
|
||||
var template := str(ordered_templates[index])
|
||||
var expanded_template := McpPathTemplate.expand(template)
|
||||
@@ -444,10 +510,113 @@ func _resolve_ordered_config_path_candidates(templates: Variant) -> Dictionary:
|
||||
return {"path": "", "error": seed["error"]}
|
||||
_clear_config_path_warning()
|
||||
return {"path": path, "error": "", "seed_path": seed.get("path", "")}
|
||||
if fallback_create_path.is_empty():
|
||||
fallback_create_path = path
|
||||
# Creating below a directory the sandbox hides would land on its
|
||||
# private tmpfs and vanish with the editor, so a hidden candidate can
|
||||
# be neither evidence nor a create target.
|
||||
var hidden_app_dir := McpPathTemplate.hidden_flatpak_app_dir(path)
|
||||
if not hidden_app_dir.is_empty():
|
||||
hidden_paths.append(path)
|
||||
if not hidden_app_dirs.has(hidden_app_dir):
|
||||
hidden_app_dirs.append(hidden_app_dir)
|
||||
continue
|
||||
create_templates.append(template)
|
||||
create_paths.append(path)
|
||||
_clear_config_path_warning()
|
||||
return {"path": fallback_create_path, "error": ""}
|
||||
var evident_create_path := _evident_create_path(create_templates, create_paths)
|
||||
if not evident_create_path.is_empty():
|
||||
return {"path": evident_create_path, "error": ""}
|
||||
var fallback_create_path := "" if create_paths.is_empty() else create_paths[0]
|
||||
if hidden_app_dirs.is_empty():
|
||||
return {"path": fallback_create_path, "error": ""}
|
||||
var hidden_error := hidden_flatpak_config_error(
|
||||
display_name, fallback_create_path.get_base_dir(), hidden_app_dirs
|
||||
)
|
||||
if fallback_create_path.is_empty():
|
||||
return {"path": "", "error": hidden_error}
|
||||
return {
|
||||
"path": fallback_create_path,
|
||||
"error": "",
|
||||
"create_error": hidden_error,
|
||||
"hidden_paths": hidden_paths,
|
||||
}
|
||||
|
||||
|
||||
## The location the client has demonstrably run from, among candidates with no
|
||||
## config file yet; "" when nothing says. Candidates are compared level by
|
||||
## level, from the directory that would hold the file upward, and the first
|
||||
## level at which one of those directories exists decides, in descriptor order.
|
||||
## A client nested in its host app's settings (a VS Code extension) therefore
|
||||
## follows the host app when the extension's own directory exists nowhere,
|
||||
## instead of creating a second, unread copy of the host's settings tree.
|
||||
##
|
||||
## Only directories the templates name below their shared root are compared,
|
||||
## and only as many levels as the shortest template has. Those belong to the
|
||||
## client; a container such as ~/.config exists whether or not the client does.
|
||||
static func _evident_create_path(templates: PackedStringArray, paths: PackedStringArray) -> String:
|
||||
if paths.is_empty():
|
||||
return ""
|
||||
var shared := _shared_root_length(templates)
|
||||
var levels := -1
|
||||
for template in templates:
|
||||
var own_directories := template.split("/").size() - 1 - shared
|
||||
levels = own_directories if levels < 0 else mini(levels, own_directories)
|
||||
var directories := PackedStringArray()
|
||||
for path in paths:
|
||||
directories.append(path.get_base_dir())
|
||||
for _level in range(maxi(levels, 0)):
|
||||
for index in range(directories.size()):
|
||||
if DirAccess.dir_exists_absolute(directories[index]):
|
||||
return paths[index]
|
||||
for index in range(directories.size()):
|
||||
directories[index] = directories[index].get_base_dir()
|
||||
return ""
|
||||
|
||||
|
||||
## How many leading path elements every template shares, counting the root
|
||||
## element (`$XDG_CONFIG_HOME`, `~`, a drive) and never the file name. At least
|
||||
## one: the root is never the client's own directory.
|
||||
static func _shared_root_length(templates: PackedStringArray) -> int:
|
||||
if templates.size() < 2:
|
||||
return 1
|
||||
var first := templates[0].split("/")
|
||||
var shared := first.size() - 1
|
||||
for index in range(1, templates.size()):
|
||||
var other := templates[index].split("/")
|
||||
var limit := mini(shared, other.size() - 1)
|
||||
var same := 0
|
||||
while same < limit and first[same] == other[same]:
|
||||
same += 1
|
||||
shared = same
|
||||
return maxi(shared, 1)
|
||||
|
||||
|
||||
## Why Configure will not create a client's settings file from inside a
|
||||
## Flatpak editor: nothing shows the client runs from the ordinary location,
|
||||
## and the location a Flatpak build of it uses cannot be inspected. Names the
|
||||
## one grant that lets this editor see it.
|
||||
static func hidden_flatpak_config_error(
|
||||
client_name: String, settings_dir: String, hidden_app_dirs: PackedStringArray
|
||||
) -> String:
|
||||
var app_id := McpPathTemplate.flatpak_app_id()
|
||||
var grants := PackedStringArray()
|
||||
for app_dir in hidden_app_dirs:
|
||||
grants.append("--filesystem=%s" % app_dir)
|
||||
var missing := (
|
||||
"%s does not exist" % settings_dir
|
||||
if not settings_dir.is_empty()
|
||||
else "it has no settings location outside Flatpak"
|
||||
)
|
||||
return (
|
||||
"Could not find where %s keeps its settings: %s, and a Flatpak build "
|
||||
+ "keeps them under %s, which Godot's own Flatpak sandbox cannot see. "
|
||||
+ "If you use the Flatpak build, run `flatpak override --user %s %s`, "
|
||||
+ "restart Godot, and configure again. If it is installed outside "
|
||||
+ "Flatpak, start it once so it creates its settings, then configure "
|
||||
+ "again."
|
||||
) % [
|
||||
client_name, missing, ", ".join(hidden_app_dirs),
|
||||
" ".join(grants), app_id if not app_id.is_empty() else "<Godot's Flatpak ID>",
|
||||
]
|
||||
|
||||
|
||||
## Find a readable seed for a newly-created authoritative wildcard target.
|
||||
|
||||
@@ -25,6 +25,8 @@ static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
|
||||
static var _searched: Dictionary = {}
|
||||
|
||||
const _LOOKUP_TIMEOUT_MS := 3000
|
||||
const _TRACE_PATH_LIMIT := 16
|
||||
const _TRACE_PATH_MAX_CHARS := 512
|
||||
|
||||
|
||||
## Find any of the supplied exe names; returns the first hit.
|
||||
@@ -56,7 +58,15 @@ static func _find_one(exe_name: String, trace: Callable = Callable()) -> String:
|
||||
var cached: String = _cache.get(exe_name, "")
|
||||
_mutex.unlock()
|
||||
if already_searched:
|
||||
_trace_lookup(trace, "cache", "resolved" if not cached.is_empty() else "cached_miss", started, {}, true)
|
||||
_trace_lookup(
|
||||
trace,
|
||||
"cache",
|
||||
"resolved" if not cached.is_empty() else "cached_miss",
|
||||
started,
|
||||
{},
|
||||
true,
|
||||
cached,
|
||||
)
|
||||
return cached
|
||||
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
|
||||
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
|
||||
@@ -76,12 +86,14 @@ static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
|
||||
var is_windows := OS.get_name() == "Windows"
|
||||
|
||||
# 1. Well-known locations
|
||||
var well_known_paths := PackedStringArray()
|
||||
for dir in _well_known_dirs():
|
||||
var full := dir.path_join(exe_name)
|
||||
well_known_paths.append(full)
|
||||
if FileAccess.file_exists(full):
|
||||
_trace_lookup(trace, "well_known", "resolved", started)
|
||||
_trace_lookup(trace, "well_known", "resolved", started, {}, false, full, well_known_paths)
|
||||
return full
|
||||
_trace_lookup(trace, "well_known", "no_match", started)
|
||||
_trace_lookup(trace, "well_known", "no_match", started, {}, false, "", well_known_paths)
|
||||
|
||||
# 2. Login shell lookup (Unix only)
|
||||
if not is_windows:
|
||||
@@ -97,22 +109,50 @@ static func _resolve(exe_name: String, trace: Callable = Callable()) -> String:
|
||||
if int(login_result.get("exit_code", -1)) == 0:
|
||||
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
|
||||
if not login_found.is_empty() and FileAccess.file_exists(login_found):
|
||||
_trace_lookup(trace, "login_shell", "resolved", started, login_result)
|
||||
_trace_lookup(
|
||||
trace,
|
||||
"login_shell",
|
||||
"resolved",
|
||||
started,
|
||||
login_result,
|
||||
false,
|
||||
login_found,
|
||||
PackedStringArray([login_found]),
|
||||
)
|
||||
return login_found
|
||||
_trace_lookup(trace, "login_shell", _lookup_failure(login_result, false), started, login_result)
|
||||
|
||||
# 3. which / where with inherited PATH
|
||||
return _resolve_inherited_path(exe_name, is_windows, trace)
|
||||
|
||||
|
||||
## Resolve from the inherited PATH through a single lookup command. The
|
||||
## optional runner keeps the Windows/where failure modes deterministic in unit
|
||||
## tests without changing the production invocation.
|
||||
static func _resolve_inherited_path(
|
||||
exe_name: String,
|
||||
is_windows: bool,
|
||||
trace: Callable = Callable(),
|
||||
lookup_runner: Callable = Callable(),
|
||||
) -> String:
|
||||
var lookup := "where" if is_windows else "which"
|
||||
started = Time.get_ticks_msec()
|
||||
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
||||
var started := Time.get_ticks_msec()
|
||||
var result: Dictionary = {}
|
||||
if lookup_runner.is_valid():
|
||||
var injected: Variant = lookup_runner.call(lookup, exe_name)
|
||||
if injected is Dictionary:
|
||||
result = injected
|
||||
else:
|
||||
result = McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
||||
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
|
||||
if int(result.get("exit_code", -1)) == 0:
|
||||
var output := str(result.get("stdout", ""))
|
||||
var lines := PackedStringArray(output.split("\n"))
|
||||
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
|
||||
if not found.is_empty():
|
||||
_trace_lookup(trace, "inherited_path", "resolved", started, result)
|
||||
_trace_lookup(trace, "inherited_path", "resolved", started, result, false, found, lines)
|
||||
return found
|
||||
_trace_lookup(trace, "inherited_path", _lookup_failure(result, true), started, result)
|
||||
_trace_lookup(
|
||||
trace, "inherited_path", _lookup_failure(result, true), started, result, false, "", lines
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -128,7 +168,8 @@ static func _lookup_failure(result: Dictionary, absence_exit: bool) -> String:
|
||||
|
||||
static func _trace_lookup(
|
||||
trace: Callable, tier: String, status: String, started: int,
|
||||
result: Dictionary = {}, cache_hit := false,
|
||||
result: Dictionary = {}, cache_hit := false, resolved_path := "",
|
||||
candidate_paths: PackedStringArray = PackedStringArray(),
|
||||
) -> void:
|
||||
if not trace.is_valid():
|
||||
return
|
||||
@@ -141,9 +182,44 @@ static func _trace_lookup(
|
||||
"spawn_failed": bool(result.get("spawn_failed", false)),
|
||||
"cancelled": bool(result.get("cancelled", false)),
|
||||
"termination_failed": bool(result.get("termination_failed", false)),
|
||||
"resolved_path": _bounded_trace_path(resolved_path),
|
||||
"candidate_paths": _bounded_trace_paths(candidate_paths),
|
||||
})
|
||||
|
||||
|
||||
## Keep lookup diagnostics useful without retaining arbitrary command output or
|
||||
## environment values. Only absolute executable paths cross this boundary.
|
||||
static func _bounded_trace_paths(paths: PackedStringArray) -> Array[String]:
|
||||
var bounded: Array[String] = []
|
||||
for raw in paths:
|
||||
var path := _bounded_trace_path(raw)
|
||||
if path.is_empty() or bounded.has(path):
|
||||
continue
|
||||
bounded.append(path)
|
||||
if bounded.size() >= _TRACE_PATH_LIMIT:
|
||||
break
|
||||
return bounded
|
||||
|
||||
|
||||
static func _bounded_trace_path(raw: String) -> String:
|
||||
var path := raw.strip_edges()
|
||||
if not _is_traceable_path(path):
|
||||
return ""
|
||||
return path.left(_TRACE_PATH_MAX_CHARS)
|
||||
|
||||
|
||||
static func _is_traceable_path(path: String) -> bool:
|
||||
if path.is_empty() or path.contains("\n") or path.contains("\r"):
|
||||
return false
|
||||
if path.begins_with("/") or path.begins_with("\\\\"):
|
||||
return true
|
||||
return (
|
||||
path.length() >= 3
|
||||
and path.substr(1, 1) == ":"
|
||||
and (path.substr(2, 1) == "\\" or path.substr(2, 1) == "/")
|
||||
)
|
||||
|
||||
|
||||
## Executable extensions Windows' CreateProcessW can launch from a path
|
||||
## (after the cmd.exe wrap in `_cli_exec.gd`). Order is preference: `.exe`
|
||||
## is a native PE binary; `.cmd` / `.bat` go through the shell; `.com` is
|
||||
|
||||
@@ -30,6 +30,9 @@ static func configure(
|
||||
server_url: String,
|
||||
launch: Dictionary = {},
|
||||
) -> Dictionary:
|
||||
var write_error := configure_write_error(client)
|
||||
if not write_error.is_empty():
|
||||
return {"status": "error", "message": write_error}
|
||||
## Fail closed before any subprocess runs: a command-shape client without a
|
||||
## verified attach launcher must not register anything (see
|
||||
## docs/client-configuration.md — an ERROR beats an entry known to be broken).
|
||||
@@ -184,6 +187,9 @@ static func _status_details(
|
||||
|
||||
|
||||
static func remove(client: McpClient, server_name: String) -> Dictionary:
|
||||
var write_error := remove_write_error(client)
|
||||
if not write_error.is_empty():
|
||||
return {"status": "error", "message": write_error}
|
||||
var cli := _resolve_cli(client)
|
||||
if cli.is_empty():
|
||||
return {"status": "error", "message": "%s not found" % client.display_name}
|
||||
@@ -440,6 +446,33 @@ static func uses_scope_token(client: McpClient) -> bool:
|
||||
return client.cli_register_template.has(SCOPE_TOKEN)
|
||||
|
||||
|
||||
## Why Configure may not run this client's CLI. The CLI runs inside this
|
||||
## editor's Flatpak sandbox, so what it puts in the file the client keeps its
|
||||
## servers in (the JSON-fallback file) lands where the client outside never
|
||||
## reads it. That file takes the `user` scope's entry and the `local` scope's
|
||||
## per-project block, and the pre-cleanup sweep (#872) removes from it at
|
||||
## every scope, so the selected scope makes no difference here: at `project`
|
||||
## the sweep would miss a stale entry in the host's file and Configure would
|
||||
## still report success. A descriptor that names no such file has nothing to
|
||||
## check.
|
||||
static func configure_write_error(client: McpClient) -> String:
|
||||
if not client.has_json_fallback():
|
||||
return ""
|
||||
return client.config_write_error()
|
||||
|
||||
|
||||
## Why Remove may not. It removes from the selected scope only: `user` and
|
||||
## `local` keep their entries in the JSON-fallback file, and `project` in the
|
||||
## CLI's working directory, which this layer does not resolve and leaves to
|
||||
## the CLI.
|
||||
static func remove_write_error(client: McpClient) -> String:
|
||||
if not client.has_json_fallback():
|
||||
return ""
|
||||
if uses_scope_token(client) and McpSettings.client_scope() == "project":
|
||||
return ""
|
||||
return client.config_write_error()
|
||||
|
||||
|
||||
## Public view of the pre-cleanup sweep, for the manual-command text: what the
|
||||
## dock tells the user to run has to match what Configure actually runs (#877).
|
||||
static func cleanup_scopes(client: McpClient) -> Array[String]:
|
||||
|
||||
@@ -64,10 +64,16 @@ static func configure(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty():
|
||||
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
|
||||
## Set only while no candidate file exists, so this never blocks an update.
|
||||
var create_error := str(resolution.get("create_error", ""))
|
||||
if not create_error.is_empty():
|
||||
return {"status": "error", "message": create_error}
|
||||
## Fail closed before touching the file — same contract as JSON/TOML/YAML.
|
||||
var launch_error := command_launch_error(client, launch)
|
||||
if not launch_error.is_empty():
|
||||
@@ -191,6 +197,8 @@ static func remove(client: McpClient, server_name: String) -> Dictionary:
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
|
||||
@@ -19,10 +19,16 @@ static func configure(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty():
|
||||
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
|
||||
## Set only while no candidate file exists, so this never blocks an update.
|
||||
var create_error := str(resolution.get("create_error", ""))
|
||||
if not create_error.is_empty():
|
||||
return {"status": "error", "message": create_error}
|
||||
|
||||
var seed_path := str(resolution.get("seed_path", ""))
|
||||
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
|
||||
@@ -59,6 +65,9 @@ static func _configure_merged(
|
||||
launch: Dictionary,
|
||||
project_roots: PackedStringArray,
|
||||
) -> Dictionary:
|
||||
var write_error := _merge_write_error(client)
|
||||
if not write_error.is_empty():
|
||||
return {"status": "error", "message": write_error}
|
||||
var launch_error := command_launch_error(client, launch)
|
||||
if not launch_error.is_empty():
|
||||
return {"status": "error", "message": launch_error}
|
||||
@@ -259,6 +268,8 @@ static func remove(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
@@ -284,6 +295,9 @@ static func remove(
|
||||
static func _remove_merged(
|
||||
client: McpClient, server_name: String, project_roots: PackedStringArray
|
||||
) -> Dictionary:
|
||||
var write_error := _merge_write_error(client)
|
||||
if not write_error.is_empty():
|
||||
return {"status": "error", "message": write_error}
|
||||
## Scope ambiguity (omp named profiles) blocks Remove too: clearing the
|
||||
## default files while a profile reads its own would report a success the
|
||||
## running client never observes (#1085).
|
||||
@@ -750,6 +764,21 @@ static func _merge_paths(client: McpClient) -> PackedStringArray:
|
||||
return result
|
||||
|
||||
|
||||
## The Flatpak write check `configure` and `remove` make on their one path,
|
||||
## made here on every tier. Any tier this sandbox does not share with the host
|
||||
## refuses the whole Configure or Remove: an unseen tier may hold the
|
||||
## definition the client actually uses. Status reads past it, as it does for
|
||||
## an ordinary client.
|
||||
static func _merge_write_error(client: McpClient) -> String:
|
||||
for path in _merge_paths(client):
|
||||
if not path.is_absolute_path():
|
||||
continue
|
||||
var write_error := McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not write_error.is_empty():
|
||||
return write_error
|
||||
return ""
|
||||
|
||||
|
||||
## Pi's project tiers are relative to Pi's process cwd, not Godot's. Inspect
|
||||
## plausible roots only to detect overrides; callers fail closed rather than
|
||||
## mutating both guesses.
|
||||
|
||||
@@ -23,6 +23,19 @@ extends RefCounted
|
||||
static var _env_snapshot := {}
|
||||
static var _env_snapshot_mutex := Mutex.new()
|
||||
|
||||
const LinuxProc := preload("res://addons/godot_ai/utils/linux_proc.gd")
|
||||
const _MOUNTINFO_PATH := "/proc/self/mountinfo"
|
||||
|
||||
## `/.flatpak-info` as Flatpak wrote it before this process started, or "" when
|
||||
## this is not a Flatpak sandbox. The sandbox it describes cannot change while
|
||||
## the process runs, so one read serves every thread. Guarded by
|
||||
## `_env_snapshot_mutex`, like the snapshot it is consulted alongside.
|
||||
static var _flatpak_info := ""
|
||||
static var _flatpak_info_read := false
|
||||
## What that sandbox mounted, from `/proc/self/mountinfo`: read with the file
|
||||
## above, and only inside a sandbox. Empty when it could not be read.
|
||||
static var _flatpak_mounts: Array[Dictionary] = []
|
||||
|
||||
## Every var this layer and its sibling consumers (`_base.gd`
|
||||
## `config_file_override_details`, `config_home_override_details`, `_cli_finder.gd` lookups,
|
||||
## `client_configurator.gd` mode/trace reads) can touch off-main.
|
||||
@@ -32,6 +45,10 @@ const _BASE_ENV_VARS: Array[String] = [
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"XDG_CONFIG_HOME",
|
||||
## What `$XDG_CONFIG_HOME` reads in a Flatpak editor — see `_env_source`.
|
||||
"HOST_XDG_CONFIG_HOME",
|
||||
## Read by `flatpak_hides_credentials` on the workers that run Configure.
|
||||
"GODOT_AI_CAPABILITY_DIR",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"SHELL",
|
||||
@@ -76,6 +93,120 @@ static func warm_env_snapshot(extra_vars: PackedStringArray = PackedStringArray(
|
||||
for var_name in extra_vars:
|
||||
if not String(var_name).is_empty():
|
||||
env_lookup(String(var_name))
|
||||
_flatpak_info_text()
|
||||
|
||||
|
||||
## Cached `/.flatpak-info`, read on first use from whichever thread asks. Only
|
||||
## Linux has Flatpak; elsewhere the leading slash would name a drive root.
|
||||
static func _flatpak_info_text() -> String:
|
||||
_env_snapshot_mutex.lock()
|
||||
_read_flatpak_sandbox()
|
||||
var info := _flatpak_info
|
||||
_env_snapshot_mutex.unlock()
|
||||
return info
|
||||
|
||||
|
||||
## The sandbox's mounts, cached with the description above. The list is built
|
||||
## once and never changed, so callers on any thread may read it.
|
||||
static func _flatpak_mount_list() -> Array[Dictionary]:
|
||||
_env_snapshot_mutex.lock()
|
||||
_read_flatpak_sandbox()
|
||||
var mounts := _flatpak_mounts
|
||||
_env_snapshot_mutex.unlock()
|
||||
return mounts
|
||||
|
||||
|
||||
## Caller holds `_env_snapshot_mutex`.
|
||||
static func _read_flatpak_sandbox() -> void:
|
||||
if _flatpak_info_read:
|
||||
return
|
||||
_flatpak_info = McpTransportCapability._flatpak_info() if _os_key() == "linux" else ""
|
||||
var mountinfo := ""
|
||||
if not _flatpak_info.is_empty():
|
||||
mountinfo = str(LinuxProc.read_text(_MOUNTINFO_PATH).text)
|
||||
_flatpak_mounts = McpTransportCapability.parse_mountinfo(mountinfo)
|
||||
_flatpak_info_read = true
|
||||
|
||||
|
||||
## Stand in for the sandbox description so tests can cover Flatpak resolution
|
||||
## on any host, with `mountinfo` as its `/proc/self/mountinfo`: by default no
|
||||
## mount evidence at all, never the real file's. Pass null to forget both; the
|
||||
## next read then asks the real files.
|
||||
static func _set_flatpak_info_for_test(info: Variant, mountinfo: String = "") -> void:
|
||||
_env_snapshot_mutex.lock()
|
||||
_flatpak_info = "" if info == null else str(info)
|
||||
_flatpak_mounts = McpTransportCapability.parse_mountinfo(mountinfo)
|
||||
_flatpak_info_read = info != null
|
||||
_env_snapshot_mutex.unlock()
|
||||
|
||||
|
||||
## This editor's Flatpak application ID, or "" outside Flatpak.
|
||||
static func flatpak_app_id() -> String:
|
||||
return McpTransportCapability.flatpak_application_id(_flatpak_info_text())
|
||||
|
||||
|
||||
## The host's config directory when this editor is a Flatpak that shares it
|
||||
## (through the home directory, or a grant for the whole of `xdg-config`); ""
|
||||
## anywhere else, and when it cannot be resolved. For callers whose own
|
||||
## default is `OS.get_config_dir()`, which in that sandbox is the per-app
|
||||
## directory that nothing outside it reads.
|
||||
static func flatpak_host_config_home() -> String:
|
||||
if not McpTransportCapability.flatpak_shares_config_home(_flatpak_info_text()):
|
||||
return ""
|
||||
var config_home := expand("$XDG_CONFIG_HOME")
|
||||
return config_home if config_home.is_absolute_path() else ""
|
||||
|
||||
|
||||
## Whether this editor's Flatpak sandbox keeps Godot AI's credentials where a
|
||||
## client outside it does not look. The bridge a client starts reads the
|
||||
## host's `<config home>/godot-ai/capabilities`, so an entry Configure writes
|
||||
## from such a sandbox cannot connect, however right its file is.
|
||||
##
|
||||
## The server publishes in the host's config directory when the sandbox
|
||||
## shares it (`McpTransportCapability.linux_config_home_variable`), and
|
||||
## otherwise in the per-app one, which is the host's only where an
|
||||
## `xdg-config/` grant mounted the host's directory inside it. Either way the
|
||||
## host's directory has to be shared read-write, which `flatpak_write_block`
|
||||
## decides. An editor given `GODOT_AI_CAPABILITY_DIR` publishes somewhere this
|
||||
## cannot judge, and its client needs the same variable: never hidden here.
|
||||
static func flatpak_hides_credentials() -> bool:
|
||||
var info := _flatpak_info_text()
|
||||
if info.is_empty():
|
||||
return false
|
||||
if not env_lookup(McpTransportCapability.CAPABILITY_DIR_ENV).strip_edges().is_empty():
|
||||
return false
|
||||
var config_home := expand("$XDG_CONFIG_HOME")
|
||||
if not config_home.is_absolute_path():
|
||||
return false
|
||||
var host_directory := config_home.path_join("godot-ai/capabilities")
|
||||
if not flatpak_write_block(host_directory.path_join("http.json")).is_empty():
|
||||
return true
|
||||
if McpTransportCapability.flatpak_shares_config_home(info):
|
||||
return false
|
||||
var grant := McpTransportCapability.flatpak_grant_over(
|
||||
info, host_directory, _home(), config_home
|
||||
)
|
||||
return not str(grant.get("location", "")).begins_with("xdg-config/")
|
||||
|
||||
|
||||
## The `~/.var/app/<id>` directory of another Flatpak app that `path` lies in,
|
||||
## when this editor's own Flatpak sandbox does not show it; "" otherwise.
|
||||
## Flatpak mounts only the running app's directory under `~/.var/app`, and a
|
||||
## `host` or `home` grant does not widen that. From in here another app's
|
||||
## settings are therefore unknown rather than absent until the user grants
|
||||
## that directory by name, and anything created below it would land on the
|
||||
## sandbox's private tmpfs.
|
||||
static func hidden_flatpak_app_dir(path: String) -> String:
|
||||
if _flatpak_info_text().is_empty():
|
||||
return ""
|
||||
var home := _home()
|
||||
if home.is_empty():
|
||||
return ""
|
||||
var apps := home.path_join(".var/app") + "/"
|
||||
if not path.begins_with(apps):
|
||||
return ""
|
||||
var app_dir := apps + path.trim_prefix(apps).get_slice("/", 0)
|
||||
return "" if DirAccess.dir_exists_absolute(app_dir) else app_dir
|
||||
|
||||
|
||||
## Pick the right entry from a {"darwin": ..., "windows": ..., "linux": ...} map.
|
||||
@@ -183,7 +314,7 @@ static func expand(template: String) -> String:
|
||||
var token := "$%s" % var_name
|
||||
if out.find(token) < 0:
|
||||
continue
|
||||
var value := env_lookup(var_name)
|
||||
var value := env_lookup(_env_source(var_name))
|
||||
if value.is_empty():
|
||||
value = _home_fallback(var_name)
|
||||
if value.is_empty():
|
||||
@@ -192,6 +323,81 @@ static func expand(template: String) -> String:
|
||||
return out
|
||||
|
||||
|
||||
## The environment variable a token's value is read from. Flatpak points
|
||||
## `XDG_CONFIG_HOME` at the editor's own `~/.var/app/<id>/config`, where no
|
||||
## client looks: a path expanded from it is written, read back, and reported
|
||||
## as configured without the client ever seeing it. Inside a sandbox the token
|
||||
## therefore reads the host's variable, so a template always names the file
|
||||
## the client reads. Whether this sandbox can reach that file is a separate
|
||||
## question, answered by `flatpak_write_block`. Every grant that shares part
|
||||
## of the host's config directory mounts it at the host's path, an
|
||||
## `xdg-config/<dir>` grant included, so no path has to go through the per-app
|
||||
## directory, where a leftover from an earlier run would pass for the client's
|
||||
## own file.
|
||||
static func _env_source(var_name: String) -> String:
|
||||
if var_name == "XDG_CONFIG_HOME" and not _flatpak_info_text().is_empty():
|
||||
return "HOST_XDG_CONFIG_HOME"
|
||||
return var_name
|
||||
|
||||
|
||||
## Why a file this editor writes at `path` would not be the one a client
|
||||
## outside its Flatpak sandbox reads there. {} when it would be, and always
|
||||
## outside Flatpak. Otherwise one of:
|
||||
## - {"needs": grant}: no read-write grant covers the file's directory, and
|
||||
## this is the `--filesystem=` value that would. That is the read-only grant
|
||||
## that does cover it when there is one, because a wider grant does not
|
||||
## lift it.
|
||||
## - {"unmounted": directory}: a read-write grant names this directory, but
|
||||
## Flatpak mounted nothing there, which is what it does when the directory
|
||||
## does not exist as the app starts.
|
||||
## - {"hidden": directory}: inside a granted directory, this one is an empty
|
||||
## tmpfs, which is how Flatpak carries out a denial (`--nofilesystem`).
|
||||
## - {"read_only": directory}: granted read-write, mounted read-only.
|
||||
##
|
||||
## The directory is what has to be shared, not the file: `McpAtomicWrite`
|
||||
## stages a temporary file beside the target and renames it into place, which
|
||||
## cannot replace a file Flatpak mounted on its own. It also follows a
|
||||
## symlinked config file to its target, so that is the file tested.
|
||||
static func flatpak_write_block(path: String) -> Dictionary:
|
||||
var info := _flatpak_info_text()
|
||||
if info.is_empty():
|
||||
return {}
|
||||
var directory := McpAtomicWrite._resolve_symlink_target(path).get_base_dir()
|
||||
var home := _home()
|
||||
var inside_home := (
|
||||
not home.is_empty() and McpTransportCapability.path_is_within(home, directory)
|
||||
)
|
||||
var grant := McpTransportCapability.flatpak_grant_over(
|
||||
info, directory, home, expand("$XDG_CONFIG_HOME")
|
||||
)
|
||||
if not grant.get("writable", false):
|
||||
if not grant.is_empty():
|
||||
return {"needs": str(grant["location"])}
|
||||
## Nothing under ~/.var/app comes with the home, so there the app's
|
||||
## directory has to be granted by name.
|
||||
var apps := home.path_join(".var/app") + "/"
|
||||
if inside_home and directory.begins_with(apps):
|
||||
return {"needs": apps + directory.trim_prefix(apps).get_slice("/", 0)}
|
||||
return {"needs": "home" if inside_home else directory}
|
||||
var root := str(grant["root"])
|
||||
var real_root := McpTransportCapability.real_path(root)
|
||||
var mount := McpTransportCapability.flatpak_blocking_mount(
|
||||
_flatpak_mount_list(),
|
||||
McpTransportCapability.real_path(directory),
|
||||
McpTransportCapability.real_path(home) if inside_home else real_root,
|
||||
)
|
||||
if mount.is_empty():
|
||||
return {}
|
||||
var mount_point := str(mount["mount_point"])
|
||||
if mount["read_only"]:
|
||||
return {"read_only": mount_point}
|
||||
## A tmpfs above the granted directory is not there because of the grant:
|
||||
## the grant's own mount is what is missing.
|
||||
if not McpTransportCapability.path_is_within(real_root, mount_point):
|
||||
return {"unmounted": root}
|
||||
return {"hidden": mount_point}
|
||||
|
||||
|
||||
## Home-derived default for a var the environment does not define. Returns ""
|
||||
## when home itself is unresolvable so `expand` leaves the token alone: rooting
|
||||
## these at an empty home yields a RELATIVE path, not an absent one —
|
||||
|
||||
@@ -19,10 +19,16 @@ static func configure(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty():
|
||||
return {"status": "error", "message": "Could not resolve config path for %s" % client.display_name}
|
||||
## Set only while no candidate file exists, so this never blocks an update.
|
||||
var create_error := str(resolution.get("create_error", ""))
|
||||
if not create_error.is_empty():
|
||||
return {"status": "error", "message": create_error}
|
||||
|
||||
var seed_path := str(resolution.get("seed_path", ""))
|
||||
var read_path := seed_path if not FileAccess.file_exists(path) and not seed_path.is_empty() else path
|
||||
@@ -198,6 +204,8 @@ static func remove(client: McpClient, _server_name: String) -> Dictionary:
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
|
||||
@@ -24,10 +24,16 @@ static func configure(
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty():
|
||||
return {"status": "error", "message": "Could not resolve config path for %s on this OS" % client.display_name}
|
||||
## Set only while no candidate file exists, so this never blocks an update.
|
||||
var create_error := str(resolution.get("create_error", ""))
|
||||
if not create_error.is_empty():
|
||||
return {"status": "error", "message": create_error}
|
||||
## Fail closed before touching the file — same contract as JSON/TOML.
|
||||
var launch_error := command_launch_error(client, launch)
|
||||
if not launch_error.is_empty():
|
||||
@@ -105,6 +111,8 @@ static func remove(client: McpClient, server_name: String) -> Dictionary:
|
||||
var resolution := client.resolved_config_path_details()
|
||||
var path := str(resolution.get("path", ""))
|
||||
var path_error := str(resolution.get("error", ""))
|
||||
if path_error.is_empty() and not path.is_empty():
|
||||
path_error = McpClient.unshared_flatpak_config_error(client.display_name, path)
|
||||
if not path_error.is_empty():
|
||||
return {"status": "error", "message": path_error}
|
||||
if path.is_empty() or not FileAccess.file_exists(path):
|
||||
|
||||
@@ -14,6 +14,14 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
}
|
||||
## globalStorage follows VS Code's user data, which the Flathub build keeps
|
||||
## in its own app directory (see vscode.gd).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["mcpServers"])
|
||||
## Cline (like Roo) defaults a typeless entry to SSE transport, which
|
||||
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
|
||||
|
||||
@@ -11,6 +11,14 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
|
||||
}
|
||||
## globalStorage follows VS Code's user data, which the Flathub build keeps
|
||||
## in its own app directory (see vscode.gd).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/kilocode.kilo-code/settings/mcp_settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["mcpServers"])
|
||||
## Kilo Code (like Roo) defaults a typeless entry to SSE transport, which
|
||||
## returns HTTP 400 against our streamable-http endpoint on `/mcp`. Pin
|
||||
|
||||
@@ -11,6 +11,14 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
|
||||
}
|
||||
## globalStorage follows VS Code's user data, which the Flathub build keeps
|
||||
## in its own app directory (see vscode.gd).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["mcpServers"])
|
||||
## Roo defaults an entry with no "type" to SSE transport — which returns
|
||||
## HTTP 400 against our streamable-http endpoint on `/mcp`. Pin the type
|
||||
|
||||
@@ -14,6 +14,15 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/mcp.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/mcp.json",
|
||||
}
|
||||
## The Flathub build keeps its user data in the app's own directory and
|
||||
## never reads ~/.config/Code. Verified against com.visualstudio.code
|
||||
## 1.139.1: `code --add-mcp` writes the second path, under `servers`.
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/mcp.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["servers"])
|
||||
entry_extra_fields = {"type": "http"}
|
||||
## Attach migration (#838). VS Code stdio entries are flat command/args/env
|
||||
|
||||
@@ -21,6 +21,16 @@ func _init() -> void:
|
||||
"linux": "$XDG_CONFIG_HOME/zed/settings.json",
|
||||
"windows": "$APPDATA/Zed/settings.json",
|
||||
}
|
||||
## The Flathub build restarts itself on the host with
|
||||
## FLATPAK_XDG_CONFIG_HOME set to its own app directory, and reads
|
||||
## `$FLATPAK_XDG_CONFIG_HOME/zed` instead of ~/.config/zed
|
||||
## (crates/paths/src/paths.rs, crates/cli/src/main.rs).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/dev.zed.Zed/config/zed/settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["context_servers"])
|
||||
## Attach migration (#838). Current Zed's context_servers entries are an
|
||||
## untagged serde enum discriminated by shape: `command` (string) + args/env
|
||||
|
||||
@@ -11,6 +11,14 @@ func _init() -> void:
|
||||
"windows": "$APPDATA/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
|
||||
"linux": "$XDG_CONFIG_HOME/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
|
||||
}
|
||||
## globalStorage follows VS Code's user data, which the Flathub build keeps
|
||||
## in its own app directory (see vscode.gd).
|
||||
config_path_candidates = {
|
||||
"linux": [
|
||||
path_template["linux"],
|
||||
"~/.var/app/com.visualstudio.code/config/Code/User/globalStorage/zoocodeorganization.zoo-code/settings/mcp_settings.json",
|
||||
],
|
||||
}
|
||||
server_key_path = PackedStringArray(["mcpServers"])
|
||||
## Local validation against the installed extension shows Zoo stores MCP
|
||||
## entries in `settings/mcp_settings.json` under `mcpServers`, matching Roo's
|
||||
|
||||
@@ -10,17 +10,30 @@ const MAX_ENTRIES := 10000
|
||||
const MAX_FILE_BYTES := 256 * 1024
|
||||
const MAX_TOTAL_BYTES := 64 * 1024 * 1024
|
||||
const MAX_TARGETS := 256
|
||||
const BUDGET_USEC := 2000
|
||||
## Container magic Godot writes for a compressed binary resource.
|
||||
const COMPRESSED_MAGIC := "RSCC"
|
||||
## Work allowed between frame yields: a share of how long the editor last took
|
||||
## to come back, within these bounds. A fixed slice starves discovery once the
|
||||
## editor is unfocused, because its low-processor mode then sleeps ~100 ms per
|
||||
## frame (`interface/editor/timers/unfocused_low_processor_mode_sleep_usec`)
|
||||
## and the frame count, not the work, reaches the deadline (#1120). The floor is
|
||||
## what a focused editor gets; the ceiling bounds one slice on a stalled editor.
|
||||
const BUDGET_MIN_USEC := 2000
|
||||
const BUDGET_MAX_USEC := 50000
|
||||
const BUDGET_GAP_DIVISOR := 3
|
||||
const DEADLINE_MSEC := 25000
|
||||
static var _busy := false
|
||||
|
||||
var _alive: Callable
|
||||
var _deadline := 0
|
||||
var _yield_at := 0
|
||||
var _budget := BUDGET_MIN_USEC
|
||||
var _bytes := 0
|
||||
var _directories: Dictionary = {}
|
||||
var _fingerprints: Dictionary = {}
|
||||
var _large_owners: Dictionary = {}
|
||||
var _fault := ""
|
||||
var _fault_code := ""
|
||||
var _io: Callable
|
||||
|
||||
## Dispatch stays synchronous; the retained job owns work across frame yields.
|
||||
@@ -59,6 +72,13 @@ func run(params: Dictionary, operation: String, active: Callable = Callable()) -
|
||||
_alive = active
|
||||
_deadline = Time.get_ticks_msec() + DEADLINE_MSEC
|
||||
_yield_at = Time.get_ticks_usec()
|
||||
_budget = BUDGET_MIN_USEC
|
||||
_bytes = 0
|
||||
_directories.clear()
|
||||
_fingerprints.clear()
|
||||
_large_owners.clear()
|
||||
_fault = ""
|
||||
_fault_code = ""
|
||||
var result := await _run(params, operation)
|
||||
_busy = false
|
||||
return result
|
||||
@@ -100,7 +120,7 @@ func _run(params: Dictionary, operation: String) -> Dictionary:
|
||||
return _unchanged(_fault)
|
||||
var files: Array[String] = []
|
||||
if not await _collect_tree("res://", files):
|
||||
return _unchanged(_fault)
|
||||
return _fault_result()
|
||||
if directory and not _directories.has(source):
|
||||
return _unchanged("Source directory was excluded or has noncanonical casing; nothing changed")
|
||||
var targets := {}
|
||||
@@ -112,27 +132,46 @@ func _run(params: Dictionary, operation: String) -> Dictionary:
|
||||
if targets.size() > MAX_TARGETS:
|
||||
return _unchanged("Mutation exceeds the %d-resource limit; use smaller groups" % MAX_TARGETS)
|
||||
for path in files:
|
||||
if path == source or (directory and path.begins_with(source + "/")) or path in [source + ".uid", source + ".import"]:
|
||||
if not _mutation_member(path, source, directory):
|
||||
continue
|
||||
if path.get_extension().to_lower() in OWNER_EXTENSIONS:
|
||||
# Owner-extension targets are read and fingerprinted by the owner
|
||||
# pass below; reading them here too would charge the byte budget twice.
|
||||
if path.get_extension().to_lower() in OWNER_EXTENSIONS:
|
||||
continue
|
||||
if await _read_bounded(path) == null:
|
||||
return _unchanged(_fault)
|
||||
continue
|
||||
if await _read_bounded(path) == null:
|
||||
return _fault_result()
|
||||
var owners := []
|
||||
for path in files:
|
||||
if path.get_extension().to_lower() not in OWNER_EXTENSIONS:
|
||||
var extension := path.get_extension().to_lower()
|
||||
if extension not in OWNER_EXTENSIONS:
|
||||
continue
|
||||
var content: Variant = await _read_bounded(path)
|
||||
if content == null:
|
||||
return _unchanged(_fault)
|
||||
# One call per owner: _references lowercases and regex-scans the whole
|
||||
# file, so a per-target loop repeated that up to MAX_TARGETS times.
|
||||
var hits := await _references(path, content, targets)
|
||||
var hits: Array[Dictionary]
|
||||
var length := _file_length(path)
|
||||
if length < 0:
|
||||
return _fault_result()
|
||||
if length > MAX_FILE_BYTES:
|
||||
# Dependency enumeration can replace a literal scan for packed binary
|
||||
# owners, but not for scripts or text resources: those can refer to a
|
||||
# target in ordinary strings the graph does not report. Fail closed.
|
||||
if extension not in ["res", "scn"]:
|
||||
_set_fault("Cannot safely read owner %s (exceeds %d bytes)" % [path, MAX_FILE_BYTES], Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return _fault_result()
|
||||
hits = await _large_owner_references(path, targets)
|
||||
else:
|
||||
var content: Variant = await _read_bounded(path)
|
||||
if content == null:
|
||||
return _fault_result()
|
||||
if extension in ["res", "scn"]:
|
||||
hits = await _binary_references(path, content, targets)
|
||||
else:
|
||||
# One call per owner: _references lowercases and regex-scans the
|
||||
# whole file, so a per-target loop repeated that up to MAX_TARGETS
|
||||
# times.
|
||||
hits = await _references(path, content, targets)
|
||||
if not _fault.is_empty():
|
||||
return _unchanged(_fault)
|
||||
return _fault_result()
|
||||
if not await _yield_if_needed():
|
||||
return _unchanged(_fault)
|
||||
return _fault_result()
|
||||
if not hits.is_empty() and not (operation == "remove" and targets.has(path)):
|
||||
owners.append({"path": path, "references": hits})
|
||||
var editor_guard := _editor_state_guard(targets)
|
||||
@@ -152,17 +191,18 @@ func _run(params: Dictionary, operation: String) -> Dictionary:
|
||||
group.append({"from": source + suffix, "to": destination + suffix})
|
||||
for item in group:
|
||||
if not _confined_without_links(item.from):
|
||||
return _unchanged(_fault)
|
||||
return _fault_result()
|
||||
if operation != "remove" and not _destination_available(item.from, item.to):
|
||||
return _unchanged(_fault)
|
||||
return _fault_result()
|
||||
# Re-read owner bytes and directory entry censuses after the yielding plan.
|
||||
if not await _revalidate():
|
||||
return _unchanged(_fault)
|
||||
return _fault_result()
|
||||
if not _active():
|
||||
return _unchanged("Filesystem request expired before mutation; nothing changed")
|
||||
_set_fault("Filesystem request expired before mutation; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return _fault_result()
|
||||
for item in group:
|
||||
if not _confined_without_links(item.from) or (operation != "remove" and not _destination_available(item.from, item.to)):
|
||||
return _unchanged(_fault)
|
||||
return _fault_result()
|
||||
# Revalidation yields. Recheck mutable editor authority after the last
|
||||
# callback/yield, with no suspension between this guard and disk effects.
|
||||
editor_guard = _editor_state_guard(targets)
|
||||
@@ -179,17 +219,26 @@ func _active() -> bool:
|
||||
|
||||
func _yield_if_needed() -> bool:
|
||||
if not _active():
|
||||
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
|
||||
_set_fault("Filesystem discovery expired or was cancelled; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return false
|
||||
if Time.get_ticks_usec() - _yield_at >= BUDGET_USEC:
|
||||
var paused := Time.get_ticks_usec()
|
||||
if paused - _yield_at >= _budget:
|
||||
await (Engine.get_main_loop() as SceneTree).process_frame
|
||||
_yield_at = Time.get_ticks_usec()
|
||||
_budget = _frame_budget(_yield_at - paused)
|
||||
if not _active():
|
||||
_fault = "Filesystem discovery expired or was cancelled; nothing changed"
|
||||
_set_fault("Filesystem discovery expired or was cancelled; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## The next slice for an editor that was away `gap_usec` between two slices.
|
||||
## The frame sleep absorbs work shorter than itself, so a third of the gap is a
|
||||
## quarter of a sleeping editor's frame and lengthens a busy one by a third.
|
||||
static func _frame_budget(gap_usec: int) -> int:
|
||||
return clampi(gap_usec / BUDGET_GAP_DIVISOR, BUDGET_MIN_USEC, BUDGET_MAX_USEC)
|
||||
|
||||
|
||||
func _collect_tree(root: String, files: Array[String]) -> bool:
|
||||
var pending: Array[String] = [root]
|
||||
var count := 0
|
||||
@@ -197,11 +246,11 @@ func _collect_tree(root: String, files: Array[String]) -> bool:
|
||||
var path: String = pending.pop_back()
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
_fault = "Cannot inspect directory: %s" % path
|
||||
_set_fault("Cannot inspect directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return false
|
||||
dir.include_hidden = true
|
||||
if dir.list_dir_begin() != OK:
|
||||
_fault = "Cannot list directory: %s" % path
|
||||
_set_fault("Cannot list directory: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return false
|
||||
var entries: Array[String] = []
|
||||
var name := dir.get_next()
|
||||
@@ -222,7 +271,7 @@ func _collect_tree(root: String, files: Array[String]) -> bool:
|
||||
files.append(child)
|
||||
count += 1
|
||||
if count > MAX_ENTRIES:
|
||||
_fault = "Project discovery exceeds %d entries; nothing changed" % MAX_ENTRIES
|
||||
_set_fault("Project discovery exceeds %d entries; nothing changed" % MAX_ENTRIES, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
dir.list_dir_end()
|
||||
return false
|
||||
if not await _yield_if_needed():
|
||||
@@ -237,20 +286,23 @@ func _collect_tree(root: String, files: Array[String]) -> bool:
|
||||
|
||||
func _read_bounded(path: String) -> Variant:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null or file.get_length() > MAX_FILE_BYTES:
|
||||
_fault = "Cannot safely read owner %s (unreadable or exceeds %d bytes)" % [path, MAX_FILE_BYTES]
|
||||
if file == null:
|
||||
_set_fault("Cannot safely read owner %s (unreadable)" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
if file.get_length() > MAX_FILE_BYTES:
|
||||
_set_fault("Cannot safely read owner %s (exceeds %d bytes)" % [path, MAX_FILE_BYTES], Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
var bytes := PackedByteArray()
|
||||
var length := file.get_length()
|
||||
while file.get_position() < length:
|
||||
var part := file.get_buffer(mini(16384, length - file.get_position()))
|
||||
if part.is_empty():
|
||||
_fault = "Short read while inspecting %s" % path
|
||||
_set_fault("Short read while inspecting %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
bytes.append_array(part)
|
||||
_bytes += part.size()
|
||||
if _bytes > MAX_TOTAL_BYTES:
|
||||
_fault = "Filesystem discovery exceeds its byte budget; nothing changed"
|
||||
_set_fault("Filesystem discovery exceeds its byte budget; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
if not await _yield_if_needed():
|
||||
return null
|
||||
@@ -262,11 +314,173 @@ func _read_bounded(path: String) -> Variant:
|
||||
return bytes
|
||||
|
||||
|
||||
func _mutation_member(path: String, source: String, directory: bool) -> bool:
|
||||
if directory:
|
||||
return path == source or path.begins_with(source + "/")
|
||||
return path == source or path in [source + ".uid", source + ".import"]
|
||||
|
||||
|
||||
func _file_length(path: String) -> int:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
_set_fault("Cannot inspect owner metadata: %s" % path, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return -1
|
||||
var length := file.get_length()
|
||||
file.close()
|
||||
return length
|
||||
|
||||
|
||||
func _owner_dependencies(path: String) -> PackedStringArray:
|
||||
var dependencies := PackedStringArray()
|
||||
for dependency in ResourceLoader.get_dependencies(path):
|
||||
dependencies.append(str(dependency))
|
||||
return dependencies
|
||||
|
||||
|
||||
## Lowercased `uid://` and path segments of the engine's dependency records.
|
||||
func _dependency_segments(dependencies: PackedStringArray) -> Dictionary:
|
||||
var segments: Dictionary = {}
|
||||
for dependency in dependencies:
|
||||
for segment in dependency.split("::", false):
|
||||
segments[segment.to_lower()] = true
|
||||
return segments
|
||||
|
||||
|
||||
func _large_owner_references(path: String, targets: Dictionary) -> Array[Dictionary]:
|
||||
var dependencies := _owner_dependencies(path)
|
||||
var dependency_paths := _dependency_segments(dependencies)
|
||||
_large_owners[path] = {
|
||||
"modified": FileAccess.get_modified_time(path),
|
||||
"size": _file_length(path),
|
||||
"dependencies": dependencies,
|
||||
}
|
||||
if int(_large_owners[path]["size"]) < 0:
|
||||
return []
|
||||
var hits: Array[Dictionary] = []
|
||||
for target: String in targets:
|
||||
if not await _yield_if_needed():
|
||||
return hits
|
||||
var uid: int = targets[target].uid
|
||||
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
|
||||
if dependency_paths.has(target.to_lower()) or (not uid_text.is_empty() and dependency_paths.has(uid_text.to_lower())):
|
||||
# A dependency path can be a UID-backed ext_resource. Without reading
|
||||
# the oversized owner we cannot prove the file reference is safe to
|
||||
# rewrite, so fail closed for moves; force-remove may still use it.
|
||||
hits.append({"path": target, "kind": "path"})
|
||||
return hits
|
||||
|
||||
|
||||
func _binary_references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
|
||||
var hits: Array[Dictionary] = []
|
||||
var payload: Variant = _binary_scan_bytes(path, bytes)
|
||||
if payload == null:
|
||||
return hits
|
||||
# The binary format stores an ext_resource UID as an integer, which no text
|
||||
# scan can see, so the engine's dependency records are consulted as well.
|
||||
var dependency_paths := _dependency_segments(_owner_dependencies(path))
|
||||
for target: String in targets:
|
||||
# Keep cancellation and frame progress even when a path match continues.
|
||||
if not await _yield_if_needed():
|
||||
return hits
|
||||
var uid: int = targets[target].uid
|
||||
var uid_text := ResourceUID.id_to_text(uid) if uid != ResourceUID.INVALID_ID else ""
|
||||
var uses_path: bool = (
|
||||
_bytes_contains_ci(payload, target.to_utf8_buffer())
|
||||
or dependency_paths.has(target.to_lower())
|
||||
or (not uid_text.is_empty() and dependency_paths.has(uid_text.to_lower()))
|
||||
)
|
||||
var uses_uid: bool = not uid_text.is_empty() and _bytes_contains_ci(payload, uid_text.to_utf8_buffer())
|
||||
if uses_path:
|
||||
hits.append({"path": target, "kind": "path"})
|
||||
elif uses_uid:
|
||||
if not ResourceUID.has_id(uid) or ResourceUID.get_id_path(uid) != target:
|
||||
_set_fault("UID mapping is not authoritative for %s" % target)
|
||||
return hits
|
||||
hits.append({"path": target, "kind": "uid"})
|
||||
return hits
|
||||
|
||||
|
||||
## The bytes a literal scan can inspect. The editor saves binary resources
|
||||
## compressed by default (`filesystem/on_save/compress_binary_resources`), and
|
||||
## a compressed container shows none of its serialized paths, so expand it
|
||||
## first. Layout: magic, mode, block size and expanded size (u32 each), one
|
||||
## u32 compressed size per block, then the blocks.
|
||||
##
|
||||
## Returns an empty array when the expanded payload exceeds the per-file limit
|
||||
## (the caller then relies on dependency records alone, like any oversized
|
||||
## binary owner) and null, with a fault, for a container that cannot be read.
|
||||
func _binary_scan_bytes(path: String, bytes: PackedByteArray) -> Variant:
|
||||
if bytes.size() < 4 or bytes.slice(0, 4).get_string_from_ascii() != COMPRESSED_MAGIC:
|
||||
return bytes
|
||||
var unreadable := "Cannot inspect compressed binary owner: %s" % path
|
||||
if bytes.size() < 16:
|
||||
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
var mode := bytes.decode_u32(4)
|
||||
var block_size := bytes.decode_u32(8)
|
||||
var total := bytes.decode_u32(12)
|
||||
if mode > FileAccess.COMPRESSION_BROTLI or block_size == 0:
|
||||
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
if total > MAX_FILE_BYTES:
|
||||
return PackedByteArray()
|
||||
var block_count := total / block_size + 1
|
||||
var offset := 16 + block_count * 4
|
||||
if offset > bytes.size():
|
||||
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
var payload := PackedByteArray()
|
||||
for index in block_count:
|
||||
var compressed_size := bytes.decode_u32(16 + index * 4)
|
||||
var expected := total % block_size if index == block_count - 1 else block_size
|
||||
if offset + compressed_size > bytes.size():
|
||||
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
if expected > 0:
|
||||
var block := bytes.slice(offset, offset + compressed_size).decompress(expected, mode)
|
||||
if block.size() != expected:
|
||||
_set_fault(unreadable, Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
payload.append_array(block)
|
||||
offset += compressed_size
|
||||
_bytes += payload.size()
|
||||
if _bytes > MAX_TOTAL_BYTES:
|
||||
_set_fault("Filesystem discovery exceeds its byte budget; nothing changed", Errors.FILESYSTEM_DISCOVERY_FAILED)
|
||||
return null
|
||||
return payload
|
||||
|
||||
|
||||
func _ascii_lower(value: int) -> int:
|
||||
if value >= 65 and value <= 90:
|
||||
return value + 32
|
||||
return value
|
||||
|
||||
|
||||
func _bytes_contains_ci(haystack: PackedByteArray, needle: PackedByteArray) -> bool:
|
||||
if needle.is_empty() or haystack.size() < needle.size():
|
||||
return false
|
||||
var lower := _ascii_lower(needle[0])
|
||||
var upper := lower - 32 if lower >= 97 and lower <= 122 else lower
|
||||
var candidates := [lower]
|
||||
if candidates[0] != upper:
|
||||
candidates.append(upper)
|
||||
for candidate in candidates:
|
||||
var position := haystack.find(candidate)
|
||||
while position >= 0:
|
||||
if position + needle.size() <= haystack.size():
|
||||
var matches := true
|
||||
for index in needle.size():
|
||||
if _ascii_lower(haystack[position + index]) != _ascii_lower(needle[index]):
|
||||
matches = false
|
||||
break
|
||||
if matches:
|
||||
return true
|
||||
position = haystack.find(candidate, position + 1)
|
||||
return false
|
||||
|
||||
|
||||
func _references(path: String, bytes: PackedByteArray, targets: Dictionary) -> Array[Dictionary]:
|
||||
var hits: Array[Dictionary] = []
|
||||
if path.get_extension().to_lower() in ["res", "scn"]:
|
||||
_fault = "Binary dependency discovery is unsupported for safe mutations: %s" % path
|
||||
return hits
|
||||
# Compare paths conservatively across case-insensitive project volumes.
|
||||
# A case-only false positive on a sensitive volume is a safe refusal.
|
||||
var text := bytes.get_string_from_utf8().to_lower()
|
||||
@@ -358,9 +572,26 @@ func _revalidate() -> bool:
|
||||
if _fingerprints[path] != original[path]:
|
||||
_fault = "Owner changed during discovery: %s" % path
|
||||
return false
|
||||
var original_large := _large_owners.duplicate(true)
|
||||
for path in original_large:
|
||||
if not await _large_owner_unchanged(path, original_large[path]):
|
||||
_fault = "Owner changed during discovery: %s" % path
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _large_owner_unchanged(path: String, original: Dictionary) -> bool:
|
||||
if not FileAccess.file_exists(path):
|
||||
return false
|
||||
if FileAccess.get_modified_time(path) != int(original.get("modified", 0)):
|
||||
return false
|
||||
if _file_length(path) != int(original.get("size", -1)):
|
||||
return false
|
||||
if str(_owner_dependencies(path)) != str(original.get("dependencies", PackedStringArray())):
|
||||
return false
|
||||
return await _yield_if_needed()
|
||||
|
||||
|
||||
func _confined_without_links(path: String) -> bool:
|
||||
var dir := DirAccess.open("res://")
|
||||
if dir == null or not dir.has_method("is_link"):
|
||||
@@ -493,6 +724,17 @@ static func _is_sidecar(path: String) -> bool:
|
||||
return path.ends_with(".uid") or path.ends_with(".import")
|
||||
|
||||
|
||||
func _set_fault(message: String, code: String = "") -> void:
|
||||
_fault = message
|
||||
_fault_code = code
|
||||
|
||||
|
||||
func _fault_result() -> Dictionary:
|
||||
if _fault_code == Errors.FILESYSTEM_DISCOVERY_FAILED:
|
||||
return _failure(_fault, "unchanged", {}, _fault_code)
|
||||
return _unchanged(_fault)
|
||||
|
||||
|
||||
static func _unchanged(message: String, details: Dictionary = {}) -> Dictionary:
|
||||
return _failure(message, "unchanged", details, Errors.INVALID_PARAMS)
|
||||
|
||||
|
||||
@@ -160,6 +160,16 @@ func reparent_node(params: Dictionary) -> Dictionary:
|
||||
_undo_redo.add_do_method(new_parent, "add_child", node, true)
|
||||
_undo_redo.add_do_method(node, "set_owner", scene_root)
|
||||
for child in descendants:
|
||||
## Preserve intentional null owners and owners that live inside the
|
||||
## moved subtree. `remove_child` clears owners outside the subtree, so
|
||||
## only those need to be re-normalized to scene_root. The subtree is
|
||||
## still intact here because the recorded do-methods have not run yet.
|
||||
##
|
||||
## Re-owning an instance's internal nodes with scene_root flattened the
|
||||
## sub-scene on save and dropped its overrides (#1118).
|
||||
var prior_owner: Node = child.owner
|
||||
if prior_owner == null or prior_owner == node or node.is_ancestor_of(prior_owner):
|
||||
continue
|
||||
_undo_redo.add_do_method(child, "set_owner", scene_root)
|
||||
_undo_redo.add_do_reference(node)
|
||||
_undo_redo.add_undo_method(new_parent, "remove_child", node)
|
||||
@@ -203,10 +213,14 @@ func set_property(params: Dictionary) -> Dictionary:
|
||||
|
||||
var found := false
|
||||
var prop_type: int = TYPE_NIL
|
||||
var prop_hint: int = PROPERTY_HINT_NONE
|
||||
var prop_hint_string: String = ""
|
||||
for prop in node.get_property_list():
|
||||
if prop.name == property:
|
||||
found = true
|
||||
prop_type = prop.get("type", TYPE_NIL)
|
||||
prop_hint = prop.get("hint", PROPERTY_HINT_NONE)
|
||||
prop_hint_string = prop.get("hint_string", "")
|
||||
break
|
||||
if not found:
|
||||
return ErrorCodes.make(ErrorCodes.PROPERTY_NOT_ON_CLASS, McpPropertyErrors.build_message(node, property))
|
||||
@@ -230,7 +244,45 @@ func set_property(params: Dictionary) -> Dictionary:
|
||||
|
||||
var nil_resource_string: bool = target_type == TYPE_NIL and (value == "" or (value is String and value.begins_with("res://")))
|
||||
var resource_string_value: bool = value is String and (target_type == TYPE_OBJECT or nil_resource_string)
|
||||
if resource_string_value:
|
||||
## Node-typed exports (`@export var target: Control`) are TYPE_OBJECT with
|
||||
## PROPERTY_HINT_NODE_TYPE, so a string here is a node path, not a
|
||||
## resource path (#1144): a leading "/" is a clean scene path like `path`;
|
||||
## anything else is relative to the node, which is how the Inspector
|
||||
## stores the reference. "" still clears through the branch below.
|
||||
var node_typed_path: bool = (
|
||||
target_type == TYPE_OBJECT
|
||||
and prop_hint == PROPERTY_HINT_NODE_TYPE
|
||||
and value is String
|
||||
and not (value as String).is_empty()
|
||||
)
|
||||
if node_typed_path:
|
||||
var target: Node = _resolve_node_value(value, node, scene_root)
|
||||
if target == null:
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.NODE_NOT_FOUND,
|
||||
"value: no node at %s. Paths starting with \"/\" are scene paths (e.g. \"/%s/Player\"); others are relative to %s (e.g. \"../Player\")." % [
|
||||
value, scene_root.name, node_path,
|
||||
],
|
||||
)
|
||||
## An absolute editor-tree path ("/root", "/root/@EditorNode@/...") or
|
||||
## a relative path climbing past the root resolves to a node outside
|
||||
## the edited scene; it would commit but can never be saved with it.
|
||||
if target != scene_root and not scene_root.is_ancestor_of(target):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.INVALID_PARAMS,
|
||||
"value: %s resolves to a node outside the edited scene; the target must be \"/%s\" or one of its descendants" % [
|
||||
value, scene_root.name,
|
||||
],
|
||||
)
|
||||
if not _node_matches_hint(target, prop_hint_string):
|
||||
return ErrorCodes.make(
|
||||
ErrorCodes.WRONG_TYPE,
|
||||
"value: %s is a %s, which is not a %s" % [
|
||||
value, _object_type_label(target.get_class(), target.get_script()), prop_hint_string,
|
||||
],
|
||||
)
|
||||
value = target
|
||||
elif resource_string_value:
|
||||
if value == "":
|
||||
value = null
|
||||
else:
|
||||
@@ -305,13 +357,56 @@ func set_property(params: Dictionary) -> Dictionary:
|
||||
"data": {
|
||||
"path": node_path,
|
||||
"property": property,
|
||||
"value": _serialize_value(node.get(property)),
|
||||
"old_value": _serialize_value(old_value),
|
||||
"value": _serialize_slot(node.get(property), scene_root),
|
||||
"old_value": _serialize_slot(old_value, scene_root),
|
||||
"undoable": true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
## Resolve the string value of a Node-typed property (#1144): a leading "/"
|
||||
## is a clean scene path with the same forms `path` accepts; anything else
|
||||
## is a NodePath relative to `node`.
|
||||
static func _resolve_node_value(value: String, node: Node, scene_root: Node) -> Node:
|
||||
if value.begins_with("/"):
|
||||
return McpScenePath.resolve(value, scene_root)
|
||||
return node.get_node_or_null(NodePath(value))
|
||||
|
||||
|
||||
## Does `target` satisfy a PROPERTY_HINT_NODE_TYPE hint string? The hint is
|
||||
## the exported type's name — a ClassDB class ("Control") or a script
|
||||
## class_name ("Player") — and may list several, comma-separated. An empty
|
||||
## hint (a plain `Node` export on some engine versions) accepts any node.
|
||||
static func _node_matches_hint(target: Node, hint_string: String) -> bool:
|
||||
var any_named := false
|
||||
for hint in hint_string.split(","):
|
||||
var wanted := hint.strip_edges()
|
||||
if wanted.is_empty():
|
||||
continue
|
||||
any_named = true
|
||||
if ClassDB.class_exists(wanted):
|
||||
if target.is_class(wanted):
|
||||
return true
|
||||
continue
|
||||
var script: Variant = target.get_script()
|
||||
while script is Script:
|
||||
if String((script as Script).get_global_name()) == wanted:
|
||||
return true
|
||||
script = (script as Script).get_base_script()
|
||||
return not any_named
|
||||
|
||||
|
||||
## set_property's response value: a Node lands as its clean scene path so the
|
||||
## agent sees "/Main/Player", not Godot's object repr (#1144); everything
|
||||
## else serializes as before.
|
||||
static func _serialize_slot(value: Variant, scene_root: Node) -> Variant:
|
||||
if value is Node:
|
||||
var clean := McpScenePath.from_node(value, scene_root)
|
||||
if not clean.is_empty():
|
||||
return clean
|
||||
return _serialize_value(value)
|
||||
|
||||
|
||||
func rename_node(params: Dictionary) -> Dictionary:
|
||||
var resolved := _resolve_node(params)
|
||||
if resolved.has("error"):
|
||||
@@ -1322,7 +1417,7 @@ func get_node_properties(params: Dictionary) -> Dictionary:
|
||||
properties.append({
|
||||
"name": prop.name,
|
||||
"type": type_string(prop.type),
|
||||
"value": _serialize_value(node.get(prop.name)),
|
||||
"value": _serialize_slot(node.get(prop.name), scene_root),
|
||||
})
|
||||
# Requested names that matched no editor-visible property — distinguishes
|
||||
# "you asked for something that doesn't exist" from "exists and is null".
|
||||
|
||||
@@ -167,11 +167,6 @@ var _uv_recheck_pending := false
|
||||
## (foreign-port branch never sets `_server_pid`, so `_stop_server`
|
||||
## can't kill it); the line has to show the mismatch honestly.
|
||||
var _setup_server_label: Label
|
||||
## Last rendered server-version string. `_update_status_label` runs every
|
||||
## frame; early-outs text repaint when nothing changed. Empty means
|
||||
## "no line rendered yet" (dev-checkout branch doesn't render a
|
||||
## user-mode Server line).
|
||||
var _last_rendered_server_text: String = ""
|
||||
## Restart-server button shown next to the Setup container when
|
||||
## `McpConnection.server_version` drifts from the plugin version. Hidden
|
||||
## in the match case so the UI stays calm.
|
||||
@@ -1358,13 +1353,19 @@ func _refresh_server_version_label(server_status: Dictionary = {}) -> void:
|
||||
## look like it had multiple restart paths.
|
||||
or (is_incompatible and can_recover and _crash_restart_btn == null)
|
||||
)
|
||||
if text == _last_rendered_server_text:
|
||||
_setup_server_label.add_theme_color_override("font_color", color)
|
||||
_update_restart_button(show_restart)
|
||||
return
|
||||
_last_rendered_server_text = text
|
||||
## Runs every frame, so write only what changed (#1121). `Label.text`
|
||||
## already ignores same-value writes; `add_theme_color_override` does
|
||||
## not — every call re-sends NOTIFICATION_THEME_CHANGED and queues a
|
||||
## redraw, which kept the idle editor redrawing while this row was
|
||||
## visible. Compare against the label itself so a rebuilt row still
|
||||
## gets its color and a same-text state change (amber drift -> red
|
||||
## incompatible) still repaints.
|
||||
_setup_server_label.text = text
|
||||
_setup_server_label.add_theme_color_override("font_color", color)
|
||||
if (
|
||||
not _setup_server_label.has_theme_color_override("font_color")
|
||||
or _setup_server_label.get_theme_color("font_color") != color
|
||||
):
|
||||
_setup_server_label.add_theme_color_override("font_color", color)
|
||||
_update_restart_button(show_restart)
|
||||
|
||||
|
||||
@@ -1382,12 +1383,10 @@ func _on_restart_stale_server() -> void:
|
||||
if _server_restart_in_progress:
|
||||
return
|
||||
_server_restart_in_progress = true
|
||||
_last_rendered_server_text = ""
|
||||
_refresh_server_version_label()
|
||||
if not is_inside_tree():
|
||||
_dispatch_stale_server_restart()
|
||||
_server_restart_in_progress = false
|
||||
_last_rendered_server_text = ""
|
||||
_refresh_server_version_label()
|
||||
return
|
||||
call_deferred("_restart_stale_server_after_feedback")
|
||||
@@ -1397,7 +1396,6 @@ func _restart_stale_server_after_feedback() -> void:
|
||||
await get_tree().create_timer(0.15).timeout
|
||||
if not _dispatch_stale_server_restart():
|
||||
_server_restart_in_progress = false
|
||||
_last_rendered_server_text = ""
|
||||
_refresh_server_version_label()
|
||||
|
||||
|
||||
@@ -1418,7 +1416,6 @@ func present_lifecycle_action_result(accepted: bool) -> void:
|
||||
if accepted:
|
||||
return
|
||||
_server_restart_in_progress = false
|
||||
_last_rendered_server_text = ""
|
||||
_refresh_server_version_label()
|
||||
|
||||
|
||||
@@ -1516,7 +1513,6 @@ func _refresh_setup_status() -> void:
|
||||
_version_restart_btn.visible = false
|
||||
server_row.add_child(_version_restart_btn)
|
||||
_setup_container.add_child(server_row)
|
||||
_last_rendered_server_text = ""
|
||||
_refresh_server_version_label()
|
||||
else:
|
||||
_setup_container.add_child(_make_status_row("uv", "not found", Color.RED))
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
name="Godot AI"
|
||||
description="MCP server and AI tools for Godot"
|
||||
author="Godot AI"
|
||||
version="4.2.3"
|
||||
version="4.3.0"
|
||||
script="plugin.gd"
|
||||
|
||||
@@ -156,8 +156,13 @@ func _process(_delta: float) -> void:
|
||||
## through the debugger packet path in a single tick. Surplus stays in
|
||||
## `_pending_outbound` and bleeds out across subsequent frames.
|
||||
if not _logger_attached or _logger == null:
|
||||
_pending_outbound.clear()
|
||||
return
|
||||
if not EngineDebugger.is_active():
|
||||
## No remote can consume these lines. Drop current and already-drained
|
||||
## batches so a detached/headless run cannot retain logs indefinitely (#1123).
|
||||
_logger.clear()
|
||||
_pending_outbound.clear()
|
||||
return
|
||||
if _pending_outbound.is_empty():
|
||||
if not _logger.has_pending():
|
||||
@@ -1024,13 +1029,7 @@ func _handle_eval(data: Array) -> void:
|
||||
_eval_token_counter += 1
|
||||
var token := str(_eval_token_counter)
|
||||
var run_fn := "_mcp_run_%s" % token
|
||||
var script_source := (
|
||||
"extends Node\n"
|
||||
+ "func execute():\n"
|
||||
+ "\treturn await %s()\n\n" % run_fn
|
||||
+ "func %s():\n" % run_fn
|
||||
+ _indent_eval_code(code)
|
||||
)
|
||||
var script_source := _build_eval_script_source(run_fn, code)
|
||||
|
||||
## Snapshot the logger's script-error seq BEFORE running so we only attribute
|
||||
## errors raised by this eval. In a debug build a parse error aborts reload()
|
||||
@@ -1227,7 +1226,23 @@ func _handle_eval_check(data: Array) -> void:
|
||||
_try_report_eval_runtime_error(request_id)
|
||||
|
||||
|
||||
func _indent_eval_code(code: String) -> String:
|
||||
## `execute()` always returns, so it carries a return type. The inner function
|
||||
## holds caller code that need not return on every path; a declared return type
|
||||
## there makes Godot reject it with "Not all code paths return a value". It
|
||||
## stays untyped and suppresses `untyped_declaration` on its own declaration
|
||||
## instead (#1119). The annotation shares the `func` line so the line numbers
|
||||
## reported for caller code do not move.
|
||||
static func _build_eval_script_source(run_fn: String, code: String) -> String:
|
||||
return (
|
||||
"extends Node\n"
|
||||
+ "func execute() -> Variant:\n"
|
||||
+ "\treturn await %s()\n\n" % run_fn
|
||||
+ "@warning_ignore(\"untyped_declaration\") func %s():\n" % run_fn
|
||||
+ _indent_eval_code(code)
|
||||
)
|
||||
|
||||
|
||||
static func _indent_eval_code(code: String) -> String:
|
||||
var lines: PackedStringArray = code.split("\n")
|
||||
var out := ""
|
||||
for line in lines:
|
||||
|
||||
@@ -37,6 +37,11 @@ var _mutex := Mutex.new()
|
||||
## (types 0/1) never count. Mutex-guarded: _log_error can fire from any thread.
|
||||
const _ERROR_TYPE_SCRIPT := 2
|
||||
const _MAX_RECENT_SCRIPT_ERRORS := 64
|
||||
const _MAX_PENDING := 4096
|
||||
const _PENDING_TRIM_THRESHOLD := _MAX_PENDING * 2
|
||||
## Lines trimmed from `_pending` since the last drain. The next drain reports
|
||||
## the count as a leading warn entry, so a gap in the log is never silent.
|
||||
var _dropped: int = 0
|
||||
var _script_error_seq: int = 0
|
||||
var _recent_script_errors: Array = []
|
||||
|
||||
@@ -93,6 +98,19 @@ func _append(level: String, text: String, details: Dictionary = {}) -> void:
|
||||
_pending.append([level, text])
|
||||
else:
|
||||
_pending.append([level, text, details.duplicate(true)])
|
||||
## Drop an old batch when a script floods the logger before the next frame.
|
||||
## Trimming at 2x the cap amortizes the slice instead of doing it per line.
|
||||
if _pending.size() >= _PENDING_TRIM_THRESHOLD:
|
||||
_dropped += _pending.size() - _MAX_PENDING
|
||||
_pending = _pending.slice(_pending.size() - _MAX_PENDING)
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
## Drop queued lines that no active debugger can consume.
|
||||
func clear() -> void:
|
||||
_mutex.lock()
|
||||
_pending.clear()
|
||||
_dropped = 0
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
@@ -102,7 +120,15 @@ func drain() -> Array:
|
||||
_mutex.lock()
|
||||
var out := _pending
|
||||
_pending = []
|
||||
var dropped := _dropped
|
||||
_dropped = 0
|
||||
_mutex.unlock()
|
||||
if dropped > 0:
|
||||
out.push_front([
|
||||
"warn",
|
||||
"[godot_ai] dropped %d older game log lines: the game logged faster than they could be forwarded"
|
||||
% dropped,
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,14 @@ static func _root_path() -> String:
|
||||
## Unlike user://, the OS config directory is shared by every Godot
|
||||
## project owned by this account. User-scope client config is shared too,
|
||||
## so a project-specific lock would leave the real cross-editor race open.
|
||||
##
|
||||
## A Flatpak editor that shares the home directory writes the host's client
|
||||
## configs, the same files an editor outside the sandbox writes, while its
|
||||
## own OS config directory is the per-app ~/.var/app/<id>/config. It takes
|
||||
## the lock where those other editors look for it.
|
||||
var host_config_home := McpPathTemplate.flatpak_host_config_home()
|
||||
if not host_config_home.is_empty():
|
||||
return host_config_home.path_join(ROOT_DIR_NAME)
|
||||
return OS.get_config_dir().path_join(ROOT_DIR_NAME)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ const EDITOR_NOT_READY := "EDITOR_NOT_READY"
|
||||
const UNKNOWN_COMMAND := "UNKNOWN_COMMAND"
|
||||
const INTERNAL_ERROR := "INTERNAL_ERROR"
|
||||
const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT"
|
||||
## #1120: filesystem mutation discovery failed before any disk effect
|
||||
## (deadline/cancellation, entry/byte limits, or unreadable owner metadata).
|
||||
## Keep this separate from INVALID_PARAMS so callers can distinguish an
|
||||
## environmental discovery failure from a bad filesystem_manage request.
|
||||
const FILESYSTEM_DISCOVERY_FAILED := "FILESYSTEM_DISCOVERY_FAILED"
|
||||
## Python-originated transport/attach bridge codes. GDScript has no emit path,
|
||||
## but the public registry intentionally mirrors protocol/errors.py.
|
||||
const TRANSPORT_OUTCOME_UNKNOWN := "TRANSPORT_OUTCOME_UNKNOWN"
|
||||
|
||||
@@ -21,6 +21,12 @@ const SNAPSHOT_DIAGNOSTIC_CATEGORIES := [
|
||||
"snapshot_size", "snapshot_json", "snapshot_shape", "collector_null", "row_shape",
|
||||
"row_pid", "row_chain", "row_identity", "lineage_cycle",
|
||||
]
|
||||
## Public lifecycle evidence uses a smaller, stable vocabulary than the
|
||||
## internal collector diagnostics. Keep the detailed category internally so a
|
||||
## future fix can distinguish malformed JSON from an inaccessible ancestor.
|
||||
const SNAPSHOT_FAILURE_CATEGORIES := [
|
||||
"process_query", "json_shape", "ancestor_capture", "identity_mismatch",
|
||||
]
|
||||
static var _process_spawn_mutex := Mutex.new()
|
||||
|
||||
enum PortOccupancy { UNKNOWN, FREE, OCCUPIED }
|
||||
@@ -531,6 +537,39 @@ static func _snapshot_failure(
|
||||
return {"capture_error": true}
|
||||
|
||||
|
||||
## Map one internal collector refusal to the bounded category retained in the
|
||||
## editor log. `row_identity` at depth zero is malformed target metadata;
|
||||
## the same refusal on an ancestor is an ancestor-capture failure. A refusal
|
||||
## this table does not know stays "unknown" instead of borrowing a category.
|
||||
static func snapshot_failure_category(detail: String, depth := -1) -> String:
|
||||
match detail:
|
||||
"shell_exit", "empty_output", "invalid_pid", "collector_null":
|
||||
return "process_query"
|
||||
"lineage_cycle":
|
||||
return "ancestor_capture"
|
||||
"row_identity":
|
||||
return "ancestor_capture" if depth > 0 else "json_shape"
|
||||
"outer_size", "outer_json", "outer_shape", "snapshot_size", "snapshot_json", \
|
||||
"snapshot_shape", "row_shape", "row_pid", "row_chain":
|
||||
return "json_shape"
|
||||
return "unknown"
|
||||
|
||||
|
||||
## Return only the creation-time portion of a captured identity. Windows rows
|
||||
## append the command line after `|`; Linux rows use `linux:<start>|<command>`.
|
||||
## The command line is intentionally never copied into diagnostics.
|
||||
static func process_creation_identity(pid: int, snapshot: Variant = null) -> String:
|
||||
if pid <= 1 or not (snapshot is Dictionary) or capture_failed(snapshot):
|
||||
return ""
|
||||
var identity := str(_process_snapshot_row(snapshot, pid).get("identity", "")).strip_edges()
|
||||
if identity.is_empty():
|
||||
return ""
|
||||
var separator := identity.find("|")
|
||||
if separator > 0:
|
||||
identity = identity.substr(0, separator).strip_edges()
|
||||
return identity.substr(0, 128)
|
||||
|
||||
|
||||
|
||||
## An unavailable capture is not evidence that its PID has exited.
|
||||
static func capture_failed(snapshot: Variant) -> bool:
|
||||
|
||||
@@ -51,6 +51,7 @@ const STALE_PRE_V4_HINT := "stale_pre_v4_server"
|
||||
const DEFAULT_PROVE_TIMEOUT_MS := 180_000
|
||||
const LAUNCH_FINGERPRINT_TIMEOUT_MS := 15_000
|
||||
const REPLACEMENT_TTL_MS := 15_000
|
||||
const IDENTITY_CAPTURE_STAGES := ["launch", "first_server", "final_server"]
|
||||
## An authenticated endpoint that drops (a late keepalive, a server restart)
|
||||
## is re-probed automatically with this backoff, each attempt re-proving the
|
||||
## server exactly as a start does; after the last one the block stays until
|
||||
@@ -126,6 +127,8 @@ func _begin_start_episode(existing_id := 0, probe := true) -> void:
|
||||
"blocked_target": {},
|
||||
"launch": {},
|
||||
"after_stop": "",
|
||||
"snapshot_diagnostic": {},
|
||||
"proof_stage_attempts": {"launch": 0, "first_server": 0, "final_server": 0},
|
||||
}
|
||||
_publish()
|
||||
if not probe:
|
||||
@@ -461,6 +464,7 @@ func _complete_launch(result: Dictionary) -> void:
|
||||
|
||||
|
||||
func _complete_prove(result: Dictionary) -> void:
|
||||
_remember_snapshot_evidence(result)
|
||||
if bool(result.get("pending", false)):
|
||||
_episode["proof_pending_reason"] = str(result.get("reason", "unknown"))
|
||||
if Time.get_ticks_msec() >= int(_episode.get("prove_deadline_msec", 0)):
|
||||
@@ -468,6 +472,7 @@ func _complete_prove(result: Dictionary) -> void:
|
||||
"proof_timeout",
|
||||
"The managed server proof timed out at %s."
|
||||
% str(_episode.get("proof_pending_reason", "unknown"))
|
||||
+ _snapshot_diagnostic_sentence()
|
||||
+ startup_report_summary(str(_plan.get("startup_report", ""))),
|
||||
)
|
||||
return
|
||||
@@ -478,6 +483,7 @@ func _complete_prove(result: Dictionary) -> void:
|
||||
var pending_reason := str(_episode.get("proof_pending_reason", ""))
|
||||
if not pending_reason.is_empty():
|
||||
message += " Last pending proof: %s." % pending_reason
|
||||
message += _snapshot_diagnostic_sentence()
|
||||
_block(str(result.get("reason", "proof_failed")), message)
|
||||
return
|
||||
var launch: Dictionary = _episode.get("launch", {})
|
||||
@@ -485,7 +491,11 @@ func _complete_prove(result: Dictionary) -> void:
|
||||
var fingerprint := str(result.get("fingerprint", ""))
|
||||
var exact_grant = _owned_process_grant(pid, fingerprint)
|
||||
if not exact_grant.is_valid():
|
||||
_block("process_proof_failed", "The server process identity changed before proof completed.")
|
||||
_block(
|
||||
"process_proof_failed",
|
||||
"The server process identity changed before proof completed."
|
||||
+ _snapshot_diagnostic_sentence(),
|
||||
)
|
||||
return
|
||||
_process_grant = exact_grant
|
||||
launch["pid"] = pid
|
||||
@@ -861,8 +871,9 @@ func _effect_launch(payload: Dictionary) -> Dictionary:
|
||||
"ok": not fingerprint.is_empty(),
|
||||
"reason": "launch_unproven" if fingerprint.is_empty() else "",
|
||||
"message": (
|
||||
_launch_unproven_message(pid, attempts, Time.get_ticks_msec() - capture_started)
|
||||
+ _snapshot_diagnostic_summary(snapshot_diagnostics)
|
||||
_launch_unproven_message(
|
||||
pid, attempts, Time.get_ticks_msec() - capture_started, snapshot_diagnostics
|
||||
)
|
||||
if fingerprint.is_empty()
|
||||
else ""
|
||||
),
|
||||
@@ -896,12 +907,13 @@ static func _snapshot_diagnostic_summary(diagnostics: Array) -> String:
|
||||
## (#988, #1012 both surfaced as this bare sentence). Diagnostic only: the
|
||||
## values are read once more after the capture budget and never grant
|
||||
## authority.
|
||||
func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> String:
|
||||
var snapshot: Variant = _capture_process_snapshot(pid)
|
||||
func _launch_unproven_message(
|
||||
pid: int, attempts: Array, elapsed_ms: int, snapshot_diagnostics: Array = []
|
||||
) -> String:
|
||||
var capture_diagnostics: Array = []
|
||||
var snapshot: Variant = _capture_process_snapshot(pid, capture_diagnostics)
|
||||
var alive := PortResolver.pid_alive(pid, snapshot)
|
||||
var commandline := PortResolver.process_commandline(pid, snapshot) if alive else ""
|
||||
if commandline.length() > 200:
|
||||
commandline = commandline.substr(0, 199) + "…"
|
||||
var creation_identity := PortResolver.process_creation_identity(pid, snapshot)
|
||||
## Summarise the per-attempt refusals as "reason×count" in first-seen order.
|
||||
var counts := {}
|
||||
var order: Array[String] = []
|
||||
@@ -914,43 +926,218 @@ func _launch_unproven_message(pid: int, attempts: Array, elapsed_ms: int) -> Str
|
||||
var summary: Array[String] = []
|
||||
for key in order:
|
||||
summary.append("%s×%d" % [key, int(counts[key])])
|
||||
return (
|
||||
var message := (
|
||||
"The launched process identity could not be captured in %d attempts over %.1f s "
|
||||
+ "(pid %d, now alive=%s, refusals: %s%s)."
|
||||
+ "(pid %d, creation_identity=%s, now alive=%s, refusals: %s)."
|
||||
) % [
|
||||
attempts.size(),
|
||||
elapsed_ms / 1000.0,
|
||||
pid,
|
||||
"unavailable" if creation_identity.is_empty() else creation_identity,
|
||||
"unknown" if PortResolver.capture_failed(snapshot) else ("yes" if alive else "no"),
|
||||
", ".join(summary) if not summary.is_empty() else "none recorded",
|
||||
"" if commandline.is_empty() else "; command: " + commandline,
|
||||
]
|
||||
return message + _snapshot_diagnostic_summary(snapshot_diagnostics + capture_diagnostics)
|
||||
|
||||
|
||||
func _capture_process_snapshot(pid: int, diagnostics: Variant = null) -> Variant:
|
||||
return PortResolver.capture_process_snapshot(pid, diagnostics)
|
||||
|
||||
|
||||
func _remember_snapshot_evidence(result: Dictionary) -> void:
|
||||
var diagnostic: Variant = result.get("snapshot_diagnostic")
|
||||
if diagnostic is Dictionary:
|
||||
_episode["snapshot_diagnostic"] = diagnostic.duplicate(true)
|
||||
var attempts: Variant = result.get("proof_stage_attempts")
|
||||
if attempts is Dictionary:
|
||||
_episode["proof_stage_attempts"] = attempts.duplicate(true)
|
||||
|
||||
|
||||
func _snapshot_diagnostic_sentence() -> String:
|
||||
var diagnostic: Variant = _episode.get("snapshot_diagnostic", {})
|
||||
if not (diagnostic is Dictionary) or diagnostic.is_empty():
|
||||
return ""
|
||||
var stage := str(diagnostic.get("stage", ""))
|
||||
if stage not in IDENTITY_CAPTURE_STAGES:
|
||||
stage = "unknown"
|
||||
var category := str(diagnostic.get("category", ""))
|
||||
if category not in PortResolver.SNAPSHOT_FAILURE_CATEGORIES:
|
||||
category = "unknown"
|
||||
var identity := str(diagnostic.get("creation_identity", "")).strip_edges()
|
||||
if identity.is_empty():
|
||||
identity = "unavailable"
|
||||
identity = identity.get_slice("|", 0).replace("\n", " ").replace("\r", " ").substr(0, 128)
|
||||
## The collector's own refusal, when one was recorded: the fixed category
|
||||
## alone cannot tell a shell that exited from one that printed nothing.
|
||||
var detail := str(diagnostic.get("detail", ""))
|
||||
if not detail.is_empty():
|
||||
if detail not in PortResolver.SNAPSHOT_DIAGNOSTIC_CATEGORIES:
|
||||
detail = "unknown"
|
||||
category += " detail=%s" % detail
|
||||
var depth := int(diagnostic.get("depth", -1))
|
||||
if depth >= 0:
|
||||
category += " depth=%d" % clampi(depth, 0, 16)
|
||||
return (
|
||||
" Identity capture: stage=%s category=%s pid=%d creation_identity=%s attempt=%d elapsed_ms=%d."
|
||||
) % [
|
||||
stage,
|
||||
category,
|
||||
int(diagnostic.get("pid", -1)),
|
||||
identity,
|
||||
clampi(int(diagnostic.get("attempt", 1)), 1, 100000),
|
||||
clampi(int(diagnostic.get("elapsed_ms", 0)), 0, 600000),
|
||||
]
|
||||
|
||||
|
||||
func _capture_process_snapshot(pid: int) -> Variant:
|
||||
return PortResolver.capture_process_snapshot(pid)
|
||||
func _capture_identity(
|
||||
stage: String, pid: int, stage_attempts: Dictionary
|
||||
) -> Dictionary:
|
||||
var attempt := int(stage_attempts.get(stage, 0)) + 1
|
||||
stage_attempts[stage] = attempt
|
||||
var diagnostics: Array = []
|
||||
var began := Time.get_ticks_msec()
|
||||
var snapshot: Variant = _capture_process_snapshot(pid, diagnostics)
|
||||
return {
|
||||
"snapshot": snapshot,
|
||||
"diagnostic": _snapshot_evidence(
|
||||
stage, pid, snapshot, diagnostics, attempt, Time.get_ticks_msec() - began
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
func _record_snapshot_capture(
|
||||
stage: String, pid: int, snapshot: Variant, stage_attempts: Dictionary, elapsed_ms: int = 0
|
||||
) -> Dictionary:
|
||||
var attempt := int(stage_attempts.get(stage, 0)) + 1
|
||||
stage_attempts[stage] = attempt
|
||||
return {
|
||||
"snapshot": snapshot,
|
||||
"diagnostic": _snapshot_evidence(stage, pid, snapshot, [], attempt, elapsed_ms),
|
||||
}
|
||||
|
||||
|
||||
static func _capture_elapsed_ms(capture: Dictionary) -> int:
|
||||
var diagnostic: Variant = capture.get("diagnostic", {})
|
||||
return int(diagnostic.get("elapsed_ms", 0)) if diagnostic is Dictionary else 0
|
||||
|
||||
|
||||
static func _retarget_snapshot_capture(
|
||||
capture: Dictionary, stage: String, pid: int
|
||||
) -> Dictionary:
|
||||
var snapshot: Variant = capture.get("snapshot")
|
||||
var old_diagnostic: Variant = capture.get("diagnostic", {})
|
||||
var attempt := int(old_diagnostic.get("attempt", 1)) if old_diagnostic is Dictionary else 1
|
||||
var elapsed_ms := int(old_diagnostic.get("elapsed_ms", 0)) if old_diagnostic is Dictionary else 0
|
||||
return {
|
||||
"snapshot": snapshot,
|
||||
"diagnostic": _snapshot_evidence(stage, pid, snapshot, [], attempt, elapsed_ms),
|
||||
}
|
||||
|
||||
|
||||
static func _snapshot_evidence(
|
||||
stage: String,
|
||||
pid: int,
|
||||
snapshot: Variant,
|
||||
diagnostics: Array,
|
||||
attempt: int,
|
||||
elapsed_ms: int,
|
||||
category_override := "",
|
||||
) -> Dictionary:
|
||||
var detail := ""
|
||||
var depth := -1
|
||||
if not diagnostics.is_empty():
|
||||
var last: Variant = diagnostics[diagnostics.size() - 1]
|
||||
if last is Dictionary:
|
||||
detail = str(last.get("category", ""))
|
||||
depth = int(last.get("depth", -1))
|
||||
var category := str(category_override)
|
||||
if category.is_empty() and not detail.is_empty():
|
||||
category = PortResolver.snapshot_failure_category(detail, depth)
|
||||
elif category.is_empty() and PortResolver.capture_failed(snapshot):
|
||||
category = "process_query"
|
||||
var creation_identity := PortResolver.process_creation_identity(pid, snapshot)
|
||||
return {
|
||||
"stage": stage if stage in IDENTITY_CAPTURE_STAGES else "unknown",
|
||||
"category": (
|
||||
category
|
||||
if category.is_empty() or category in PortResolver.SNAPSHOT_FAILURE_CATEGORIES
|
||||
else "unknown"
|
||||
),
|
||||
"detail": (
|
||||
detail
|
||||
if detail.is_empty() or detail in PortResolver.SNAPSHOT_DIAGNOSTIC_CATEGORIES
|
||||
else "unknown"
|
||||
),
|
||||
"depth": clampi(depth, -1, 16),
|
||||
"pid": pid,
|
||||
"creation_identity": "unavailable" if creation_identity.is_empty() else creation_identity,
|
||||
"attempt": clampi(attempt, 1, 100000),
|
||||
"elapsed_ms": clampi(elapsed_ms, 0, 600000),
|
||||
}
|
||||
|
||||
|
||||
func _proof_pending(
|
||||
reason: String, stage_attempts: Dictionary, diagnostic: Dictionary = {}
|
||||
) -> Dictionary:
|
||||
var result := {
|
||||
"pending": true,
|
||||
"reason": reason,
|
||||
"proof_stage_attempts": stage_attempts.duplicate(true),
|
||||
}
|
||||
if not diagnostic.is_empty():
|
||||
result["snapshot_diagnostic"] = diagnostic.duplicate(true)
|
||||
return result
|
||||
|
||||
|
||||
func _capture_failure(
|
||||
stage_attempts: Dictionary, capture: Dictionary, category_override := ""
|
||||
) -> Dictionary:
|
||||
var diagnostic: Dictionary = capture.get("diagnostic", {}).duplicate(true)
|
||||
if not category_override.is_empty():
|
||||
diagnostic["category"] = category_override
|
||||
return _proof_pending("identity_unavailable", stage_attempts, diagnostic)
|
||||
|
||||
|
||||
func _identity_mismatch(
|
||||
reason: String, stage_attempts: Dictionary, capture: Dictionary
|
||||
) -> Dictionary:
|
||||
var diagnostic: Dictionary = capture.get("diagnostic", {}).duplicate(true)
|
||||
diagnostic["category"] = "identity_mismatch"
|
||||
return _proof_pending(reason, stage_attempts, diagnostic)
|
||||
|
||||
|
||||
func _effect_prove(payload: Dictionary) -> Dictionary:
|
||||
var launch_grant = payload.get("grant")
|
||||
var launch_pid := int(launch_grant.process_id()) if launch_grant != null else -1
|
||||
var stage_attempts: Variant = payload.get("proof_stage_attempts", {})
|
||||
if not (stage_attempts is Dictionary):
|
||||
stage_attempts = {}
|
||||
else:
|
||||
stage_attempts = stage_attempts.duplicate(true)
|
||||
for stage in IDENTITY_CAPTURE_STAGES:
|
||||
stage_attempts[stage] = maxi(0, int(stage_attempts.get(stage, 0)))
|
||||
## The pid file is an untrusted hint. Reuse its ancestor snapshot only
|
||||
## when it includes the launcher; the original PID read and all final
|
||||
## capability, listener, identity, and lineage checks still follow.
|
||||
var hinted_pid := -1
|
||||
var first_snapshot: Variant = null
|
||||
var launch_snapshot: Variant = null
|
||||
var launch_capture: Dictionary = {}
|
||||
var first_capture: Dictionary = {}
|
||||
if OS.get_name() == "Windows" and launch_pid > 1:
|
||||
hinted_pid = PortResolver.read_pid_file(str(payload.get("pid_file", "")))
|
||||
if hinted_pid > 1:
|
||||
first_snapshot = _capture_process_snapshot(hinted_pid)
|
||||
first_capture = _capture_identity("launch", hinted_pid, stage_attempts)
|
||||
first_snapshot = first_capture.get("snapshot")
|
||||
if PortResolver.process_descends_from(hinted_pid, launch_pid, first_snapshot):
|
||||
launch_capture = _retarget_snapshot_capture(first_capture, "launch", launch_pid)
|
||||
launch_snapshot = first_snapshot
|
||||
if launch_snapshot == null:
|
||||
launch_snapshot = _capture_process_snapshot(launch_pid)
|
||||
launch_capture = _capture_identity("launch", launch_pid, stage_attempts)
|
||||
launch_snapshot = launch_capture.get("snapshot")
|
||||
first_snapshot = null
|
||||
if PortResolver.capture_failed(launch_snapshot):
|
||||
return {"pending": true, "reason": "identity_unavailable"}
|
||||
return _capture_failure(stage_attempts, launch_capture)
|
||||
var launch_alive := PortResolver.pid_alive(launch_pid, launch_snapshot)
|
||||
var launch_fingerprint := (
|
||||
PortResolver.process_fingerprint(launch_pid, launch_snapshot)
|
||||
@@ -963,72 +1150,103 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
|
||||
launch_fingerprint,
|
||||
)
|
||||
if launch_disposition != "owned":
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "launch_%s" % launch_disposition,
|
||||
"message": (
|
||||
"The launched server process exited or changed identity before publishing capabilities."
|
||||
+ startup_report_summary(str(payload.get("startup_report", "")))
|
||||
),
|
||||
}
|
||||
if launch_disposition == "gone":
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "launch_gone",
|
||||
"message": (
|
||||
"The launched server process exited or changed identity before publishing capabilities."
|
||||
+ startup_report_summary(str(payload.get("startup_report", "")))
|
||||
),
|
||||
"proof_stage_attempts": stage_attempts.duplicate(true),
|
||||
}
|
||||
var mismatch := _identity_mismatch("launch_%s" % launch_disposition, stage_attempts, launch_capture)
|
||||
mismatch.erase("pending")
|
||||
mismatch["ok"] = false
|
||||
mismatch["message"] = (
|
||||
"The launched server process exited or changed identity before publishing capabilities."
|
||||
+ startup_report_summary(str(payload.get("startup_report", "")))
|
||||
)
|
||||
return mismatch
|
||||
var port := int(payload.http_port)
|
||||
var capability := _read_capability(port)
|
||||
if capability.is_empty() or str(capability.get("instance_nonce", "")) == str(payload.get("baseline_instance_id", "")):
|
||||
return {"pending": true, "reason": "capability_record"}
|
||||
return _proof_pending("capability_record", stage_attempts)
|
||||
if (
|
||||
str(capability.get("http", "")) != str(payload.get("http_capability", ""))
|
||||
or str(capability.get("websocket", "")) != str(payload.get("ws_capability", ""))
|
||||
):
|
||||
return {"pending": true, "reason": "capability_pair"}
|
||||
return _proof_pending("capability_pair", stage_attempts)
|
||||
var listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
|
||||
var live := _probe_with_capability(port, capability, int(payload.timeout_ms), listeners)
|
||||
if not _authenticated_status_matches_record(live, capability):
|
||||
return {"pending": true, "reason": "authenticated_status"}
|
||||
return _proof_pending("authenticated_status", stage_attempts)
|
||||
var version := str(live.get("version", ""))
|
||||
var ws_port := int(live.get("ws_port", 0))
|
||||
if not _server_status_compatibility(
|
||||
version, str(payload.expected_version), ws_port, int(payload.expected_ws_port)
|
||||
).get("compatible", false):
|
||||
return {"ok": false, "reason": "incompatible", "message": "The launched server does not match this plugin."}
|
||||
return {
|
||||
"ok": false,
|
||||
"reason": "incompatible",
|
||||
"message": "The launched server does not match this plugin.",
|
||||
"proof_stage_attempts": stage_attempts.duplicate(true),
|
||||
}
|
||||
var pid := PortResolver.read_pid_file(str(payload.get("pid_file", "")))
|
||||
if pid <= 1:
|
||||
return {"pending": true, "reason": "pid_file"}
|
||||
return _proof_pending("pid_file", stage_attempts)
|
||||
if not PortResolver.find_all_pids_on_port(port, Callable(), listeners).has(pid):
|
||||
return {"pending": true, "reason": "listener_pid"}
|
||||
return _proof_pending("listener_pid", stage_attempts)
|
||||
if first_snapshot == null or pid != hinted_pid:
|
||||
first_snapshot = (
|
||||
launch_snapshot if pid == launch_pid else _capture_process_snapshot(pid)
|
||||
if pid == launch_pid:
|
||||
first_snapshot = launch_snapshot
|
||||
first_capture = _record_snapshot_capture(
|
||||
"first_server", pid, first_snapshot, stage_attempts,
|
||||
_capture_elapsed_ms(launch_capture)
|
||||
)
|
||||
else:
|
||||
first_capture = _capture_identity("first_server", pid, stage_attempts)
|
||||
first_snapshot = first_capture.get("snapshot")
|
||||
else:
|
||||
first_capture = _record_snapshot_capture(
|
||||
"first_server", pid, first_snapshot, stage_attempts,
|
||||
_capture_elapsed_ms(first_capture)
|
||||
)
|
||||
if PortResolver.capture_failed(first_snapshot):
|
||||
return {"pending": true, "reason": "identity_unavailable"}
|
||||
return _capture_failure(stage_attempts, first_capture)
|
||||
if not PortResolver.pid_cmdline_is_godot_ai(pid, first_snapshot):
|
||||
return {"pending": true, "reason": "process_brand"}
|
||||
return _identity_mismatch("process_brand", stage_attempts, first_capture)
|
||||
if not PortResolver.process_descends_from(pid, launch_pid, first_snapshot):
|
||||
return {"pending": true, "reason": "launch_lineage"}
|
||||
return _identity_mismatch("launch_lineage", stage_attempts, first_capture)
|
||||
var fingerprint := PortResolver.process_fingerprint(pid, first_snapshot)
|
||||
if fingerprint.is_empty():
|
||||
return {"pending": true, "reason": "process_fingerprint"}
|
||||
return _identity_mismatch("process_fingerprint", stage_attempts, first_capture)
|
||||
## Close every capture window before minting process authority. The same
|
||||
## launch-lineage PID must still own the listener, the private capability
|
||||
## record must still name the authenticated instance, and the process
|
||||
## fingerprint must remain unchanged after the final probe.
|
||||
var final_capability := _read_capability(port)
|
||||
var final_live := _probe_with_capability(port, final_capability, int(payload.timeout_ms), listeners)
|
||||
var final_snapshot: Variant = _capture_process_snapshot(pid)
|
||||
var final_capture := _capture_identity("final_server", pid, stage_attempts)
|
||||
var final_snapshot: Variant = final_capture.get("snapshot")
|
||||
if PortResolver.capture_failed(final_snapshot):
|
||||
return {"pending": true, "reason": "identity_unavailable"}
|
||||
return _capture_failure(stage_attempts, final_capture)
|
||||
var final_fingerprint := PortResolver.process_fingerprint(pid, final_snapshot)
|
||||
var final_listeners := PortResolver.windows_listener_snapshot() if OS.get_name() == "Windows" else {}
|
||||
if (
|
||||
PortResolver.read_pid_file(str(payload.get("pid_file", ""))) != pid
|
||||
or not PortResolver.find_all_pids_on_port(port, Callable(), final_listeners).has(pid)
|
||||
or not PortResolver.process_descends_from(pid, launch_pid, final_snapshot)
|
||||
or not PortResolver.pid_cmdline_is_godot_ai(pid, final_snapshot)
|
||||
or PortResolver.process_fingerprint(pid, final_snapshot) != fingerprint
|
||||
or final_capability != capability
|
||||
or final_fingerprint != fingerprint
|
||||
):
|
||||
return _identity_mismatch("final_capture_window", stage_attempts, final_capture)
|
||||
if (
|
||||
final_capability != capability
|
||||
or not _authenticated_status_matches_record(final_live, final_capability)
|
||||
or str(final_live.get("instance_id", "")) != str(live.get("instance_id", ""))
|
||||
):
|
||||
return {"pending": true, "reason": "final_capture_window"}
|
||||
return _proof_pending("final_capture_window", stage_attempts)
|
||||
var final_version := str(final_live.get("version", ""))
|
||||
var final_ws_port := int(final_live.get("ws_port", 0))
|
||||
if not _server_status_compatibility(
|
||||
@@ -1041,6 +1259,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
|
||||
"ok": false,
|
||||
"reason": "incompatible",
|
||||
"message": "The launched server changed compatibility before authority was granted.",
|
||||
"proof_stage_attempts": stage_attempts.duplicate(true),
|
||||
}
|
||||
return {
|
||||
"ok": true,
|
||||
@@ -1048,6 +1267,7 @@ func _effect_prove(payload: Dictionary) -> Dictionary:
|
||||
"fingerprint": fingerprint,
|
||||
"version": final_version,
|
||||
"transport": _transport_from(port, final_ws_port, final_live, final_capability),
|
||||
"proof_stage_attempts": stage_attempts.duplicate(true),
|
||||
}
|
||||
|
||||
|
||||
@@ -1289,6 +1509,9 @@ func _prove_payload() -> Dictionary:
|
||||
"ws_capability": str(launch.get("ws_capability", "")),
|
||||
"baseline_instance_id": str(launch.get("baseline_instance_id", "")),
|
||||
"grant": _process_grant,
|
||||
"proof_stage_attempts": _episode.get(
|
||||
"proof_stage_attempts", {"launch": 0, "first_server": 0, "final_server": 0}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -1742,7 +1965,8 @@ static func _dormant_episode(episode_id: int) -> Dictionary:
|
||||
"id": episode_id, "state": DORMANT, "phase": "", "reason": "",
|
||||
"message": "Server lifecycle is dormant.", "ready_kind": "", "effect": "",
|
||||
"expected_version": "", "actual_version": "", "blocked_target": {},
|
||||
"launch": {}, "after_stop": "",
|
||||
"launch": {}, "after_stop": "", "snapshot_diagnostic": {},
|
||||
"proof_stage_attempts": {"launch": 0, "first_server": 0, "final_server": 0},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,19 @@ const _GROUP_OTHER_PERMISSION_MASK := 0x3f ## 0077
|
||||
const _GROUP_OTHER_WRITE_MASK := 0x12 ## 0022
|
||||
const _MAX_LINK_HOPS := 8
|
||||
const _SYSTEM_TEMP_ROOTS: Array[String] = ["/tmp", "/private/tmp", "/var/tmp"]
|
||||
const _FLATPAK_INFO_PATH := "/.flatpak-info"
|
||||
## Every read-write spelling of the two grants that expose the home directory.
|
||||
## Flatpak 1.16 writes plain read-write as the bare name; `:create` is
|
||||
## read-write too. `:ro` is absent on purpose.
|
||||
const _FLATPAK_HOME_GRANTS: Array[String] = [
|
||||
"host", "host:rw", "host:create", "home", "home:rw", "home:create",
|
||||
]
|
||||
## Root directories a `host` grant leaves out of the sandbox, where Flatpak's
|
||||
## own runtime lives (`dont_mount_in_root` in flatpak-context.c).
|
||||
const _FLATPAK_HOST_EXCLUDED: Array[String] = [
|
||||
"lib", "lib32", "lib64", "bin", "sbin", "usr", "boot", "root",
|
||||
"tmp", "etc", "app", "run", "proc", "sys", "dev", "var",
|
||||
]
|
||||
const _KEYS: Array[String] = [
|
||||
"version", "http", "websocket", "instance_nonce",
|
||||
]
|
||||
@@ -322,10 +335,371 @@ static func path_for_http_port(http_port: int) -> String:
|
||||
"Library/Application Support/godot-ai/capabilities"
|
||||
)
|
||||
else:
|
||||
directory = OS.get_environment("XDG_CONFIG_HOME").strip_edges()
|
||||
if directory.is_empty():
|
||||
directory = OS.get_environment("HOME").path_join(".config")
|
||||
directory = directory.path_join("godot-ai/capabilities")
|
||||
directory = linux_config_home(_flatpak_info()).path_join("godot-ai/capabilities")
|
||||
if directory.is_empty() or not directory.is_absolute_path():
|
||||
return ""
|
||||
return directory.simplify_path().path_join("http-%d.json" % http_port)
|
||||
|
||||
|
||||
## The config directory a Linux process outside this one would also name.
|
||||
## Flatpak points `XDG_CONFIG_HOME` at the app's own `~/.var/app/<id>/config`,
|
||||
## which a client outside that sandbox never reads. When the sandbox shares
|
||||
## the host's config directory, that is `HOST_XDG_CONFIG_HOME` if the host set
|
||||
## one, else the `~/.config` default. Python's `capability_directory` applies
|
||||
## the same rule, so the server publishes where this reads.
|
||||
static func linux_config_home(flatpak_info: String) -> String:
|
||||
var config := OS.get_environment(linux_config_home_variable(flatpak_info)).strip_edges()
|
||||
if config.is_empty():
|
||||
config = OS.get_environment("HOME").path_join(".config")
|
||||
return config
|
||||
|
||||
|
||||
## The variable that names that directory; unset or empty means `~/.config`.
|
||||
static func linux_config_home_variable(flatpak_info: String) -> String:
|
||||
return (
|
||||
"HOST_XDG_CONFIG_HOME" if flatpak_shares_config_home(flatpak_info) else "XDG_CONFIG_HOME"
|
||||
)
|
||||
|
||||
|
||||
## Whether `/.flatpak-info` text describes a sandbox that can write the host's
|
||||
## config directory at its own path: one that shares the home, or holds a
|
||||
## read-write grant for the whole of `xdg-config`. Flatpak mounts that grant at
|
||||
## the host's path only and leaves `XDG_CONFIG_HOME` on the per-app directory.
|
||||
## An `xdg-config/<dir>` grant does not count: Flatpak mounts it inside the
|
||||
## per-app directory as well, so `XDG_CONFIG_HOME` already reaches it.
|
||||
static func flatpak_shares_config_home(flatpak_info: String) -> bool:
|
||||
return (
|
||||
flatpak_shares_home(flatpak_info)
|
||||
or bool(_flatpak_filesystems(flatpak_info).get("xdg-config", false))
|
||||
)
|
||||
|
||||
|
||||
## Whether `/.flatpak-info` text describes a sandbox that can write the host's
|
||||
## home directory. A read-write `host` or `home` grant exposes the real home at
|
||||
## its own path; a `:ro` grant, a narrower one, or none leaves the app's private
|
||||
## directories as the only ones a process outside the sandbox can also see.
|
||||
static func flatpak_shares_home(flatpak_info: String) -> bool:
|
||||
var group := ""
|
||||
for line in flatpak_info.split("\n"):
|
||||
if line.begins_with("["):
|
||||
group = line.strip_edges()
|
||||
elif group == "[Context]" and line.begins_with("filesystems="):
|
||||
for grant in line.trim_prefix("filesystems=").strip_edges().split(";"):
|
||||
if grant in _FLATPAK_HOME_GRANTS:
|
||||
return true
|
||||
return false
|
||||
return false
|
||||
|
||||
|
||||
## The grant in `/.flatpak-info` text that decides whether the sandbox shares
|
||||
## `path` with the host, so that a process outside it finds the same file at
|
||||
## the same location. Returned as {root, location, writable}: the directory or
|
||||
## file the grant covers, the grant as Flatpak spells it (`home`, `~/dir`,
|
||||
## `xdg-config/dir`), and whether it is read-write. {} when no grant covers
|
||||
## the path.
|
||||
##
|
||||
## `home` and `host` share the home directory, except `~/.var/app`: there
|
||||
## Flatpak hides every app's directory but the sandbox's own, whatever the
|
||||
## grant, and shares that one with no grant at all. `host` also shares the
|
||||
## root directories its runtime does not occupy. `~/dir` and `/dir` share that
|
||||
## location, and `xdg-config[/dir]` the host's config directory,
|
||||
## `host_config_home`. The narrowest grant decides, as the narrowest mount
|
||||
## does. The other `xdg-` names share nothing here: where they lie depends on
|
||||
## host settings the sandbox cannot read, so their paths fail closed.
|
||||
##
|
||||
## A grant says what Flatpak was asked for, not what it mounted. It skips a
|
||||
## location that does not exist when the app starts, and `/.flatpak-info`
|
||||
## leaves out denials (`--nofilesystem`). `flatpak_blocking_mount` checks the
|
||||
## answer against the mounts.
|
||||
static func flatpak_grant_over(
|
||||
flatpak_info: String, path: String, home: String, host_config_home: String
|
||||
) -> Dictionary:
|
||||
var target := path.simplify_path()
|
||||
if not target.is_absolute_path():
|
||||
return {}
|
||||
var grants := _flatpak_filesystems(flatpak_info)
|
||||
var found := {}
|
||||
for location in grants:
|
||||
var root := _flatpak_grant_path(str(location), home, host_config_home)
|
||||
if root.is_empty() or not path_is_within(root, target):
|
||||
continue
|
||||
var covered := str(found.get("root", ""))
|
||||
if root.length() > covered.length():
|
||||
found = {"root": root, "location": str(location), "writable": bool(grants[location])}
|
||||
elif root == covered and found["writable"] and not grants[location]:
|
||||
## Two spellings of one location: Flatpak mounts whichever it
|
||||
## reaches last, so only agreement counts as writable.
|
||||
found = {"root": root, "location": str(location), "writable": false}
|
||||
var home_dir := home.simplify_path()
|
||||
var top := target.get_slice("/", 1)
|
||||
var wide := {}
|
||||
if not home.is_empty() and path_is_within(home_dir, target):
|
||||
var apps_dir := home_dir.path_join(".var/app")
|
||||
var app_id := flatpak_application_id(flatpak_info)
|
||||
if path_is_within(apps_dir, target):
|
||||
if not app_id.is_empty() and path_is_within(apps_dir.path_join(app_id), target):
|
||||
wide = {
|
||||
"root": apps_dir.path_join(app_id),
|
||||
"location": apps_dir.path_join(app_id),
|
||||
"writable": true,
|
||||
}
|
||||
elif grants.has("home") or grants.has("host"):
|
||||
## Flatpak applies the wider of the two modes to the home directory,
|
||||
## so a read-write `home` is always enough for a path inside it.
|
||||
wide = {
|
||||
"root": home_dir,
|
||||
"location": "home",
|
||||
"writable": bool(grants.get("home", false)) or bool(grants.get("host", false)),
|
||||
}
|
||||
elif grants.has("host") and not top.is_empty() and top not in _FLATPAK_HOST_EXCLUDED:
|
||||
wide = {"root": "/" + top, "location": "host", "writable": bool(grants["host"])}
|
||||
if str(wide.get("root", "")).length() > str(found.get("root", "")).length():
|
||||
return wide
|
||||
return found
|
||||
|
||||
|
||||
## The sandboxed app's ID from `/.flatpak-info` text, or "" outside Flatpak.
|
||||
static func flatpak_application_id(flatpak_info: String) -> String:
|
||||
var group := ""
|
||||
for line in flatpak_info.split("\n"):
|
||||
if line.begins_with("["):
|
||||
group = line.strip_edges()
|
||||
elif group == "[Application]" and line.begins_with("name="):
|
||||
return line.trim_prefix("name=").strip_edges()
|
||||
return ""
|
||||
|
||||
|
||||
## Whether `directory` is `path` or one of its ancestors.
|
||||
static func path_is_within(directory: String, path: String) -> bool:
|
||||
return path == directory or path.begins_with(directory.trim_suffix("/") + "/")
|
||||
|
||||
|
||||
## Where a grant's location lies in the sandbox, or "" for one this cannot
|
||||
## place (`host-os`, `xdg-documents`, ...).
|
||||
static func _flatpak_grant_path(location: String, home: String, host_config_home: String) -> String:
|
||||
var placed := ""
|
||||
if location == "~" or location.begins_with("~/"):
|
||||
placed = home.path_join(location.substr(2))
|
||||
elif location.begins_with("/"):
|
||||
placed = _home_spelling(location, home)
|
||||
elif not host_config_home.is_empty() and (
|
||||
location == "xdg-config" or location.begins_with("xdg-config/")
|
||||
):
|
||||
placed = host_config_home + location.trim_prefix("xdg-config")
|
||||
return placed.simplify_path() if placed.is_absolute_path() else ""
|
||||
|
||||
|
||||
## `/home` and `/var/home` are one directory on ostree systems, and Flatpak
|
||||
## mounts a grant at whichever is real, keeping the link. A grant spelled the
|
||||
## way the home directory is not therefore reads as the one that is.
|
||||
static func _home_spelling(path: String, home: String) -> String:
|
||||
for spelling in [["/var/home/", "/home/"], ["/home/", "/var/home/"]]:
|
||||
if home.begins_with(spelling[0]) and path.begins_with(spelling[1]):
|
||||
return str(spelling[0]) + path.trim_prefix(spelling[1])
|
||||
return path
|
||||
|
||||
|
||||
## The `[Context] filesystems=` grants of `/.flatpak-info` text as
|
||||
## {location: writable}, a location being the grant without its mode: `host`,
|
||||
## `~/dir`, `/dir`, `xdg-config/dir`. Plain and `:create` grants are writable.
|
||||
## `:ro`, a denial (`!`) and a mode this does not know are not.
|
||||
static func _flatpak_filesystems(flatpak_info: String) -> Dictionary:
|
||||
var grants := {}
|
||||
var group := ""
|
||||
for line in flatpak_info.split("\n"):
|
||||
if line.begins_with("["):
|
||||
group = line.strip_edges()
|
||||
elif group == "[Context]" and line.begins_with("filesystems="):
|
||||
for entry in _key_file_list(line.trim_prefix("filesystems=").strip_edges()):
|
||||
var grant := _flatpak_location_and_mode(entry.trim_prefix("!"))
|
||||
## `!host:reset` withdraws inherited grants. It names no location.
|
||||
if grant[0].is_empty() or grant[1] == "reset":
|
||||
continue
|
||||
grants[grant[0]] = not entry.begins_with("!") and grant[1] in ["", "rw", "create"]
|
||||
break
|
||||
return grants
|
||||
|
||||
|
||||
## A key file list value split on its unescaped `;`, with the key file's own
|
||||
## escapes resolved. Flatpak's `\:` and `\\` reach the entry as `\:` and `\\`.
|
||||
static func _key_file_list(value: String) -> PackedStringArray:
|
||||
var entries := PackedStringArray()
|
||||
var entry := ""
|
||||
var index := 0
|
||||
while index < value.length():
|
||||
var character := value[index]
|
||||
if character == "\\" and index + 1 < value.length():
|
||||
index += 1
|
||||
match value[index]:
|
||||
"s":
|
||||
entry += " "
|
||||
"n":
|
||||
entry += "\n"
|
||||
"t":
|
||||
entry += "\t"
|
||||
"r":
|
||||
entry += "\r"
|
||||
_:
|
||||
entry += value[index]
|
||||
elif character == ";":
|
||||
entries.append(entry)
|
||||
entry = ""
|
||||
else:
|
||||
entry += character
|
||||
index += 1
|
||||
if not entry.is_empty():
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
## One grant as [location, mode]. Flatpak escapes a `:` or `\` that belongs to
|
||||
## the location, and the first bare `:` starts the mode (`ro`, `create`).
|
||||
static func _flatpak_location_and_mode(entry: String) -> PackedStringArray:
|
||||
var location := ""
|
||||
var index := 0
|
||||
while index < entry.length():
|
||||
var character := entry[index]
|
||||
if character == "\\" and index + 1 < entry.length():
|
||||
index += 1
|
||||
location += entry[index]
|
||||
elif character == ":":
|
||||
return PackedStringArray([location, entry.substr(index + 1)])
|
||||
else:
|
||||
location += character
|
||||
index += 1
|
||||
return PackedStringArray([location, ""])
|
||||
|
||||
|
||||
## `/proc/self/mountinfo` text as one {mount_point, root, fstype, read_only}
|
||||
## per mount, in the kernel's order. A line that does not parse is left out.
|
||||
static func parse_mountinfo(mountinfo: String) -> Array[Dictionary]:
|
||||
var mounts: Array[Dictionary] = []
|
||||
for line in mountinfo.split("\n", false):
|
||||
var fields := line.split(" ", false)
|
||||
## Optional fields end at a lone "-"; the filesystem type follows it.
|
||||
var separator := fields.find("-", 6)
|
||||
if separator < 0 or separator + 1 >= fields.size():
|
||||
continue
|
||||
mounts.append({
|
||||
"mount_point": _unescape_mount_path(fields[4]),
|
||||
"root": _unescape_mount_path(fields[3]),
|
||||
"fstype": fields[separator + 1],
|
||||
"read_only": fields[5].split(",").has("ro"),
|
||||
})
|
||||
return mounts
|
||||
|
||||
|
||||
## The mount that holds `path`: the one with the longest mount point at or
|
||||
## above it and, among equals, the last listed, which is mounted on top.
|
||||
## {} when none is.
|
||||
static func mount_for_path(mounts: Array[Dictionary], path: String) -> Dictionary:
|
||||
var target := path.simplify_path()
|
||||
var found := {}
|
||||
for mount in mounts:
|
||||
var mount_point := str(mount["mount_point"])
|
||||
if (
|
||||
path_is_within(mount_point, target)
|
||||
and mount_point.length() >= str(found.get("mount_point", "")).length()
|
||||
):
|
||||
found = mount
|
||||
return found
|
||||
|
||||
|
||||
## The mount that contradicts a grant covering `path`: a file written there
|
||||
## would stay inside the sandbox, or cannot be written. {} when the mounts
|
||||
## bear the grant out.
|
||||
##
|
||||
## An ungranted location lies on the tmpfs Flatpak builds the sandbox on, and
|
||||
## so does a granted one that did not exist when the app started. A denial
|
||||
## hides a location behind an empty tmpfs of its own, as Flatpak hides
|
||||
## `~/.var/app`. Such a tmpfs is recognised by being mounted whole (root `/`)
|
||||
## below `boundary`, the directory the caller knows to be the host's: the home
|
||||
## directory for a path inside it, else the granted directory. A tmpfs at or
|
||||
## above the boundary is the host's own (`/home` on a tmpfs) and blocks
|
||||
## nothing. Neither does an empty mount list, so unreadable evidence leaves
|
||||
## the grant's answer standing.
|
||||
static func flatpak_blocking_mount(
|
||||
mounts: Array[Dictionary], path: String, boundary: String
|
||||
) -> Dictionary:
|
||||
var mount := mount_for_path(mounts, path)
|
||||
if mount.is_empty():
|
||||
return {}
|
||||
var mount_point := str(mount["mount_point"])
|
||||
var host_directory := boundary.simplify_path()
|
||||
if mount["fstype"] == "tmpfs" and (
|
||||
mount_point == "/"
|
||||
or (
|
||||
mount["root"] == "/"
|
||||
and mount_point != host_directory
|
||||
and path_is_within(host_directory, mount_point)
|
||||
)
|
||||
):
|
||||
return mount
|
||||
return mount if mount["read_only"] else {}
|
||||
|
||||
|
||||
## `path` as the kernel names it, with every symlink among its existing
|
||||
## components replaced by its target. Mount points are listed by real path, so
|
||||
## a home spelled `/home/<user>` on an ostree system has to be read as
|
||||
## `/var/home/<user>` before it can be looked up among them. Components that do
|
||||
## not exist are kept as written. A chain longer than `_MAX_LINK_HOPS` returns
|
||||
## the path unchanged.
|
||||
static func real_path(path: String) -> String:
|
||||
if OS.get_name() == "Windows":
|
||||
return path
|
||||
var remaining := path.simplify_path().split("/", false)
|
||||
var current := "/"
|
||||
var hops := 0
|
||||
while not remaining.is_empty():
|
||||
var parent := DirAccess.open(current)
|
||||
if parent == null:
|
||||
break
|
||||
var candidate := current.path_join(remaining[0])
|
||||
remaining.remove_at(0)
|
||||
if not parent.is_link(candidate):
|
||||
current = candidate
|
||||
continue
|
||||
var target := parent.read_link(candidate)
|
||||
if hops >= _MAX_LINK_HOPS or target.is_empty():
|
||||
return path
|
||||
hops += 1
|
||||
if not target.is_absolute_path():
|
||||
target = current.path_join(target)
|
||||
var target_parts := target.simplify_path().split("/", false)
|
||||
target_parts.append_array(remaining)
|
||||
remaining = target_parts
|
||||
current = "/"
|
||||
return current if remaining.is_empty() else current.path_join("/".join(remaining))
|
||||
|
||||
|
||||
## Undo the octal escapes mountinfo uses for space, tab, newline and backslash.
|
||||
static func _unescape_mount_path(field: String) -> String:
|
||||
if not field.contains("\\"):
|
||||
return field
|
||||
var path := ""
|
||||
var index := 0
|
||||
while index < field.length():
|
||||
var code := field.substr(index + 1, 3)
|
||||
if field[index] == "\\" and _is_octal_byte(code):
|
||||
path += char(int(code[0]) * 64 + int(code[1]) * 8 + int(code[2]))
|
||||
index += 4
|
||||
else:
|
||||
path += field[index]
|
||||
index += 1
|
||||
return path
|
||||
|
||||
|
||||
static func _is_octal_byte(code: String) -> bool:
|
||||
if code.length() != 3:
|
||||
return false
|
||||
for digit in code:
|
||||
if digit not in "01234567":
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _flatpak_info() -> String:
|
||||
if not FileAccess.file_exists(_FLATPAK_INFO_PATH):
|
||||
return ""
|
||||
var file := FileAccess.open(_FLATPAK_INFO_PATH, FileAccess.READ)
|
||||
return "" if file == null else file.get_as_text()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023-2026 Cory Petkovsek and Contributors
|
||||
Copyright (c) 2021 J. Cuéllar
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
----
|
||||
|
||||
Third party assets have their own licenses found under assets/thirdparty.
|
||||
@@ -0,0 +1,73 @@
|
||||

|
||||
|
||||
|
||||
# Sky3D
|
||||
|
||||
A dynamic day/night cycle for Godot Engine 4, written in GDScript.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
* Supports Godot 4.3+, Forward, Mobile, and Compatibility renderers
|
||||
* Automatically rotating sun, moon, and stars, with moon phases
|
||||
* Dynamic atmosphere, fog, and clouds that change with the day cycle
|
||||
* Consolidated controls to manage lighting and camera exposure
|
||||
* Management of game time: current time, day length, day or night
|
||||
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
* Clone or download the repository.
|
||||
* Open the project in Godot and run `demo/Sky3DDemo.tscn` to test it.
|
||||
* Copy `addons/sky_3d` into your project `addons` directory. Create the folder if missing.
|
||||
* Open `Project -> Project Settings -> Plugins` and enable the plugin.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
* Create or open a Scene.
|
||||
* Remove any existing `WorldEnvironment` node.
|
||||
* Create a new `Sky3D` node.
|
||||
* Customize the settings of the `Sky3D`, `Sky3D/Environment`, `TimeOfDay`, `SkyDome`, `SunLight`, and `MoonLight` nodes. Some settings like light energy, color, and angle are driven by Sky3D and not directly changeable on the light nodes. You'll know if they are reset on time updates. Adjust those settings in `Sky3D` or `SkyDome`.
|
||||
|
||||
|
||||
## Compatibility Renderer Note
|
||||
This render needs a bit of adjustment to show similar results to Vulkan:
|
||||
* `Sky3D / Sky Contribution = 0.75`
|
||||
* `SkyDome / Fog / Fog Density = 0.01`
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation is built in to Godot. Look for tooltips in the inspector where available, or press F1 and search for `Sky3D`, `SkyDome`, `TimeOfDay`.
|
||||
|
||||
|
||||
## Support
|
||||
|
||||
For support, join our [Discord server](https://tokisan.com/discord).
|
||||
|
||||
|
||||
## Credit
|
||||
|
||||
Developed for the Godot community by:
|
||||
|||
|
||||
|--|--|
|
||||
| **Cory Petkovsek, Tokisan Games** | [<img src="https://github.com/dmhendricks/signature-social-icons/blob/master/icons/round-flat-filled/35px/twitter.png?raw=true" width="24"/>](https://twitter.com/TokisanGames) [<img src="https://github.com/dmhendricks/signature-social-icons/blob/master/icons/round-flat-filled/35px/github.png?raw=true" width="24"/>](https://github.com/TokisanGames) [<img src="https://github.com/dmhendricks/signature-social-icons/blob/master/icons/round-flat-filled/35px/www.png?raw=true" width="24"/>](https://tokisan.com/) [<img src="https://github.com/dmhendricks/signature-social-icons/blob/master/icons/round-flat-filled/35px/discord.png?raw=true" width="24"/>](https://tokisan.com/discord) [<img src="https://github.com/dmhendricks/signature-social-icons/blob/master/icons/round-flat-filled/35px/youtube.png?raw=true" width="24"/>](https://www.youtube.com/@TokisanGames)|
|
||||
|
||||
**And all of the wonderful contributors shown in the right sidebar of the [github repository](https://github.com/TokisanGames/Sky3D/).**
|
||||
|
||||
The original version of this plugin, *TimeOfDay v1*, was made by [J. Cuéllar](https://twitter.com/JayKuellar) for Godot 3. You can find it in the `godot3` branch. The original repository was deleted. We revived it, ported the GDScript version to Godot 4, and have continued to build on it to produce Sky3D.
|
||||
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This addon has been released under the [MIT License](LICENSE.txt).
|
||||
|
||||
If using the star map assets, you must [credit the author](https://github.com/TokisanGames/Sky3D/blob/main/addons/sky_3d/assets/thirdparty/textures/milkyway/LICENSE.md).
|
||||
@@ -0,0 +1,8 @@
|
||||
## Attribution for Third Party Assets
|
||||
|
||||
`Milkyway.jpg` "[The Milky Way panorama](https://www.eso.org/public/images/eso0932a/)" by ESO/S. Brunier, licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/
|
||||
).
|
||||
[Read more](/addons/sky_3d/assets/thirdparty/textures/milkyway/LICENSE.md)
|
||||
|
||||
`MoonMap.png` Copyright (c) 2019 GPoSM, MIT License
|
||||
[Read more](/addons/sky_3d/assets/thirdparty/textures/moon/LICENSE.md)
|
||||
@@ -0,0 +1,7 @@
|
||||
[gd_resource type="NoiseTexture2D" load_steps=2 format=3 uid="uid://cfqk60lpl5ljv"]
|
||||
|
||||
[sub_resource type="FastNoiseLite" id="1"]
|
||||
|
||||
[resource]
|
||||
seamless = true
|
||||
noise = SubResource("1")
|
||||
@@ -0,0 +1,5 @@
|
||||
[gd_resource type="Curve" format=3 uid="uid://70482fhm3qg7"]
|
||||
|
||||
[resource]
|
||||
_data = [Vector2(0, 0), 0.0, 0.0, 0, 0, Vector2(0.617886, 0), 0.0, 0.0467088, 1, 0, Vector2(0.699187, 1), 0.0, 0.0, 0, 0, Vector2(1, 1), 0.0, 0.0, 0, 0]
|
||||
point_count = 4
|
||||
|
After Width: | Height: | Size: 860 B |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bwooawylkf7f2"
|
||||
path="res://.godot/imported/SkyIcon.png-7b3cbfa68354965cdbfdba8b95dacf67.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/sky_3d/assets/textures/SkyIcon.png"
|
||||
dest_files=["res://.godot/imported/SkyIcon.png-7b3cbfa68354965cdbfdba8b95dacf67.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
|
||||
|
After Width: | Height: | Size: 48 KiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://djpfuyxkryegn"
|
||||
path.s3tc="res://.godot/imported/noise.jpg-b47d5d36d9abfdaec2a09c140a78ba24.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/sky_3d/assets/textures/noise.jpg"
|
||||
dest_files=["res://.godot/imported/noise.jpg-b47d5d36d9abfdaec2a09c140a78ba24.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
|
||||
|
After Width: | Height: | Size: 142 KiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bd7u40ws8s57f"
|
||||
path.s3tc="res://.godot/imported/noise2.png-37a5cbd5451b4b9c2c18ab32099e6965.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/sky_3d/assets/textures/noise2.png"
|
||||
dest_files=["res://.godot/imported/noise2.png-37a5cbd5451b4b9c2c18ab32099e6965.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=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
|
||||
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cecwdqjol0ckd"
|
||||
path.s3tc="res://.godot/imported/noiseClouds.png-07511094ef8b72a49cc860b35c60d0fd.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/sky_3d/assets/textures/noiseClouds.png"
|
||||
dest_files=["res://.godot/imported/noiseClouds.png-07511094ef8b72a49cc860b35c60d0fd.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
|
||||
@@ -0,0 +1,40 @@
|
||||
# Attribution
|
||||
`Milkyway.jpg` "[The Milky Way panorama](https://www.eso.org/public/images/eso0932a/)" by ESO/S. Brunier, licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/
|
||||
).
|
||||
|
||||
`StarField.jpg` is the same image modified to hide all but the brightest stars.
|
||||
|
||||
Your game credits should include attribution similar to the above with a link to the license where reasonable.
|
||||
|
||||
e.g. `The Milky Way panorama by ESO/S. Brunier, licensed under CC BY 4.0 and used in original and modified forms.`
|
||||
|
||||
Higher resolutions and more information are available at: https://www.eso.org/public/images/eso0932a/
|
||||
|
||||
Licensing questions answered here: https://www.eso.org/public/copyright/
|
||||
|
||||
## License
|
||||
[Creative Commons Attribution 4.0 International License](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
|
||||
## Relevant FAQs
|
||||
|
||||
[From ESO](https://www.eso.org/public/copyright/)
|
||||
|
||||
```
|
||||
Q: I am making an electronic game. Where should I place the credit?
|
||||
A: As long as the credit is clearly visible to all users and reproduced in full, for instance in a splashscreen, all is fine.
|
||||
```
|
||||
|
||||
[From CC](https://wiki.creativecommons.org/wiki/Recommended_practices_for_attribution#This_is_a_great_attribution)
|
||||
|
||||
```
|
||||
This is a great attribution
|
||||
"Creative Commons 10th Birthday Celebration San Francisco"(source link) by Timothy Vollmer is licensed under CC BY 4.0 (license link)
|
||||
|
||||
Let’s go through TASL:
|
||||
Title? "Creative Commons 10th Birthday Celebration San Francisco"
|
||||
Author? "Timothy Vollmer" - linked to his profile page
|
||||
Source? "Creative Commons 10th Birthday Celebration San Francisco" - linked to original Flickr page
|
||||
License? "CC BY 4.0" - linked to license deed
|
||||
Most importantly, this attribution reasonably includes all the relevant information provided by the author.
|
||||
```
|
||||
|
After Width: | Height: | Size: 4.6 MiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c1vwcdcdvb74a"
|
||||
path.s3tc="res://.godot/imported/Milkyway.jpg-a06044e407cf6953ab6e46a2f0f17f3a.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/sky_3d/assets/thirdparty/textures/milkyway/Milkyway.jpg"
|
||||
dest_files=["res://.godot/imported/Milkyway.jpg-a06044e407cf6953ab6e46a2f0f17f3a.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
|
||||
|
After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bm7dot7t7u1q4"
|
||||
path.s3tc="res://.godot/imported/StarField.jpg-9d224e20fcbe8fdc7ff5028dae269839.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/sky_3d/assets/thirdparty/textures/milkyway/StarField.jpg"
|
||||
dest_files=["res://.godot/imported/StarField.jpg-9d224e20fcbe8fdc7ff5028dae269839.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
|
||||
@@ -0,0 +1,23 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 GPoSM
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Source: https://www.highend3d.com/downloads/3d-textures/c/16k-earth-w-4k-moon-free#dialog_license
|
||||
|
After Width: | Height: | Size: 5.8 MiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://2r8ylu6rg5dp"
|
||||
path.s3tc="res://.godot/imported/MoonMap.png-81d542c3fafff4e5b235a177af6f3221.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/sky_3d/assets/thirdparty/textures/moon/MoonMap.png"
|
||||
dest_files=["res://.godot/imported/MoonMap.png-81d542c3fafff4e5b235a177af6f3221.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
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="Sky3D"
|
||||
description="Atmospheric Day/Night Cycle"
|
||||
author="Cory Petkovsek, J. Cuéllar, and Contributors"
|
||||
version="2.1"
|
||||
script="src/Plugin.gd"
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2023-2025 Cory Petkovsek and Contributors
|
||||
// Copyright (c) 2021 J. Cuellar
|
||||
|
||||
shader_type spatial;
|
||||
render_mode blend_mix, cull_front, unshaded, fog_disabled;
|
||||
|
||||
|
||||
uniform vec2 color_correction;
|
||||
|
||||
uniform float sea_level: hint_range(-2048,2048,.01) = 0.0;
|
||||
uniform float fog_density = .0001;
|
||||
uniform float fog_rayleigh_depth = .115;
|
||||
uniform float fog_mie_depth = 0.;
|
||||
uniform float fog_falloff = 3.;
|
||||
uniform float fog_start = 0.;
|
||||
uniform float fog_end = 1000.;
|
||||
uniform vec3 sun_direction = vec3(.25, -.25, .25);
|
||||
uniform vec3 moon_direction = vec3(.45, -.25, .45);
|
||||
|
||||
uniform float atm_darkness = .5;
|
||||
uniform float atm_sun_intensity = 30.;
|
||||
uniform vec4 atm_day_tint: source_color = vec4(.78, .85, .98, 1.);
|
||||
uniform vec4 atm_horizon_light_tint: source_color = vec4(.98, .73, .49, 1.);
|
||||
uniform vec4 atm_night_tint: source_color = vec4(.16, .2, .25, 1.);
|
||||
uniform vec3 atm_level_params = vec3(1., 0., 0.);
|
||||
uniform float atm_thickness = .7;
|
||||
|
||||
uniform vec3 atm_beta_ray;
|
||||
uniform vec3 atm_beta_mie;
|
||||
|
||||
uniform vec3 atm_sun_partial_mie_phase;
|
||||
uniform vec4 atm_sun_mie_tint: source_color = vec4(1.);
|
||||
uniform float atm_sun_mie_intensity = 1.;
|
||||
|
||||
uniform vec3 atm_moon_partial_mie_phase;
|
||||
uniform vec4 atm_moon_mie_tint: source_color = vec4(.13, .18, .29, 1.);
|
||||
uniform float atm_moon_mie_intensity = .7;
|
||||
|
||||
uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap;
|
||||
|
||||
varying vec4 v_angle_mult;
|
||||
|
||||
#include "Common.gdshaderinc"
|
||||
|
||||
// Atmosphere
|
||||
vec3 calc_atmospheric_scatter(float sr, float sm, vec2 mu, vec3 mult, float depth) {
|
||||
vec3 beta_mie = atm_beta_mie;
|
||||
vec3 beta_ray = atm_beta_ray * atm_thickness;
|
||||
|
||||
vec3 extc_factor = clamp_rgb_to_unit(exp(-(beta_ray * sr + beta_mie * sm)));
|
||||
|
||||
float extc_ff = mix(clamp_to_unit(atm_thickness * 0.5), 1.0, mult.x);
|
||||
vec3 final_extc_factor = mix(1.0 - extc_factor, (1.0 - extc_factor) * extc_factor, extc_ff);
|
||||
|
||||
float rayleigh_phase = calc_rayleigh_phase(mu.x);
|
||||
vec3 BRT = beta_ray * rayleigh_phase * clamp_to_unit(depth * fog_rayleigh_depth);
|
||||
vec3 BMT = beta_mie * calc_mie_phase(mu.x, atm_sun_partial_mie_phase);
|
||||
BMT *= atm_sun_mie_intensity * atm_sun_mie_tint.rgb * clamp_to_unit(depth * fog_mie_depth);
|
||||
|
||||
vec3 BRMT = (BRT + BMT) / (beta_ray + beta_mie);
|
||||
vec3 scatter = atm_sun_intensity * (BRMT * final_extc_factor) * atm_day_tint.rgb * mult.y;
|
||||
scatter = mix(scatter, scatter * (1.0 - extc_factor), atm_darkness);
|
||||
|
||||
vec3 lcol = mix(atm_day_tint.rgb, atm_horizon_light_tint.rgb, mult.x);
|
||||
vec3 nscatter = (1.0 - extc_factor) * atm_night_tint.rgb * clamp_to_unit(depth * fog_rayleigh_depth);
|
||||
nscatter += calc_mie_phase(mu.y, atm_moon_partial_mie_phase) *
|
||||
atm_moon_mie_tint.rgb * atm_moon_mie_intensity * 0.005 * clamp_to_unit(depth * fog_mie_depth);
|
||||
|
||||
return (scatter * lcol) + nscatter;
|
||||
}
|
||||
|
||||
// Fog
|
||||
float calc_fog_exp(float depth, float density) {
|
||||
return 1.0 - clamp_to_unit(exp2(-depth * density));
|
||||
}
|
||||
|
||||
float calc_fog_falloff(float y, float zeroLevel, float falloff) {
|
||||
return clamp_to_unit(exp(-(y + zeroLevel) * falloff));
|
||||
}
|
||||
|
||||
float calc_fog_distance(float depth) {
|
||||
float d = depth;
|
||||
d = (fog_end - d) / (fog_end - fog_start);
|
||||
return clamp_to_unit(1.0 - d);
|
||||
}
|
||||
|
||||
|
||||
// Main
|
||||
|
||||
void vertex() {
|
||||
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
|
||||
v_angle_mult = vec4(
|
||||
clamp_to_unit(1.0 - sun_direction.y),
|
||||
clamp_to_unit(sun_direction.y + 0.45),
|
||||
clamp_to_unit(-sun_direction.y + 0.30),
|
||||
clamp_to_unit(-sun_direction.y + 0.60));
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
// Calculate world pixel position from depth
|
||||
float depth_raw = texture(DEPTH_TEXTURE, SCREEN_UV).r;
|
||||
#ifndef CURRENT_RENDERER
|
||||
// For 4.3 or earlier, will break if far plane is less than 1m.
|
||||
depth_raw = PROJECTION_MATRIX[3][2] > PROJECTION_MATRIX[2][2] ? depth_raw : depth_raw * 2.0 - 1.0;
|
||||
#else
|
||||
#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
|
||||
depth_raw = depth_raw * 2.0 - 1.0;
|
||||
#endif
|
||||
#endif
|
||||
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, depth_raw);
|
||||
vec4 clip_pos = INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
|
||||
vec3 view_pos = clip_pos.xyz / clip_pos.w;
|
||||
vec4 world_pos_hom = INV_VIEW_MATRIX * vec4(view_pos, 1.0);
|
||||
vec3 world_position = world_pos_hom.xyz / world_pos_hom.w;
|
||||
|
||||
// Ray from camera to world intersection point
|
||||
vec3 intersection_ray = world_position - CAMERA_POSITION_WORLD;
|
||||
// Unit ray direction
|
||||
vec3 ray_direction = normalize(intersection_ray);
|
||||
//float linear_depth = -view_pos.z; // Approximate ray distance
|
||||
//float ray_distance = length(intersection_ray); // Exact ray distance
|
||||
|
||||
// Cap ray if there's a sea_level plane intersection
|
||||
float original_distance = length(intersection_ray);
|
||||
// Y difference from camera to sea_level
|
||||
float dy = sea_level - CAMERA_POSITION_WORLD.y;
|
||||
// Distance along ray to sea_level plane
|
||||
float plane_distance = dy / (ray_direction.y + 1e-6 * sign(ray_direction.y)); // avoid divide by zero
|
||||
float plane_hit = float(ray_direction.y < 0.) *
|
||||
float(plane_distance > 0.) *
|
||||
float(plane_distance < original_distance);
|
||||
|
||||
// Use final distance or t_plane distance Final distance: mix original vs plane distance
|
||||
float ray_distance = mix(original_distance, plane_distance, plane_hit);
|
||||
|
||||
// Capped world position
|
||||
//world_position = CAMERA_POSITION_WORLD + ray_distance * ray_direction;
|
||||
|
||||
float fog_factor = calc_fog_exp(ray_distance, fog_density);
|
||||
fog_factor *= calc_fog_falloff(ray_direction.y, 0.0, fog_falloff);
|
||||
fog_factor *= calc_fog_distance(ray_distance);
|
||||
|
||||
vec2 mu = vec2(
|
||||
dot(sun_direction, ray_direction),
|
||||
dot(moon_direction, ray_direction)
|
||||
);
|
||||
float sr, sm;
|
||||
calc_simple_optical_depth(ray_direction.y + atm_level_params.z, sr, sm, atm_level_params.xy);
|
||||
vec3 scatter = calc_atmospheric_scatter(sr, sm, mu, v_angle_mult.xyz, ray_distance);
|
||||
|
||||
vec4 fog_color = vec4(scatter, fog_factor);
|
||||
fog_color.a = clamp(fog_color.a, 0.0, 1.0);
|
||||
fog_color.rgb = apply_photo_tonemap(fog_color.rgb, color_correction.y, color_correction.x);
|
||||
|
||||
ALBEDO = fog_color.rgb;
|
||||
ALPHA = fog_color.a;
|
||||
|
||||
// View depth map
|
||||
//ALPHA = 1.;
|
||||
//ALBEDO = vec3(ray_distance*.0001);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqaetpq827vg6
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2023-2025 Cory Petkovsek and Contributors
|
||||
// Copyright (c) 2021 J. Cuellar
|
||||
|
||||
const float INV_PI = 1. / PI;
|
||||
const float HALF_PI = PI / 2.;
|
||||
const float INV_HALF_PI = 1. / HALF_PI;
|
||||
const float QRT_PI = PI / 4.;
|
||||
const float INV_QRT_PI = 1. / QRT_PI;
|
||||
const float PI4 = PI * 4.;
|
||||
const float INV_PI4 = 1. / PI4;
|
||||
const float INV_TAU = 1. / TAU;
|
||||
|
||||
const float RAYLEIGH_ZENITH_LENGTH = 8.4e3;
|
||||
const float MIE_ZENITH_LENGTH = 1.25e3;
|
||||
const float THREE_OVER_EIGHT_PI = 3.0 / (8.0 * PI);
|
||||
|
||||
|
||||
float clamp_to_unit(float value) {
|
||||
return clamp(value, 0.0, 1.0);
|
||||
}
|
||||
|
||||
vec3 clamp_rgb_to_unit(vec3 value) {
|
||||
return clamp(value.rgb, 0.0, 1.0);
|
||||
}
|
||||
|
||||
vec3 adjust_contrast(vec3 vec, float level) {
|
||||
return mix(vec, vec * vec * vec, level); // pow3
|
||||
}
|
||||
|
||||
vec3 apply_photo_tonemap(vec3 color, float exposure, float level) {
|
||||
color.rgb *= exposure;
|
||||
return mix(color.rgb, 1.0 - exp(-color.rgb), level);
|
||||
}
|
||||
|
||||
float calc_rayleigh_phase(float mu) {
|
||||
return THREE_OVER_EIGHT_PI * (1.0 + mu * mu);
|
||||
}
|
||||
|
||||
float calc_mie_phase(float mu, vec3 partial) {
|
||||
float denominator = partial.y - partial.z * mu;
|
||||
float safe_denominator = max(denominator, 0.0001); // Prevent division by zero on negative powers
|
||||
return PI4 * partial.x * pow(safe_denominator, -1.5);
|
||||
}
|
||||
|
||||
// Simplifield for more performance
|
||||
void calc_simple_optical_depth(float y, out float sr, out float sm, vec2 params) {
|
||||
y = max(0.03, y + 0.03) + params.y;
|
||||
y = 1.0 / (y * params.x);
|
||||
sr = y * RAYLEIGH_ZENITH_LENGTH;
|
||||
sm = y * MIE_ZENITH_LENGTH;
|
||||
}
|
||||
|
||||
// Paper based (Unused)
|
||||
void calc_optical_depth(float y, out float sr, out float sm, vec2 params) {
|
||||
y = max(0.0, y);
|
||||
y = clamp_to_unit(y * params.x);
|
||||
|
||||
float zenith = acos(y);
|
||||
zenith = cos(zenith) + 0.15 * pow(93.885 - ((zenith * 180.0) * INV_PI), -1.253);
|
||||
zenith = 1.0 / (zenith + params.y);
|
||||
|
||||
sr = zenith * RAYLEIGH_ZENITH_LENGTH;
|
||||
sm = zenith * MIE_ZENITH_LENGTH;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://0auvnvj85575
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 J. Cuéllar
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,517 @@
|
||||
// Copyright (c) 2023-2025 Cory Petkovsek and Contributors
|
||||
// Copyright (c) 2021 J. Cuellar
|
||||
|
||||
shader_type sky;
|
||||
render_mode use_debanding;
|
||||
|
||||
group_uniforms general;
|
||||
uniform bool sky_visible = true;
|
||||
uniform vec2 color_correction = vec2(0.0, 1.0);
|
||||
uniform vec4 ground_color: source_color = vec4(0.3, 0.3, 0.3, 1.0);
|
||||
uniform float horizon_offset = 0.0;
|
||||
|
||||
group_uniforms atmosphere;
|
||||
uniform float atm_darkness = 0.5;
|
||||
uniform float atm_sun_intensity = 18.0;
|
||||
uniform vec4 atm_day_tint: source_color = vec4(0.808, 0.91, 1.0, 1.0);
|
||||
uniform vec4 atm_horizon_light_tint: source_color = vec4(0.98, 0.635, 0.463, 1.0);
|
||||
uniform vec4 atm_night_tint: source_color = vec4 (0.041, 0.049, 0.061, 0.245);
|
||||
uniform vec3 atm_level_params = vec3(1.0, 0.0, 0.0);
|
||||
uniform float atm_thickness = 0.7;
|
||||
uniform vec3 atm_beta_ray = vec3(0.00001, 0.00001, 0.00003);
|
||||
uniform vec3 atm_beta_mie = vec3(0.00000003038); // Value computed by multiplying Mie, turbidity, and 0.000434.
|
||||
|
||||
group_uniforms sun_disk;
|
||||
uniform vec4 sun_light_color: source_color = vec4(1.0);
|
||||
uniform vec4 sun_disk_color: source_color = vec4(0.996, 0.541, 0.14, 1.0);
|
||||
uniform float sun_disk_intensity = 2.0;
|
||||
uniform float sun_disk_size = 0.015;
|
||||
|
||||
group_uniforms sun_mie;
|
||||
uniform vec4 atm_sun_mie_tint: source_color = vec4(1.0);
|
||||
uniform float atm_sun_mie_intensity = 1.0;
|
||||
uniform vec3 atm_sun_partial_mie_phase = vec3(0.36, 1.64, 1.6);
|
||||
|
||||
group_uniforms moon;
|
||||
uniform vec4 moon_color: source_color = vec4(1.0);
|
||||
uniform sampler2D moon_texture: source_color, repeat_disable;
|
||||
uniform vec3 moon_texture_alignment = vec3(7.0, 1.4, 4.8);
|
||||
uniform bool moon_texture_flip_u;
|
||||
uniform bool moon_texture_flip_v;
|
||||
uniform float moon_size = 0.07;
|
||||
|
||||
group_uniforms moon_mie;
|
||||
uniform vec4 atm_moon_mie_tint: source_color = vec4(0.137, 0.184, 0.292, 1.0);
|
||||
uniform float atm_moon_mie_intensity = 0.17152;
|
||||
uniform vec3 atm_moon_partial_mie_phase = vec3(0.36, 1.64, 1.6);
|
||||
|
||||
group_uniforms star_map;
|
||||
uniform vec4 starmap_color: source_color = vec4(0.71, 0.71, 0.71, 0.855);
|
||||
uniform sampler2D starmap_texture: source_color;
|
||||
uniform bool starmap_flip_u = false;
|
||||
uniform bool starmap_flip_v = false;
|
||||
uniform vec3 starmap_alignment = vec3(2.68288, -0.25891, 0.40101);
|
||||
uniform float star_rotation_offset = 9.38899;
|
||||
uniform float star_rotation = 0.0; // For simulating Earth rotation.
|
||||
uniform float star_tilt = 0.0; // For tilting the sky according to observer latitude in radians. (90° - latitude)
|
||||
|
||||
group_uniforms star_field;
|
||||
uniform vec4 star_field_color: source_color = vec4(1.0);
|
||||
uniform sampler2D star_field_texture: source_color;
|
||||
uniform float star_scintillation = 0.75;
|
||||
uniform float star_scintillation_speed = 0.01;
|
||||
uniform sampler2D noise_tex: source_color;
|
||||
|
||||
/* Sky alignment values for a star map that is in galactic coordinates. Works for most star maps.
|
||||
X: 2.6555
|
||||
Y: -0.23935
|
||||
Z: 0.4505
|
||||
|
||||
These alignment values are consistent with a sky view from the North Pole (latitude zero)
|
||||
at midnight. The positions of Polaris, Vega, Rigel, and Alpha Centauri confirmed
|
||||
through various sources and calculations.
|
||||
*/
|
||||
|
||||
group_uniforms clouds;
|
||||
uniform vec4 clouds_night_color: source_color = vec4(0.09, 0.094, 0.129, 1.0);
|
||||
|
||||
group_uniforms cirrus_clouds;
|
||||
uniform bool cirrus_visible = true;
|
||||
uniform float cirrus_intensity = 2.0;
|
||||
uniform float cirrus_coverage = 0.5;
|
||||
uniform float cirrus_thickness = 10.0;
|
||||
uniform float cirrus_absorption = 2.0;
|
||||
uniform float cirrus_sky_tint_fade = 0.5;
|
||||
uniform float cirrus_size = 1.0;
|
||||
uniform vec2 cirrus_uv = vec2(0.16, 0.11);
|
||||
uniform vec2 cirrus_position1 = vec2(0.0);
|
||||
uniform vec2 cirrus_position2 = vec2(0.0);
|
||||
uniform sampler2D cirrus_texture;
|
||||
|
||||
group_uniforms cumulus_clouds;
|
||||
uniform bool cumulus_visible = true;
|
||||
uniform float cumulus_intensity = 0.6;
|
||||
uniform float cumulus_coverage = 0.55;
|
||||
uniform float cumulus_thickness = 0.0243;
|
||||
uniform float cumulus_absorption = 2.0;
|
||||
uniform float cumulus_noise_freq = 2.7;
|
||||
uniform float cumulus_sky_tint_fade = 0.0;
|
||||
uniform float cumulus_size = 0.5;
|
||||
uniform vec2 cumulus_position = vec2(0.0);
|
||||
uniform sampler2D cumulus_texture;
|
||||
uniform vec3 cumulus_partial_mie_phase = vec3(0.95756, 1.04244, 0.412);
|
||||
uniform float cumulus_mie_intensity = 1.0;
|
||||
|
||||
group_uniforms overlays;
|
||||
uniform bool show_azimuthal_grid = false;
|
||||
uniform vec4 azimuthal_grid_color : source_color = vec4(.871, .722, .529, 1.);
|
||||
uniform float azimuthal_grid_rotation_offset = 0.03;
|
||||
uniform bool show_equatorial_grid = false;
|
||||
uniform vec4 equatorial_grid_color : source_color = vec4(0., .75, 1., 1.);
|
||||
uniform float equatorial_grid_rotation_offset = 0.03;
|
||||
|
||||
group_uniforms transform_matrices;
|
||||
uniform mat3 moon_matrix; // Calculated externally via GDScript.
|
||||
|
||||
#include "Common.gdshaderinc"
|
||||
|
||||
// Provides a transformation matrix when passed a vec3.
|
||||
mat3 create_rotation_matrix(vec3 r) {
|
||||
float c1 = cos(r.x), s1 = sin(r.x);
|
||||
float c2 = cos(r.y), s2 = sin(r.y);
|
||||
float c3 = cos(r.z), s3 = sin(r.z);
|
||||
|
||||
mat3 rotX = mat3(
|
||||
vec3(1.0, 0.0, 0.0),
|
||||
vec3(0.0, c1, -s1),
|
||||
vec3(0.0, s1, c1)
|
||||
);
|
||||
|
||||
mat3 rotY = mat3(
|
||||
vec3(c2, 0.0, s2),
|
||||
vec3(0.0, 1.0, 0.0),
|
||||
vec3(-s2, 0.0, c2)
|
||||
);
|
||||
|
||||
mat3 rotZ = mat3(
|
||||
vec3(c3, -s3, 0.0),
|
||||
vec3(s3, c3, 0.0),
|
||||
vec3(0.0, 0.0, 1.0)
|
||||
);
|
||||
|
||||
return rotZ * rotY * rotX; // Combine rotations in ZYX order
|
||||
}
|
||||
|
||||
vec2 calc_equirect_uv(vec3 norm) {
|
||||
float flipu = starmap_flip_u ? -1.0 : 1.0;
|
||||
float flipv = starmap_flip_v ? -1.0 : 1.0;
|
||||
float u = flipu * ((atan(norm.y, norm.x) + PI) * INV_TAU); // 0 to 1 horizontally
|
||||
float v = -flipv * (acos(norm.z) * INV_PI); // 0 (north pole) to 1 (south pole)
|
||||
return vec2(u, v);
|
||||
}
|
||||
|
||||
vec3 sample_moon_texture(vec3 dir) {
|
||||
dir = moon_matrix * dir * create_rotation_matrix(moon_texture_alignment);
|
||||
dir.x = -dir.x;
|
||||
dir.z = -dir.z;
|
||||
float flipu = moon_texture_flip_u ? 1.0 : -1.0;
|
||||
float flipv = moon_texture_flip_v ? 1.0 : -1.0;
|
||||
float u = 0.5 + flipu * atan(dir.z, dir.x) * INV_TAU; // Longitude
|
||||
float v = 0.5 + flipv * asin(dir.y) * INV_PI; // Latitude
|
||||
return texture(moon_texture, vec2(u, v)).rgb;
|
||||
}
|
||||
|
||||
float calc_disk_mask(vec3 norm, vec3 coords, lowp float size) {
|
||||
float dist = length(norm - coords);
|
||||
return 1.0 - step(size, dist);
|
||||
}
|
||||
|
||||
float map_range(float value, float from_min, float from_max, float to_min, float to_max) {
|
||||
return to_min + (value - from_min) * (to_max - to_min) / (from_max - from_min);
|
||||
}
|
||||
|
||||
bool ray_sphere_intersect(float r, vec3 origin, vec3 dir, out float t, out vec3 nrm) {
|
||||
origin += vec3(0.0, 450.0, 0.0);
|
||||
float a = dot(dir, dir);
|
||||
float b = 2.0 * dot(origin, dir);
|
||||
float c = dot(origin, origin) - r * r;
|
||||
float d = b * b - 4.0 * a * c;
|
||||
float valid = step(0.0, d); // 1.0 if d >= 0.0, else 0.0
|
||||
d = sqrt(max(d, 0.0)); // Prevent sqrt of negative numbers
|
||||
a *= 2.0;
|
||||
float t1 = 0.5 * (-b + d);
|
||||
float t2 = 0.5 * (-b - d);
|
||||
float t_min = min(t1, t2); // Select the smallest intersection
|
||||
t = mix(t2, t1, step(t2, t1) * step(0.0, t1)); // Use t1 if t2 < t1 and t1 >= 0, else t2
|
||||
t = mix(0.0, t, step(0.0, t) * valid); // Invalidate if t < 0 or d < 0
|
||||
nrm = mix(vec3(0.0), origin + t * dir, step(0.0, t) * valid); // Set nrm only if valid
|
||||
return bool(step(0.0, t) * valid);
|
||||
}
|
||||
|
||||
// For rendering the moon. This function courtesy of Kelvin van Hoorn.
|
||||
// https://kelvinvanhoorn.com/2024/12/07/skybox-tutorial-godot/
|
||||
float simple_sphere_intersect(vec3 ray_dir, vec3 sphere_pos, float radius) {
|
||||
vec3 oc = -sphere_pos;
|
||||
float b = dot(oc, ray_dir);
|
||||
float c = dot(oc, oc) - radius * radius;
|
||||
float h = b * b - c;
|
||||
float valid = step(0.0, h); // 1.0 if h >= 0.0, else 0.0
|
||||
h = sqrt(max(h, 0.0)); // Ensure sqrt is non-negative
|
||||
return mix(-1.0, -b - h, valid);
|
||||
}
|
||||
|
||||
// Atmosphere
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
vec3 calc_atmospheric_scatter(float sr, float sm, vec2 mu, vec3 mult) {
|
||||
vec3 beta_mie = atm_beta_mie;
|
||||
vec3 beta_ray = atm_beta_ray * atm_thickness;
|
||||
|
||||
vec3 extc_factor = clamp_rgb_to_unit(exp(-(beta_ray * sr + beta_mie * sm)));
|
||||
|
||||
float extc_ff = mix(clamp_to_unit(atm_thickness * 0.5), 1.0, mult.x);
|
||||
vec3 final_extc_factor = mix(1.0 - extc_factor, (1.0 - extc_factor) * extc_factor, extc_ff);
|
||||
float rayleigh_phase = calc_rayleigh_phase(mu.x);
|
||||
vec3 BRT = beta_ray * rayleigh_phase;
|
||||
vec3 BMT = beta_mie * calc_mie_phase(mu.x, atm_sun_partial_mie_phase);
|
||||
BMT *= atm_sun_mie_intensity * atm_sun_mie_tint.rgb;
|
||||
|
||||
vec3 BRMT = (BRT + BMT) / (beta_ray + beta_mie);
|
||||
vec3 scatter = atm_sun_intensity * (BRMT * final_extc_factor) * atm_day_tint.rgb * mult.y;
|
||||
scatter = mix(scatter, scatter * (1.0 - extc_factor), atm_darkness);
|
||||
|
||||
vec3 lcol = mix(atm_day_tint.rgb, atm_horizon_light_tint.rgb, mult.x);
|
||||
vec3 nscatter = (1.0 - extc_factor) * atm_night_tint.rgb;
|
||||
nscatter += calc_mie_phase(mu.y, atm_moon_partial_mie_phase) *
|
||||
atm_moon_mie_tint.rgb * atm_moon_mie_intensity * 0.005;
|
||||
|
||||
return (scatter * lcol) + nscatter;
|
||||
}
|
||||
|
||||
// Clouds
|
||||
//------------------------------------------------------------------------------
|
||||
const int CUMULUS_STEP = 10;
|
||||
|
||||
float sample_cirrus_noise(vec2 coords) {
|
||||
float a = textureLod(cirrus_texture, coords.xy * cirrus_uv + cirrus_position1, 0.0).r;
|
||||
float b = textureLod(cirrus_texture, coords.xy * cirrus_uv + cirrus_position2, 0.0).r;
|
||||
return ((a + b) * 0.5);
|
||||
}
|
||||
|
||||
float sample_cumulus_noise(vec3 p) {
|
||||
vec3 pos = vec3(p * 0.01);
|
||||
pos.z *= 256.0;
|
||||
vec2 offset = vec2(0.317, 0.123);
|
||||
vec4 uv = vec4(0.0);
|
||||
uv.xy = pos.xy + offset * floor(pos.z);
|
||||
uv.zw = uv.xy + offset;
|
||||
float x1 = textureLod(cumulus_texture, uv.xy, 0.0).r;
|
||||
float x2 = textureLod(cumulus_texture, uv.zw, 0.0).r;
|
||||
return mix(x1, x2, fract(pos.z));
|
||||
}
|
||||
|
||||
float calc_cumulus_fbm(vec3 p, float l) {
|
||||
float ret;
|
||||
ret = 0.51749673 * sample_cumulus_noise(p);
|
||||
p *= l;
|
||||
ret += 0.25584929 * sample_cumulus_noise(p);
|
||||
p *= l;
|
||||
ret += 0.12527603 * sample_cumulus_noise(p);
|
||||
p *= l;
|
||||
ret += 0.06255931 * sample_cumulus_noise(p);
|
||||
return ret;
|
||||
}
|
||||
|
||||
float calc_cirrus_density(vec2 p) {
|
||||
float d = sample_cirrus_noise(p);
|
||||
float c = 1.0 - cirrus_coverage;
|
||||
d = d - c;
|
||||
return clamp_to_unit(d);
|
||||
}
|
||||
|
||||
float calc_cumulus_density(vec3 p, vec3 offset, float t) {
|
||||
vec3 pos = p * 0.0212242 - offset;
|
||||
float dens = calc_cumulus_fbm(pos, cumulus_noise_freq);
|
||||
dens += dens;
|
||||
|
||||
float cov = 1.0 - cumulus_coverage;
|
||||
cov = smoothstep(0.00, (cov * 3.5) + t, dens);
|
||||
dens *= cov;
|
||||
dens = map_range(dens, 1.0 - cov, 1.0, 0.0, 1.0);
|
||||
|
||||
return clamp_to_unit(dens);
|
||||
}
|
||||
|
||||
vec4 render_cirrus_clouds(vec3 pos) {
|
||||
pos.xy = pos.xz / pos.y;
|
||||
pos *= cirrus_size;
|
||||
float density = calc_cirrus_density(pos.xy);
|
||||
float sh = clamp_to_unit(exp(-cirrus_absorption * density));
|
||||
float a = clamp_to_unit(density * cirrus_thickness);
|
||||
return vec4(vec3(density*sh) * cirrus_intensity, a);
|
||||
}
|
||||
|
||||
vec4 render_cumulus_clouds(vec3 ro, vec3 rd, float am, vec3 sun_pos, vec3 moon_pos) {
|
||||
vec4 ret = vec4(0.);
|
||||
vec3 wind = -vec3(cumulus_position.x, 0.0, cumulus_position.y);
|
||||
float a = 0.0;
|
||||
|
||||
// n and tt doesnt need to be initialized since it would be set by ray_sphere_intersect
|
||||
vec3 n; float tt;
|
||||
if (ray_sphere_intersect(500., ro, rd, tt, n)) {
|
||||
float march_step = float(CUMULUS_STEP) * cumulus_thickness;
|
||||
vec3 dir_step = rd / rd.y * march_step;
|
||||
vec3 pos = n * cumulus_size;
|
||||
|
||||
vec2 mu = vec2(dot(sun_pos, rd), dot(moon_pos, rd));
|
||||
vec3 mph = ((calc_mie_phase(mu.x, cumulus_partial_mie_phase) * atm_sun_mie_tint.rgb) +
|
||||
calc_mie_phase(mu.y, cumulus_partial_mie_phase) * am);
|
||||
|
||||
vec4 t = vec4(1.0);
|
||||
t.rgb += (mph.rgb * cumulus_mie_intensity);
|
||||
|
||||
for(int i = 0; i < CUMULUS_STEP; i++) {
|
||||
float h = float(i) * 0.1; // / float(kCLOUDS_STEP);
|
||||
float density = calc_cumulus_density(pos, wind, h);
|
||||
float sh = clamp_to_unit(exp(-cumulus_absorption * density * march_step));
|
||||
t *= sh;
|
||||
ret += (t * (exp(h) * 0.571428571) * density * march_step);
|
||||
a += (1.0 - sh) * (1.0 - a);
|
||||
pos += dir_step;
|
||||
}
|
||||
return vec4(ret.rgb * cumulus_intensity, a);
|
||||
}
|
||||
return vec4(ret.rgb * cumulus_intensity, a);
|
||||
}
|
||||
|
||||
// Main
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
vec3 render_sky(vec3 world_pos, vec3 clouds_pos, vec3 sun_pos, vec3 moon_pos, float p_time) {
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
vec4 angle_mult;
|
||||
angle_mult.x = clamp_to_unit(1.0 - sun_pos.y);
|
||||
angle_mult.y = clamp_to_unit(sun_pos.y + 0.45);
|
||||
angle_mult.z = clamp_to_unit(-sun_pos.y + 0.30);
|
||||
angle_mult.w = clamp_to_unit(-sun_pos.y + 0.60);
|
||||
|
||||
// Atmosphere
|
||||
vec2 mu = vec2(dot(sun_pos, world_pos), dot(moon_pos, world_pos));
|
||||
float sr, sm;
|
||||
calc_simple_optical_depth(world_pos.y + atm_level_params.z - horizon_offset, sr, sm, atm_level_params.xy);
|
||||
|
||||
world_pos.y -= horizon_offset;
|
||||
float horizonBlend = clamp_to_unit((world_pos.y - 0.03) * 3.0);
|
||||
float horizonBlendCumulus = clamp_to_unit((world_pos.y + 0.01) * 50.0);
|
||||
|
||||
vec3 scatter = calc_atmospheric_scatter(sr, sm, mu.xy, angle_mult.xyz);
|
||||
col.rgb += scatter.rgb;
|
||||
|
||||
// Sun
|
||||
vec3 sun_disk = calc_disk_mask(world_pos, sun_pos, sun_disk_size) * sun_disk_color.rgb * scatter.rgb;
|
||||
sun_disk *= sun_disk_intensity;
|
||||
|
||||
// Moon
|
||||
float moon_intersect = simple_sphere_intersect(world_pos, moon_pos, moon_size);
|
||||
float moon_mask = moon_intersect > (-1. + sqrt(moon_size)) ? 1.0 : 0.0;
|
||||
vec3 moon_normal = normalize(world_pos * moon_intersect - moon_pos);
|
||||
|
||||
float moon_ndotl = clamp(dot(moon_normal, sun_pos), 0.0, 1.0);
|
||||
vec3 moon_tex = sample_moon_texture(moon_normal);
|
||||
vec3 moon_output = moon_mask * moon_ndotl * exp2(1.0) * moon_tex * moon_color.rgb;
|
||||
float moonMask = (1.0 - moon_mask);
|
||||
|
||||
// Deep space
|
||||
|
||||
// Tilt the sky along the X-axis.
|
||||
mat3 tilt = create_rotation_matrix(vec3(star_tilt, 0.0, 0.0));
|
||||
|
||||
// Rotation around Y-axis. For simulating Earth's rotation.
|
||||
mat3 rotation = create_rotation_matrix(vec3(0.0, star_rotation + star_rotation_offset, 0.0));
|
||||
|
||||
// Initial XYZ rotation for alignment.
|
||||
mat3 alignment_xyz = create_rotation_matrix(starmap_alignment);
|
||||
|
||||
// Combine everything together to get the desired alignment.
|
||||
vec3 final_alignment = normalize(alignment_xyz * rotation * tilt * world_pos);
|
||||
|
||||
// Now generate UV coordinates for the texture.
|
||||
vec2 deep_space_uv = calc_equirect_uv(normalize(final_alignment));
|
||||
|
||||
// Star map
|
||||
vec3 deep_space_background = textureLod(starmap_texture, deep_space_uv, 0.0).rgb;
|
||||
deep_space_background *= starmap_color.rgb;
|
||||
deep_space_background = adjust_contrast(deep_space_background, starmap_color.a);
|
||||
vec3 deep_space = deep_space_background.rgb * moonMask + moon_output;
|
||||
|
||||
// Star Field
|
||||
float star_scint = textureLod(noise_tex, deep_space_uv + (p_time * star_scintillation_speed), 0.0).r;
|
||||
star_scint = mix(1.0, star_scint * 1.5, star_scintillation);
|
||||
|
||||
vec3 star_field = textureLod(star_field_texture, deep_space_uv, 0.0).rgb * star_field_color.rgb;
|
||||
star_field = clamp_rgb_to_unit(mix(star_field.rgb, star_field.rgb * star_scint, star_scintillation));
|
||||
star_field = clamp_rgb_to_unit(star_field.rgb);
|
||||
//deep_space.rgb -= clamp_to_unit(star_field.r * 10.0);
|
||||
deep_space.rgb += star_field.rgb * moonMask;
|
||||
deep_space.rgb *= angle_mult.z;
|
||||
col.rgb += sun_disk.rgb + deep_space.rgb * horizonBlend;
|
||||
|
||||
// Clouds
|
||||
if (cirrus_visible) {
|
||||
vec4 cirrus_clouds = render_cirrus_clouds(clouds_pos);
|
||||
cirrus_clouds.a = clamp_to_unit(cirrus_clouds.a);
|
||||
vec3 cloud_tint = sun_light_color.rgb;
|
||||
cloud_tint = mix(cloud_tint, clouds_night_color.rgb, angle_mult.w);
|
||||
cirrus_clouds.rgb *= cloud_tint * cirrus_intensity;
|
||||
cirrus_clouds.a = mix(0.0, cirrus_clouds.a, horizonBlend);
|
||||
col.rgb = mix(col.rgb, cirrus_clouds.rgb + mix(vec3(0.0), scatter, cirrus_sky_tint_fade), cirrus_clouds.a);
|
||||
}
|
||||
|
||||
if (cumulus_visible) {
|
||||
vec4 cumulus_clouds = render_cumulus_clouds(vec3(0.0), clouds_pos, angle_mult.z, sun_pos, moon_pos);
|
||||
cumulus_clouds.a = clamp_to_unit(cumulus_clouds.a);
|
||||
vec3 cloud_tint = sun_light_color.rgb;
|
||||
cloud_tint = mix(cloud_tint, clouds_night_color.rgb, angle_mult.w);
|
||||
cumulus_clouds.rgb *= cloud_tint * cumulus_intensity;
|
||||
cumulus_clouds.a = mix(0.0, cumulus_clouds.a, horizonBlend);
|
||||
col.rgb = mix(col.rgb, cumulus_clouds.rgb + mix(vec3(0.0), scatter, cumulus_sky_tint_fade), cumulus_clouds.a);
|
||||
}
|
||||
|
||||
col.rgb = mix(col.rgb, ground_color.rgb * scatter, clamp_to_unit((-world_pos.y - atm_level_params.z) * 100.0));
|
||||
col.rgb = apply_photo_tonemap(col.rgb, color_correction.y, color_correction.x);
|
||||
return col;
|
||||
}
|
||||
|
||||
vec4 create_axis_angle_quat(vec3 axis, float angle) {
|
||||
float s = sin(angle * 0.5);
|
||||
return vec4(axis * s, cos(angle * 0.5));
|
||||
}
|
||||
|
||||
vec3 rotate_vector_by_quat(vec3 v, vec4 q) {
|
||||
vec3 t = 2.0 * cross(q.xyz, v);
|
||||
return v + q.w * t + cross(q.xyz, t);
|
||||
}
|
||||
|
||||
vec3 align_and_rotate_vector(vec3 dir, vec3 target, float rotation_angle) {
|
||||
vec3 z_axis = vec3(0.0, 0.0, 1.0); // Original grid alignment
|
||||
|
||||
// First, align Z-axis with target (Polaris)
|
||||
target = normalize(target);
|
||||
vec3 axis = cross(z_axis, target);
|
||||
float angle = acos(dot(z_axis, target));
|
||||
float valid = step(0.0001, length(axis)); // 1.0 if length(axis) >= 0.0001
|
||||
axis = normalize(axis);
|
||||
vec4 align_quat = create_axis_angle_quat(axis, angle);
|
||||
dir = mix(dir, rotate_vector_by_quat(dir, align_quat), valid);
|
||||
vec4 rot_quat = create_axis_angle_quat(vec3(0.0, 0.0, 1.0), rotation_angle);
|
||||
dir = mix(dir, rotate_vector_by_quat(dir, rot_quat), valid);
|
||||
return dir;
|
||||
}
|
||||
|
||||
void render_grid(vec3 dir, vec3 pole_dir, float rotation_angle, vec4 line_color, float grid_spacing, float line_thickness, inout vec3 color) {
|
||||
float spacing = max(grid_spacing, 0.001);
|
||||
float thickness = max(line_thickness, 0.0001);
|
||||
|
||||
// Rotate dir with sidereal motion and align with pole_dir
|
||||
dir = align_and_rotate_vector(dir, pole_dir, rotation_angle);
|
||||
|
||||
// Latitude / Circles / Theta Spherical coordinates
|
||||
float theta = acos(dir.z);
|
||||
float theta_deriv = fwidth(theta);
|
||||
float circle_thickness = max(thickness, theta_deriv);
|
||||
float circle = mod(theta, spacing);
|
||||
float circle_line = 1.0 - smoothstep(0.0, circle_thickness * 1.5, abs(circle - circle_thickness * 0.5));
|
||||
circle_line *= clamp_to_unit(thickness / circle_thickness);
|
||||
|
||||
// Add poles
|
||||
float pole_thickness = circle_thickness;
|
||||
float north_pole = 1.0 - smoothstep(0.0, pole_thickness, theta);
|
||||
float south_pole = 1.0 - smoothstep(0.0, pole_thickness, abs(theta - PI));
|
||||
circle_line = max(circle_line, max(north_pole, south_pole));
|
||||
|
||||
// Darken Lat at poles
|
||||
float circle_adjust = sin(theta);
|
||||
circle_line *= clamp(circle_adjust, 0.2, 1.0);
|
||||
|
||||
// Longitude / Radial lines / Phi Spherical coordinates
|
||||
float phi = atan(dir.y, dir.x);
|
||||
float phi_deriv = fwidth(phi);
|
||||
float radial_thickness = max(thickness, phi_deriv);
|
||||
float radial = mod(phi + PI, spacing); // PI w/ less precision to remove moire
|
||||
float radial_line = 1.0 - smoothstep(0.0, radial_thickness * 1.5, abs(radial - radial_thickness));
|
||||
radial_line *= clamp_to_unit(thickness / radial_thickness);
|
||||
|
||||
float grid = max(radial_line, circle_line);
|
||||
color += line_color.rgb * line_color.a * grid;
|
||||
}
|
||||
|
||||
void sky() {
|
||||
float current_time = TIME;
|
||||
vec3 col = vec3(0.0); // Set up the initial sky color which will be added to later to produce the final result.
|
||||
vec3 world_pos = EYEDIR; // Original shader set this up in the vertex() function. For sky shaders the overall equivalent is EYEDIR.
|
||||
vec3 clouds_pos = EYEDIR; // The same is used for cloud rendering.
|
||||
vec3 sun_pos = LIGHT0_DIRECTION; // Redefined from now-deprecated _sun_direction.
|
||||
vec3 moon_pos = LIGHT1_DIRECTION; // Redefined from now-deprecated _moon_direction.
|
||||
|
||||
if (sky_visible) {
|
||||
col = render_sky(world_pos, clouds_pos, sun_pos, moon_pos, current_time);
|
||||
}
|
||||
|
||||
// Draw overlay grids
|
||||
{
|
||||
float grid_thickness = 0.0002;
|
||||
float grid_spacing = 0.261799; // 15 degrees
|
||||
vec3 zenith_dir = vec3(0.0, 1.0, 0.0); // Y-axis aligned azimuthal
|
||||
vec3 north_celestial_pole = create_rotation_matrix(vec3(star_tilt, 0.0, 0.0)) * zenith_dir;
|
||||
float rotation_angle = star_rotation + star_rotation_offset;
|
||||
if (show_azimuthal_grid) {
|
||||
render_grid(EYEDIR, zenith_dir, azimuthal_grid_rotation_offset, azimuthal_grid_color, grid_spacing, grid_thickness, col);
|
||||
}
|
||||
if (show_equatorial_grid) {
|
||||
render_grid(EYEDIR, north_celestial_pole, rotation_angle + equatorial_grid_rotation_offset, equatorial_grid_color, grid_spacing, grid_thickness, col);
|
||||
}
|
||||
}
|
||||
|
||||
COLOR = col.rgb;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvd0x7lon1ur6
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
|
||||
# Copyright (c) 2021 J. Cuellar
|
||||
|
||||
class_name OrbitalElements
|
||||
|
||||
# Handles Orbital elements for planetary system
|
||||
|
||||
var N: float # Longitude of the ascending node.
|
||||
var i: float # The Inclination to the ecliptic.
|
||||
var w: float # Argument of perihelion.
|
||||
var a: float # Semi-major axis, or mean distance from sun.
|
||||
var e: float # Eccentricity.
|
||||
var M: float # Mean anomaly.
|
||||
|
||||
|
||||
func get_orbital_elements(index: int, timeScale: float) -> void:
|
||||
if index == 0: # Sun
|
||||
N = 0.0
|
||||
i = 0.0
|
||||
w = 282.9404 + 4.70935e-5 * timeScale
|
||||
a = 0.0
|
||||
e = 0.016709 - 1.151e-9 * timeScale
|
||||
M = 356.0470 + 0.9856002585 * timeScale
|
||||
else: # Moon
|
||||
N = 125.1228 - 0.0529538083 * timeScale
|
||||
i = 5.1454
|
||||
w = 318.0634 + 0.1643573223 * timeScale
|
||||
a = 60.2666
|
||||
e = 0.054900
|
||||
M = 115.3654 + 13.0649929509 * timeScale
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://4ka6iw74pja3
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
|
||||
# Copyright (c) 2021 J. Cuellar
|
||||
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
const __sky3d_script: String = "res://addons/sky_3d/src/Sky3D.gd"
|
||||
const __sky3d_icon: String = "res://addons/sky_3d/assets/textures/SkyIcon.png"
|
||||
const __skydome_script: String = "res://addons/sky_3d/src/SkyDome.gd"
|
||||
const __skydome_icon: String = "res://addons/sky_3d/assets/textures/SkyIcon.png"
|
||||
const __time_of_day_script: String = "res://addons/sky_3d/src/TimeOfDay.gd"
|
||||
const __time_of_day_icon: String = "res://addons/sky_3d/assets/textures/SkyIcon.png"
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
add_custom_type("Sky3D", "Node", load(__sky3d_script), load(__sky3d_icon))
|
||||
add_custom_type("SkyDome", "Node", load(__skydome_script), load(__skydome_icon))
|
||||
add_custom_type("TimeOfDay", "Node", load(__time_of_day_script), load(__time_of_day_icon))
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
remove_custom_type("Sky3D")
|
||||
remove_custom_type("SkyDome")
|
||||
remove_custom_type("TimeOfDay")
|
||||
@@ -0,0 +1 @@
|
||||
uid://ri5mkwojuh33
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
|
||||
# Copyright (c) 2021 J. Cuellar
|
||||
|
||||
class_name ScatterLib
|
||||
|
||||
# Atmospheric Scattering Lib.
|
||||
# References:
|
||||
# - Preetham and Hoffman Paper:
|
||||
# See: https://developer.amd.com/wordpress/media/2012/10/ATI-LightScattering.pdf
|
||||
|
||||
const n: float = 1.0003 # Index of the air refraction
|
||||
const n2: float = 1.00060009 # Index of the air refraction ˆ 2
|
||||
const N: float = 2.545e25 # Molecular Density
|
||||
const pn: float = 0.035 # Depolatization factor for standard air.
|
||||
|
||||
|
||||
static func compute_wavelenghts_lambda(value: Vector3) -> Vector3:
|
||||
return value * 1e-9
|
||||
|
||||
|
||||
static func compute_wavelenghts(lambda: Vector3) -> Vector3:
|
||||
var k = 4.0
|
||||
var ret: Vector3 = lambda
|
||||
ret.x = pow(lambda.x, k)
|
||||
ret.y = pow(lambda.y, k)
|
||||
ret.z = pow(lambda.z, k)
|
||||
return ret
|
||||
|
||||
|
||||
static func compute_beta_ray(wavelenghts: Vector3) -> Vector3:
|
||||
var kr: float = (8.0 * pow(PI, 3.0) * pow(n2 - 1.0, 2.0) * (6.0 + 3.0 * pn))
|
||||
var ret: Vector3 = 3.0 * N * wavelenghts * (6.0 - 7.0 * pn)
|
||||
ret.x = kr / ret.x
|
||||
ret.y = kr / ret.y
|
||||
ret.z = kr / ret.z
|
||||
return ret
|
||||
|
||||
|
||||
static func compute_beta_mie(mie: float, turbidity: float) -> Vector3:
|
||||
var k: float = 434e-6
|
||||
return Vector3.ONE * mie * turbidity * k
|
||||
|
||||
|
||||
static func get_partial_mie_phase(g: float) -> Vector3:
|
||||
var g2: float = g * g
|
||||
var ret: Vector3
|
||||
ret.x = 1.0 - g2
|
||||
ret.y = 1.0 + g2
|
||||
ret.z = 2.0 * g
|
||||
return ret
|
||||
@@ -0,0 +1 @@
|
||||
uid://dc70wk0t64fwe
|
||||
@@ -0,0 +1,549 @@
|
||||
# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
|
||||
# Copyright (c) 2021 J. Cuellar
|
||||
|
||||
## Sky3D is an Atmosphereic Day/Night Cycle for Godot 4.
|
||||
##
|
||||
## This plugin manages time, moving the sun, moon, and stars, and consolidates environmental lighting controls.
|
||||
## To use it, remove any WorldEnvironment node from you scene, then add a new Sky3D node.
|
||||
## Explore and configure the settings in the Sky3D, SunLight, MoonLight, [SkyDome], and [TimeOfDay] nodes.
|
||||
|
||||
@tool
|
||||
class_name Sky3D
|
||||
extends WorldEnvironment
|
||||
|
||||
## Emitted when the environment has changed to a new resource.
|
||||
signal environment_changed
|
||||
|
||||
const SKY_SHADER: String = "res://addons/sky_3d/shaders/SkyMaterial.gdshader"
|
||||
|
||||
@export_custom(PROPERTY_HINT_NONE, "", PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_READ_ONLY)
|
||||
var version: String = "2.1"
|
||||
|
||||
## The Sun DirectionalLight.
|
||||
var sun: DirectionalLight3D
|
||||
## The Moon DirectionalLight.
|
||||
var moon: DirectionalLight3D
|
||||
## The TimeOfDay node.
|
||||
var tod: TimeOfDay
|
||||
## The SkyDome node.
|
||||
var sky: SkyDome
|
||||
## The Sky shader.
|
||||
var sky_material: ShaderMaterial
|
||||
|
||||
## Enables all rendering and time tracking.
|
||||
@export var sky3d_enabled: bool = true :
|
||||
set(value):
|
||||
sky3d_enabled = value
|
||||
if value:
|
||||
show_sky()
|
||||
resume()
|
||||
else:
|
||||
hide_sky()
|
||||
pause()
|
||||
|
||||
|
||||
#####################
|
||||
## Visibility
|
||||
#####################
|
||||
|
||||
@export_group("Visibility")
|
||||
|
||||
|
||||
## Enables the sky shader. Disable sky, lights, fog for a black sky or call [method hide_sky].
|
||||
@export var sky_enabled: bool = true :
|
||||
set(value):
|
||||
sky_enabled = value
|
||||
if sky and sky_material:
|
||||
sky_material.set_shader_parameter("sky_visible", value)
|
||||
sky.cumulus_visible = clouds_enabled and value
|
||||
sky.cirrus_visible = clouds_enabled and value
|
||||
|
||||
|
||||
## Enables both 2D and cumulus cloud layers.
|
||||
@export var clouds_enabled: bool = true :
|
||||
set(value):
|
||||
clouds_enabled = value
|
||||
if sky:
|
||||
sky.cumulus_visible = value
|
||||
sky.cirrus_visible = value
|
||||
|
||||
|
||||
## Enables the Sun and Moon [DirectionalLight3D]s.
|
||||
@export var lights_enabled: bool = true :
|
||||
set(value):
|
||||
lights_enabled = value
|
||||
if sky:
|
||||
sky.sun_light_enabled = value
|
||||
sky.moon_light_enabled = value
|
||||
|
||||
|
||||
## Enables the screen space fog shader. Sky3D also works with the other two fog methods built into Godot.
|
||||
@export var fog_enabled: bool = true :
|
||||
set(value):
|
||||
fog_enabled = value
|
||||
if sky:
|
||||
sky.fog_visible = value
|
||||
|
||||
|
||||
## Disables rendering of sky, fog, and lights.
|
||||
func hide_sky() -> void:
|
||||
sky_enabled = false
|
||||
lights_enabled = false
|
||||
fog_enabled = false
|
||||
clouds_enabled = false
|
||||
|
||||
|
||||
## Enables rendering of sky, fog, and lights.
|
||||
func show_sky() -> void:
|
||||
sky_enabled = true
|
||||
lights_enabled = true
|
||||
fog_enabled = true
|
||||
clouds_enabled = true
|
||||
|
||||
|
||||
#####################
|
||||
## Time
|
||||
#####################
|
||||
|
||||
@export_group("Time")
|
||||
|
||||
|
||||
## Allows time to progress in the editor. Alias for [member TimeOfDay.editor_time_enabled].
|
||||
@export var editor_time_enabled: bool = true :
|
||||
set(value):
|
||||
if tod:
|
||||
tod.editor_time_enabled = value
|
||||
get:
|
||||
return tod.editor_time_enabled if tod else editor_time_enabled
|
||||
|
||||
|
||||
## Allows time to progress in game. Alias for [member TimeOfDay.game_time_enabled].
|
||||
@export var game_time_enabled: bool = true :
|
||||
set(value):
|
||||
if tod:
|
||||
tod.game_time_enabled = value
|
||||
get:
|
||||
return tod.game_time_enabled if tod else game_time_enabled
|
||||
|
||||
|
||||
## A readable game date string, eg. '2025-01-01'. Alias for [member TimeOfDay.game_date].
|
||||
@export_custom(PROPERTY_HINT_NONE, "", PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_READ_ONLY)
|
||||
var game_date: String = "" :
|
||||
get:
|
||||
return tod.game_date if tod else game_date
|
||||
|
||||
|
||||
## A readable game time string, e.g. '08:00:00'. Alias for [member TimeOfDay.game_time].
|
||||
@export_custom(PROPERTY_HINT_NONE, "", PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_READ_ONLY)
|
||||
var game_time: String = "" :
|
||||
get:
|
||||
return tod.game_time if tod else game_time
|
||||
|
||||
|
||||
## The current in-game time in hours from 0.0 to 23.99. Smaller or larger values than the range will wrap.
|
||||
## Alias for [member TimeOfDay.current_time].
|
||||
@export_range(0.0, 23.9998, 0.01) var current_time: float = 8.0 :
|
||||
set(value):
|
||||
if tod:
|
||||
tod.current_time = value
|
||||
get:
|
||||
return tod.current_time if tod else current_time
|
||||
|
||||
|
||||
## The total length of time for a complete day and night cycle in real world minutes. Setting this to
|
||||
## [param 15] means a full in-game day takes 15 real-world minutes. [member game_time_enabled] must be
|
||||
## enabled for this to work. Negative values moves time backwards. The Witcher 3 uses a 96 minute cycle.
|
||||
## Adjust [member update_interval] to match. Shorter days needs more updates. Longer days need less.
|
||||
## Alias for [member TimeOfDay.minutes_per_day].
|
||||
@export_range(-1440, 1440, 0.1) var minutes_per_day: float = 15.0 :
|
||||
set(value):
|
||||
if tod:
|
||||
tod.minutes_per_day = value
|
||||
get:
|
||||
return tod.minutes_per_day if tod else minutes_per_day
|
||||
|
||||
|
||||
## Frequency of sky updates, per second. The smaller the number, the more frequent the updates and
|
||||
## the smoother the animation. Set to [param 0.016] for 60fps, for example.[br][br]
|
||||
## [b]Note:[/b] Setting this value too small may cause unwanted behavior. See [member Timer.wait_time].
|
||||
## Alias for [member TimeOfDay.update_interval].
|
||||
@export_range(0.016, 10.0) var update_interval: float = 0.016 :
|
||||
set(value):
|
||||
if tod:
|
||||
tod.update_interval = value
|
||||
get:
|
||||
return tod.update_interval if tod else update_interval
|
||||
|
||||
|
||||
## Returns true if the sun is above the horizon.
|
||||
func is_day() -> bool:
|
||||
return sky and sky.is_day()
|
||||
|
||||
|
||||
## Returns true if the sun is below the horizon.
|
||||
func is_night() -> bool:
|
||||
return sky and not sky.is_day()
|
||||
|
||||
|
||||
## Pauses time calculation. Alias for [method TimeOfDay.pause].
|
||||
func pause() -> void:
|
||||
if tod:
|
||||
tod.pause()
|
||||
|
||||
|
||||
## Resumes time calculation. Alias for [method TimeOfDay.resume].
|
||||
func resume() -> void:
|
||||
if tod:
|
||||
tod.resume()
|
||||
|
||||
|
||||
var _contrib_tween: Tween
|
||||
|
||||
# Adjusts sky contribution if transitioning to day or night.
|
||||
func _start_sky_contrib_tween(daytime: bool = is_day()) -> void:
|
||||
if not (sky and environment and is_inside_tree()):
|
||||
return
|
||||
|
||||
if _contrib_tween:
|
||||
_contrib_tween.kill()
|
||||
_contrib_tween = get_tree().create_tween()
|
||||
_contrib_tween.set_parallel(true)
|
||||
|
||||
if daytime:
|
||||
_contrib_tween.tween_property(environment, "ambient_light_sky_contribution", sky_contribution, contribution_tween_time)
|
||||
else:
|
||||
var night_contrib: float = minf(night_sky_contribution, sky_contribution) if night_ambient_boost else sky_contribution
|
||||
_contrib_tween.tween_property(environment, "ambient_light_sky_contribution", night_contrib, contribution_tween_time)
|
||||
|
||||
|
||||
#####################
|
||||
## Lighting
|
||||
#####################
|
||||
|
||||
@export_group("Lighting")
|
||||
|
||||
|
||||
## Light intensity scaled before the tonemapper. Softer highlights.
|
||||
## Alias for [member Environment.camera_attributes].
|
||||
## Connect this same resource to your [member Camera3D.attributes].
|
||||
@export_range(0, 16, 0.005) var camera_exposure: float = 1.0 :
|
||||
set(value):
|
||||
if camera_attributes:
|
||||
camera_attributes.exposure_multiplier = value
|
||||
get:
|
||||
return camera_attributes.exposure_multiplier if camera_attributes else camera_exposure
|
||||
|
||||
|
||||
## Light intensity scaled in post processing. Hotter highlights.
|
||||
## Alias for [member Environment.tonemap_exposure].
|
||||
## Connect this same resource to your [member Camera3D.environment].
|
||||
@export_range(0, 16, 0.005) var tonemap_exposure: float = 1.0 :
|
||||
set(value):
|
||||
if environment:
|
||||
environment.tonemap_exposure = value
|
||||
get:
|
||||
return environment.tonemap_exposure if environment else tonemap_exposure
|
||||
|
||||
|
||||
## Light energy coming from the sky shader. Alias for [member SkyDome.exposure].
|
||||
@export_range(0, 16, 0.005) var skydome_energy: float = 1.0 :
|
||||
set(value):
|
||||
if sky:
|
||||
sky.exposure = value
|
||||
get:
|
||||
return sky.exposure if sky else skydome_energy
|
||||
|
||||
|
||||
## Brightness of and light energy coming from the clouds. Alias for [member SkyDome.cumulus_intensity].
|
||||
@export_range(0, 16, 0.005) var cloud_intensity: float = 0.6 :
|
||||
set(value):
|
||||
if sky:
|
||||
sky.cumulus_intensity = value
|
||||
get:
|
||||
return sky.cumulus_intensity if sky else cloud_intensity
|
||||
|
||||
|
||||
## Maximum brightness of the Sun DirectionalLight, visible during the day.
|
||||
## Alias for [member SkyDome.sun_light_energy].
|
||||
@export_range(0, 16, 0.005) var sun_energy: float = 1.0 :
|
||||
set(value):
|
||||
if sky:
|
||||
sky.sun_light_energy = value
|
||||
get:
|
||||
return sky.sun_light_energy if sky else sun_energy
|
||||
|
||||
|
||||
## Opacity of Sun DirectionalLight shadow. Alias for [member DirectionalLight3D.shadow_opacity].
|
||||
@export_range(0, 1, 0.005) var sun_shadow_opacity: float = 1.0 :
|
||||
set(value):
|
||||
if sun:
|
||||
sun.shadow_opacity = value
|
||||
get:
|
||||
return sun.shadow_opacity if sun else sun_shadow_opacity
|
||||
|
||||
|
||||
## Ratio of ambient light to sky light. Works when there are no Reflection Probes or GI.
|
||||
## Sets the target for [member Environment.ambient_light_sky_contribution], which may change at night
|
||||
## depending on [member night_ambient_boost] and [member night_sky_contribution].
|
||||
@export_range(0, 1, 0.005) var sky_contribution: float = 1.0 :
|
||||
set(value):
|
||||
if environment:
|
||||
sky_contribution = value
|
||||
environment.ambient_light_sky_contribution = value
|
||||
_start_sky_contrib_tween()
|
||||
|
||||
|
||||
## Strength of ambient light. Works when there are no Reflection Probes or GI, and
|
||||
## [member sky_contribution] < 1. Alias for [member Environment.ambient_light_energy].
|
||||
@export_range(0, 16, 0.005) var ambient_energy: float = 1.0 :
|
||||
set(value):
|
||||
environment.ambient_light_energy = value
|
||||
_start_sky_contrib_tween()
|
||||
get:
|
||||
return environment.ambient_light_energy if environment else ambient_energy
|
||||
|
||||
|
||||
@export_subgroup("Night")
|
||||
|
||||
|
||||
## Maximum strength of Moon DirectionalLight, visible at night. Alias for [member SkyDome.moon_light_energy].
|
||||
@export_range(0, 16, 0.005) var moon_energy: float = 0.3 :
|
||||
set(value):
|
||||
if sky:
|
||||
sky.moon_light_energy = value
|
||||
get:
|
||||
return sky.moon_light_energy if sky else moon_energy
|
||||
|
||||
|
||||
## Opacity of Moon DirectionalLight shadow. Alias for [member DirectionalLight3D.shadow_opacity].
|
||||
@export_range(0, 1, 0.005) var moon_shadow_opacity: float = 1.0 :
|
||||
set(value):
|
||||
if moon:
|
||||
moon.shadow_opacity = value
|
||||
get:
|
||||
return moon.shadow_opacity if moon else moon_shadow_opacity
|
||||
|
||||
|
||||
## Enables a lower sky_contribution at night, which allows more ambient energy to show.
|
||||
## To use, ensure there are no ReflectionProbes or GI. Set [member ambient_energy] > 0.
|
||||
## Set [member night_sky_contribution] < [member sky_contribution].
|
||||
## Then at night, [member Environment.ambient_light_sky_contribution] will be set lower, which
|
||||
## will show more [member ambient_energy].
|
||||
@export var night_ambient_boost: bool = true :
|
||||
set(value):
|
||||
night_ambient_boost = value
|
||||
_start_sky_contrib_tween()
|
||||
|
||||
|
||||
## Sets [member Environment.ambient_light_sky_contribution] at night if [member night_ambient_boost] is enabled.
|
||||
## See [member night_ambient_boost] and [member sky_contribution].
|
||||
@export_range(0, 1, 0.005) var night_sky_contribution: float = 0.7 :
|
||||
set(value):
|
||||
night_sky_contribution = value
|
||||
if night_ambient_boost:
|
||||
_start_sky_contrib_tween()
|
||||
|
||||
|
||||
## Transition time for changing sky contribution when shifting between day and night.
|
||||
@export_range(0, 30, 0.05) var contribution_tween_time: float = 3.0
|
||||
|
||||
|
||||
@export_subgroup("Auto Exposure")
|
||||
|
||||
|
||||
## Alias for [member CameraAttributes.auto_exposure_enabled].
|
||||
@export var auto_exposure: bool = false :
|
||||
set(value):
|
||||
if camera_attributes:
|
||||
camera_attributes.auto_exposure_enabled = value
|
||||
get:
|
||||
return camera_attributes.auto_exposure_enabled if camera_attributes else auto_exposure
|
||||
|
||||
|
||||
## Alias for [member CameraAttributes.auto_exposure_scale].
|
||||
@export_range(0.01, 16, 0.005) var auto_exposure_scale: float = 0.4 :
|
||||
set(value):
|
||||
if camera_attributes:
|
||||
camera_attributes.auto_exposure_scale = value
|
||||
get:
|
||||
return camera_attributes.auto_exposure_scale if camera_attributes else auto_exposure_scale
|
||||
|
||||
|
||||
## Alias for [member CameraAttributesPractical.auto_exposure_min_sensitivity].
|
||||
@export_range(0, 1600, 0.5) var auto_exposure_min: float = 0.0 :
|
||||
set(value):
|
||||
if camera_attributes:
|
||||
camera_attributes.auto_exposure_min_sensitivity = value
|
||||
if value > auto_exposure_max:
|
||||
auto_exposure_max = value
|
||||
get:
|
||||
return camera_attributes.auto_exposure_min_sensitivity if camera_attributes else auto_exposure_min
|
||||
|
||||
|
||||
## Alias for [member CameraAttributesPractical.auto_exposure_max_sensitivity].
|
||||
@export_range(30, 64000, 0.5) var auto_exposure_max: float = 800.0 :
|
||||
set(value):
|
||||
if camera_attributes:
|
||||
camera_attributes.auto_exposure_max_sensitivity = value
|
||||
if value < auto_exposure_min:
|
||||
auto_exposure_min = value
|
||||
get:
|
||||
return camera_attributes.auto_exposure_max_sensitivity if camera_attributes else auto_exposure_max
|
||||
|
||||
|
||||
## Alias for [member CameraAttributes.auto_exposure_speed].
|
||||
@export_range(0.1, 64, 0.1) var auto_exposure_speed: float = 0.5 :
|
||||
set(value):
|
||||
if camera_attributes:
|
||||
camera_attributes.auto_exposure_speed = value
|
||||
get:
|
||||
return camera_attributes.auto_exposure_speed if camera_attributes else auto_exposure_speed
|
||||
|
||||
|
||||
#####################
|
||||
## Weather
|
||||
#####################
|
||||
|
||||
@export_group("Weather")
|
||||
|
||||
## Sets the wind speed. Alias for [member SkyDome.wind_speed].
|
||||
@export_custom(PROPERTY_HINT_RANGE, "0,120,0.1,or_greater,or_less,suffix:m/s") var wind_speed: float = 1.0 :
|
||||
set(value):
|
||||
if sky:
|
||||
sky.wind_speed = value
|
||||
get:
|
||||
return sky.wind_speed if sky else wind_speed
|
||||
|
||||
## Sets the wind direction. Zero means the wind is coming from the north, 90 from the east,
|
||||
## 180 from the south and 270 (or -90) from the west. Alias for [member SkyDome.wind_direction].
|
||||
@export_custom(PROPERTY_HINT_RANGE, "-180,180,0.1,radians_as_degrees") var wind_direction: float = 0.0 :
|
||||
set(value):
|
||||
if sky:
|
||||
sky.wind_direction = value
|
||||
get:
|
||||
return sky.wind_direction if sky else wind_direction
|
||||
|
||||
|
||||
#####################
|
||||
## Overlays
|
||||
#####################
|
||||
|
||||
@export_group("Overlays")
|
||||
|
||||
|
||||
## Overlays a grid aligned to the horizon and the sky zenith.
|
||||
## Change color in SkyDome. Alias for [member SkyDome.show_azimuthal_grid].
|
||||
@export var show_azimuthal_grid: bool = false :
|
||||
set(value):
|
||||
if sky:
|
||||
sky.show_azimuthal_grid = value
|
||||
get:
|
||||
return sky.show_azimuthal_grid if sky else show_azimuthal_grid
|
||||
|
||||
|
||||
## Overlays a grid aligned to the celestial equator and the north celestial pole (near Polaris).
|
||||
## Change color in SkyDome. Alias for [member SkyDome.show_equatorial_grid].
|
||||
@export var show_equatorial_grid: bool = false :
|
||||
set(value):
|
||||
if sky:
|
||||
sky.show_equatorial_grid = value
|
||||
get:
|
||||
return sky.show_equatorial_grid if sky else show_equatorial_grid
|
||||
|
||||
|
||||
#####################
|
||||
## Setup
|
||||
#####################
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
# Must be after _init and before _enter_tree to properly set vars like 'sky' for setters
|
||||
if what in [ NOTIFICATION_SCENE_INSTANTIATED, NOTIFICATION_ENTER_TREE ]:
|
||||
_initialize()
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
# Create default environment
|
||||
if environment == null:
|
||||
environment = Environment.new()
|
||||
environment.background_mode = Environment.BG_SKY
|
||||
environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||
environment.ambient_light_sky_contribution = 0.7
|
||||
environment.ambient_light_energy = 1.0
|
||||
environment.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||
environment.tonemap_mode = Environment.TONE_MAPPER_ACES
|
||||
environment.tonemap_white = 6
|
||||
emit_signal("environment_changed", environment)
|
||||
|
||||
# Setup Sky material & Upgrade old
|
||||
if environment.sky == null or environment.sky.sky_material is PhysicalSkyMaterial:
|
||||
environment.sky = Sky.new()
|
||||
environment.sky.sky_material = ShaderMaterial.new()
|
||||
environment.sky.sky_material.shader = load(SKY_SHADER)
|
||||
|
||||
# Set a reference to the sky material for easy access.
|
||||
sky_material = environment.sky.sky_material
|
||||
|
||||
# Create default camera attributes
|
||||
if camera_attributes == null:
|
||||
camera_attributes = CameraAttributesPractical.new()
|
||||
|
||||
# Assign children nodes
|
||||
|
||||
if has_node("SunLight"):
|
||||
sun = $SunLight
|
||||
elif is_inside_tree():
|
||||
sun = DirectionalLight3D.new()
|
||||
sun.name = "SunLight"
|
||||
add_child(sun, true)
|
||||
sun.owner = get_tree().edited_scene_root
|
||||
sun.shadow_enabled = true
|
||||
|
||||
if has_node("MoonLight"):
|
||||
moon = $MoonLight
|
||||
elif is_inside_tree():
|
||||
moon = DirectionalLight3D.new()
|
||||
moon.name = "MoonLight"
|
||||
add_child(moon, true)
|
||||
moon.owner = get_tree().edited_scene_root
|
||||
moon.shadow_enabled = true
|
||||
|
||||
# DEPRECATED - Remove 2.2
|
||||
if has_node("Skydome"):
|
||||
$Skydome.name = "SkyDome"
|
||||
if has_node("SkyDome"):
|
||||
sky = $SkyDome
|
||||
sky.environment = environment
|
||||
elif is_inside_tree():
|
||||
sky = SkyDome.new()
|
||||
sky.name = "SkyDome"
|
||||
add_child(sky, true)
|
||||
sky.owner = get_tree().edited_scene_root
|
||||
sky.sun_light_path = "../SunLight"
|
||||
sky.moon_light_path = "../MoonLight"
|
||||
sky.environment = environment
|
||||
|
||||
if has_node("TimeOfDay"):
|
||||
tod = $TimeOfDay
|
||||
elif is_inside_tree():
|
||||
tod = TimeOfDay.new()
|
||||
tod.name = "TimeOfDay"
|
||||
add_child(tod, true)
|
||||
tod.owner = get_tree().edited_scene_root
|
||||
tod.dome_path = "../SkyDome"
|
||||
if sky and not sky.day_night_changed.is_connected(_start_sky_contrib_tween):
|
||||
sky.day_night_changed.connect(_start_sky_contrib_tween)
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
_start_sky_contrib_tween()
|
||||
|
||||
|
||||
func _set(property: StringName, value: Variant) -> bool:
|
||||
match property:
|
||||
"environment":
|
||||
sky.environment = value
|
||||
environment = value
|
||||
emit_signal("environment_changed", environment)
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://deo0gxkmk2ahd
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4tkplvh7fq40
|
||||
@@ -0,0 +1,18 @@
|
||||
# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
|
||||
# Copyright (c) 2021 J. Cuellar
|
||||
|
||||
class_name TOD_Math
|
||||
|
||||
|
||||
static func rev(val: float) -> float:
|
||||
return val - int(floor(val / 360.0)) * 360.0
|
||||
|
||||
|
||||
static func spherical_to_cartesian(theta: float, azimuth: float, radius: float = 1.0) -> Vector3:
|
||||
var ret: Vector3
|
||||
var sinTheta: float = sin(theta)
|
||||
ret.x = sinTheta * sin(azimuth)
|
||||
ret.y = cos(theta)
|
||||
ret.z = sinTheta * cos(azimuth)
|
||||
return ret * radius
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://couf46uxtsrhg
|
||||
@@ -0,0 +1,593 @@
|
||||
# Copyright (c) 2023-2025 Cory Petkovsek and Contributors
|
||||
# Copyright (c) 2021 J. Cuellar
|
||||
|
||||
## TimeOfDay is a component of [Sky3D].
|
||||
##
|
||||
## This class tracks the progress of time, as well as the planetary calculations. See [Sky3D].
|
||||
|
||||
@tool
|
||||
class_name TimeOfDay
|
||||
extends Node
|
||||
|
||||
|
||||
signal time_changed(value)
|
||||
signal minute_changed(value)
|
||||
signal hour_changed(value)
|
||||
signal day_changed(value)
|
||||
signal month_changed(value)
|
||||
signal year_changed(value)
|
||||
|
||||
var _previous_minute: int = -1
|
||||
var _previous_hour: int = -1
|
||||
|
||||
const HOURS_PER_DAY: int = 24
|
||||
const RADIANS_PER_HOUR: float = PI / 12.0
|
||||
const HALFPI: float = PI / 2.0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
dome_path = dome_path
|
||||
|
||||
_previous_minute = floori(fmod(current_time, 1.0) * 60.0)
|
||||
_previous_hour = floori(current_time)
|
||||
|
||||
_update_timer = Timer.new()
|
||||
_update_timer.name = "Timer"
|
||||
add_child(_update_timer)
|
||||
_update_timer.timeout.connect(_on_timeout)
|
||||
_update_timer.wait_time = update_interval
|
||||
resume()
|
||||
|
||||
|
||||
func _on_timeout() -> void:
|
||||
if system_sync:
|
||||
_update_time_from_os()
|
||||
else:
|
||||
var delta: float = 0.001 * (Time.get_ticks_msec() - _last_update)
|
||||
_progress_time(delta)
|
||||
_update_celestial_coords()
|
||||
_last_update = Time.get_ticks_msec()
|
||||
|
||||
|
||||
## Halts the automatic progression of in-game time until resumed.
|
||||
func pause() -> void:
|
||||
if is_instance_valid(_update_timer):
|
||||
_update_timer.stop()
|
||||
|
||||
|
||||
## Restarts the automatic progression of in-game time.
|
||||
func resume() -> void:
|
||||
if is_instance_valid(_update_timer):
|
||||
# Assume resuming from a pause, so timer only gets one tick
|
||||
_last_update = Time.get_ticks_msec() - update_interval
|
||||
if (Engine.is_editor_hint() and editor_time_enabled) or \
|
||||
(not Engine.is_editor_hint() and game_time_enabled):
|
||||
_update_timer.start()
|
||||
|
||||
|
||||
#####################
|
||||
## General
|
||||
#####################
|
||||
|
||||
var _update_timer: Timer
|
||||
var _last_update: int = 0
|
||||
var _sky_dome: SkyDome
|
||||
|
||||
@export_group("General")
|
||||
|
||||
## Allows time to progress in the editor.
|
||||
@export var editor_time_enabled: bool = true :
|
||||
set(value):
|
||||
editor_time_enabled = value
|
||||
if Engine.is_editor_hint():
|
||||
if editor_time_enabled:
|
||||
resume()
|
||||
else:
|
||||
pause()
|
||||
|
||||
|
||||
## Allows time to progress in game.
|
||||
@export var game_time_enabled: bool = true :
|
||||
set(value):
|
||||
game_time_enabled = value
|
||||
if not Engine.is_editor_hint():
|
||||
if game_time_enabled:
|
||||
resume()
|
||||
else:
|
||||
pause()
|
||||
|
||||
|
||||
## NodePath to SkyDome
|
||||
@export var dome_path: NodePath :
|
||||
set(value):
|
||||
dome_path = value
|
||||
if dome_path:
|
||||
# DEPRECATED - Remove 2.2
|
||||
if dome_path == NodePath("../Skydome"):
|
||||
dome_path = NodePath("../SkyDome")
|
||||
_sky_dome = get_node_or_null(dome_path)
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## The total length of time for a complete day and night cycle in real world minutes. Setting this to
|
||||
## [param 15] means a full in-game day takes 15 real-world minutes. [member game_time_enabled] must be
|
||||
## enabled for this to work. Negative values moves time backwards. The Witcher 3 uses a 96 minute cycle.
|
||||
## Adjust [member update_interval] to match. Shorter days needs more updates. Longer days need less.
|
||||
@export var minutes_per_day: float = 15.0
|
||||
|
||||
|
||||
## Celestial coordinates are updated based upon a timer, which continuously fires based on
|
||||
## this interval: [0.016, 10s]. Set to the lowest, 0.016 (60fps) if your [member minutes_per_day] is short,
|
||||
## such as less than 15 minutes. The Witcher 3 uses a 96 minute day cycle, so 0.1 (10fps) is adequate.
|
||||
@export_range(0.016, 10) var update_interval: float = 0.016 :
|
||||
set(value):
|
||||
update_interval = clamp(value, .016, 10)
|
||||
if is_instance_valid(_update_timer):
|
||||
_update_timer.wait_time = update_interval
|
||||
resume()
|
||||
|
||||
|
||||
#####################
|
||||
## DateTime
|
||||
#####################
|
||||
|
||||
@export_group("Current Time")
|
||||
|
||||
## Syncronize all of Sky3D with your system clock for a realtime sky, time, and date.
|
||||
@export var system_sync: bool = false
|
||||
|
||||
## A readable game date string, eg. '2025-01-01'. Alias for [member TimeOfDay.game_date].
|
||||
@export_custom(PROPERTY_HINT_NONE, "", PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_READ_ONLY)
|
||||
var game_date: String = "" :
|
||||
get():
|
||||
return "%04d-%02d-%02d" % [ year, month, day ]
|
||||
|
||||
|
||||
## A readable game time string, e.g. '08:00:00'. Alias for [member TimeOfDay.game_time].
|
||||
@export_custom(PROPERTY_HINT_NONE, "", PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_READ_ONLY)
|
||||
var game_time: String = "" :
|
||||
get():
|
||||
return "%02d:%02d:%02d" % [ floor(current_time), floor(fmod(current_time, 1.0) * 60.0),
|
||||
floor(fmod(current_time * 60.0, 1.0) * 60.0) ]
|
||||
|
||||
|
||||
## The current in-game time in hours, in the range [0.0 .. 23.9998]
|
||||
@export_range(0.,23.9998) var current_time: float = 8.0 :
|
||||
set(value):
|
||||
if current_time != value:
|
||||
current_time = value
|
||||
while current_time > 23.9999:
|
||||
current_time -= 24
|
||||
day += 1
|
||||
while current_time < 0.0000:
|
||||
current_time += 24
|
||||
day -= 1
|
||||
emit_signal("time_changed", current_time)
|
||||
|
||||
var new_hour: int = floori(current_time)
|
||||
var new_minute: int = floori(fmod(current_time, 1.0) * 60.0)
|
||||
|
||||
if new_hour != _previous_hour:
|
||||
_previous_hour = new_hour
|
||||
emit_signal("hour_changed", new_hour)
|
||||
|
||||
if new_minute != _previous_minute:
|
||||
_previous_minute = new_minute
|
||||
emit_signal("minute_changed", new_minute)
|
||||
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## The current day of the month
|
||||
@export_range(0,31) var day: int = 1 :
|
||||
set(value):
|
||||
if day != value:
|
||||
day = value
|
||||
while day > max_days_per_month():
|
||||
day -= max_days_per_month()
|
||||
month += 1
|
||||
while day < 1:
|
||||
month -= 1
|
||||
day += max_days_per_month()
|
||||
emit_signal("day_changed", day)
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## The current month of the year
|
||||
@export_range(0,12) var month: int = 1 :
|
||||
set(value):
|
||||
if month != value:
|
||||
month = value
|
||||
while month > 12:
|
||||
month -= 12
|
||||
year += 1
|
||||
while month < 1:
|
||||
month += 12
|
||||
year -= 1
|
||||
emit_signal("month_changed", month)
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## The current year
|
||||
@export_range(-9999,9999) var year: int = 2025 :
|
||||
set(value):
|
||||
if year != value:
|
||||
year = value
|
||||
emit_signal("year_changed", year)
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## Determines whether the current year has 366 days.
|
||||
func is_leap_year() -> bool:
|
||||
return (year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0)
|
||||
|
||||
|
||||
## Calculates the number of days in the current month, including adjustments for February during leap years.
|
||||
func max_days_per_month() -> int:
|
||||
match month:
|
||||
1, 3, 5, 7, 8, 10, 12:
|
||||
return 31
|
||||
2:
|
||||
return 29 if is_leap_year() else 28
|
||||
return 30
|
||||
|
||||
|
||||
## Sets [member current_time] from parameters.
|
||||
func set_time(hour: int, minute: int, second: int) -> void:
|
||||
current_time = float(hour) + float(minute) / 60.0 + float(second) / 3600.0
|
||||
|
||||
|
||||
## Sets the in-game date and time from a dictionary.
|
||||
func set_from_datetime_dict(datetime_dict: Dictionary) -> void:
|
||||
year = datetime_dict.year
|
||||
month = datetime_dict.month
|
||||
day = datetime_dict.day
|
||||
set_time(datetime_dict.hour, datetime_dict.minute, datetime_dict.second)
|
||||
|
||||
|
||||
## Returns the current date and time as a Godot Time format compatible dictionary.
|
||||
func get_datetime_dict() -> Dictionary:
|
||||
var datetime_dict: Dictionary = {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"day": day,
|
||||
"hour": floor(current_time),
|
||||
"minute": floor(fmod(current_time, 1.0) * 60.0),
|
||||
"second": floor(fmod(current_time * 60.0, 1.0) * 60.0)
|
||||
}
|
||||
return datetime_dict
|
||||
|
||||
|
||||
## Initializes datetime from Unix timestamp, used by users' save/load extentions.
|
||||
func set_from_unix_timestamp(timestamp: int) -> void:
|
||||
set_from_datetime_dict(Time.get_datetime_dict_from_unix_time(timestamp))
|
||||
|
||||
|
||||
## Converts current datetime to Unix timestamp, used by users' save/load extentions.
|
||||
func get_unix_timestamp() -> int:
|
||||
return Time.get_unix_time_from_datetime_dict(get_datetime_dict())
|
||||
|
||||
|
||||
func _progress_time(delta: float) -> void:
|
||||
var hours_per_real_second: float = 0. if is_zero_approx(minutes_per_day) else HOURS_PER_DAY / (minutes_per_day * 60.0)
|
||||
current_time += delta * hours_per_real_second
|
||||
|
||||
|
||||
func _update_time_from_os() -> void:
|
||||
var date_time_os: Dictionary = Time.get_datetime_dict_from_system()
|
||||
set_time(date_time_os.hour, date_time_os.minute, date_time_os.second)
|
||||
day = date_time_os.day
|
||||
month = date_time_os.month
|
||||
year = date_time_os.year
|
||||
|
||||
|
||||
#####################
|
||||
## Planetary
|
||||
#####################
|
||||
|
||||
@export_group("Planetary And Location")
|
||||
|
||||
var _sun_coords: Vector2 = Vector2.ZERO
|
||||
var _moon_coords: Vector2 = Vector2.ZERO
|
||||
var _sun_distance: float
|
||||
var _mean_sun_longitude: float
|
||||
var _sideral_time: float
|
||||
var _local_sideral_time: float
|
||||
var _sun_orbital_elements := OrbitalElements.new()
|
||||
var _moon_orbital_elements := OrbitalElements.new()
|
||||
|
||||
|
||||
enum CelestialMode { SIMPLE, REALISTIC }
|
||||
## Selects between simple trigonometric approximations or full orbital mechanics for computing sun and moon positions.
|
||||
@export var celestials_calculations := CelestialMode.REALISTIC:
|
||||
set(value):
|
||||
celestials_calculations = value
|
||||
_update_celestial_coords()
|
||||
notify_property_list_changed()
|
||||
|
||||
|
||||
## The observer's position on Earth, north or south of the equator (0°=equator, +90°=North Pole, -90°=South Pole).
|
||||
@export_range(-90, 90, 0.00001, "radians_as_degrees") var latitude: float = deg_to_rad(16.) :
|
||||
set(value):
|
||||
latitude = value
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## The observer's position on Earth, east or west of the prime meridian (0°=Greenwich, +180°=International Date Line).
|
||||
@export_range(-180, 180, 0.00001, "radians_as_degrees") var longitude: float = deg_to_rad(108.) :
|
||||
set(value):
|
||||
longitude = value
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## Specifies the time zone offset from UTC in hours to adjust local time calculations.
|
||||
@export_range(-12,14,.25) var utc: float = 7.0 :
|
||||
set(value):
|
||||
utc = value
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## Calculate moon orbital position. Disable for performance if moon lighting not needed.
|
||||
@export var compute_moon_coords: bool = true :
|
||||
set(value):
|
||||
compute_moon_coords = value
|
||||
_update_celestial_coords()
|
||||
notify_property_list_changed()
|
||||
|
||||
|
||||
## Toggles the calculation of sidereal time and star field rotation for realistic deep space alignments.
|
||||
@export var compute_deep_space_coords: bool = true :
|
||||
set(value):
|
||||
compute_deep_space_coords = value
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## Offset the moon azimuth and altitude. Simple celestial calculation mode only.
|
||||
@export var moon_coords_offset: Vector2 = Vector2(0.0, 0.0) :
|
||||
set(value):
|
||||
moon_coords_offset = value
|
||||
_update_celestial_coords()
|
||||
|
||||
|
||||
## Returns the current time at UTC 0
|
||||
func get_current_time_utc0() -> float:
|
||||
return current_time - utc
|
||||
|
||||
|
||||
func _get_time_scale() -> float:
|
||||
# All divisions must be integer divisions, see: https://stjarnhimlen.se/comp/ppcomp.html#3
|
||||
var leap_term: int = int(7 * int((year + int((month + 9) / 12))) / 4)
|
||||
var century_term: int = int(3 * int((int((year + int((month - 9) / 7))) / 100) + 1) / 4)
|
||||
var month_term: int = int(275 * month / 9)
|
||||
var time_scale: int = 367 * year - leap_term - century_term + month_term + day - 730515
|
||||
return time_scale + current_time / 24.0
|
||||
|
||||
|
||||
func _get_oblecl() -> float:
|
||||
# From: https://stjarnhimlen.se/comp/ppcomp.html#4
|
||||
# Another quantity we will need is ecl, the obliquity of the ecliptic,
|
||||
# i.e. the "tilt" of the Earth's axis of rotation (currently 23.4 degrees
|
||||
# and slowly decreasing). First, compute the "d" of the moment of interest
|
||||
# (section 3). Then, compute the obliquity of the ecliptic:
|
||||
return deg_to_rad(23.4393 - 3.563e-7 * _get_time_scale())
|
||||
|
||||
|
||||
func _update_celestial_coords() -> void:
|
||||
if not _sky_dome:
|
||||
return
|
||||
|
||||
match celestials_calculations:
|
||||
CelestialMode.SIMPLE:
|
||||
_compute_simple_sun_coords()
|
||||
_sky_dome.sun_altitude = _sun_coords.y
|
||||
_sky_dome.sun_azimuth = _sun_coords.x
|
||||
if compute_moon_coords:
|
||||
_compute_simple_moon_coords()
|
||||
_sky_dome.moon_altitude = _moon_coords.y
|
||||
_sky_dome.moon_azimuth = _moon_coords.x
|
||||
# Celestial North Pole (SIMPLE convention: +Z = north)
|
||||
_sky_dome.celestial_north_pole = Vector3(0.0, sin(latitude), cos(latitude))
|
||||
|
||||
if compute_deep_space_coords:
|
||||
if _sky_dome.is_scene_built:
|
||||
_sky_dome.sky_material.set_shader_parameter("star_tilt", HALFPI - latitude)
|
||||
|
||||
CelestialMode.REALISTIC:
|
||||
_compute_realistic_sun_coords()
|
||||
_sky_dome.sun_altitude = -_sun_coords.y
|
||||
_sky_dome.sun_azimuth = -_sun_coords.x
|
||||
if compute_moon_coords:
|
||||
_compute_realistic_moon_coords()
|
||||
_sky_dome.moon_altitude = -_moon_coords.y
|
||||
_sky_dome.moon_azimuth = -_moon_coords.x
|
||||
# Celestial North Pole (REALISTIC convention: -Z = north)
|
||||
_sky_dome.celestial_north_pole = Vector3(0.0, sin(latitude), -cos(latitude))
|
||||
|
||||
if compute_deep_space_coords:
|
||||
if _sky_dome.is_scene_built:
|
||||
_sky_dome.sky_material.set_shader_parameter("star_tilt", latitude - HALFPI)
|
||||
_sky_dome.sky_material.set_shader_parameter("star_rotation", -_local_sideral_time)
|
||||
_sky_dome.update_moon_coords()
|
||||
|
||||
|
||||
func _compute_simple_sun_coords() -> void:
|
||||
# PI/12.0 radians = 15 degrees => 1 hour is 15 degrees of rotation
|
||||
var altitude: float = (get_current_time_utc0() * RADIANS_PER_HOUR) + longitude
|
||||
_sun_coords.y = PI - altitude
|
||||
_sun_coords.x = latitude
|
||||
|
||||
|
||||
func _compute_simple_moon_coords() -> void:
|
||||
_moon_coords.y = (PI - _sun_coords.y) + moon_coords_offset.y
|
||||
_moon_coords.x = (PI + _sun_coords.x) + moon_coords_offset.x
|
||||
|
||||
|
||||
func _compute_realistic_sun_coords() -> void:
|
||||
# Orbital Elements
|
||||
_sun_orbital_elements.get_orbital_elements(0, _get_time_scale())
|
||||
_sun_orbital_elements.M = TOD_Math.rev(_sun_orbital_elements.M)
|
||||
|
||||
# Mean anomaly in radians
|
||||
var MRad: float = deg_to_rad(_sun_orbital_elements.M)
|
||||
|
||||
# Eccentric Anomaly
|
||||
var E: float = _sun_orbital_elements.M + rad_to_deg(_sun_orbital_elements.e * sin(MRad) * (1 + _sun_orbital_elements.e * cos(MRad)))
|
||||
|
||||
var ERad: float = deg_to_rad(E)
|
||||
|
||||
# Rectangular coordinates of the sun in the plane of the ecliptic
|
||||
var xv: float = cos(ERad) - _sun_orbital_elements.e
|
||||
var yv: float = sin(ERad) * sqrt(1 - _sun_orbital_elements.e * _sun_orbital_elements.e)
|
||||
|
||||
# Distance and true anomaly
|
||||
# Convert to distance and true anomaly(r = radians, v = degrees)
|
||||
var r: float = sqrt(xv * xv + yv * yv)
|
||||
|
||||
var v: float = atan2(yv, xv)
|
||||
_sun_distance = r
|
||||
|
||||
# True longitude
|
||||
var lonSun: float = v + deg_to_rad(_sun_orbital_elements.w)
|
||||
lonSun = fmod(lonSun + TAU, TAU) # Wrap to [0, 2π)
|
||||
|
||||
## Ecliptic and ecuatorial coords
|
||||
|
||||
# Ecliptic rectangular coords
|
||||
var xs: float = r * cos(lonSun)
|
||||
var ys: float = r * sin(lonSun)
|
||||
|
||||
# Ecliptic rectangular coordinates rotate these to equatorial coordinates
|
||||
var obleclCos: float = cos(_get_oblecl())
|
||||
var obleclSin: float = sin(_get_oblecl())
|
||||
|
||||
var xe: float = xs
|
||||
var ye: float = ys * obleclCos - 0.0 * obleclSin
|
||||
var ze: float = ys * obleclSin + 0.0 * obleclCos
|
||||
|
||||
# Ascencion and declination
|
||||
var RA: float = atan2(ye, xe) # right ascension.
|
||||
var decl: float = atan2(ze, sqrt(xe * xe + ye * ye)) # declination
|
||||
|
||||
# Mean longitude
|
||||
var L: float = deg_to_rad(_sun_orbital_elements.w + _sun_orbital_elements.M)
|
||||
L = fmod(L + TAU, TAU) # Wrap to [0, 2π)
|
||||
_mean_sun_longitude = L
|
||||
var GMST0: float = L + PI
|
||||
|
||||
# Sidereal, see: https://stjarnhimlen.se/comp/ppcomp.html#5b
|
||||
# Local Sidereal Time = GMST0 + UT + longitude
|
||||
_sideral_time = GMST0 + get_current_time_utc0() * RADIANS_PER_HOUR + longitude
|
||||
_local_sideral_time = _sideral_time
|
||||
|
||||
# Hour Angle = Sidereal Time - Right Ascension
|
||||
# See: https://stjarnhimlen.se/comp/ppcomp.html#12b
|
||||
var HA: float = _sideral_time - RA
|
||||
|
||||
# Hour angle and declination in rectangular coords
|
||||
var declCos: float = cos(decl)
|
||||
var x: float = cos(HA) * declCos # X Axis points to the celestial equator in the south
|
||||
var y: float = sin(HA) * declCos # Y axis points to the horizon in the west
|
||||
var z: float = sin(decl) # Z axis points to the north celestial pole
|
||||
|
||||
# Rotate the rectangualar coordinates system along of the Y axis
|
||||
var sinLat: float = sin(latitude)
|
||||
var cosLat: float = cos(latitude)
|
||||
var xhor: float = x * sinLat - z * cosLat
|
||||
var yhor: float = y
|
||||
var zhor: float = x * cosLat + z * sinLat
|
||||
|
||||
# Azimuth and altitude
|
||||
_sun_coords.x = atan2(yhor, xhor) + PI
|
||||
_sun_coords.y = (PI * 0.5) - asin(zhor)
|
||||
|
||||
|
||||
func _compute_realistic_moon_coords() -> void:
|
||||
# Orbital Elements
|
||||
_moon_orbital_elements.get_orbital_elements(1, _get_time_scale())
|
||||
_moon_orbital_elements.N = TOD_Math.rev(_moon_orbital_elements.N)
|
||||
_moon_orbital_elements.w = TOD_Math.rev(_moon_orbital_elements.w)
|
||||
_moon_orbital_elements.M = TOD_Math.rev(_moon_orbital_elements.M)
|
||||
|
||||
var NRad: float = deg_to_rad(_moon_orbital_elements.N)
|
||||
var IRad: float = deg_to_rad(_moon_orbital_elements.i)
|
||||
var MRad: float = deg_to_rad(_moon_orbital_elements.M)
|
||||
|
||||
# Eccentric anomaly
|
||||
var E: float = _moon_orbital_elements.M + rad_to_deg(_moon_orbital_elements.e * sin(MRad) * (1 + _moon_orbital_elements.e * cos(MRad)))
|
||||
var ERad: float = deg_to_rad(E)
|
||||
|
||||
# See: https://stjarnhimlen.se/comp/ppcomp.html#6
|
||||
var xv: float = _moon_orbital_elements.a * (cos(ERad) - _moon_orbital_elements.e)
|
||||
var yv: float = _moon_orbital_elements.a * sqrt(1 - _moon_orbital_elements.e * _moon_orbital_elements.e) * sin(ERad)
|
||||
|
||||
# Convert to distance and true anomaly(r = radians, v = degrees)
|
||||
var r: float = sqrt(xv * xv + yv * yv)
|
||||
var v: float = atan2(yv, xv)
|
||||
var l: float = v + deg_to_rad(_moon_orbital_elements.w)
|
||||
|
||||
var cosL: float = cos(l)
|
||||
var sinL: float = sin(l)
|
||||
var cosNRad: float = cos(NRad)
|
||||
var sinNRad: float = sin(NRad)
|
||||
var cosIRad: float = cos(IRad)
|
||||
|
||||
var xeclip: float = r * (cosNRad * cosL - sinNRad * sinL * cosIRad)
|
||||
var yeclip: float = r * (sinNRad * cosL + cosNRad * sinL * cosIRad)
|
||||
var zeclip: float = r * (sinL * sin(IRad))
|
||||
|
||||
# Position in space: https://stjarnhimlen.se/comp/ppcomp.html#7
|
||||
# Geocentric position for the moon and Heliocentric position for the planets
|
||||
var lonecl: float = atan2(yeclip, xeclip) # Ecliptic longitude
|
||||
var latecl: float = atan2(zeclip, sqrt(xeclip * xeclip + yeclip * yeclip)) # Ecliptic latitude
|
||||
|
||||
var nr: float = 1.0
|
||||
var xh: float = nr * cos(lonecl) * cos(latecl)
|
||||
var yh: float = nr * sin(lonecl) * cos(latecl)
|
||||
var zh: float = nr * sin(latecl)
|
||||
|
||||
# Geocentric coords
|
||||
var xs: float = 0.0
|
||||
var ys: float = 0.0
|
||||
|
||||
# Convert the geocentric position to heliocentric position
|
||||
var xg: float = xh + xs
|
||||
var yg: float = yh + ys
|
||||
var zg: float = zh
|
||||
|
||||
# Ecuatorial coords
|
||||
# Convert xg, yg to equatorial coords
|
||||
var obleclCos: float = cos(_get_oblecl())
|
||||
var obleclSin: float = sin(_get_oblecl())
|
||||
|
||||
var xe: float = xg
|
||||
var ye: float = yg * obleclCos - zg * obleclSin
|
||||
var ze: float = yg * obleclSin + zg * obleclCos
|
||||
|
||||
# Right ascention
|
||||
var RA: float = atan2(ye, xe)
|
||||
var decl: float = atan2(ze, sqrt(xe * xe + ye * ye))
|
||||
|
||||
# Sideral time and hour angle
|
||||
var HA: float = ((_sideral_time) - RA)
|
||||
|
||||
# HA y Decl in rectangular coordinates
|
||||
var declCos: float = cos(decl)
|
||||
var xr: float = cos(HA) * declCos
|
||||
var yr: float = sin(HA) * declCos
|
||||
var zr: float = sin(decl)
|
||||
|
||||
# Rotate the rectangualar coordinates system along of the Y axis(radians)
|
||||
var sinLat: float = sin(latitude)
|
||||
var cosLat: float = cos(latitude)
|
||||
|
||||
var xhor: float = xr * sinLat - zr * cosLat
|
||||
var yhor: float = yr
|
||||
var zhor: float = xr * cosLat + zr * sinLat
|
||||
|
||||
# Azimuth and altitude
|
||||
_moon_coords.x = atan2(yhor, xhor) + PI
|
||||
_moon_coords.y = (PI *0.5) - atan2(zhor, sqrt(xhor * xhor + yhor * yhor)) # Mathf.Asin(zhor)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjmkcj8hwm7ts
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="Toon 材质自动刷新"
|
||||
description="在编辑器里修改 shader/toon.tres(含内联的描边 next_pass 子材质)参数时,自动重刷当前编辑场景中所有 toon_apply 节点的材质,不必再手动勾 refresh_now。也可从 项目 > 工具 菜单手动触发。"
|
||||
author="agame"
|
||||
version="1.0"
|
||||
script="plugin.gd"
|
||||
@@ -0,0 +1,143 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
## 让 toon_apply 的材质在编辑器里"改模板即时生效"。
|
||||
##
|
||||
## 背景:toon_apply.gd 给每个 MeshInstance3D 都 duplicate() 了一份独立材质
|
||||
## (因为每个模型有自己的贴图,必须独立),所以之后再改 shader/toon.tres 上的
|
||||
## 材质级参数(color / specular / fresnel / uv_scale / uv_offset / normal_scale …)
|
||||
## 不会传到那些副本上,必须重新跑一次 _apply_materials()。
|
||||
##
|
||||
## ShaderMaterial 的 Resource.changed 信号在改 shader 参数时不会发出(官方出于性能
|
||||
## 考虑只在 resource_name 变化时发),所以没法在 toon_apply.gd 内部自己监听。
|
||||
## 这里改从编辑器侧挂:监听 Inspector 的 property_edited,命中 toon.tres 时重刷。
|
||||
##
|
||||
## 注意:本插件只影响"当前正在编辑的场景"里的 toon_apply 节点。
|
||||
|
||||
const TARGET_MATERIAL := "res://shader/toon.tres"
|
||||
const TARGET_SCRIPT_FILE := "toon_apply.gd"
|
||||
|
||||
## 防抖间隔:拖颜色滑块时每一步都会触发 property_edited,等停手再重刷一次。
|
||||
const DEBOUNCE_SEC := 0.2
|
||||
|
||||
const MENU_ITEM := "刷新 toon 材质(当前场景)"
|
||||
|
||||
var _inspector: EditorInspector
|
||||
var _dirty := false
|
||||
var _elapsed := 0.0
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
_inspector = EditorInterface.get_inspector()
|
||||
if _inspector != null and not _inspector.property_edited.is_connected(_on_property_edited):
|
||||
_inspector.property_edited.connect(_on_property_edited)
|
||||
|
||||
add_tool_menu_item(MENU_ITEM, refresh_edited_scene)
|
||||
set_process(false)
|
||||
print("[toon_refresh] 已启用:改 %s 会自动重刷当前场景的 toon_apply 材质" % TARGET_MATERIAL)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
remove_tool_menu_item(MENU_ITEM)
|
||||
|
||||
if _inspector != null and _inspector.property_edited.is_connected(_on_property_edited):
|
||||
_inspector.property_edited.disconnect(_on_property_edited)
|
||||
_inspector = null
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# 即便 set_process(false) 被引擎重开也安全:这里第一件事就是挡掉
|
||||
if not _dirty:
|
||||
return
|
||||
|
||||
_elapsed += delta
|
||||
if _elapsed < DEBOUNCE_SEC:
|
||||
return
|
||||
|
||||
_dirty = false
|
||||
_elapsed = 0.0
|
||||
set_process(false)
|
||||
refresh_edited_scene()
|
||||
|
||||
|
||||
# --- 触发源 ---------------------------------------------------------------
|
||||
|
||||
func _on_property_edited(_property: String) -> void:
|
||||
if _inspector == null:
|
||||
return
|
||||
if not _is_target_material(_inspector.get_edited_object()):
|
||||
return
|
||||
|
||||
_dirty = true
|
||||
_elapsed = 0.0
|
||||
set_process(true)
|
||||
|
||||
|
||||
## 判断被编辑的资源是不是 toon.tres,或者是它内联的 next_pass 子材质(描边)。
|
||||
func _is_target_material(obj: Object) -> bool:
|
||||
if not (obj is ShaderMaterial):
|
||||
return false
|
||||
|
||||
var mat: ShaderMaterial = obj
|
||||
|
||||
if mat.resource_path == TARGET_MATERIAL:
|
||||
return true
|
||||
|
||||
# 内联子资源(描边材质)的路径形如
|
||||
# res://shader/toon.tres::ShaderMaterial_m5gih
|
||||
if mat.resource_path.begins_with(TARGET_MATERIAL + "::"):
|
||||
return true
|
||||
|
||||
# 兜底:按实例比对 next_pass 链
|
||||
var template := load(TARGET_MATERIAL) as ShaderMaterial
|
||||
if template == null:
|
||||
return false
|
||||
|
||||
var pass_mat: Material = template.next_pass
|
||||
while pass_mat != null:
|
||||
if pass_mat == mat:
|
||||
return true
|
||||
pass_mat = pass_mat.next_pass
|
||||
|
||||
return false
|
||||
|
||||
|
||||
# --- 刷新 -----------------------------------------------------------------
|
||||
|
||||
## 重刷当前编辑场景里所有挂着 toon_apply.gd 的节点。返回刷新的节点数。
|
||||
func refresh_edited_scene() -> int:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return 0
|
||||
|
||||
var count := _refresh_branch(root)
|
||||
if count > 0:
|
||||
print("[toon_refresh] 已重刷 %d 个 toon_apply 节点的材质" % count)
|
||||
return count
|
||||
|
||||
|
||||
func _refresh_branch(root: Node) -> int:
|
||||
var count := 0
|
||||
var stack: Array[Node] = [root]
|
||||
|
||||
while not stack.is_empty():
|
||||
var node: Node = stack.pop_back()
|
||||
if _is_toon_apply(node):
|
||||
node.call("_apply_materials")
|
||||
count += 1
|
||||
for child in node.get_children():
|
||||
stack.append(child)
|
||||
|
||||
return count
|
||||
|
||||
|
||||
## 按脚本文件名匹配,避免依赖 class_name;同时要求确实有 _apply_materials,
|
||||
## 防止脚本编译失败时调用出错。
|
||||
func _is_toon_apply(node: Node) -> bool:
|
||||
if not node.has_method("_apply_materials"):
|
||||
return false
|
||||
|
||||
var scr := node.get_script()
|
||||
if not (scr is Script):
|
||||
return false
|
||||
return (scr as Script).resource_path.get_file() == TARGET_SCRIPT_FILE
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvoc5463koalp
|
||||
@@ -3,13 +3,13 @@
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://cj6oa0aye8omk"
|
||||
path="res://.godot/imported/Bathroom_Mirror2.fbx-4a0005e834336133440e19b8d47bdd97.scn"
|
||||
uid="uid://bmfsqins86dwg"
|
||||
path="res://.godot/imported/Chalice by Quaternius - VZencXtC2I.glb-af6d23c77cdbee337672fd083b0ec8d7.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/FBX/Bathroom_Mirror2.fbx"
|
||||
dest_files=["res://.godot/imported/Bathroom_Mirror2.fbx-4a0005e834336133440e19b8d47bdd97.scn"]
|
||||
source_file="res://assets/CC-BY/Chalice by Quaternius - VZencXtC2I.glb"
|
||||
dest_files=["res://.godot/imported/Chalice by Quaternius - VZencXtC2I.glb-af6d23c77cdbee337672fd083b0ec8d7.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -32,7 +32,7 @@ meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=true
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
@@ -40,7 +40,6 @@ materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
fbx/importer=0
|
||||
fbx/allow_geometry_helper_nodes=false
|
||||
fbx/embedded_image_handling=1
|
||||
fbx/naming_version=2
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
@@ -3,13 +3,13 @@
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://dkvxl0c8ygjqe"
|
||||
path="res://.godot/imported/Bathroom_Shower1.fbx-98c42ade0f9085c4faa6c1dbbfcc00c2.scn"
|
||||
uid="uid://1wlg811mf7wu"
|
||||
path="res://.godot/imported/Light switch by Poly by Google - 8sR1PkyAg-F.glb-d046051d1ef2ce3186641c56319ce2d9.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/FBX/Bathroom_Shower1.fbx"
|
||||
dest_files=["res://.godot/imported/Bathroom_Shower1.fbx-98c42ade0f9085c4faa6c1dbbfcc00c2.scn"]
|
||||
source_file="res://assets/CC-BY/Light switch by Poly by Google - 8sR1PkyAg-F.glb"
|
||||
dest_files=["res://.godot/imported/Light switch by Poly by Google - 8sR1PkyAg-F.glb-d046051d1ef2ce3186641c56319ce2d9.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -32,7 +32,7 @@ meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=true
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
@@ -40,7 +40,6 @@ materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
fbx/importer=0
|
||||
fbx/allow_geometry_helper_nodes=false
|
||||
fbx/embedded_image_handling=1
|
||||
fbx/naming_version=2
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
@@ -3,13 +3,13 @@
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://cog1wka5poqjn"
|
||||
path="res://.godot/imported/Bathroom_Bathtub.fbx-756d957486824576e1b70e791ab28c3a.scn"
|
||||
uid="uid://cgiusgn6vqviw"
|
||||
path="res://.godot/imported/Sconce light by jrich01 - iLXKjr9t4A.glb-efd61c21cab94f3803efeacaaab49d6c.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/FBX/Bathroom_Bathtub.fbx"
|
||||
dest_files=["res://.godot/imported/Bathroom_Bathtub.fbx-756d957486824576e1b70e791ab28c3a.scn"]
|
||||
source_file="res://assets/CC-BY/Sconce light by jrich01 - iLXKjr9t4A.glb"
|
||||
dest_files=["res://.godot/imported/Sconce light by jrich01 - iLXKjr9t4A.glb-efd61c21cab94f3803efeacaaab49d6c.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -32,7 +32,7 @@ meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=true
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
@@ -40,7 +40,6 @@ materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
fbx/importer=0
|
||||
fbx/allow_geometry_helper_nodes=false
|
||||
fbx/embedded_image_handling=1
|
||||
fbx/naming_version=2
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
@@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://ca0ndulkuavmm"
|
||||
path="res://.godot/imported/Time Hotel December Curtains by S. Paul Michael - 3YmINBsAteR.glb-a6e0de863e588552b76d5af3e62cac1d.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/CC-BY/Time Hotel December Curtains by S. Paul Michael - 3YmINBsAteR.glb"
|
||||
dest_files=["res://.godot/imported/Time Hotel December Curtains by S. Paul Michael - 3YmINBsAteR.glb-a6e0de863e588552b76d5af3e62cac1d.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=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
@@ -3,13 +3,13 @@
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://dv1p1aoy4wp73"
|
||||
path="res://.godot/imported/Curtains_Single.fbx-cc8665befadedfa615895791e2fbf79b.scn"
|
||||
uid="uid://di8imuponpg55"
|
||||
path="res://.godot/imported/candle- b07YM_KsRhb.glb-d35035c14ad94736834f0c7f11d21dad.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/FBX/Curtains_Single.fbx"
|
||||
dest_files=["res://.godot/imported/Curtains_Single.fbx-cc8665befadedfa615895791e2fbf79b.scn"]
|
||||
source_file="res://assets/CC-BY/candle- b07YM_KsRhb.glb"
|
||||
dest_files=["res://.godot/imported/candle- b07YM_KsRhb.glb-d35035c14ad94736834f0c7f11d21dad.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -32,7 +32,7 @@ meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=true
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
@@ -40,7 +40,6 @@ materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
fbx/importer=0
|
||||
fbx/allow_geometry_helper_nodes=false
|
||||
fbx/embedded_image_handling=1
|
||||
fbx/naming_version=2
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
@@ -3,13 +3,13 @@
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://c1lffepceknfa"
|
||||
path="res://.godot/imported/Fork.fbx-f86f30deb1876842226ad3552c1f1e4f.scn"
|
||||
uid="uid://ca8qrq4pj5hsu"
|
||||
path="res://.godot/imported/plants.glb-858cf0493f44ee731e0584c5284a46f7.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/FBX/Fork.fbx"
|
||||
dest_files=["res://.godot/imported/Fork.fbx-f86f30deb1876842226ad3552c1f1e4f.scn"]
|
||||
source_file="res://assets/CC-BY/plants.glb"
|
||||
dest_files=["res://.godot/imported/plants.glb-858cf0493f44ee731e0584c5284a46f7.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -32,7 +32,7 @@ meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=true
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
@@ -40,7 +40,6 @@ materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
fbx/importer=0
|
||||
fbx/allow_geometry_helper_nodes=false
|
||||
fbx/embedded_image_handling=1
|
||||
fbx/naming_version=2
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
|
After Width: | Height: | Size: 919 KiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://yyieskp4j2y8"
|
||||
path.s3tc="res://.godot/imported/plants_orchidTxt.png-49b21dcfd49627cc1594cf4c906c7a66.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "3c6053e85832172750aab59d10dbc633"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/CC-BY/plants_orchidTxt.png"
|
||||
dest_files=["res://.godot/imported/plants_orchidTxt.png-49b21dcfd49627cc1594cf4c906c7a66.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
|
||||
@@ -3,13 +3,13 @@
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://4y2xtv1pahu6"
|
||||
path="res://.godot/imported/Spoon.fbx-e0ac8d06845d8fc7512721bec9fe05dc.scn"
|
||||
uid="uid://cmomtlbcr54ts"
|
||||
path="res://.godot/imported/tea_set.glb-afc096b8fcfd2a334aaf7e9d00f69397.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/FBX/Spoon.fbx"
|
||||
dest_files=["res://.godot/imported/Spoon.fbx-e0ac8d06845d8fc7512721bec9fe05dc.scn"]
|
||||
source_file="res://assets/CC-BY/tea_set.glb"
|
||||
dest_files=["res://.godot/imported/tea_set.glb-afc096b8fcfd2a334aaf7e9d00f69397.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -32,7 +32,7 @@ meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=true
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
@@ -40,7 +40,6 @@ materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
fbx/importer=0
|
||||
fbx/allow_geometry_helper_nodes=false
|
||||
fbx/embedded_image_handling=1
|
||||
fbx/naming_version=2
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
|
After Width: | Height: | Size: 2.4 MiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bkfivem3it4le"
|
||||
path.s3tc="res://.godot/imported/tea_set_Image_0.jpg-46e618d2405359418caf168ac029cb70.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "c5eb6507f5a940f651124834ad02d6ac"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/CC-BY/tea_set_Image_0.jpg"
|
||||
dest_files=["res://.godot/imported/tea_set_Image_0.jpg-46e618d2405359418caf168ac029cb70.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
|
||||
|
After Width: | Height: | Size: 338 KiB |