Experimental canvas 2D engine for tile-based sidescroller/sandbox games, created strictly for educational purposes.
entity-component-system
game-engine
canvas-2d
1import { Entity, System } from "@cosmic/core";
2import { Transform, Collider } from "@cosmic/kit/components";
3
4export class CollisionSystem extends System {
5 public readonly requiredComponents = new Set([Transform.name, Collider.name]);
6
7 public update(entities: Entity[], _deltaTime: number) {
8 // Reset collision flag for all first
9 for (const entity of entities) {
10 const transform = entity.getComponent(Transform)!;
11 transform.isCollidingWithEntity = false;
12 }
13
14 // Check each pair once
15 for (let i = 0; i < entities.length; i++) {
16 const entityA = entities[i];
17 const aTransform = entityA.getComponent(Transform)!;
18 const aCollider = entityA.getComponent(Collider)!;
19
20 for (let j = i + 1; j < entities.length; j++) {
21 const entityB = entities[j];
22 const bTransform = entityB.getComponent(Transform)!;
23 const bCollider = entityB.getComponent(Collider)!;
24
25 if (this.areColliding(aTransform, bTransform)) {
26 aTransform.isCollidingWithEntity = true;
27 bTransform.isCollidingWithEntity = true;
28
29 this.resolveOverlap(aTransform, bTransform, aCollider, bCollider);
30 }
31 }
32 }
33 }
34
35 protected areColliding(a: Transform, b: Transform): boolean {
36 // AABB vs AABB
37 return (
38 a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
39 );
40 }
41
42 protected resolveOverlap(a: Transform, b: Transform, colA: Collider, colB: Collider) {
43 // Centers
44 const centerAx = a.x + a.width / 2;
45 const centerAy = a.y + a.height / 2;
46 const centerBx = b.x + b.width / 2;
47 const centerBy = b.y + b.height / 2;
48
49 const dx = centerAx - centerBx;
50 const dy = centerAy - centerBy;
51
52 const combinedHalfWidths = (a.width + b.width) / 2;
53 const combinedHalfHeights = (a.height + b.height) / 2;
54
55 const overlapX = combinedHalfWidths - Math.abs(dx);
56 const overlapY = combinedHalfHeights - Math.abs(dy);
57
58 if (overlapX <= 0 || overlapY <= 0) return; // just in case
59
60 const aStatic = colA.isStatic;
61 const bStatic = colB.isStatic;
62
63 // Decide which axis to resolve along (least penetration)
64 if (overlapX < overlapY) {
65 // Horizontal
66 if (dx > 0) {
67 // A is to the right of B
68 this.pushApartX(a, b, overlapX, +1, aStatic, bStatic);
69 } else {
70 // A is to the left of B
71 this.pushApartX(a, b, overlapX, -1, aStatic, bStatic);
72 }
73 } else {
74 // Vertical
75 if (dy > 0) {
76 // A is below B
77 this.pushApartY(a, b, overlapY, +1, aStatic, bStatic);
78 } else {
79 // A is above B
80 this.pushApartY(a, b, overlapY, -1, aStatic, bStatic);
81 }
82 }
83 }
84
85 private pushApartX(
86 a: Transform,
87 b: Transform,
88 overlap: number,
89 direction: 1 | -1, // +1: A right of B, -1: A left of B
90 aStatic: boolean,
91 bStatic: boolean,
92 ) {
93 if (aStatic && bStatic) return;
94
95 if (aStatic && !bStatic) {
96 // Only move B
97 b.x -= direction * overlap;
98 } else if (!aStatic && bStatic) {
99 // Only move A
100 a.x += direction * overlap;
101 } else {
102 // Both dynamic → split
103 const half = overlap / 2;
104 a.x += direction * half;
105 b.x -= direction * half;
106 }
107 }
108
109 private pushApartY(
110 a: Transform,
111 b: Transform,
112 overlap: number,
113 direction: 1 | -1, // +1: A below B, -1: A above B
114 aStatic: boolean,
115 bStatic: boolean,
116 ) {
117 if (aStatic && bStatic) return;
118
119 if (aStatic && !bStatic) {
120 b.y -= direction * overlap;
121 } else if (!aStatic && bStatic) {
122 a.y += direction * overlap;
123 } else {
124 const half = overlap / 2;
125 a.y += direction * half;
126 b.y -= direction * half;
127 }
128 }
129}