Skip to content

Commit 126080c

Browse files
snake refactor and README
1 parent 9a54085 commit 126080c

File tree

12 files changed

+499
-356
lines changed

12 files changed

+499
-356
lines changed

examples/snake/src/main.ts

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ECS, update } from "@typeonce/ecs";
1+
import { ECS } from "@typeonce/ecs";
22
import {
33
Collidable,
44
FollowTarget,
@@ -11,14 +11,17 @@ import {
1111
import type { GameEventMap } from "./events";
1212
import { InputManager } from "./input-manager";
1313
import renderer from "./loop";
14-
import { CollisionSystem } from "./systems/collision";
15-
import { FollowSystem } from "./systems/follow";
16-
import { FoodSpawnSystem } from "./systems/food-spawn";
17-
import { MovementSystem } from "./systems/movement";
18-
import { RenderSystem } from "./systems/render";
19-
import { SnakeControllerSystem } from "./systems/snake-controller";
20-
import { SnakeGrowSystem } from "./systems/snake-grow";
21-
import { TargetSystem } from "./systems/target";
14+
import {
15+
CollisionSystem,
16+
FollowSystem,
17+
FoodSpawnSystem,
18+
MovementSystem,
19+
RenderSystem,
20+
SnakeControllerSystem,
21+
SnakeGrowSystem,
22+
TargetSystem,
23+
type SystemTags,
24+
} from "./systems";
2225
import { spawnFood } from "./utils";
2326

2427
const canvas = document.getElementById("canvas");
@@ -27,13 +30,8 @@ if (canvas && canvas instanceof HTMLCanvasElement) {
2730

2831
if (ctx) {
2932
const inputManager = new InputManager();
30-
const world = ECS.create<GameEventMap>(
31-
({
32-
addComponent,
33-
createEntity,
34-
registerSystemEvent,
35-
registerSystemUpdate,
36-
}) => {
33+
const world = ECS.create<SystemTags, GameEventMap>(
34+
({ addComponent, createEntity, addSystem }) => {
3735
addComponent(
3836
createEntity(),
3937
new Size({ size: 10 }),
@@ -53,26 +51,23 @@ if (canvas && canvas instanceof HTMLCanvasElement) {
5351
...spawnFood(new Position({ x: 200, y: 100 }))
5452
);
5553

56-
registerSystemUpdate(
57-
CollisionSystem,
58-
MovementSystem,
59-
FollowSystem,
60-
TargetSystem(),
61-
RenderSystem(ctx),
62-
SnakeControllerSystem(inputManager)
63-
);
64-
65-
registerSystemEvent(
66-
SnakeGrowSystem,
67-
FoodSpawnSystem({
54+
addSystem(
55+
new SnakeGrowSystem(),
56+
new CollisionSystem(),
57+
new MovementSystem(),
58+
new FollowSystem(),
59+
new TargetSystem({ followDelayCycles: undefined }),
60+
new RenderSystem({ ctx }),
61+
new SnakeControllerSystem({ inputManager }),
62+
new FoodSpawnSystem({
6863
width: ctx.canvas.width,
6964
height: ctx.canvas.height,
7065
})
7166
);
7267
}
7368
);
7469

75-
renderer(update(world));
70+
renderer(world.update);
7671
} else {
7772
console.error("Canvas context not found");
7873
}

examples/snake/src/systems.ts

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { query, queryRequired, System } from "@typeonce/ecs";
2+
import {
3+
Collidable,
4+
FollowTarget,
5+
Position,
6+
Renderable,
7+
Size,
8+
SnakeBody,
9+
SnakeHead,
10+
Velocity,
11+
} from "./components";
12+
import { FoodEatenEvent, type GameEventMap } from "./events";
13+
import type { InputManager } from "./input-manager";
14+
import { spawnFood } from "./utils";
15+
16+
export type SystemTags =
17+
| "SnakeGrow"
18+
| "SnakeController"
19+
| "Render"
20+
| "Movement"
21+
| "FoodSpawn"
22+
| "Follow"
23+
| "Collision"
24+
| "Target";
25+
26+
const SystemFactory = System<SystemTags, GameEventMap>();
27+
28+
const moving = query({ position: Position, velocity: Velocity });
29+
30+
const collidable = query({
31+
position: Position,
32+
collidable: Collidable,
33+
size: Size,
34+
});
35+
36+
const snakeBodyPosition = query({ position: Position, snakeBody: SnakeBody });
37+
38+
const requiredHead = queryRequired({
39+
snake: SnakeHead,
40+
velocity: Velocity,
41+
position: Position,
42+
size: Size,
43+
});
44+
45+
const tail = query({
46+
snake: SnakeBody,
47+
position: Position,
48+
});
49+
50+
const renderPosition = query({
51+
renderable: Renderable,
52+
position: Position,
53+
size: Size,
54+
});
55+
56+
const follow = query({
57+
position: Position,
58+
followTarget: FollowTarget,
59+
});
60+
61+
export class SnakeGrowSystem extends SystemFactory<{}>("SnakeGrow", {
62+
dependencies: ["Collision"],
63+
execute: ({ world, poll, addComponent, createEntity }) => {
64+
poll(FoodEatenEvent).forEach(() => {
65+
const snakeHead = requiredHead(world)[0];
66+
67+
const snakeTail = tail(world).find((entity) => entity.snake.isTail);
68+
69+
if (snakeTail) {
70+
snakeTail.snake.isTail = false;
71+
}
72+
73+
addComponent(
74+
createEntity(),
75+
new SnakeBody({
76+
parentSegment: snakeTail?.entityId ?? snakeHead.entityId,
77+
isTail: true,
78+
}),
79+
new Position({
80+
x:
81+
(snakeTail ?? snakeHead).position.x -
82+
snakeHead.velocity.dx * snakeHead.size.size * 2,
83+
y:
84+
(snakeTail ?? snakeHead).position.y -
85+
snakeHead.velocity.dy * snakeHead.size.size * 2,
86+
}),
87+
new Size({ size: snakeHead.size.size }),
88+
new FollowTarget({ x: 0, y: 0 }),
89+
new Collidable({ entity: "tail" }),
90+
new Renderable({ color: "#ffa500" })
91+
);
92+
});
93+
},
94+
}) {}
95+
96+
export class SnakeControllerSystem extends SystemFactory<{
97+
inputManager: InputManager;
98+
}>("SnakeController", {
99+
execute: ({ world, input: { inputManager } }) => {
100+
const snakeHead = requiredHead(world)[0];
101+
if (inputManager.isKeyPressed("ArrowUp")) {
102+
snakeHead.velocity.dx = 0;
103+
snakeHead.velocity.dy = -1;
104+
} else if (inputManager.isKeyPressed("ArrowDown")) {
105+
snakeHead.velocity.dx = 0;
106+
snakeHead.velocity.dy = 1;
107+
} else if (inputManager.isKeyPressed("ArrowLeft")) {
108+
snakeHead.velocity.dx = -1;
109+
snakeHead.velocity.dy = 0;
110+
} else if (inputManager.isKeyPressed("ArrowRight")) {
111+
snakeHead.velocity.dx = 1;
112+
snakeHead.velocity.dy = 0;
113+
}
114+
},
115+
}) {}
116+
117+
export class RenderSystem extends SystemFactory<{
118+
ctx: CanvasRenderingContext2D;
119+
}>("Render", {
120+
execute: ({ world, input: { ctx } }) => {
121+
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
122+
renderPosition(world).forEach(({ renderable, position, size }) => {
123+
ctx.fillStyle = renderable.color;
124+
ctx.beginPath();
125+
ctx.arc(position.x, position.y, size.size, 0, Math.PI * 2);
126+
ctx.fill();
127+
});
128+
},
129+
}) {}
130+
131+
export class MovementSystem extends SystemFactory<{}>("Movement", {
132+
execute: ({ world, deltaTime }) => {
133+
moving(world).forEach(({ position, velocity }) => {
134+
position.x += velocity.dx * velocity.speed * deltaTime;
135+
position.y += velocity.dy * velocity.speed * deltaTime;
136+
});
137+
},
138+
}) {}
139+
140+
export class FoodSpawnSystem extends SystemFactory<{
141+
width: number;
142+
height: number;
143+
}>("FoodSpawn", {
144+
dependencies: ["Collision"],
145+
execute: ({
146+
poll,
147+
destroyEntity,
148+
createEntity,
149+
addComponent,
150+
input: { width, height },
151+
}) => {
152+
poll(FoodEatenEvent).forEach((event) => {
153+
destroyEntity(event.data.entityId);
154+
addComponent(
155+
createEntity(),
156+
...spawnFood(
157+
new Position({
158+
x: Math.random() * width,
159+
y: Math.random() * height,
160+
})
161+
)
162+
);
163+
});
164+
},
165+
}) {}
166+
167+
export class FollowSystem extends SystemFactory<{}>("Follow", {
168+
execute: ({ world, deltaTime, getComponentRequired }) => {
169+
snakeBodyPosition(world).forEach(({ position, snakeBody }) => {
170+
const { followTarget } = getComponentRequired({
171+
followTarget: FollowTarget,
172+
})(snakeBody.parentSegment);
173+
174+
const targetPosition = FollowSystem.interpolate2DWithDeltaTime(
175+
position,
176+
followTarget,
177+
deltaTime,
178+
0.1
179+
);
180+
181+
position.x = targetPosition.x;
182+
position.y = targetPosition.y;
183+
});
184+
},
185+
}) {
186+
static readonly interpolate2DWithDeltaTime = (
187+
current: { x: number; y: number },
188+
target: { x: number; y: number },
189+
deltaTime: number,
190+
speed: number
191+
): { x: number; y: number } => {
192+
// Calculate the distance to the target
193+
const distanceX = target.x - current.x;
194+
const distanceY = target.y - current.y;
195+
const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
196+
197+
// If already at the target or very close, return the target
198+
if (distance < 0.001) {
199+
return { x: target.x, y: target.y };
200+
}
201+
202+
// Calculate the maximum distance to move this frame
203+
const maxDistance = speed * deltaTime;
204+
205+
// Determine the interpolation factor (clamp to [0, 1])
206+
const t = Math.min(maxDistance / distance, 1);
207+
208+
// Interpolate towards the target
209+
return {
210+
x: current.x + t * distanceX,
211+
y: current.y + t * distanceY,
212+
};
213+
};
214+
}
215+
216+
export class CollisionSystem extends SystemFactory<{}>("Collision", {
217+
execute: ({ world, emit, getComponentRequired }) => {
218+
const entities = collidable(world);
219+
220+
for (let i = 0; i < entities.length; i++) {
221+
for (let j = i + 1; j < entities.length; j++) {
222+
const entity1 = entities[i]!;
223+
const entity2 = entities[j]!;
224+
225+
if (
226+
CollisionSystem.checkCollision(
227+
entity1.position,
228+
entity2.position,
229+
entity1.size,
230+
entity2.size
231+
)
232+
) {
233+
const getSnakeBody = getComponentRequired({
234+
snake: SnakeBody,
235+
});
236+
237+
if (
238+
entity1.collidable.entity === "snake" &&
239+
entity2.collidable.entity === "food"
240+
) {
241+
emit({
242+
type: FoodEatenEvent,
243+
data: { entityId: entity2.entityId },
244+
});
245+
} else if (
246+
entity1.collidable.entity === "food" &&
247+
entity2.collidable.entity === "snake"
248+
) {
249+
emit({
250+
type: FoodEatenEvent,
251+
data: { entityId: entity1.entityId },
252+
});
253+
} else if (
254+
entity1.collidable.entity === "snake" &&
255+
entity2.collidable.entity === "tail"
256+
) {
257+
const snakeBody = getSnakeBody(entity2.entityId);
258+
if (snakeBody.snake.parentSegment !== entity1.entityId) {
259+
// this.resetGame();
260+
}
261+
} else if (
262+
entity1.collidable.entity === "tail" &&
263+
entity2.collidable.entity === "snake"
264+
) {
265+
const snakeBody = getSnakeBody(entity1.entityId);
266+
if (snakeBody.snake.parentSegment !== entity2.entityId) {
267+
// this.resetGame();
268+
}
269+
}
270+
}
271+
}
272+
}
273+
},
274+
}) {
275+
/**
276+
* https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection#circle_collision
277+
*/
278+
static readonly checkCollision = (
279+
pos1: Position,
280+
pos2: Position,
281+
size1: Size,
282+
size2: Size
283+
): boolean => {
284+
const dx = pos1.x - pos2.x;
285+
const dy = pos1.y - pos2.y;
286+
const distance = Math.sqrt(dx * dx + dy * dy);
287+
return distance < size1.size + size2.size;
288+
};
289+
}
290+
291+
let countCycles = 0;
292+
export class TargetSystem extends SystemFactory<{ followDelayCycles?: number }>(
293+
"Target",
294+
{
295+
execute: ({ world, input: { followDelayCycles = 10 } }) => {
296+
if (countCycles > followDelayCycles) {
297+
countCycles = 0;
298+
299+
follow(world).forEach(({ position, followTarget }) => {
300+
followTarget.x = position.x;
301+
followTarget.y = position.y;
302+
});
303+
} else {
304+
countCycles++;
305+
}
306+
},
307+
}
308+
) {}

0 commit comments

Comments
 (0)