|
| 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 | +} |
0 commit comments