Skip to content

Commit b84c1ad

Browse files
committed
fix: merge rigidbody + collider component to physicsBody2D component
1 parent 2974a4f commit b84c1ad

12 files changed

Lines changed: 312 additions & 326 deletions

File tree

pkg/plugin/physics2d/component/collider.go

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const (
3333
ShapeTypeEdge
3434
)
3535

36-
// ColliderShape is one child shape inside a compound Collider2D.
36+
// ColliderShape is one child shape inside a compound PhysicsBody2D.
3737
//
3838
// Each entry has its own local transform, sensor flag, material, and collision filter (category, mask, group).
3939
// Geometry fields are a tagged-union style: only the fields that match ShapeType are used.
@@ -65,36 +65,6 @@ type ColliderShape struct {
6565
GroupIndex int16 `json:"group_index,omitempty"`
6666
}
6767

68-
// Collider2D is the authoritative collider description for an entity.
69-
//
70-
// Cardinal allows one instance per component type per entity, so compound colliders are
71-
// modeled as multiple ColliderShape entries in Shapes.
72-
//
73-
// Shape identity (v1): index i in Shapes identifies fixture slot i. Reordering, inserting,
74-
// or removing entries is a structural change and requires fixture/body recreation during
75-
// reconciliation.
76-
type Collider2D struct {
77-
Shapes []ColliderShape `json:"shapes"`
78-
}
79-
80-
// Name returns the ECS component name.
81-
func (Collider2D) Name() string { return "collider_2d" }
82-
83-
// Validate guards against NaN/Inf in float fields. Geometry correctness (convexity, vertex
84-
// count, winding) is enforced by Box2D at fixture creation time — duplicating those checks
85-
// here adds maintenance cost without value.
86-
func (c Collider2D) Validate() error {
87-
if len(c.Shapes) == 0 {
88-
return errors.New("collider_2d.shapes: at least one ColliderShape is required")
89-
}
90-
for i := range c.Shapes {
91-
if err := c.Shapes[i].Validate(); err != nil {
92-
return fmt.Errorf("collider_2d.shapes[%d]: %w", i, err)
93-
}
94-
}
95-
return nil
96-
}
97-
9868
// Validate checks for NaN/Inf in all float fields and a valid ShapeType tag.
9969
func (s ColliderShape) Validate() error {
10070
if err := validateVec2("local_offset", s.LocalOffset); err != nil {
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. To keep a body permanently awake (e.g. a
26+
// stationary kinematic sensor that must always generate contacts), set SleepingAllowed=false
27+
// instead.
28+
//
29+
// SleepingAllowed controls whether Box2D is permitted to put the body to sleep when it comes
30+
// to rest. When false, the body stays awake indefinitely. Use this for kinematic/manual
31+
// bodies that are stationary but must still generate contacts (the common "sensor" pattern).
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: 0 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
1-
//nolint:recvcheck // UnmarshalJSON must be pointer receiver to support json.Unmarshal
21
package component
32

4-
import (
5-
"fmt"
6-
7-
"github.com/goccy/go-json"
8-
)
9-
103
// BodyType selects how the rigid body participates in the simulation.
114
type BodyType uint8
125

@@ -29,141 +22,3 @@ const (
2922
// not with static or other kinematic/manual bodies.
3023
BodyTypeManual
3124
)
32-
33-
// Rigidbody2D holds simulation parameters for a rigid body.
34-
//
35-
// BodyType selects static vs dynamic vs kinematic behavior. LinearDamping and AngularDamping
36-
// are simulation damping coefficients. GravityScale multiplies the world's gravity vector
37-
// for this body; world gravity itself is runtime configuration, not a component field.
38-
//
39-
// # Body flags
40-
//
41-
// Active controls whether the body participates in the simulation at all. An inactive body
42-
// has no contacts, no collisions, and is effectively removed from Box2D without destroying it.
43-
// Set Active=false to temporarily disable an entity's physics (e.g. a dormant trap).
44-
//
45-
// Awake controls whether the body is currently awake in the simulation. Setting Awake=true
46-
// wakes a sleeping body; Box2D may put it back to sleep on subsequent ticks if nothing
47-
// disturbs it and SleepingAllowed is true. To keep a body permanently awake (e.g. a
48-
// stationary kinematic sensor that must always generate contacts), set SleepingAllowed=false
49-
// instead.
50-
//
51-
// SleepingAllowed controls whether Box2D is permitted to put the body to sleep when it comes
52-
// to rest. When false, the body stays awake indefinitely. Use this for kinematic/manual
53-
// bodies that are stationary but must still generate contacts (the common "sensor" pattern).
54-
//
55-
// Bullet enables continuous collision detection (CCD) for fast-moving dynamic bodies to
56-
// prevent tunneling through thin geometry. Has a performance cost; only enable for
57-
// projectiles or similarly fast objects.
58-
//
59-
// FixedRotation prevents the body from rotating in response to torques or collisions.
60-
// Useful for top-down characters that should not spin.
61-
//
62-
// # Defaults
63-
//
64-
// Box2D defaults Active, Awake, and SleepingAllowed to true and GravityScale to 1. Use
65-
// [NewRigidbody2D] to create a Rigidbody2D with these defaults set correctly. Bare struct
66-
// literals leave bool fields at false and GravityScale at 0, which produces an inactive,
67-
// sleeping body with no gravity — almost never what you want.
68-
//
69-
// When deserializing from JSON (e.g. snapshot recovery), missing fields are defaulted to
70-
// their Box2D values automatically via a custom UnmarshalJSON. Explicitly serialized false
71-
// values are preserved exactly.
72-
//
73-
// Bullet and FixedRotation default to false (off), matching Box2D defaults.
74-
//
75-
// # Post-step writeback
76-
//
77-
// Writeback applies to dynamic and kinematic bodies. Static and manual bodies are
78-
// not written back.
79-
type Rigidbody2D struct {
80-
BodyType BodyType `json:"body_type"`
81-
LinearDamping float64 `json:"linear_damping"`
82-
AngularDamping float64 `json:"angular_damping"`
83-
GravityScale float64 `json:"gravity_scale"`
84-
Active bool `json:"active"`
85-
Awake bool `json:"awake"`
86-
SleepingAllowed bool `json:"sleeping_allowed"`
87-
Bullet bool `json:"bullet"`
88-
FixedRotation bool `json:"fixed_rotation"`
89-
}
90-
91-
// NewRigidbody2D returns a Rigidbody2D with the given body type and Box2D-compatible defaults:
92-
// Active=true, Awake=true, SleepingAllowed=true, GravityScale=1.
93-
func NewRigidbody2D(bodyType BodyType) Rigidbody2D {
94-
return Rigidbody2D{
95-
BodyType: bodyType,
96-
GravityScale: 1,
97-
Active: true,
98-
Awake: true,
99-
SleepingAllowed: true,
100-
}
101-
}
102-
103-
// UnmarshalJSON decodes a Rigidbody2D from JSON, applying Box2D-compatible defaults for
104-
// fields missing from the payload. This handles old snapshots that predate the body flags
105-
// (Active, Awake, SleepingAllowed default to true; GravityScale defaults to 1) while
106-
// preserving explicitly serialized values including false.
107-
func (r *Rigidbody2D) UnmarshalJSON(data []byte) error {
108-
type raw struct {
109-
BodyType BodyType `json:"body_type"`
110-
LinearDamping float64 `json:"linear_damping"`
111-
AngularDamping float64 `json:"angular_damping"`
112-
GravityScale *float64 `json:"gravity_scale"`
113-
Active *bool `json:"active"`
114-
Awake *bool `json:"awake"`
115-
SleepingAllowed *bool `json:"sleeping_allowed"`
116-
Bullet bool `json:"bullet"`
117-
FixedRotation bool `json:"fixed_rotation"`
118-
}
119-
var aux raw
120-
if err := json.Unmarshal(data, &aux); err != nil {
121-
return err
122-
}
123-
*r = Rigidbody2D{
124-
BodyType: aux.BodyType,
125-
LinearDamping: aux.LinearDamping,
126-
AngularDamping: aux.AngularDamping,
127-
GravityScale: 1,
128-
Active: true,
129-
Awake: true,
130-
SleepingAllowed: true,
131-
Bullet: aux.Bullet,
132-
FixedRotation: aux.FixedRotation,
133-
}
134-
if aux.GravityScale != nil {
135-
r.GravityScale = *aux.GravityScale
136-
}
137-
if aux.Active != nil {
138-
r.Active = *aux.Active
139-
}
140-
if aux.Awake != nil {
141-
r.Awake = *aux.Awake
142-
}
143-
if aux.SleepingAllowed != nil {
144-
r.SleepingAllowed = *aux.SleepingAllowed
145-
}
146-
return nil
147-
}
148-
149-
// Name returns the ECS component name.
150-
func (Rigidbody2D) Name() string { return "rigidbody_2d" }
151-
152-
// Validate guards against NaN/Inf in float fields and an invalid body type tag.
153-
func (r Rigidbody2D) Validate() error {
154-
switch r.BodyType {
155-
case BodyTypeStatic, BodyTypeDynamic, BodyTypeKinematic, BodyTypeManual:
156-
default:
157-
return fmt.Errorf("rigidbody_2d.body_type: invalid value %d", r.BodyType)
158-
}
159-
if !isFinite(r.LinearDamping) {
160-
return fmt.Errorf("rigidbody_2d.linear_damping: must be finite, got %v", r.LinearDamping)
161-
}
162-
if !isFinite(r.AngularDamping) {
163-
return fmt.Errorf("rigidbody_2d.angular_damping: must be finite, got %v", r.AngularDamping)
164-
}
165-
if !isFinite(r.GravityScale) {
166-
return fmt.Errorf("rigidbody_2d.gravity_scale: must be finite, got %v", r.GravityScale)
167-
}
168-
return nil
169-
}

0 commit comments

Comments
 (0)