Skip to content

Commit e015af6

Browse files
committed
feat: physics2d component setup
1 parent 740f2e8 commit e015af6

27 files changed

Lines changed: 2751 additions & 0 deletions

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
buf.build/go/protovalidate v1.0.0
88
connectrpc.com/connect v1.19.1
99
connectrpc.com/validate v0.6.0
10+
github.com/ByteArena/box2d v1.0.2
1011
github.com/aws/aws-sdk-go-v2 v1.41.3
1112
github.com/aws/aws-sdk-go-v2/config v1.32.11
1213
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
88
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
99
connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4=
1010
connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs=
11+
github.com/ByteArena/box2d v1.0.2 h1:f7f9KEQWhCs1n516DMLzi5w6u0MeeE78Mes4fWMcj9k=
12+
github.com/ByteArena/box2d v1.0.2/go.mod h1:LzEuxY9iCz+tskfWCY3o0ywYBRafDDugdSj+/YGI6sE=
1113
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE=
1214
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
1315
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package component
2+
3+
// PhysicsSingletonTag marks the single entity that holds physics plugin state (ActiveContacts).
4+
type PhysicsSingletonTag struct{}
5+
6+
func (PhysicsSingletonTag) Name() string { return "physics_singleton_tag" }
7+
8+
// ContactPairEntry is one active contact pair tracked by the physics engine. Entries are
9+
// normalized: EntityA < EntityB (or if equal, ShapeIndexA <= ShapeIndexB).
10+
type ContactPairEntry struct {
11+
EntityA uint64 `json:"a"`
12+
ShapeIndexA int `json:"sa"`
13+
EntityB uint64 `json:"b"`
14+
ShapeIndexB int `json:"sb"`
15+
IsSensor bool `json:"sensor"`
16+
// Fixture filters for normalized EntityA/B (recovery End / trigger vs contact routing).
17+
// Omitempty keeps older snapshots valid.
18+
FilterACategoryBits uint16 `json:"fa_cat,omitempty"`
19+
FilterAMaskBits uint16 `json:"fa_mask,omitempty"`
20+
FilterAGroupIndex int16 `json:"fa_grp,omitempty"`
21+
FilterBCategoryBits uint16 `json:"fb_cat,omitempty"`
22+
FilterBMaskBits uint16 `json:"fb_mask,omitempty"`
23+
FilterBGroupIndex int16 `json:"fb_grp,omitempty"`
24+
}
25+
26+
// ActiveContacts persists which contact pairs have had Begin emitted (and not yet End).
27+
// After a rebuild, the physics step diffs this against Box2D's live contact list to emit
28+
// correct Begin/End events without duplicates or missed ends.
29+
type ActiveContacts struct {
30+
Pairs []ContactPairEntry `json:"pairs"`
31+
}
32+
33+
func (ActiveContacts) Name() string { return "active_contacts" }
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package component
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
)
7+
8+
// ShapeType selects which geometry fields in ColliderShape are valid.
9+
//
10+
// Callers must set ShapeType consistently with the populated geometry fields.
11+
// Box2D validates geometry internally (convexity, vertex count, welding) and will panic
12+
// on invalid input. This is caught during development.
13+
type ShapeType uint8
14+
15+
const (
16+
// ShapeTypeCircle uses Radius; fixture is a circle in the shape's local frame.
17+
ShapeTypeCircle ShapeType = iota + 1
18+
// ShapeTypeBox uses HalfExtents (half-width, half-height) for an axis-aligned box in the
19+
// shape's local frame before applying LocalOffset/LocalRotation.
20+
ShapeTypeBox
21+
// ShapeTypeConvexPolygon uses Vertices as a convex polygon in the shape's local frame.
22+
ShapeTypeConvexPolygon
23+
// ShapeTypeStaticChain uses ChainPoints for open chain segments (static/terrain-style
24+
// geometry only; not for moving dynamic shapes).
25+
ShapeTypeStaticChain
26+
)
27+
28+
// ColliderShape is one child shape inside a compound Collider2D.
29+
//
30+
// Each entry has its own local transform, sensor flag, material, and collision filter (category, mask, group).
31+
// Geometry fields are a tagged-union style: only the fields that match ShapeType are used.
32+
// - ShapeTypeCircle → Radius
33+
// - ShapeTypeBox → HalfExtents (half-width on X, half-height on Y, axis-aligned before LocalOffset/LocalRotation)
34+
// - ShapeTypeConvexPolygon → Vertices (convex polygon, respect backend limits)
35+
// - ShapeTypeStaticChain → ChainPoints (open polyline in local space)
36+
type ColliderShape struct {
37+
ShapeType ShapeType `json:"shape_type"`
38+
LocalOffset Vec2 `json:"local_offset"`
39+
LocalRotation float64 `json:"local_rotation"`
40+
IsSensor bool `json:"is_sensor"`
41+
42+
// Geometry (use fields matching ShapeType).
43+
Radius float64 `json:"radius,omitempty"`
44+
HalfExtents Vec2 `json:"half_extents,omitempty"`
45+
Vertices []Vec2 `json:"vertices,omitempty"`
46+
ChainPoints []Vec2 `json:"chain_points,omitempty"`
47+
48+
// Material and per-shape collision filtering (fixture-level in Box2D).
49+
Friction float64 `json:"friction"`
50+
Restitution float64 `json:"restitution"`
51+
Density float64 `json:"density"`
52+
CategoryBits uint16 `json:"category_bits"`
53+
MaskBits uint16 `json:"mask_bits"`
54+
GroupIndex int16 `json:"group_index,omitempty"`
55+
}
56+
57+
// Collider2D is the authoritative collider description for an entity.
58+
//
59+
// Cardinal allows one instance per component type per entity, so compound colliders are
60+
// modeled as multiple ColliderShape entries in Shapes.
61+
//
62+
// Shape identity (v1): index i in Shapes identifies fixture slot i. Reordering, inserting,
63+
// or removing entries is a structural change and requires fixture/body recreation during
64+
// reconciliation.
65+
type Collider2D struct {
66+
Shapes []ColliderShape `json:"shapes"`
67+
}
68+
69+
// Name returns the ECS component name.
70+
func (Collider2D) Name() string { return "collider_2d" }
71+
72+
// Validate guards against NaN/Inf in float fields. Geometry correctness (convexity, vertex
73+
// count, winding) is enforced by Box2D at fixture creation time — duplicating those checks
74+
// here adds maintenance cost without value.
75+
func (c Collider2D) Validate() error {
76+
if len(c.Shapes) == 0 {
77+
return errors.New("collider_2d.shapes: at least one ColliderShape is required")
78+
}
79+
for i := range c.Shapes {
80+
if err := c.Shapes[i].Validate(); err != nil {
81+
return fmt.Errorf("collider_2d.shapes[%d]: %w", i, err)
82+
}
83+
}
84+
return nil
85+
}
86+
87+
// Validate checks for NaN/Inf in all float fields and a valid ShapeType tag.
88+
func (s ColliderShape) Validate() error {
89+
if err := validateVec2("local_offset", s.LocalOffset); err != nil {
90+
return err
91+
}
92+
if !isFinite(s.LocalRotation) {
93+
return errors.New("local_rotation: must be finite")
94+
}
95+
if !isFinite(s.Friction) {
96+
return fmt.Errorf("friction: must be finite, got %v", s.Friction)
97+
}
98+
if !isFinite(s.Restitution) {
99+
return fmt.Errorf("restitution: must be finite, got %v", s.Restitution)
100+
}
101+
if !isFinite(s.Density) {
102+
return fmt.Errorf("density: must be finite, got %v", s.Density)
103+
}
104+
if !isFinite(s.Radius) {
105+
return fmt.Errorf("radius: must be finite, got %v", s.Radius)
106+
}
107+
if err := validateVec2("half_extents", s.HalfExtents); err != nil {
108+
return err
109+
}
110+
for i, v := range s.Vertices {
111+
if err := validateVec2(fmt.Sprintf("vertices[%d]", i), v); err != nil {
112+
return err
113+
}
114+
}
115+
for i, v := range s.ChainPoints {
116+
if err := validateVec2(fmt.Sprintf("chain_points[%d]", i), v); err != nil {
117+
return err
118+
}
119+
}
120+
121+
switch s.ShapeType {
122+
case ShapeTypeCircle, ShapeTypeBox, ShapeTypeConvexPolygon, ShapeTypeStaticChain:
123+
default:
124+
return fmt.Errorf("shape_type: unknown value %d", s.ShapeType)
125+
}
126+
return nil
127+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package component
2+
3+
import "fmt"
4+
5+
// BodyType selects how the rigid body participates in the simulation.
6+
type BodyType uint8
7+
8+
const (
9+
// BodyTypeStatic is immovable world geometry; zero velocity; does not respond to forces.
10+
BodyTypeStatic BodyType = iota + 1
11+
// BodyTypeDynamic is fully simulated: forces, collisions, and integration apply.
12+
BodyTypeDynamic
13+
// BodyTypeKinematic is moved by setting velocity/transform from gameplay; does not respond
14+
// to forces like a dynamic body but can affect dynamic bodies on contact.
15+
BodyTypeKinematic
16+
)
17+
18+
// Rigidbody2D holds simulation parameters for a rigid body.
19+
//
20+
// BodyType selects static vs dynamic vs kinematic behavior. LinearDamping and AngularDamping
21+
// are simulation damping coefficients. GravityScale multiplies the world's gravity vector
22+
// for this body; world gravity itself is runtime configuration, not a component field.
23+
type Rigidbody2D struct {
24+
BodyType BodyType `json:"body_type"`
25+
LinearDamping float64 `json:"linear_damping"`
26+
AngularDamping float64 `json:"angular_damping"`
27+
GravityScale float64 `json:"gravity_scale"`
28+
}
29+
30+
// Name returns the ECS component name.
31+
func (Rigidbody2D) Name() string { return "rigidbody_2d" }
32+
33+
// Validate guards against NaN/Inf in float fields and an invalid body type tag.
34+
func (r Rigidbody2D) Validate() error {
35+
switch r.BodyType {
36+
case BodyTypeStatic, BodyTypeDynamic, BodyTypeKinematic:
37+
default:
38+
return fmt.Errorf("rigidbody_2d.body_type: invalid value %d", r.BodyType)
39+
}
40+
if !isFinite(r.LinearDamping) {
41+
return fmt.Errorf("rigidbody_2d.linear_damping: must be finite, got %v", r.LinearDamping)
42+
}
43+
if !isFinite(r.AngularDamping) {
44+
return fmt.Errorf("rigidbody_2d.angular_damping: must be finite, got %v", r.AngularDamping)
45+
}
46+
if !isFinite(r.GravityScale) {
47+
return fmt.Errorf("rigidbody_2d.gravity_scale: must be finite, got %v", r.GravityScale)
48+
}
49+
return nil
50+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package component
2+
3+
import "errors"
4+
5+
// Vec2 is a 2D vector in world space, used by physics-facing components and APIs.
6+
type Vec2 struct {
7+
X float64 `json:"x"`
8+
Y float64 `json:"y"`
9+
}
10+
11+
// Transform2D is the authoritative world-space pose of an entity.
12+
//
13+
// Position is the world-space origin of the body transform. Rotation is the world-space
14+
// orientation in radians (see package doc for handedness and sign).
15+
type Transform2D struct {
16+
Position Vec2 `json:"position"`
17+
Rotation float64 `json:"rotation"`
18+
}
19+
20+
// Name returns the ECS component name.
21+
func (Transform2D) Name() string { return "transform_2d" }
22+
23+
// Validate checks that the transform uses finite scalars only.
24+
func (t Transform2D) Validate() error {
25+
if err := validateVec2("transform_2d.position", t.Position); err != nil {
26+
return err
27+
}
28+
if !isFinite(t.Rotation) {
29+
return errors.New("transform_2d.rotation: must be finite")
30+
}
31+
return nil
32+
}
33+
34+
// Velocity2D is the authoritative linear and angular motion state.
35+
//
36+
// Linear is world-space linear velocity. Angular is angular velocity in radians per second
37+
// about the axis perpendicular to the XY plane (required for rotating rigid bodies and
38+
// oriented colliders).
39+
type Velocity2D struct {
40+
Linear Vec2 `json:"linear"`
41+
Angular float64 `json:"angular"`
42+
}
43+
44+
// Name returns the ECS component name.
45+
func (Velocity2D) Name() string { return "velocity_2d" }
46+
47+
// Validate checks that linear and angular velocity are finite.
48+
func (v Velocity2D) Validate() error {
49+
if err := validateVec2("velocity_2d.linear", v.Linear); err != nil {
50+
return err
51+
}
52+
if !isFinite(v.Angular) {
53+
return errors.New("velocity_2d.angular: must be finite")
54+
}
55+
return nil
56+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package component
2+
3+
// Shared helpers for Validate methods on spatial, rigidbody, and collider types.
4+
5+
import (
6+
"fmt"
7+
"math"
8+
)
9+
10+
func isFinite(f float64) bool {
11+
return !math.IsNaN(f) && !math.IsInf(f, 0)
12+
}
13+
14+
func validateVec2(field string, v Vec2) error {
15+
if !isFinite(v.X) || !isFinite(v.Y) {
16+
return fmt.Errorf("%s: must be finite (got %v, %v)", field, v.X, v.Y)
17+
}
18+
return nil
19+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package event
2+
3+
import (
4+
"github.com/argus-labs/world-engine/pkg/cardinal"
5+
"github.com/argus-labs/world-engine/pkg/plugin/physics2d/component"
6+
)
7+
8+
// FixtureFilterBits is the Box2D collision filter for one fixture at contact time. It matches
9+
// ECS ColliderShape CategoryBits/MaskBits and the fixture’s GroupIndex (non-zero group rules
10+
// override category/mask in Box2D).
11+
type FixtureFilterBits struct {
12+
CategoryBits uint16 `json:"category_bits"`
13+
MaskBits uint16 `json:"mask_bits"`
14+
GroupIndex int16 `json:"group_index"`
15+
}
16+
17+
// ContactEventPayload is shared data for all flushed contact/trigger events.
18+
// Entity/fixture ordering and how filter/manifold fields are filled can differ between a
19+
// normal step (live Box2D callbacks) and recovery after a world rebuild; see
20+
// internal/contact_flush.go.
21+
type ContactEventPayload struct {
22+
FilterA FixtureFilterBits `json:"filter_a"`
23+
FilterB FixtureFilterBits `json:"filter_b"`
24+
EntityA cardinal.EntityID `json:"entity_a"`
25+
EntityB cardinal.EntityID `json:"entity_b"`
26+
ShapeIndexA int `json:"shape_index_a"`
27+
ShapeIndexB int `json:"shape_index_b"`
28+
Normal component.Vec2 `json:"normal"`
29+
NormalValid bool `json:"normal_valid"`
30+
Point component.Vec2 `json:"point"`
31+
PointValid bool `json:"point_valid"`
32+
}
33+
34+
// ContactBeginEvent is emitted after the physics step when two non-sensor fixtures begin touching.
35+
// Normal and Point are populated when the collision manifold has at least one point (see NormalValid / PointValid).
36+
type ContactBeginEvent struct {
37+
ContactEventPayload
38+
}
39+
40+
func (ContactBeginEvent) Name() string { return "physics2d_contact_begin" }
41+
42+
// ContactEndEvent is emitted after the physics step when two non-sensor fixtures stop touching.
43+
// Manifold data is usually unavailable for EndContact; NormalValid/PointValid are typically false.
44+
type ContactEndEvent struct {
45+
ContactEventPayload
46+
}
47+
48+
func (ContactEndEvent) Name() string { return "physics2d_contact_end" }
49+
50+
// TriggerBeginEvent is emitted after the physics step when an overlap involving at least one sensor begins.
51+
type TriggerBeginEvent struct {
52+
ContactEventPayload
53+
}
54+
55+
func (TriggerBeginEvent) Name() string { return "physics2d_trigger_begin" }
56+
57+
// TriggerEndEvent is emitted after the physics step when an overlap involving at least one sensor ends.
58+
type TriggerEndEvent struct {
59+
ContactEventPayload
60+
}
61+
62+
func (TriggerEndEvent) Name() string { return "physics2d_trigger_end" }
63+
64+
// ContactEventEmitter is the per-step sink for flushed physics contact/trigger events. The physics
65+
// step driver assigns an implementation (PhysicsRuntime.Emitter) before World.Step and calls
66+
// FlushBufferedContacts after the step.
67+
type ContactEventEmitter interface {
68+
EmitContactBegin(ContactBeginEvent)
69+
EmitContactEnd(ContactEndEvent)
70+
EmitTriggerBegin(TriggerBeginEvent)
71+
EmitTriggerEnd(TriggerEndEvent)
72+
}

0 commit comments

Comments
 (0)