Skip to content

Commit ec0e666

Browse files
committed
feat: physics2d component setup
1 parent 8a13b07 commit ec0e666

106 files changed

Lines changed: 46113 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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 or kinematic
24+
// bodies only; not for dynamic bodies which require mass).
25+
ShapeTypeStaticChain
26+
// ShapeTypeStaticChainLoop uses ChainPoints for closed chain loops (static or kinematic
27+
// bodies only; not for dynamic bodies). Unlike ShapeTypeStaticChain, the last vertex
28+
// automatically connects back to the first, creating a sealed boundary.
29+
ShapeTypeStaticChainLoop
30+
// ShapeTypeEdge uses EdgeVertices (exactly 2 points) for a single line segment
31+
// (static or kinematic bodies only). Lighter than a 2-point chain for isolated barriers
32+
// or triggers.
33+
ShapeTypeEdge
34+
// ShapeTypeCapsule uses CapsuleCenter1, CapsuleCenter2, and Radius; fixture is a capsule
35+
// (two semicircles connected by a rectangle) in the shape's local frame.
36+
ShapeTypeCapsule
37+
)
38+
39+
// ColliderShape is one child shape inside a compound PhysicsBody2D.
40+
//
41+
// Each entry has its own local transform, sensor flag, material, and collision filter (category, mask, group).
42+
// Geometry fields are a tagged-union style: only the fields that match ShapeType are used.
43+
// - ShapeTypeCircle → Radius
44+
// - ShapeTypeBox → HalfExtents (half-width on X, half-height on Y, axis-aligned before LocalOffset/LocalRotation)
45+
// - ShapeTypeConvexPolygon → Vertices (convex polygon, respect backend limits)
46+
// - ShapeTypeStaticChain → ChainPoints (open polyline in local space)
47+
// - ShapeTypeStaticChainLoop → ChainPoints (closed loop in local space)
48+
// - ShapeTypeEdge → EdgeVertices (exactly 2 points in local space)
49+
// - ShapeTypeCapsule → CapsuleCenter1, CapsuleCenter2, Radius (two semicircles connected by a rectangle)
50+
type ColliderShape struct {
51+
ShapeType ShapeType `json:"shape_type"`
52+
LocalOffset Vec2 `json:"local_offset"`
53+
LocalRotation float64 `json:"local_rotation"`
54+
IsSensor bool `json:"is_sensor"`
55+
56+
// Geometry (use fields matching ShapeType).
57+
Radius float64 `json:"radius,omitempty"`
58+
HalfExtents Vec2 `json:"half_extents,omitempty"`
59+
Vertices []Vec2 `json:"vertices,omitempty"`
60+
ChainPoints []Vec2 `json:"chain_points,omitempty"`
61+
EdgeVertices [2]Vec2 `json:"edge_vertices,omitempty"`
62+
CapsuleCenter1 Vec2 `json:"capsule_center1,omitempty"`
63+
CapsuleCenter2 Vec2 `json:"capsule_center2,omitempty"`
64+
65+
// Material and per-shape collision filtering (fixture-level in Box2D).
66+
Friction float64 `json:"friction"`
67+
Restitution float64 `json:"restitution"`
68+
Density float64 `json:"density"`
69+
CategoryBits uint16 `json:"category_bits"`
70+
MaskBits uint16 `json:"mask_bits"`
71+
GroupIndex int16 `json:"group_index,omitempty"`
72+
}
73+
74+
// Validate checks for NaN/Inf in all float fields and a valid ShapeType tag.
75+
func (s ColliderShape) Validate() error {
76+
if err := validateVec2("local_offset", s.LocalOffset); err != nil {
77+
return err
78+
}
79+
if !isFinite(s.LocalRotation) {
80+
return errors.New("local_rotation: must be finite")
81+
}
82+
if !isFinite(s.Friction) {
83+
return fmt.Errorf("friction: must be finite, got %v", s.Friction)
84+
}
85+
if !isFinite(s.Restitution) {
86+
return fmt.Errorf("restitution: must be finite, got %v", s.Restitution)
87+
}
88+
if !isFinite(s.Density) {
89+
return fmt.Errorf("density: must be finite, got %v", s.Density)
90+
}
91+
if !isFinite(s.Radius) {
92+
return fmt.Errorf("radius: must be finite, got %v", s.Radius)
93+
}
94+
if err := validateVec2("half_extents", s.HalfExtents); err != nil {
95+
return err
96+
}
97+
for i, v := range s.Vertices {
98+
if err := validateVec2(fmt.Sprintf("vertices[%d]", i), v); err != nil {
99+
return err
100+
}
101+
}
102+
for i, v := range s.ChainPoints {
103+
if err := validateVec2(fmt.Sprintf("chain_points[%d]", i), v); err != nil {
104+
return err
105+
}
106+
}
107+
for i, v := range s.EdgeVertices {
108+
if err := validateVec2(fmt.Sprintf("edge_vertices[%d]", i), v); err != nil {
109+
return err
110+
}
111+
}
112+
if err := validateVec2("capsule_center1", s.CapsuleCenter1); err != nil {
113+
return err
114+
}
115+
if err := validateVec2("capsule_center2", s.CapsuleCenter2); err != nil {
116+
return err
117+
}
118+
119+
switch s.ShapeType {
120+
case ShapeTypeCircle, ShapeTypeBox, ShapeTypeConvexPolygon, ShapeTypeStaticChain,
121+
ShapeTypeStaticChainLoop, ShapeTypeEdge, ShapeTypeCapsule:
122+
default:
123+
return fmt.Errorf("shape_type: unknown value %d", s.ShapeType)
124+
}
125+
return nil
126+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
//nolint:recvcheck // UnmarshalJSON must be pointer receiver to support json.Unmarshal
2+
package component
3+
4+
import (
5+
"errors"
6+
"fmt"
7+
8+
"github.com/goccy/go-json"
9+
)
10+
11+
// PhysicsBody2D holds simulation parameters for a rigid body and its collider shapes.
12+
//
13+
// BodyType selects static vs dynamic vs kinematic behavior. LinearDamping and AngularDamping
14+
// are simulation damping coefficients. GravityScale multiplies the world's gravity vector
15+
// for this body; world gravity itself is runtime configuration, not a component field.
16+
//
17+
// # Body flags
18+
//
19+
// Active controls whether the body participates in the simulation at all. An inactive body
20+
// has no contacts, no collisions, and is effectively removed from Box2D without destroying it.
21+
// Set Active=false to temporarily disable an entity's physics (e.g. a dormant trap).
22+
//
23+
// Awake controls whether the body is currently awake in the simulation. Setting Awake=true
24+
// wakes a sleeping body; Box2D may put it back to sleep on subsequent ticks if nothing
25+
// disturbs it and SleepingAllowed is true.
26+
//
27+
// SleepingAllowed controls whether Box2D is permitted to put the body to sleep when it comes
28+
// to rest. When false, the body stays awake indefinitely.
29+
//
30+
// Note: Box2D v3 processes sensors in a separate overlap pass that runs regardless of body
31+
// sleep state (only disabled bodies are skipped). Sensor contacts are not affected by sleeping.
32+
//
33+
// Bullet enables continuous collision detection (CCD) for fast-moving dynamic bodies to
34+
// prevent tunneling through thin geometry. Has a performance cost; only enable for
35+
// projectiles or similarly fast objects.
36+
//
37+
// FixedRotation prevents the body from rotating in response to torques or collisions.
38+
// Useful for top-down characters that should not spin.
39+
//
40+
// # Shapes
41+
//
42+
// Shapes holds the compound collider description. Cardinal allows one instance per component
43+
// type per entity, so compound colliders are modeled as multiple ColliderShape entries.
44+
// Shape identity (v1): index i in Shapes identifies fixture slot i.
45+
//
46+
// # Defaults
47+
//
48+
// Box2D defaults Active, Awake, and SleepingAllowed to true and GravityScale to 1. Use
49+
// [NewPhysicsBody2D] to create a PhysicsBody2D with these defaults set correctly. Bare struct
50+
// literals leave bool fields at false and GravityScale at 0, which produces an inactive,
51+
// sleeping body with no gravity — almost never what you want.
52+
//
53+
// When deserializing from JSON (e.g. snapshot recovery), missing fields are defaulted to
54+
// their Box2D values automatically via a custom UnmarshalJSON. Explicitly serialized false
55+
// values are preserved exactly.
56+
//
57+
// Bullet and FixedRotation default to false (off), matching Box2D defaults.
58+
//
59+
// # Post-step writeback
60+
//
61+
// Writeback applies to dynamic and kinematic bodies. Static and manual bodies are
62+
// not written back.
63+
type PhysicsBody2D struct {
64+
BodyType BodyType `json:"body_type"`
65+
LinearDamping float64 `json:"linear_damping"`
66+
AngularDamping float64 `json:"angular_damping"`
67+
GravityScale float64 `json:"gravity_scale"`
68+
Active bool `json:"active"`
69+
Awake bool `json:"awake"`
70+
SleepingAllowed bool `json:"sleeping_allowed"`
71+
Bullet bool `json:"bullet"`
72+
FixedRotation bool `json:"fixed_rotation"`
73+
74+
Shapes []ColliderShape `json:"shapes"`
75+
}
76+
77+
// NewPhysicsBody2D returns a PhysicsBody2D with the given body type, Box2D-compatible defaults
78+
// (Active=true, Awake=true, SleepingAllowed=true, GravityScale=1), and the provided shapes.
79+
func NewPhysicsBody2D(bodyType BodyType, shapes ...ColliderShape) PhysicsBody2D {
80+
return PhysicsBody2D{
81+
BodyType: bodyType,
82+
GravityScale: 1,
83+
Active: true,
84+
Awake: true,
85+
SleepingAllowed: true,
86+
Shapes: shapes,
87+
}
88+
}
89+
90+
// UnmarshalJSON decodes a PhysicsBody2D from JSON, applying Box2D-compatible defaults for
91+
// fields missing from the payload. This handles old snapshots that predate the body flags
92+
// (Active, Awake, SleepingAllowed default to true; GravityScale defaults to 1) while
93+
// preserving explicitly serialized values including false.
94+
func (p *PhysicsBody2D) UnmarshalJSON(data []byte) error {
95+
type raw struct {
96+
BodyType BodyType `json:"body_type"`
97+
LinearDamping float64 `json:"linear_damping"`
98+
AngularDamping float64 `json:"angular_damping"`
99+
GravityScale *float64 `json:"gravity_scale"`
100+
Active *bool `json:"active"`
101+
Awake *bool `json:"awake"`
102+
SleepingAllowed *bool `json:"sleeping_allowed"`
103+
Bullet bool `json:"bullet"`
104+
FixedRotation bool `json:"fixed_rotation"`
105+
Shapes []ColliderShape `json:"shapes"`
106+
}
107+
var aux raw
108+
if err := json.Unmarshal(data, &aux); err != nil {
109+
return err
110+
}
111+
*p = PhysicsBody2D{
112+
BodyType: aux.BodyType,
113+
LinearDamping: aux.LinearDamping,
114+
AngularDamping: aux.AngularDamping,
115+
GravityScale: 1,
116+
Active: true,
117+
Awake: true,
118+
SleepingAllowed: true,
119+
Bullet: aux.Bullet,
120+
FixedRotation: aux.FixedRotation,
121+
Shapes: aux.Shapes,
122+
}
123+
if aux.GravityScale != nil {
124+
p.GravityScale = *aux.GravityScale
125+
}
126+
if aux.Active != nil {
127+
p.Active = *aux.Active
128+
}
129+
if aux.Awake != nil {
130+
p.Awake = *aux.Awake
131+
}
132+
if aux.SleepingAllowed != nil {
133+
p.SleepingAllowed = *aux.SleepingAllowed
134+
}
135+
return nil
136+
}
137+
138+
// Name returns the ECS component name.
139+
func (PhysicsBody2D) Name() string { return "physics_body_2d" }
140+
141+
// Validate guards against NaN/Inf in float fields, an invalid body type tag, and invalid shapes.
142+
func (p PhysicsBody2D) Validate() error {
143+
switch p.BodyType {
144+
case BodyTypeStatic, BodyTypeDynamic, BodyTypeKinematic, BodyTypeManual:
145+
default:
146+
return fmt.Errorf("physics_body_2d.body_type: invalid value %d", p.BodyType)
147+
}
148+
if !isFinite(p.LinearDamping) {
149+
return fmt.Errorf("physics_body_2d.linear_damping: must be finite, got %v", p.LinearDamping)
150+
}
151+
if !isFinite(p.AngularDamping) {
152+
return fmt.Errorf("physics_body_2d.angular_damping: must be finite, got %v", p.AngularDamping)
153+
}
154+
if !isFinite(p.GravityScale) {
155+
return fmt.Errorf("physics_body_2d.gravity_scale: must be finite, got %v", p.GravityScale)
156+
}
157+
if len(p.Shapes) == 0 {
158+
return errors.New("physics_body_2d.shapes: at least one ColliderShape is required")
159+
}
160+
for i := range p.Shapes {
161+
if err := p.Shapes[i].Validate(); err != nil {
162+
return fmt.Errorf("physics_body_2d.shapes[%d]: %w", i, err)
163+
}
164+
}
165+
return nil
166+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package component
2+
3+
// BodyType selects how the rigid body participates in the simulation.
4+
type BodyType uint8
5+
6+
const (
7+
// BodyTypeStatic is immovable world geometry; zero velocity; does not respond to forces.
8+
BodyTypeStatic BodyType = iota + 1
9+
// BodyTypeDynamic is fully simulated: forces, collisions, and integration apply.
10+
BodyTypeDynamic
11+
// BodyTypeKinematic is moved by setting velocity from gameplay; Box2D integrates velocity
12+
// into position each step. Does not respond to forces but can push dynamic bodies on
13+
// contact. Post-step writeback keeps ECS in sync with Box2D's integrated position.
14+
BodyTypeKinematic
15+
// BodyTypeManual is for gameplay-driven entities that use Box2D only for contact detection.
16+
// Under the hood it creates a kinematic body, but post-step writeback is skipped: ECS owns
17+
// position and velocity, and the reconciler pushes ECS values into Box2D each tick.
18+
// Use this for characters, enemies, and other entities where gameplay code (input handling,
19+
// AI, pathfinding) computes position directly.
20+
//
21+
// Box2D collision rules apply: manual bodies generate contacts with dynamic bodies only,
22+
// not with static or other kinematic/manual bodies.
23+
BodyTypeManual
24+
)

0 commit comments

Comments
 (0)