Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 1 addition & 31 deletions pkg/plugin/physics2d/component/collider.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const (
ShapeTypeEdge
)

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

// Collider2D is the authoritative collider description for an entity.
//
// Cardinal allows one instance per component type per entity, so compound colliders are
// modeled as multiple ColliderShape entries in Shapes.
//
// Shape identity (v1): index i in Shapes identifies fixture slot i. Reordering, inserting,
// or removing entries is a structural change and requires fixture/body recreation during
// reconciliation.
type Collider2D struct {
Shapes []ColliderShape `json:"shapes"`
}

// Name returns the ECS component name.
func (Collider2D) Name() string { return "collider_2d" }

// Validate guards against NaN/Inf in float fields. Geometry correctness (convexity, vertex
// count, winding) is enforced by Box2D at fixture creation time — duplicating those checks
// here adds maintenance cost without value.
func (c Collider2D) Validate() error {
if len(c.Shapes) == 0 {
return errors.New("collider_2d.shapes: at least one ColliderShape is required")
}
for i := range c.Shapes {
if err := c.Shapes[i].Validate(); err != nil {
return fmt.Errorf("collider_2d.shapes[%d]: %w", i, err)
}
}
return nil
}

// Validate checks for NaN/Inf in all float fields and a valid ShapeType tag.
func (s ColliderShape) Validate() error {
if err := validateVec2("local_offset", s.LocalOffset); err != nil {
Expand Down
166 changes: 166 additions & 0 deletions pkg/plugin/physics2d/component/physics_body.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//nolint:recvcheck // UnmarshalJSON must be pointer receiver to support json.Unmarshal
package component

import (
"errors"
"fmt"

"github.com/goccy/go-json"
)

// PhysicsBody2D holds simulation parameters for a rigid body and its collider shapes.
//
// BodyType selects static vs dynamic vs kinematic behavior. LinearDamping and AngularDamping
// are simulation damping coefficients. GravityScale multiplies the world's gravity vector
// for this body; world gravity itself is runtime configuration, not a component field.
//
// # Body flags
//
// Active controls whether the body participates in the simulation at all. An inactive body
// has no contacts, no collisions, and is effectively removed from Box2D without destroying it.
// Set Active=false to temporarily disable an entity's physics (e.g. a dormant trap).
//
// Awake controls whether the body is currently awake in the simulation. Setting Awake=true
// wakes a sleeping body; Box2D may put it back to sleep on subsequent ticks if nothing
// disturbs it and SleepingAllowed is true. To keep a body permanently awake (e.g. a
// stationary kinematic sensor that must always generate contacts), set SleepingAllowed=false
// instead.
//
// SleepingAllowed controls whether Box2D is permitted to put the body to sleep when it comes
// to rest. When false, the body stays awake indefinitely. Use this for kinematic/manual
// bodies that are stationary but must still generate contacts (the common "sensor" pattern).
//
// Bullet enables continuous collision detection (CCD) for fast-moving dynamic bodies to
// prevent tunneling through thin geometry. Has a performance cost; only enable for
// projectiles or similarly fast objects.
//
// FixedRotation prevents the body from rotating in response to torques or collisions.
// Useful for top-down characters that should not spin.
//
// # Shapes
//
// Shapes holds the compound collider description. Cardinal allows one instance per component
// type per entity, so compound colliders are modeled as multiple ColliderShape entries.
// Shape identity (v1): index i in Shapes identifies fixture slot i.
//
// # Defaults
//
// Box2D defaults Active, Awake, and SleepingAllowed to true and GravityScale to 1. Use
// [NewPhysicsBody2D] to create a PhysicsBody2D with these defaults set correctly. Bare struct
// literals leave bool fields at false and GravityScale at 0, which produces an inactive,
// sleeping body with no gravity — almost never what you want.
//
// When deserializing from JSON (e.g. snapshot recovery), missing fields are defaulted to
// their Box2D values automatically via a custom UnmarshalJSON. Explicitly serialized false
// values are preserved exactly.
//
// Bullet and FixedRotation default to false (off), matching Box2D defaults.
//
// # Post-step writeback
//
// Writeback applies to dynamic and kinematic bodies. Static and manual bodies are
// not written back.
type PhysicsBody2D struct {
BodyType BodyType `json:"body_type"`
LinearDamping float64 `json:"linear_damping"`
AngularDamping float64 `json:"angular_damping"`
GravityScale float64 `json:"gravity_scale"`
Active bool `json:"active"`
Awake bool `json:"awake"`
SleepingAllowed bool `json:"sleeping_allowed"`
Bullet bool `json:"bullet"`
FixedRotation bool `json:"fixed_rotation"`

Shapes []ColliderShape `json:"shapes"`
}

// NewPhysicsBody2D returns a PhysicsBody2D with the given body type, Box2D-compatible defaults
// (Active=true, Awake=true, SleepingAllowed=true, GravityScale=1), and the provided shapes.
func NewPhysicsBody2D(bodyType BodyType, shapes ...ColliderShape) PhysicsBody2D {
return PhysicsBody2D{
BodyType: bodyType,
GravityScale: 1,
Active: true,
Awake: true,
SleepingAllowed: true,
Shapes: shapes,
}
}

// UnmarshalJSON decodes a PhysicsBody2D from JSON, applying Box2D-compatible defaults for
// fields missing from the payload. This handles old snapshots that predate the body flags
// (Active, Awake, SleepingAllowed default to true; GravityScale defaults to 1) while
// preserving explicitly serialized values including false.
func (p *PhysicsBody2D) UnmarshalJSON(data []byte) error {
type raw struct {
BodyType BodyType `json:"body_type"`
LinearDamping float64 `json:"linear_damping"`
AngularDamping float64 `json:"angular_damping"`
GravityScale *float64 `json:"gravity_scale"`
Active *bool `json:"active"`
Awake *bool `json:"awake"`
SleepingAllowed *bool `json:"sleeping_allowed"`
Bullet bool `json:"bullet"`
FixedRotation bool `json:"fixed_rotation"`
Shapes []ColliderShape `json:"shapes"`
}
var aux raw
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
*p = PhysicsBody2D{
BodyType: aux.BodyType,
LinearDamping: aux.LinearDamping,
AngularDamping: aux.AngularDamping,
GravityScale: 1,
Active: true,
Awake: true,
SleepingAllowed: true,
Bullet: aux.Bullet,
FixedRotation: aux.FixedRotation,
Shapes: aux.Shapes,
}
if aux.GravityScale != nil {
p.GravityScale = *aux.GravityScale
}
if aux.Active != nil {
p.Active = *aux.Active
}
if aux.Awake != nil {
p.Awake = *aux.Awake
}
if aux.SleepingAllowed != nil {
p.SleepingAllowed = *aux.SleepingAllowed
}
return nil
}

// Name returns the ECS component name.
func (PhysicsBody2D) Name() string { return "physics_body_2d" }

// Validate guards against NaN/Inf in float fields, an invalid body type tag, and invalid shapes.
func (p PhysicsBody2D) Validate() error {
switch p.BodyType {
case BodyTypeStatic, BodyTypeDynamic, BodyTypeKinematic, BodyTypeManual:
default:
return fmt.Errorf("physics_body_2d.body_type: invalid value %d", p.BodyType)
}
if !isFinite(p.LinearDamping) {
return fmt.Errorf("physics_body_2d.linear_damping: must be finite, got %v", p.LinearDamping)
}
if !isFinite(p.AngularDamping) {
return fmt.Errorf("physics_body_2d.angular_damping: must be finite, got %v", p.AngularDamping)
}
if !isFinite(p.GravityScale) {
return fmt.Errorf("physics_body_2d.gravity_scale: must be finite, got %v", p.GravityScale)
}
if len(p.Shapes) == 0 {
return errors.New("physics_body_2d.shapes: at least one ColliderShape is required")
}
for i := range p.Shapes {
if err := p.Shapes[i].Validate(); err != nil {
return fmt.Errorf("physics_body_2d.shapes[%d]: %w", i, err)
}
}
return nil
}
145 changes: 0 additions & 145 deletions pkg/plugin/physics2d/component/rigidbody.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
//nolint:recvcheck // UnmarshalJSON must be pointer receiver to support json.Unmarshal
package component

import (
"fmt"

"github.com/goccy/go-json"
)

// BodyType selects how the rigid body participates in the simulation.
type BodyType uint8

Expand All @@ -29,141 +22,3 @@ const (
// not with static or other kinematic/manual bodies.
BodyTypeManual
)

// Rigidbody2D holds simulation parameters for a rigid body.
//
// BodyType selects static vs dynamic vs kinematic behavior. LinearDamping and AngularDamping
// are simulation damping coefficients. GravityScale multiplies the world's gravity vector
// for this body; world gravity itself is runtime configuration, not a component field.
//
// # Body flags
//
// Active controls whether the body participates in the simulation at all. An inactive body
// has no contacts, no collisions, and is effectively removed from Box2D without destroying it.
// Set Active=false to temporarily disable an entity's physics (e.g. a dormant trap).
//
// Awake controls whether the body is currently awake in the simulation. Setting Awake=true
// wakes a sleeping body; Box2D may put it back to sleep on subsequent ticks if nothing
// disturbs it and SleepingAllowed is true. To keep a body permanently awake (e.g. a
// stationary kinematic sensor that must always generate contacts), set SleepingAllowed=false
// instead.
//
// SleepingAllowed controls whether Box2D is permitted to put the body to sleep when it comes
// to rest. When false, the body stays awake indefinitely. Use this for kinematic/manual
// bodies that are stationary but must still generate contacts (the common "sensor" pattern).
//
// Bullet enables continuous collision detection (CCD) for fast-moving dynamic bodies to
// prevent tunneling through thin geometry. Has a performance cost; only enable for
// projectiles or similarly fast objects.
//
// FixedRotation prevents the body from rotating in response to torques or collisions.
// Useful for top-down characters that should not spin.
//
// # Defaults
//
// Box2D defaults Active, Awake, and SleepingAllowed to true and GravityScale to 1. Use
// [NewRigidbody2D] to create a Rigidbody2D with these defaults set correctly. Bare struct
// literals leave bool fields at false and GravityScale at 0, which produces an inactive,
// sleeping body with no gravity — almost never what you want.
//
// When deserializing from JSON (e.g. snapshot recovery), missing fields are defaulted to
// their Box2D values automatically via a custom UnmarshalJSON. Explicitly serialized false
// values are preserved exactly.
//
// Bullet and FixedRotation default to false (off), matching Box2D defaults.
//
// # Post-step writeback
//
// Writeback applies to dynamic and kinematic bodies. Static and manual bodies are
// not written back.
type Rigidbody2D struct {
BodyType BodyType `json:"body_type"`
LinearDamping float64 `json:"linear_damping"`
AngularDamping float64 `json:"angular_damping"`
GravityScale float64 `json:"gravity_scale"`
Active bool `json:"active"`
Awake bool `json:"awake"`
SleepingAllowed bool `json:"sleeping_allowed"`
Bullet bool `json:"bullet"`
FixedRotation bool `json:"fixed_rotation"`
}

// NewRigidbody2D returns a Rigidbody2D with the given body type and Box2D-compatible defaults:
// Active=true, Awake=true, SleepingAllowed=true, GravityScale=1.
func NewRigidbody2D(bodyType BodyType) Rigidbody2D {
return Rigidbody2D{
BodyType: bodyType,
GravityScale: 1,
Active: true,
Awake: true,
SleepingAllowed: true,
}
}

// UnmarshalJSON decodes a Rigidbody2D from JSON, applying Box2D-compatible defaults for
// fields missing from the payload. This handles old snapshots that predate the body flags
// (Active, Awake, SleepingAllowed default to true; GravityScale defaults to 1) while
// preserving explicitly serialized values including false.
func (r *Rigidbody2D) UnmarshalJSON(data []byte) error {
type raw struct {
BodyType BodyType `json:"body_type"`
LinearDamping float64 `json:"linear_damping"`
AngularDamping float64 `json:"angular_damping"`
GravityScale *float64 `json:"gravity_scale"`
Active *bool `json:"active"`
Awake *bool `json:"awake"`
SleepingAllowed *bool `json:"sleeping_allowed"`
Bullet bool `json:"bullet"`
FixedRotation bool `json:"fixed_rotation"`
}
var aux raw
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
*r = Rigidbody2D{
BodyType: aux.BodyType,
LinearDamping: aux.LinearDamping,
AngularDamping: aux.AngularDamping,
GravityScale: 1,
Active: true,
Awake: true,
SleepingAllowed: true,
Bullet: aux.Bullet,
FixedRotation: aux.FixedRotation,
}
if aux.GravityScale != nil {
r.GravityScale = *aux.GravityScale
}
if aux.Active != nil {
r.Active = *aux.Active
}
if aux.Awake != nil {
r.Awake = *aux.Awake
}
if aux.SleepingAllowed != nil {
r.SleepingAllowed = *aux.SleepingAllowed
}
return nil
}

// Name returns the ECS component name.
func (Rigidbody2D) Name() string { return "rigidbody_2d" }

// Validate guards against NaN/Inf in float fields and an invalid body type tag.
func (r Rigidbody2D) Validate() error {
switch r.BodyType {
case BodyTypeStatic, BodyTypeDynamic, BodyTypeKinematic, BodyTypeManual:
default:
return fmt.Errorf("rigidbody_2d.body_type: invalid value %d", r.BodyType)
}
if !isFinite(r.LinearDamping) {
return fmt.Errorf("rigidbody_2d.linear_damping: must be finite, got %v", r.LinearDamping)
}
if !isFinite(r.AngularDamping) {
return fmt.Errorf("rigidbody_2d.angular_damping: must be finite, got %v", r.AngularDamping)
}
if !isFinite(r.GravityScale) {
return fmt.Errorf("rigidbody_2d.gravity_scale: must be finite, got %v", r.GravityScale)
}
return nil
}
Loading
Loading