pyforge3d 2.1.1__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.1 → pyforge3d-2.2.0}/PKG-INFO +1 -1
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/pyproject.toml +4 -2
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/__init__.py +2 -1
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/app.py +43 -25
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/camera.py +181 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/character.py +103 -4
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/detection.py +103 -7
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/gjk.py +2 -2
- {pyforge3d-2.1.1 → 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.1 → pyforge3d-2.2.0}/src/forge3d/ecs/system.py +14 -2
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/facade.py +531 -30
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/input.py +123 -3
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/meshes.py +190 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/window_renderer.py +38 -21
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/sim/world.py +4 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/viewer.py +60 -9
- pyforge3d-2.1.1/src/forge3d/ecs/component.py +0 -56
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/Cargo.lock +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/Cargo.toml +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/LICENSE +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/README.md +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/_core.pyi +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/clip.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/ik_fabrik.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/player.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/skeleton.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/animation/system.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/clip.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/null_driver.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/openal_driver.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/source.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/audio/system.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/backend.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/epa.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/layers.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/collision/raycast.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/constraints/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/constraints/base.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/constraints/joint_type.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/constraints/joints.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/contact/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/contact/solver.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/aba.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/crba.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/model.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/dynamics/rnea.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/bridge.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/entity.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/serialization.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ecs/transform.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/editor/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/editor/editor_app.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/editor/gizmo.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/editor/layout.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/errors.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/events.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/io/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/io/mesh_data.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/io/obj_loader.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/io/world_snapshot.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/logging.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/inertia.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/quaternion.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/se3.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/math/spatial.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/model/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/model/kinematics.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/model/robot_config.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/model/urdf_loader.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/particle/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/particle/emitter.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/particle/presets.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/particle/system.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/profiler.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/py.typed +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/recorder.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/base.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/deferred/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/deferred/renderer.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/hq/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/hq/raytracer.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/hq/renderer.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/hq/scene.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/passes/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/passes/base.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/context.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/renderer.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/realtime/shaders.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/bloom_down.frag +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/bloom_up.frag +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/fullscreen.vert +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/gbuffer.frag +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/gbuffer.vert +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/lighting.frag +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/pbr.wgsl +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/shadow.frag +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/shadow.vert +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/ssao.frag +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/ssao_blur.frag +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/tonemap.frag +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/shaders/update_particles.comp +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/snapshot.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/pipeline.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/render/wgpu_backend/renderer.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/robot/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/robot/presets.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/robot/robot.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/scene/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/scene/manager.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/scene/node.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/scene/prefab.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/sim/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/sim/domain_rand.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/sim/jax_batch.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/__init__.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/backend.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/canvas.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/panels.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d/ui/system.py +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/Cargo.toml +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/benches/physics_bench.rs +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/src/bvh.rs +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/src/gjk_epa.rs +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/src/lib.rs +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/src/math_simd.rs +0 -0
- {pyforge3d-2.1.1 → pyforge3d-2.2.0}/src/forge3d_core/src/pgs_solver.rs +0 -0
|
@@ -6,7 +6,7 @@ build-backend = "maturin"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "pyforge3d"
|
|
9
|
-
version = "2.
|
|
9
|
+
version = "2.2.0"
|
|
10
10
|
description = "Pure-Python 3D physics game engine — own dynamics, own rules, no compromises."
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
license = { file = "LICENSE" }
|
|
@@ -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
|
|
|
@@ -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)
|
|
@@ -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,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)
|
|
@@ -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()}, "
|
|
@@ -63,12 +63,17 @@ class CharacterController:
|
|
|
63
63
|
# Throttle ground-detection raycast. At 10 Hz (bots) this cuts the
|
|
64
64
|
# per-move cost from ~1 ms to ~0.017 ms with no gameplay difference.
|
|
65
65
|
self._ground_check_interval = 1.0 / max(1.0, float(ground_check_hz))
|
|
66
|
-
self._ground_check_timer
|
|
66
|
+
self._ground_check_timer = 0.0
|
|
67
67
|
|
|
68
68
|
# Jump cooldown prevents infinite jumping when the capsule is still
|
|
69
69
|
# close to the ground in the frame right after a jump.
|
|
70
70
|
self._jump_cooldown: float = 0.0
|
|
71
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
|
|
76
|
+
|
|
72
77
|
# ── State queries ─────────────────────────────────────────────────────────
|
|
73
78
|
|
|
74
79
|
@property
|
|
@@ -101,6 +106,9 @@ class CharacterController:
|
|
|
101
106
|
) -> None:
|
|
102
107
|
"""Apply horizontal movement toward *direction* at *speed* m/s.
|
|
103
108
|
|
|
109
|
+
Also carries the character on moving platforms automatically — no
|
|
110
|
+
manual delta-passing needed.
|
|
111
|
+
|
|
104
112
|
Parameters
|
|
105
113
|
----------
|
|
106
114
|
direction : (3,) movement vector (only x/y components used unless z != 0).
|
|
@@ -108,6 +116,8 @@ class CharacterController:
|
|
|
108
116
|
speed : Maximum movement speed in m/s.
|
|
109
117
|
dt : Frame delta-time in seconds.
|
|
110
118
|
"""
|
|
119
|
+
self._update_ground(dt)
|
|
120
|
+
|
|
111
121
|
d = np.asarray(direction, dtype=float)
|
|
112
122
|
norm = np.linalg.norm(d[:2])
|
|
113
123
|
if norm > 1e-9:
|
|
@@ -118,7 +128,6 @@ class CharacterController:
|
|
|
118
128
|
cur = self.body.velocity.copy()
|
|
119
129
|
cur[:2] = target_vel[:2]
|
|
120
130
|
self.body.set_velocity(cur)
|
|
121
|
-
self._update_ground(dt)
|
|
122
131
|
|
|
123
132
|
def jump(self, impulse: float = 5.0) -> None:
|
|
124
133
|
"""Apply an upward velocity impulse if grounded.
|
|
@@ -134,6 +143,55 @@ class CharacterController:
|
|
|
134
143
|
self._grounded = False
|
|
135
144
|
self._jump_cooldown = 0.40 # 400 ms before the next jump is allowed
|
|
136
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
|
|
194
|
+
|
|
137
195
|
def glide(self, target_fall_speed: float = -1.5, dt: float = 1 / 60) -> None:
|
|
138
196
|
"""Reduce falling speed to *target_fall_speed* for a glide effect.
|
|
139
197
|
|
|
@@ -152,26 +210,67 @@ class CharacterController:
|
|
|
152
210
|
# ── Internal ──────────────────────────────────────────────────────────────
|
|
153
211
|
|
|
154
212
|
def _update_ground(self, dt: float) -> None:
|
|
155
|
-
"""Update is_grounded via downward raycast (throttled + jump-cooldown aware).
|
|
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
|
+
"""
|
|
156
218
|
# Decrement jump cooldown unconditionally every call
|
|
157
219
|
if self._jump_cooldown > 0.0:
|
|
158
220
|
self._jump_cooldown = max(0.0, self._jump_cooldown - dt)
|
|
159
|
-
self._grounded = False
|
|
221
|
+
self._grounded = False
|
|
222
|
+
self._ground_body_id = None
|
|
223
|
+
self._ground_body_last_pos = None
|
|
160
224
|
return
|
|
161
225
|
|
|
162
226
|
self._ground_check_timer += dt
|
|
163
227
|
if self._ground_check_timer < self._ground_check_interval:
|
|
228
|
+
# Apply stored platform delta even between checks
|
|
229
|
+
self._apply_platform_delta()
|
|
164
230
|
return
|
|
165
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
|
+
|
|
166
236
|
pos = self.body.position
|
|
167
237
|
ray_len = self._height / 2.0 + self._radius + self._GROUND_RAY_EXTRA
|
|
168
238
|
hit = self._world.raycast(
|
|
169
239
|
origin=pos,
|
|
170
240
|
direction=(0.0, 0.0, -1.0),
|
|
171
241
|
max_dist=ray_len,
|
|
242
|
+
layer_mask=self._ground_layer_mask,
|
|
172
243
|
)
|
|
173
244
|
self._grounded = hit is not None
|
|
174
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
|
+
|
|
175
274
|
def __repr__(self) -> str:
|
|
176
275
|
pos = self.position
|
|
177
276
|
g = "grounded" if self._grounded else "airborne"
|