pyforge3d 2.1.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/PKG-INFO +3 -3
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/README.md +1 -1
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/pyproject.toml +5 -3
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/__init__.py +4 -3
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/ik_fabrik.py +2 -1
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/system.py +1 -3
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/app.py +43 -25
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/clip.py +1 -1
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/camera.py +181 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/character.py +124 -4
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/detection.py +104 -11
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/gjk.py +12 -1
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/heightfield.py +91 -0
- pyforge3d-2.2.0/src/forge3d/ecs/component.py +102 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/entity.py +2 -2
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/system.py +14 -2
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/facade.py +546 -40
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/input.py +146 -7
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/particle/emitter.py +3 -2
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/meshes.py +190 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/shaders.py +17 -10
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/window_renderer.py +279 -56
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/scene/node.py +2 -2
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/sim/world.py +8 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/panels.py +2 -4
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/system.py +1 -1
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/viewer.py +99 -10
- pyforge3d-2.1.0/src/forge3d/ecs/component.py +0 -56
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/Cargo.lock +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/Cargo.toml +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/LICENSE +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/_core.pyi +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/clip.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/player.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/animation/skeleton.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/null_driver.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/openal_driver.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/source.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/audio/system.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/backend.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/epa.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/layers.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/collision/raycast.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/constraints/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/constraints/base.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/constraints/joint_type.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/constraints/joints.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/contact/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/contact/solver.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/aba.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/crba.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/model.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/dynamics/rnea.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/bridge.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/serialization.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ecs/transform.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/editor/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/editor/editor_app.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/editor/gizmo.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/editor/layout.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/errors.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/events.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/io/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/io/mesh_data.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/io/obj_loader.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/io/world_snapshot.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/logging.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/inertia.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/quaternion.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/se3.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/math/spatial.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/model/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/model/kinematics.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/model/robot_config.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/model/urdf_loader.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/particle/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/particle/presets.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/particle/system.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/profiler.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/py.typed +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/recorder.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/base.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/deferred/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/deferred/renderer.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/hq/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/hq/raytracer.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/hq/renderer.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/hq/scene.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/passes/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/passes/base.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/context.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/realtime/renderer.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/bloom_down.frag +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/bloom_up.frag +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/fullscreen.vert +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/gbuffer.frag +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/gbuffer.vert +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/lighting.frag +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/pbr.wgsl +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/shadow.frag +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/shadow.vert +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/ssao.frag +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/ssao_blur.frag +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/tonemap.frag +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/shaders/update_particles.comp +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/snapshot.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/pipeline.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/renderer.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/robot/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/robot/presets.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/robot/robot.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/scene/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/scene/manager.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/scene/prefab.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/sim/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/sim/domain_rand.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/sim/jax_batch.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/__init__.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/backend.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d/ui/canvas.py +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/Cargo.toml +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/benches/physics_bench.rs +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/bvh.rs +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/gjk_epa.rs +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/lib.rs +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/math_simd.rs +0 -0
- {pyforge3d-2.1.0 → pyforge3d-2.2.0}/src/forge3d_core/src/pgs_solver.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyforge3d
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Classifier: Development Status :: 5 - Production/Stable
|
|
5
5
|
Classifier: Programming Language :: Rust
|
|
6
6
|
Classifier: Intended Audience :: Developers
|
|
@@ -46,7 +46,7 @@ Provides-Extra: docs
|
|
|
46
46
|
Provides-Extra: render
|
|
47
47
|
Provides-Extra: rl
|
|
48
48
|
License-File: LICENSE
|
|
49
|
-
Summary: Pure-Python 3D physics game engine —
|
|
49
|
+
Summary: Pure-Python 3D physics game engine — own dynamics, own rules, no compromises.
|
|
50
50
|
Keywords: game-engine,physics,simulation,3d,rigid-body,collision,rendering,robotics,reinforcement-learning,numpy,jax
|
|
51
51
|
Author-email: iruki <me@iruki.dev>
|
|
52
52
|
Requires-Python: >=3.12
|
|
@@ -59,7 +59,7 @@ Project-URL: Repository, https://github.com/iruki-dev/forge3d
|
|
|
59
59
|
|
|
60
60
|
# forge3d
|
|
61
61
|
|
|
62
|
-
> **Pure-Python 3D game engine —
|
|
62
|
+
> **Pure-Python 3D physics game engine — own dynamics, own rules, no compromises.**
|
|
63
63
|
|
|
64
64
|
[](https://pypi.org/project/pyforge3d/)
|
|
65
65
|
[](https://www.python.org/)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# forge3d
|
|
2
2
|
|
|
3
|
-
> **Pure-Python 3D game engine —
|
|
3
|
+
> **Pure-Python 3D physics game engine — own dynamics, own rules, no compromises.**
|
|
4
4
|
|
|
5
5
|
[](https://pypi.org/project/pyforge3d/)
|
|
6
6
|
[](https://www.python.org/)
|
|
@@ -6,8 +6,8 @@ build-backend = "maturin"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "pyforge3d"
|
|
9
|
-
version = "2.
|
|
10
|
-
description = "Pure-Python 3D physics game engine —
|
|
9
|
+
version = "2.2.0"
|
|
10
|
+
description = "Pure-Python 3D physics game engine — own dynamics, own rules, no compromises."
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
license = { file = "LICENSE" }
|
|
13
13
|
requires-python = ">=3.12"
|
|
@@ -103,13 +103,15 @@ addopts = "-q --tb=short"
|
|
|
103
103
|
line-length = 100
|
|
104
104
|
target-version = "py312"
|
|
105
105
|
src = ["src"]
|
|
106
|
+
exclude = ["archive", "demos"]
|
|
106
107
|
|
|
107
108
|
[tool.ruff.lint]
|
|
108
109
|
select = ["E", "F", "I", "UP", "B", "C4", "SIM"]
|
|
109
110
|
ignore = ["B008", "SIM118"]
|
|
110
111
|
|
|
111
112
|
[tool.ruff.lint.per-file-ignores]
|
|
112
|
-
"tests/**"
|
|
113
|
+
"tests/**" = ["E501"]
|
|
114
|
+
"apps/**" = ["E501", "E701", "E702", "B905"]
|
|
113
115
|
|
|
114
116
|
# ── Type-check ────────────────────────────────────────────────────────────────
|
|
115
117
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""forge3d — pure-Python 3D game engine.
|
|
2
2
|
|
|
3
|
-
"
|
|
3
|
+
"Pure-Python 3D physics game engine — own dynamics, own rules, no compromises."
|
|
4
4
|
Coordinate system: z-up, SI units (metres, kg, seconds).
|
|
5
5
|
|
|
6
6
|
Minimal example (14 lines)::
|
|
@@ -103,7 +103,7 @@ from forge3d.editor import EditorApp, PlayState
|
|
|
103
103
|
from forge3d.errors import Forge3dError, PhysicsError, RenderError, ValidationError
|
|
104
104
|
from forge3d.events import CollisionEvent, CollisionHandler
|
|
105
105
|
from forge3d.facade import Body, Material, Shape, World
|
|
106
|
-
from forge3d.input import Input, InputBuilder, Key
|
|
106
|
+
from forge3d.input import Input, InputBuilder, Key, ScriptedInput
|
|
107
107
|
from forge3d.io.world_snapshot import StateRecorder
|
|
108
108
|
|
|
109
109
|
# Particle system (P31)
|
|
@@ -121,7 +121,7 @@ from forge3d.scene import Prefab, SceneManager, SceneNode
|
|
|
121
121
|
from forge3d.ui import Canvas, DebugPanel, HierarchyPanel, InspectorPanel, UISystem
|
|
122
122
|
from forge3d.viewer import Viewer
|
|
123
123
|
|
|
124
|
-
__version__ = "2.1.
|
|
124
|
+
__version__ = "2.1.1"
|
|
125
125
|
|
|
126
126
|
# ── API 안정성 선언 ───────────────────────────────────────────────────────────
|
|
127
127
|
# Stable (v3까지 Breaking change 없음):
|
|
@@ -158,6 +158,7 @@ __all__ = [
|
|
|
158
158
|
"Input",
|
|
159
159
|
"InputBuilder",
|
|
160
160
|
"Key",
|
|
161
|
+
"ScriptedInput",
|
|
161
162
|
# Camera
|
|
162
163
|
"OrbitCamera",
|
|
163
164
|
"FollowCamera",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""FABRIK IK 솔버 — Forward And Backward Reaching Inverse Kinematics.
|
|
2
2
|
|
|
3
|
-
참고: Aristidou & Lasenby (2011),
|
|
3
|
+
참고: Aristidou & Lasenby (2011),
|
|
4
|
+
"FABRIK: A fast, iterative solver for the Inverse Kinematics problem"
|
|
4
5
|
"""
|
|
5
6
|
|
|
6
7
|
from __future__ import annotations
|
|
@@ -33,11 +33,9 @@ class AnimationSystem(System):
|
|
|
33
33
|
|
|
34
34
|
# AnimationPlayer가 있으면 체인 위치 읽기
|
|
35
35
|
try:
|
|
36
|
-
|
|
36
|
+
anim_player: AnimationPlayer = ew.get_component(e, AnimationPlayer)
|
|
37
37
|
except KeyError:
|
|
38
38
|
continue
|
|
39
|
-
|
|
40
|
-
anim_player: AnimationPlayer = player
|
|
41
39
|
joint_positions = anim_player.skeleton.joint_positions()
|
|
42
40
|
if len(joint_positions) < 2:
|
|
43
41
|
continue
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""forge3d App — high-level game-loop abstraction.
|
|
2
2
|
|
|
3
|
-
Provides a decorator-driven API
|
|
3
|
+
Provides a decorator-driven API::
|
|
4
4
|
|
|
5
5
|
import forge3d as f3d
|
|
6
6
|
|
|
@@ -39,10 +39,10 @@ class App:
|
|
|
39
39
|
|
|
40
40
|
Parameters
|
|
41
41
|
----------
|
|
42
|
-
title : Window title
|
|
42
|
+
title : Window title.
|
|
43
43
|
width : Render width in pixels.
|
|
44
44
|
height : Render height in pixels.
|
|
45
|
-
fps :
|
|
45
|
+
fps : Physics step rate and target render rate.
|
|
46
46
|
gravity : World gravity vector (x, y, z) in m/s².
|
|
47
47
|
|
|
48
48
|
Examples
|
|
@@ -66,6 +66,11 @@ class App:
|
|
|
66
66
|
height: int = 720,
|
|
67
67
|
fps: float = 60.0,
|
|
68
68
|
gravity: Any = (0.0, 0.0, -9.81),
|
|
69
|
+
substeps: int = 2,
|
|
70
|
+
shadow_resolution: int = 0,
|
|
71
|
+
sky_color: tuple | None = None,
|
|
72
|
+
show_grid: bool = False,
|
|
73
|
+
max_dt: float = 1 / 25,
|
|
69
74
|
) -> None:
|
|
70
75
|
from forge3d.facade import World
|
|
71
76
|
|
|
@@ -75,6 +80,11 @@ class App:
|
|
|
75
80
|
self._height = height
|
|
76
81
|
self._fps = float(fps)
|
|
77
82
|
self._dt = 1.0 / self._fps
|
|
83
|
+
self._substeps = substeps
|
|
84
|
+
self._shadow_resolution = shadow_resolution
|
|
85
|
+
self._sky_color = sky_color
|
|
86
|
+
self._show_grid = show_grid
|
|
87
|
+
self._max_dt = max_dt
|
|
78
88
|
|
|
79
89
|
self._on_start: Callable | None = None
|
|
80
90
|
self._on_update: Callable | None = None
|
|
@@ -132,52 +142,56 @@ class App:
|
|
|
132
142
|
# ── Run ───────────────────────────────────────────────────────────────────
|
|
133
143
|
|
|
134
144
|
def run(self, max_frames: int | None = None) -> None:
|
|
135
|
-
"""
|
|
145
|
+
"""Open a window and start the game loop.
|
|
136
146
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
147
|
+
Fires ``on_start`` once, then each frame:
|
|
148
|
+
|
|
149
|
+
1. Read input from the OS window (keyboard / mouse).
|
|
150
|
+
2. Call ``on_update(world, dt, inp)``.
|
|
140
151
|
3. ``world.step(dt)``
|
|
141
152
|
4. ``viewer.draw()``
|
|
142
|
-
5.
|
|
143
|
-
|
|
153
|
+
5. Call ``on_render(world, viewer)``.
|
|
154
|
+
|
|
155
|
+
The loop ends when the window is closed, ESC is pressed, or
|
|
156
|
+
``max_frames`` frames have been rendered.
|
|
144
157
|
|
|
145
158
|
Parameters
|
|
146
159
|
----------
|
|
147
|
-
max_frames :
|
|
148
|
-
|
|
149
|
-
the default headless limit is reached.
|
|
160
|
+
max_frames : Stop after this many frames. ``None`` (default) runs
|
|
161
|
+
until the user closes the window.
|
|
150
162
|
"""
|
|
151
|
-
from forge3d.input import _InputBuilder
|
|
152
163
|
from forge3d.viewer import Viewer
|
|
153
164
|
|
|
154
|
-
# Fire on_start
|
|
155
165
|
if self._on_start is not None:
|
|
156
166
|
_call_flexible(self._on_start, self._world)
|
|
157
167
|
|
|
168
|
+
kw: dict[str, Any] = {}
|
|
169
|
+
if self._sky_color is not None:
|
|
170
|
+
kw["sky_color"] = self._sky_color
|
|
158
171
|
viewer = Viewer(
|
|
159
172
|
self._world,
|
|
173
|
+
title=self._title,
|
|
160
174
|
width=self._width,
|
|
161
175
|
height=self._height,
|
|
176
|
+
fps=int(self._fps),
|
|
162
177
|
max_frames=max_frames,
|
|
178
|
+
shadow_resolution=self._shadow_resolution,
|
|
179
|
+
show_grid=self._show_grid,
|
|
180
|
+
**kw,
|
|
163
181
|
)
|
|
164
|
-
inp_builder = _InputBuilder()
|
|
165
182
|
|
|
166
183
|
while viewer.is_open:
|
|
167
|
-
inp =
|
|
184
|
+
inp = viewer.input
|
|
168
185
|
|
|
169
186
|
if self._on_update is not None:
|
|
170
187
|
_call_flexible(self._on_update, self._world, self._dt, inp)
|
|
171
188
|
|
|
172
|
-
self._world.step(self._dt)
|
|
173
|
-
|
|
189
|
+
self._world.step(self._dt, substeps=self._substeps)
|
|
174
190
|
viewer.draw()
|
|
175
191
|
|
|
176
192
|
if self._on_render is not None:
|
|
177
193
|
_call_flexible(self._on_render, self._world, viewer)
|
|
178
194
|
|
|
179
|
-
inp_builder.end_frame()
|
|
180
|
-
|
|
181
195
|
viewer.close()
|
|
182
196
|
|
|
183
197
|
def __repr__(self) -> str:
|
|
@@ -194,6 +208,10 @@ def _call_flexible(func: Callable, *positional: Any) -> Any:
|
|
|
194
208
|
``fn(world, dt, inp)`` interchangeably — missing args are omitted.
|
|
195
209
|
Works with regular functions, lambdas, and bound methods.
|
|
196
210
|
"""
|
|
211
|
+
# Determine arg count from signature — only this part can fail for builtins.
|
|
212
|
+
# The function call itself is NOT wrapped in try/except so that errors raised
|
|
213
|
+
# inside the user's callback (e.g. wrong kwargs, missing imports) propagate
|
|
214
|
+
# immediately instead of being mistaken for an arg-count mismatch.
|
|
197
215
|
try:
|
|
198
216
|
sig = inspect.signature(func)
|
|
199
217
|
n = len(
|
|
@@ -207,12 +225,12 @@ def _call_flexible(func: Callable, *positional: Any) -> Any:
|
|
|
207
225
|
)
|
|
208
226
|
]
|
|
209
227
|
)
|
|
210
|
-
# If a parameter has VAR_POSITIONAL (*args), pass everything
|
|
211
228
|
has_var_positional = any(
|
|
212
229
|
p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values()
|
|
213
230
|
)
|
|
214
|
-
if has_var_positional:
|
|
215
|
-
return func(*positional)
|
|
216
|
-
return func(*positional[:n])
|
|
231
|
+
call_args = positional if has_var_positional else positional[:n]
|
|
217
232
|
except (ValueError, TypeError):
|
|
218
|
-
|
|
233
|
+
# inspect.signature failed (e.g. C extension) — pass all args
|
|
234
|
+
call_args = positional
|
|
235
|
+
|
|
236
|
+
return func(*call_args)
|
|
@@ -69,7 +69,7 @@ def _load_wav_stdlib(path: Path) -> tuple[np.ndarray, int]:
|
|
|
69
69
|
sr = wf.getframerate()
|
|
70
70
|
raw = wf.readframes(n_frames)
|
|
71
71
|
|
|
72
|
-
dtype_map = {1: np.int8, 2: np.int16, 4: np.int32}
|
|
72
|
+
dtype_map: dict[int, type[np.signedinteger]] = {1: np.int8, 2: np.int16, 4: np.int32}
|
|
73
73
|
dtype = dtype_map.get(sampwidth, np.int16)
|
|
74
74
|
data = np.frombuffer(raw, dtype=dtype).astype(np.float32)
|
|
75
75
|
# 정규화
|
|
@@ -167,6 +167,187 @@ class OrbitCamera:
|
|
|
167
167
|
fov_deg=self.fov_deg,
|
|
168
168
|
)
|
|
169
169
|
|
|
170
|
+
@property
|
|
171
|
+
def forward_azimuth(self) -> float:
|
|
172
|
+
"""Azimuth the camera is *looking toward* (azimuth + 180°, mod 360).
|
|
173
|
+
|
|
174
|
+
Useful as a "player forward" heading for camera-relative movement::
|
|
175
|
+
|
|
176
|
+
yaw_deg = cam.forward_azimuth
|
|
177
|
+
fwd = (cos(radians(yaw_deg)), sin(radians(yaw_deg)), 0)
|
|
178
|
+
"""
|
|
179
|
+
return (self.azimuth + 180.0) % 360.0
|
|
180
|
+
|
|
181
|
+
def handle_input(
|
|
182
|
+
self,
|
|
183
|
+
inp: Any,
|
|
184
|
+
dt: float,
|
|
185
|
+
*,
|
|
186
|
+
rotate_button: int = 1,
|
|
187
|
+
mouse_sensitivity: float = 0.25,
|
|
188
|
+
rotate_key_left: str = "q",
|
|
189
|
+
rotate_key_right: str = "e",
|
|
190
|
+
pitch_key_up: str = "r",
|
|
191
|
+
pitch_key_down: str = "f",
|
|
192
|
+
key_deg_per_s: float = 130.0,
|
|
193
|
+
scroll_zoom: bool = True,
|
|
194
|
+
min_distance: float = 1.0,
|
|
195
|
+
max_distance: float = 50.0,
|
|
196
|
+
min_elevation: float = -89.0,
|
|
197
|
+
max_elevation: float = 89.0,
|
|
198
|
+
) -> OrbitCamera:
|
|
199
|
+
"""Process one frame of keyboard / mouse input and update the camera.
|
|
200
|
+
|
|
201
|
+
Designed to replace per-game boilerplate camera-rig update logic.
|
|
202
|
+
Call once per frame before :meth:`to_snapshot`::
|
|
203
|
+
|
|
204
|
+
cam.handle_input(inp, dt).follow(player_pos, dt=dt).occlude(world)
|
|
205
|
+
viewer.set_camera(cam.to_snapshot())
|
|
206
|
+
|
|
207
|
+
Parameters
|
|
208
|
+
----------
|
|
209
|
+
inp : :class:`~forge3d.input.Input` or :class:`~forge3d.input.ScriptedInput`.
|
|
210
|
+
dt : Frame delta-time in seconds.
|
|
211
|
+
rotate_button : Mouse button held to orbit (default 1 = right).
|
|
212
|
+
mouse_sensitivity: Degrees per pixel for mouse drag.
|
|
213
|
+
rotate_key_left : Key to rotate left (default ``'q'``).
|
|
214
|
+
rotate_key_right: Key to rotate right (default ``'e'``).
|
|
215
|
+
pitch_key_up : Key to pitch up (default ``'r'``).
|
|
216
|
+
pitch_key_down : Key to pitch down (default ``'f'``).
|
|
217
|
+
key_deg_per_s : Rotation speed in degrees/s for key-driven orbit.
|
|
218
|
+
scroll_zoom : If True (default), scroll wheel zooms.
|
|
219
|
+
min_distance : Minimum zoom distance in metres.
|
|
220
|
+
max_distance : Maximum zoom distance in metres.
|
|
221
|
+
min_elevation : Elevation clamp lower bound (degrees).
|
|
222
|
+
max_elevation : Elevation clamp upper bound (degrees).
|
|
223
|
+
|
|
224
|
+
Returns ``self`` for method chaining.
|
|
225
|
+
"""
|
|
226
|
+
# Mouse drag rotate
|
|
227
|
+
if inp.mouse_button(rotate_button):
|
|
228
|
+
dx, dy = inp.mouse_delta()
|
|
229
|
+
self.rotate(d_azimuth=-dx * mouse_sensitivity, d_elevation=dy * mouse_sensitivity)
|
|
230
|
+
|
|
231
|
+
# Key-driven rotate
|
|
232
|
+
k = key_deg_per_s * dt
|
|
233
|
+
if inp.key_held(rotate_key_left):
|
|
234
|
+
self.rotate(d_azimuth=k)
|
|
235
|
+
if inp.key_held(rotate_key_right):
|
|
236
|
+
self.rotate(d_azimuth=-k)
|
|
237
|
+
if inp.key_held(pitch_key_up):
|
|
238
|
+
self.rotate(d_elevation=k * 0.6)
|
|
239
|
+
if inp.key_held(pitch_key_down):
|
|
240
|
+
self.rotate(d_elevation=-k * 0.6)
|
|
241
|
+
|
|
242
|
+
# Clamp elevation
|
|
243
|
+
self.elevation = float(np.clip(self.elevation, min_elevation, max_elevation))
|
|
244
|
+
|
|
245
|
+
# Scroll zoom
|
|
246
|
+
if scroll_zoom:
|
|
247
|
+
sd = inp.scroll_delta()
|
|
248
|
+
if sd:
|
|
249
|
+
self.distance = float(
|
|
250
|
+
np.clip(
|
|
251
|
+
self.distance * (1.0 - sd * 0.1),
|
|
252
|
+
min_distance,
|
|
253
|
+
max_distance,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return self
|
|
258
|
+
|
|
259
|
+
def follow(
|
|
260
|
+
self,
|
|
261
|
+
target: Any,
|
|
262
|
+
head_height: float = 1.2,
|
|
263
|
+
smooth_hz: float = 10.0,
|
|
264
|
+
dt: float = 0.0,
|
|
265
|
+
) -> OrbitCamera:
|
|
266
|
+
"""Smoothly move the camera target toward *target* + *head_height*.
|
|
267
|
+
|
|
268
|
+
Replaces the common per-frame ``smooth_target`` lerp pattern that
|
|
269
|
+
third-person games need. Call once per frame before :meth:`to_snapshot`.
|
|
270
|
+
|
|
271
|
+
Parameters
|
|
272
|
+
----------
|
|
273
|
+
target : World position to track — usually ``player.position``.
|
|
274
|
+
head_height : Vertical offset added to *target* (default 1.2 m).
|
|
275
|
+
smooth_hz : Smoothing frequency in Hz — higher = snappier.
|
|
276
|
+
dt : Frame delta-time; pass 0 for instant snap.
|
|
277
|
+
|
|
278
|
+
Returns ``self`` for method chaining::
|
|
279
|
+
|
|
280
|
+
cam.follow(player.position, dt=dt).occlude(world)
|
|
281
|
+
viewer.set_camera(cam.to_snapshot())
|
|
282
|
+
"""
|
|
283
|
+
import math
|
|
284
|
+
|
|
285
|
+
goal = np.asarray(target, dtype=float) + np.array([0.0, 0.0, head_height])
|
|
286
|
+
alpha = 1.0 - math.exp(-smooth_hz * dt) if dt > 0.0 and smooth_hz > 0.0 else 1.0
|
|
287
|
+
self.target = self.target + (goal - self.target) * alpha
|
|
288
|
+
return self
|
|
289
|
+
|
|
290
|
+
def occlude(
|
|
291
|
+
self,
|
|
292
|
+
world: Any,
|
|
293
|
+
terrain_sampler: Any = None,
|
|
294
|
+
min_distance: float = 1.6,
|
|
295
|
+
layer_mask: int = 0x0001,
|
|
296
|
+
terrain_steps: int = 16,
|
|
297
|
+
terrain_clearance: float = 0.35,
|
|
298
|
+
) -> OrbitCamera:
|
|
299
|
+
"""Pull the camera distance inward to avoid geometry occlusion.
|
|
300
|
+
|
|
301
|
+
Raycasts against physics bodies and (optionally) marches the eye ray
|
|
302
|
+
against a terrain height sampler. Sets ``self.distance`` to the safe
|
|
303
|
+
distance and returns ``self`` for chaining.
|
|
304
|
+
|
|
305
|
+
Parameters
|
|
306
|
+
----------
|
|
307
|
+
world : :class:`~forge3d.facade.World` to raycast against.
|
|
308
|
+
terrain_sampler : Callable ``(x, y) → z`` for terrain height (e.g.
|
|
309
|
+
``heightfield.height_at`` or your own function).
|
|
310
|
+
Pass ``None`` to skip terrain marching — when the
|
|
311
|
+
world has heightfields and raycasts already hit them
|
|
312
|
+
this is usually not needed.
|
|
313
|
+
min_distance : Minimum eye-to-target distance (m).
|
|
314
|
+
layer_mask : Collision layers included in the occlusion raycast.
|
|
315
|
+
terrain_steps : Number of steps for the terrain march.
|
|
316
|
+
terrain_clearance: Camera is pulled in when closer than this to terrain.
|
|
317
|
+
|
|
318
|
+
Returns ``self``::
|
|
319
|
+
|
|
320
|
+
cam.follow(player.position, dt=dt).occlude(world)
|
|
321
|
+
viewer.set_camera(cam.to_snapshot())
|
|
322
|
+
"""
|
|
323
|
+
eye_dir = self.position - self.target
|
|
324
|
+
eye_len = float(np.linalg.norm(eye_dir))
|
|
325
|
+
if eye_len < 1e-9:
|
|
326
|
+
return self
|
|
327
|
+
|
|
328
|
+
u = eye_dir / eye_len
|
|
329
|
+
dist = self.distance
|
|
330
|
+
|
|
331
|
+
# Raycast against bodies
|
|
332
|
+
hits = world.raycast_all(self.target, u, max_dist=dist, layer_mask=layer_mask)
|
|
333
|
+
for hit in hits:
|
|
334
|
+
if hit.body is not None:
|
|
335
|
+
dist = min(dist, max(min_distance, float(hit.distance) - 0.4))
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
# Optional terrain height-function march
|
|
339
|
+
if terrain_sampler is not None:
|
|
340
|
+
for k in range(1, terrain_steps + 1):
|
|
341
|
+
t = dist * k / terrain_steps
|
|
342
|
+
p = self.target + u * t
|
|
343
|
+
h_terrain = float(terrain_sampler(float(p[0]), float(p[1])))
|
|
344
|
+
if float(p[2]) < h_terrain + terrain_clearance:
|
|
345
|
+
dist = max(min_distance, t - 0.5)
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
self.distance = max(min_distance, dist)
|
|
349
|
+
return self
|
|
350
|
+
|
|
170
351
|
def __repr__(self) -> str:
|
|
171
352
|
return (
|
|
172
353
|
f"OrbitCamera(target={self.target.round(2).tolist()}, "
|
|
@@ -50,6 +50,7 @@ class CharacterController:
|
|
|
50
50
|
height: float,
|
|
51
51
|
radius: float,
|
|
52
52
|
ground_layer_mask: int = 0xFFFF,
|
|
53
|
+
ground_check_hz: float = 60.0,
|
|
53
54
|
) -> None:
|
|
54
55
|
self._world = world
|
|
55
56
|
self.body = body
|
|
@@ -57,7 +58,21 @@ class CharacterController:
|
|
|
57
58
|
self._radius = float(radius)
|
|
58
59
|
self._ground_layer_mask = ground_layer_mask
|
|
59
60
|
self._grounded = False
|
|
60
|
-
self._vertical_vel = 0.0
|
|
61
|
+
self._vertical_vel = 0.0
|
|
62
|
+
|
|
63
|
+
# Throttle ground-detection raycast. At 10 Hz (bots) this cuts the
|
|
64
|
+
# per-move cost from ~1 ms to ~0.017 ms with no gameplay difference.
|
|
65
|
+
self._ground_check_interval = 1.0 / max(1.0, float(ground_check_hz))
|
|
66
|
+
self._ground_check_timer = 0.0
|
|
67
|
+
|
|
68
|
+
# Jump cooldown prevents infinite jumping when the capsule is still
|
|
69
|
+
# close to the ground in the frame right after a jump.
|
|
70
|
+
self._jump_cooldown: float = 0.0
|
|
71
|
+
|
|
72
|
+
# Platform riding: track which body we're standing on and its last position
|
|
73
|
+
# so we can apply its displacement to ourselves each frame.
|
|
74
|
+
self._ground_body_id: int | None = None # None → terrain / nothing
|
|
75
|
+
self._ground_body_last_pos: Any | None = None # np.ndarray
|
|
61
76
|
|
|
62
77
|
# ── State queries ─────────────────────────────────────────────────────────
|
|
63
78
|
|
|
@@ -91,6 +106,9 @@ class CharacterController:
|
|
|
91
106
|
) -> None:
|
|
92
107
|
"""Apply horizontal movement toward *direction* at *speed* m/s.
|
|
93
108
|
|
|
109
|
+
Also carries the character on moving platforms automatically — no
|
|
110
|
+
manual delta-passing needed.
|
|
111
|
+
|
|
94
112
|
Parameters
|
|
95
113
|
----------
|
|
96
114
|
direction : (3,) movement vector (only x/y components used unless z != 0).
|
|
@@ -98,6 +116,8 @@ class CharacterController:
|
|
|
98
116
|
speed : Maximum movement speed in m/s.
|
|
99
117
|
dt : Frame delta-time in seconds.
|
|
100
118
|
"""
|
|
119
|
+
self._update_ground(dt)
|
|
120
|
+
|
|
101
121
|
d = np.asarray(direction, dtype=float)
|
|
102
122
|
norm = np.linalg.norm(d[:2])
|
|
103
123
|
if norm > 1e-9:
|
|
@@ -108,7 +128,6 @@ class CharacterController:
|
|
|
108
128
|
cur = self.body.velocity.copy()
|
|
109
129
|
cur[:2] = target_vel[:2]
|
|
110
130
|
self.body.set_velocity(cur)
|
|
111
|
-
self._update_ground(dt)
|
|
112
131
|
|
|
113
132
|
def jump(self, impulse: float = 5.0) -> None:
|
|
114
133
|
"""Apply an upward velocity impulse if grounded.
|
|
@@ -117,11 +136,61 @@ class CharacterController:
|
|
|
117
136
|
----------
|
|
118
137
|
impulse : Upward speed added in m/s (think: initial jump velocity).
|
|
119
138
|
"""
|
|
120
|
-
if self._grounded:
|
|
139
|
+
if self._grounded and self._jump_cooldown <= 0.0:
|
|
121
140
|
vel = self.body.velocity.copy()
|
|
122
141
|
vel[2] = float(impulse)
|
|
123
142
|
self.body.set_velocity(vel)
|
|
124
143
|
self._grounded = False
|
|
144
|
+
self._jump_cooldown = 0.40 # 400 ms before the next jump is allowed
|
|
145
|
+
|
|
146
|
+
def move_camera_relative(
|
|
147
|
+
self,
|
|
148
|
+
inp: Any,
|
|
149
|
+
cam: Any,
|
|
150
|
+
speed: float,
|
|
151
|
+
dt: float,
|
|
152
|
+
*,
|
|
153
|
+
forward_key: str = "w",
|
|
154
|
+
back_key: str = "s",
|
|
155
|
+
left_key: str = "a",
|
|
156
|
+
right_key: str = "d",
|
|
157
|
+
) -> np.ndarray:
|
|
158
|
+
"""Move relative to the camera's facing direction.
|
|
159
|
+
|
|
160
|
+
Eliminates the boilerplate yaw-angle → forward/right vector pattern that
|
|
161
|
+
every third-person game repeats. Returns the world-space move vector
|
|
162
|
+
(useful for updating a ``facing`` direction)::
|
|
163
|
+
|
|
164
|
+
move = cc.move_camera_relative(inp, cam, speed=7.2, dt=dt)
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
inp : :class:`~forge3d.input.Input` or
|
|
169
|
+
:class:`~forge3d.input.ScriptedInput`.
|
|
170
|
+
cam : :class:`~forge3d.camera.OrbitCamera` — only its
|
|
171
|
+
``forward_azimuth`` property is used.
|
|
172
|
+
speed : Horizontal movement speed in m/s.
|
|
173
|
+
dt : Frame delta-time in seconds.
|
|
174
|
+
forward_key : Key for forward movement (default ``'w'``).
|
|
175
|
+
back_key : Key for backward movement (default ``'s'``).
|
|
176
|
+
left_key : Key for left movement (default ``'a'``).
|
|
177
|
+
right_key : Key for right movement (default ``'d'``).
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
np.ndarray
|
|
182
|
+
The (3,) world-space move vector (zero if no input).
|
|
183
|
+
"""
|
|
184
|
+
import math
|
|
185
|
+
|
|
186
|
+
yaw = math.radians(float(cam.forward_azimuth))
|
|
187
|
+
fwd = np.array([math.cos(yaw), math.sin(yaw), 0.0])
|
|
188
|
+
right = np.array([math.sin(yaw), -math.cos(yaw), 0.0])
|
|
189
|
+
mx = inp.axis(left_key, right_key)
|
|
190
|
+
my = inp.axis(back_key, forward_key)
|
|
191
|
+
move = fwd * my + right * mx
|
|
192
|
+
self.move(direction=tuple(move), speed=speed, dt=dt)
|
|
193
|
+
return move
|
|
125
194
|
|
|
126
195
|
def glide(self, target_fall_speed: float = -1.5, dt: float = 1 / 60) -> None:
|
|
127
196
|
"""Reduce falling speed to *target_fall_speed* for a glide effect.
|
|
@@ -141,16 +210,67 @@ class CharacterController:
|
|
|
141
210
|
# ── Internal ──────────────────────────────────────────────────────────────
|
|
142
211
|
|
|
143
212
|
def _update_ground(self, dt: float) -> None:
|
|
144
|
-
"""Update is_grounded via downward raycast.
|
|
213
|
+
"""Update is_grounded via downward raycast (throttled + jump-cooldown aware).
|
|
214
|
+
|
|
215
|
+
Also applies platform displacement: if the body we were standing on
|
|
216
|
+
has moved since the last check, we teleport ourselves by the same delta.
|
|
217
|
+
"""
|
|
218
|
+
# Decrement jump cooldown unconditionally every call
|
|
219
|
+
if self._jump_cooldown > 0.0:
|
|
220
|
+
self._jump_cooldown = max(0.0, self._jump_cooldown - dt)
|
|
221
|
+
self._grounded = False
|
|
222
|
+
self._ground_body_id = None
|
|
223
|
+
self._ground_body_last_pos = None
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
self._ground_check_timer += dt
|
|
227
|
+
if self._ground_check_timer < self._ground_check_interval:
|
|
228
|
+
# Apply stored platform delta even between checks
|
|
229
|
+
self._apply_platform_delta()
|
|
230
|
+
return
|
|
231
|
+
self._ground_check_timer = 0.0
|
|
232
|
+
|
|
233
|
+
# Apply platform delta from last frame before updating ground state
|
|
234
|
+
self._apply_platform_delta()
|
|
235
|
+
|
|
145
236
|
pos = self.body.position
|
|
146
237
|
ray_len = self._height / 2.0 + self._radius + self._GROUND_RAY_EXTRA
|
|
147
238
|
hit = self._world.raycast(
|
|
148
239
|
origin=pos,
|
|
149
240
|
direction=(0.0, 0.0, -1.0),
|
|
150
241
|
max_dist=ray_len,
|
|
242
|
+
layer_mask=self._ground_layer_mask,
|
|
151
243
|
)
|
|
152
244
|
self._grounded = hit is not None
|
|
153
245
|
|
|
246
|
+
# Track which body (or terrain) we're on for platform riding
|
|
247
|
+
if hit is not None and hit.body is not None:
|
|
248
|
+
self._ground_body_id = hit.body._id
|
|
249
|
+
self._ground_body_last_pos = hit.body.position.copy()
|
|
250
|
+
else:
|
|
251
|
+
# Terrain hit (body is None) or no hit
|
|
252
|
+
self._ground_body_id = None
|
|
253
|
+
self._ground_body_last_pos = None
|
|
254
|
+
|
|
255
|
+
def _apply_platform_delta(self) -> None:
|
|
256
|
+
"""If standing on a moving body, carry ourselves with it."""
|
|
257
|
+
if self._ground_body_id is None or self._ground_body_last_pos is None:
|
|
258
|
+
return
|
|
259
|
+
body = self._world._bodies.get(self._ground_body_id)
|
|
260
|
+
if body is None:
|
|
261
|
+
self._ground_body_id = None
|
|
262
|
+
self._ground_body_last_pos = None
|
|
263
|
+
return
|
|
264
|
+
try:
|
|
265
|
+
current_pos = body.position
|
|
266
|
+
delta = current_pos - self._ground_body_last_pos
|
|
267
|
+
if np.linalg.norm(delta) > 1e-6:
|
|
268
|
+
self.body.set_position(self.body.position + delta)
|
|
269
|
+
self._ground_body_last_pos = current_pos.copy()
|
|
270
|
+
except Exception:
|
|
271
|
+
self._ground_body_id = None
|
|
272
|
+
self._ground_body_last_pos = None
|
|
273
|
+
|
|
154
274
|
def __repr__(self) -> str:
|
|
155
275
|
pos = self.position
|
|
156
276
|
g = "grounded" if self._grounded else "airborne"
|