|
| 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