diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30f2e06..5e4d297 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,4 +22,6 @@ jobs: - run: npm run typecheck + - run: npm run lint + - run: npm run test diff --git a/README.MD b/README.MD deleted file mode 100644 index a1fbc24..0000000 --- a/README.MD +++ /dev/null @@ -1,61 +0,0 @@ -# TeEngine - -Simple **2D TypeScript game engine** using **WebGPU** and **Rapier** physics. - -Ships as the **`teengine`** npm package. This repo is a monorepo: library in `packages/teengine`, demo in `examples/demo`. - -## Quick start - -```bash -npm install -npm run dev -``` - -Open http://localhost:5173 — click the canvas to focus. - -## Use in your project - -```bash -npm install teengine -``` - -```ts -import { Engine, World, PhysicsBridge, PhysicsWorld, Layers, Color } from "teengine"; - -const engine = await Engine.create({ canvas }); -const physics = new PhysicsBridge(await PhysicsWorld.create()); -const world = new World(physics); -// … register layers, spawn entities, add systems -``` - -See [docs/PACKAGE.md](./docs/PACKAGE.md) for monorepo layout and [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for engine design. - -## Demo controls - -| Input | Action | -|-------|--------| -| Arrow / WASD | Move player | -| Space / Up | Jump | -| Mouse | Debug circle in world | - -## Scripts - -| Command | Description | -|---------|-------------| -| `npm run dev` | Build engine + run demo | -| `npm run build` | Build `teengine` package | -| `npm run build:demo` | Build engine + demo | -| `npm run typecheck` | Typecheck all workspaces | - -## Package layout - -``` -packages/teengine/ → publishable library (`teengine`) -examples/demo/ → Vite demo app -docs/ → architecture + package docs -legacy/ → original Canvas 2D prototype -``` - -## License - -MIT — personal learning project by @dioveath. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2889c72 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# TeEngine + +Minimal 2D TypeScript game engine — WebGPU, systems ECS, Rapier physics. Published as **`teengine`**. + +## Quick start + +```bash +npm install +npm run dev # build engine + run examples/demo → http://localhost:5173 +``` + +```ts +import { Engine, World, PhysicsBridge, PhysicsWorld, Color } from "teengine"; + +const physics = new PhysicsBridge(await PhysicsWorld.create({ gravityY: 980 })); +const world = new World(physics); +const engine = await Engine.create({ canvas }); + +engine.setLoop({ + fixedUpdate: (ctx) => world.fixedUpdate({ ...ctx, physics }), + render: (ctx) => { + engine.graphics.beginFrame(Color.hex("#0d1117")); + world.render({ ...ctx, physics }); + engine.graphics.endFrame(); + }, +}); +engine.start(); +``` + +## Repo layout + +``` +packages/teengine/src/ publishable library — public API is index.ts only +examples/demo/ reference game (copy systems from here) +legacy/ old Canvas 2D prototype (ignore) +``` + +## Scope + +The **`teengine`** package provides **run, draw, simulate, input** only. Gameplay (movement, AI, scenes, animation) belongs in **your code** or **`examples/demo/`** — never in the package. + +| In engine | Not in engine | +|-----------|---------------| +| Loop, graphics, ECS, input, physics bridge | Character controller, scene manager, tilemaps, audio | + +## Physics (minimal) + +Spawn with separate `collider`, `collision`, and `rigidBody` components. Pass `PhysicsBridge` to `World`. Call `world.fixedUpdate({ ...ctx, physics })` — do not call `physics.step()` yourself. Movement uses `physics.setLinearVelocity` / `applyImpulse` on entity ids. See `examples/demo/src/PlayerControllerSystem.ts`. + +Vite needs Rapier WASM handling: + +```ts +optimizeDeps: { exclude: ["@dimforge/rapier2d"] }, +assetsInclude: ["**/*.wasm"], +``` + +## Scripts + +| Command | Description | +|---------|-------------| +| `npm run dev` | Build engine + run demo | +| `npm run build` | Build `teengine` → `dist/` | +| `npm run typecheck` | Typecheck all workspaces | +| `npm run lint` | ESLint | +| `npm run test` | Vitest (teengine package) | + +## Consume elsewhere + +```bash +npm install teengine +``` + +In this monorepo, use `"teengine": "*"` in workspace `package.json`. + +MIT — @dioveath diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 3687e67..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,84 +0,0 @@ -# TeEngine Architecture - -TeEngine is a **simple 2D TypeScript game engine** with **WebGPU** rendering, **systems-based ECS**, and **Rapier physics**. It ships as the **`teengine` npm package** — see [PACKAGE.md](./PACKAGE.md) for layout. - -## Layer stack - -``` -Your game (examples/demo or your app) - ↓ -World entities, systems, physics sync, render interpolation - ↓ -Engine fixed timestep (1/60s) + input + render loop - ↓ -Graphics API cameras, layers, drawSprite, shapes - ↓ -DrawQueue collects commands per frame - ↓ -FrameRenderer sorts per layer (registry order), submits GPU passes - ├── SpriteBatcher textured quads (primary) - └── ShapeBatcher colored rects/circles/lines - ↓ -WebGPUContext - -PhysicsBridge (Rapier 2D) ←→ World.fixedUpdate() -``` - -## ECS + Systems - -Entities are component bags. Behavior lives in **systems**: - -```ts -world.addFixedSystem(new SpinSystem()); -world.addRenderSystem(new WorldEntityRenderSystem(graphics)); -world.addRenderSystem(new CameraFollowSystem(worldCamera)); -``` - -Game-specific systems (e.g. `PlayerControllerSystem`) live in your app, not the package. - -## Game loop - -```ts -import { Engine, World, PhysicsBridge, PhysicsWorld, Color } from "teengine"; - -const physics = new PhysicsBridge(await PhysicsWorld.create({ gravityY: 980 })); -const world = new World(physics); -const engine = await Engine.create({ canvas }); - -engine.setLoop({ - fixedUpdate: (ctx) => world.fixedUpdate({ ...ctx, physics }), - render: (ctx) => { - engine.graphics.beginFrame(Color.hex("#0d1117")); - world.render({ ...ctx, physics }); - engine.graphics.endFrame(); - }, -}); -engine.start(); -``` - -## Directory layout (package) - -``` -packages/teengine/src/ - engine/ Fixed timestep game loop - ecs/ World, Entity, built-in systems - input/ Input, ActionMap - physics/ PhysicsWorld, PhysicsBridge, coords - graphics/ Graphics, Camera2D, Layers, DrawQueue - gpu/ WebGPU, batchers (internal) - assets/ Atlas types, JSON loader - math/ Color, Mat3 -``` - -## Roadmap - -- [x] WebGPU + cameras + layers + sprites -- [x] Entity system + fixed timestep + systems -- [x] Input system -- [x] Shape primitives -- [x] Rapier 2D physics + PhysicsBridge -- [x] Render interpolation -- [x] JSON atlas loader -- [x] npm package layout -- [x] Collision events / sensors -- [ ] Kinematic character controller diff --git a/docs/PACKAGE.md b/docs/PACKAGE.md deleted file mode 100644 index 481f634..0000000 --- a/docs/PACKAGE.md +++ /dev/null @@ -1,122 +0,0 @@ -# Package layout - -TeEngine is structured as an **npm workspace monorepo**: a publishable library plus runnable examples. - -``` -teengine-js/ -├── packages/ -│ └── teengine/ # npm package — import as `teengine` -│ ├── package.json -│ ├── tsup.config.ts -│ └── src/ -│ ├── index.ts # public API surface -│ ├── engine/ # game loop, fixed timestep -│ ├── ecs/ # World, Entity, systems -│ ├── graphics/ # cameras, layers, draw API -│ ├── gpu/ # internal WebGPU (not exported) -│ ├── input/ -│ ├── physics/ -│ ├── assets/ # Atlas types + JSON loader -│ └── math/ -├── examples/ -│ └── demo/ # Vite app — `npm run dev` -│ └── src/ -│ ├── main.ts -│ ├── DemoScene.ts -│ └── createDemoAtlas.ts -├── docs/ -├── legacy/ -└── package.json # workspace root -``` - -## Design principles - -| Concern | Where it lives | -|---------|----------------| -| **Reusable engine** | `packages/teengine` | -| **Game-specific logic** | `examples/*` (PlayerController, demo atlas, scenes) | -| **GPU internals** | `packages/teengine/src/gpu` — private, not exported | -| **Editor / UI** | Out of scope — use your own UI framework in the app | - -## Public API (`teengine`) - -Single entry point today: - -```ts -import { - Engine, - World, - Graphics, - PhysicsBridge, - PhysicsWorld, - Layers, - loadAtlasFromJson, - SpinSystem, - CameraFollowSystem, - WorldEntityRenderSystem, -} from "teengine"; -``` - -### Future subpath exports (optional) - -If the API grows, add without breaking the main entry: - -```json -{ - "exports": { - ".": "./dist/index.js", - "./systems": "./dist/systems/index.js", - "./physics": "./dist/physics/index.js" - } -} -``` - -Only split when consumers need tree-shaking or clearer boundaries — not required yet. - -## Consuming the package - -### In this repo (workspace) - -```json -{ - "dependencies": { - "teengine": "workspace:*" - } -} -``` - -### Published (npm) - -```bash -npm install teengine -``` - -Bundlers must handle Rapier's WASM (`@dimforge/rapier2d`). Vite example: - -```ts -optimizeDeps: { exclude: ["@dimforge/rapier2d"] }, -assetsInclude: ["**/*.wasm"], -``` - -## Build - -```bash -npm install -npm run build # builds packages/teengine → dist/ -npm run dev # builds engine + runs examples/demo -``` - -## What not to put in the package - -- Demo scenes and game systems -- Procedural demo assets (`createDemoAtlas`) -- Editor UI -- App entry points (`main.ts`, `index.html`) - -These belong in `examples/` or the consumer's app. - -## Versioning - -- `packages/teengine` is versioned and published -- Root `teengine-js` stays `private: true` -- Examples are never published diff --git a/docs/PHYSICS.md b/docs/PHYSICS.md deleted file mode 100644 index 7a75368..0000000 --- a/docs/PHYSICS.md +++ /dev/null @@ -1,86 +0,0 @@ -# Physics — Rapier 2D Integration - -## Status: implemented (v1) - -TeEngine uses [`@dimforge/rapier2d`](https://www.npmjs.com/package/@dimforge/rapier2d) with a thin wrapper in `src/physics/`. - -## Architecture - -``` -Entity.collider + Entity.collision + Entity.rigidBody → PhysicsWorld.createPhysicsForEntity() - ↓ - Rapier World (Y-up) - ↓ -World.syncFromPhysics() → Entity.transform (Y-down) -``` - -### Coordinate boundary (`src/physics/coords.ts`) - -| Space | Y axis | Used by | -|-------|--------|---------| -| Engine / render | Down | Graphics, entities | -| Rapier | Up | Physics simulation | - -All conversion happens in `PhysicsWorld` — game code stays in engine coordinates. - -## Usage - -```ts -const physics = await PhysicsWorld.create({ gravityY: 980 }); -world.attachPhysics(physics); - -// Static ground (engine coords: x,y = top-left) -physics.createStaticBox(0, 300, 2000, 40); - -world.spawn({ - transform: { x: 400, y: 286 }, - rigidBody: { - type: "dynamic", - collider: { kind: "box", width: 28, height: 28 }, - lockRotation: true, - }, -}); - -fixedUpdate: ({ dt, input }) => { - physics.setLinearVelocity(handle, dx * speed, vy); - physics.applyImpulse(handle, 0, jumpImpulse); - physics.step(dt); - world.syncFromPhysics(); -}; -``` - -## RigidBodyComponent - -| Field | Purpose | -|-------|---------| -| `type` | `dynamic`, `fixed`, `kinematicPosition` | -| `lockRotation` | Passed to Rapier | - -Collider and collision policy live on separate components (`collider`, `collision`). See entity spawn examples below. - -## Next phases - -### Phase 2 — Events -- `PhysicsWorld.onCollisionEnter` / `Exit` via Rapier event queue -- Sensor colliders for triggers - -### Phase 3 — Character controller -- Rapier `KinematicCharacterController` for platformer movement -- Replace impulse-based jump with controller API - -### Phase 4 — Performance -- Reuse translation buffers (avoid alloc per body per frame) -- Optional Web Worker for `world.step()` - -## Vite config - -```ts -// vite.config.ts -optimizeDeps: { exclude: ["@dimforge/rapier2d"] }, -assetsInclude: ["**/*.wasm"], -``` - -## References - -- [Rapier JS getting started](https://rapier.rs/docs/user_guides/javascript/getting_started_js) -- [rapier.js GitHub](https://github.com/dimforge/rapier.js) diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..011acec --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,34 @@ +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsparser from "@typescript-eslint/parser"; + +export default [ + { + ignores: [ + "**/dist/**", + "**/node_modules/**", + "legacy/**", + "**/*.config.ts", + ], + }, + { + files: ["packages/teengine/src/**/*.ts", "examples/demo/src/**/*.ts"], + languageOptions: { + parser: tsparser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + "@typescript-eslint": tseslint, + }, + rules: { + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + }, +]; diff --git a/examples/demo/package.json b/examples/demo/package.json index e1c4c8f..c1707fb 100644 --- a/examples/demo/package.json +++ b/examples/demo/package.json @@ -8,7 +8,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "teengine": "file:../../packages/teengine" + "teengine": "*" }, "devDependencies": { "@webgpu/types": "^0.1.60", diff --git a/examples/demo/src/CoinPickupSystem.ts b/examples/demo/src/CoinPickupSystem.ts index e9caec2..52b49d4 100644 --- a/examples/demo/src/CoinPickupSystem.ts +++ b/examples/demo/src/CoinPickupSystem.ts @@ -1,9 +1,10 @@ -import type { FixedSystem } from "teengine"; +import type { FixedSystem, FixedSystemContext } from "teengine"; +import { DemoTags } from "./demoConstants.js"; export class CoinPickupSystem implements FixedSystem { readonly name = "CoinPickupSystem"; - fixedUpdate(ctx: import("teengine").FixedSystemContext): void { + fixedUpdate(ctx: FixedSystemContext): void { const { world, physics } = ctx; if (!physics) return; @@ -12,7 +13,7 @@ export class CoinPickupSystem implements FixedSystem { const self = world.get(event.self); const other = world.get(event.other); - if (self?.coin && other?.player) { + if (self?.tags.has(DemoTags.coin) && other?.tags.has(DemoTags.player)) { world.remove(event.self); } } diff --git a/examples/demo/src/DemoScene.ts b/examples/demo/src/DemoScene.ts index 3d3bdcd..633e951 100644 --- a/examples/demo/src/DemoScene.ts +++ b/examples/demo/src/DemoScene.ts @@ -1,7 +1,5 @@ -import type { DemoAtlas } from "teengine"; import { CameraFollowSystem, - CollisionGroups, Color, createUiCamera, createWorldCamera, @@ -16,6 +14,7 @@ import { import type { PhysicsBridge } from "teengine"; import { CoinPickupSystem } from "./CoinPickupSystem.js"; import { DebugOverlaySystem } from "./DebugOverlaySystem.js"; +import { DemoCollisionGroups, DemoTags, type DemoAtlas } from "./demoConstants.js"; import { PlayerControllerSystem } from "./PlayerControllerSystem.js"; export const GROUND_Y = 300; @@ -54,6 +53,7 @@ export function createDemoScene( const playerId = world.spawn({ name: "Player", transform: { x: 400, y: GROUND_Y - PLAYER_SIZE * 0.5 }, + tags: [DemoTags.player, DemoTags.cameraTarget], sprite: { region: atlas.player, layer: Layers.world }, shape: { kind: "circle", @@ -65,14 +65,15 @@ export function createDemoScene( collider: { shape: { kind: "box", width: PLAYER_SIZE, height: PLAYER_SIZE }, friction: 0.8, restitution: 0 }, collision: { response: "solid", - layers: layers(CollisionGroups.PLAYER, CollisionGroups.PICKUP | CollisionGroups.GROUND | CollisionGroups.ENEMY), + layers: layers( + DemoCollisionGroups.PLAYER, + DemoCollisionGroups.PICKUP | DemoCollisionGroups.GROUND | DemoCollisionGroups.ENEMY, + ), }, rigidBody: { type: "dynamic", lockRotation: true, }, - player: { _tag: "player" }, - cameraTarget: { _tag: "cameraTarget" }, }); world.spawn({ @@ -82,7 +83,10 @@ export function createDemoScene( collider: { shape: { kind: "box", width: 28, height: 28 } }, collision: { response: "solid", - layers: layers(CollisionGroups.ENEMY, CollisionGroups.PLAYER | CollisionGroups.GROUND), + layers: layers( + DemoCollisionGroups.ENEMY, + DemoCollisionGroups.PLAYER | DemoCollisionGroups.GROUND, + ), }, rigidBody: { type: "dynamic", @@ -93,13 +97,13 @@ export function createDemoScene( world.spawn({ name: "Coin", transform: { x: 280, y: 260 }, + tags: [DemoTags.coin], sprite: { region: atlas.coin, layer: Layers.world }, collider: { shape: { kind: "ball", radius: 12 } }, collision: { response: "sensor", - layers: layers(CollisionGroups.PICKUP, CollisionGroups.PLAYER), + layers: layers(DemoCollisionGroups.PICKUP, DemoCollisionGroups.PLAYER), }, - coin: { _tag: "coin" }, spin: { speed: 2 }, }); @@ -118,7 +122,7 @@ export function createDemoScene( world.addFixedSystem(new PlayerControllerSystem()); world.addPostPhysicsSystem(new CoinPickupSystem()); world.addFixedSystem(new SpinSystem()); - world.addRenderSystem(new CameraFollowSystem(worldCam)); + world.addRenderSystem(new CameraFollowSystem(worldCam, DemoTags.cameraTarget)); world.addRenderSystem(new WorldEntityRenderSystem(engine.graphics)); world.addRenderSystem( new DebugOverlaySystem(engine.graphics, { groundY: GROUND_Y, worldCamera: worldCam }), diff --git a/examples/demo/src/PlayerControllerSystem.ts b/examples/demo/src/PlayerControllerSystem.ts index 7bc5280..babf8be 100644 --- a/examples/demo/src/PlayerControllerSystem.ts +++ b/examples/demo/src/PlayerControllerSystem.ts @@ -1,4 +1,5 @@ -import type { FixedSystem } from "teengine"; +import type { FixedSystem, FixedSystemContext } from "teengine"; +import { DemoTags } from "./demoConstants.js"; const MOVE_SPEED = 220; const JUMP_IMPULSE = 280; @@ -6,12 +7,11 @@ const JUMP_IMPULSE = 280; export class PlayerControllerSystem implements FixedSystem { readonly name = "PlayerControllerSystem"; - fixedUpdate(ctx: import("teengine").FixedSystemContext): void { + fixedUpdate(ctx: FixedSystemContext): void { const { world, input, physics } = ctx; if (!physics) return; - for (const entity of world.getAll()) { - if (!entity.active || !entity.player || !entity.rigidBody) continue; + for (const entity of world.query({ withTags: [DemoTags.player], with: ["rigidBody"], active: true })) { const dx = input.actionAxis("move_left", "move_right"); const vel = physics.getLinearVelocity(entity.id); diff --git a/examples/demo/src/createDemoAtlas.ts b/examples/demo/src/createDemoAtlas.ts index d796762..c5e3eca 100644 --- a/examples/demo/src/createDemoAtlas.ts +++ b/examples/demo/src/createDemoAtlas.ts @@ -1,33 +1,11 @@ -import type { AtlasRegion, DemoAtlas, GpuTexture } from "teengine"; +import { uploadRgbaTexture, type AtlasRegion, type Engine } from "teengine"; +import type { DemoAtlas } from "./demoConstants.js"; const CELL = 32; const COLS = 4; const SIZE = CELL * COLS; -function createTextureFromRgba( - device: GPUDevice, - pixels: Uint8ClampedArray, - width: number, - height: number, -): GpuTexture { - const data = new Uint8Array(pixels); - const texture = device.createTexture({ - size: { width, height }, - format: "rgba8unorm", - usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, - }); - device.queue.writeTexture( - { texture }, - data, - { bytesPerRow: width * 4 }, - { width, height }, - ); - const view = texture.createView(); - const sampler = device.createSampler({ magFilter: "nearest", minFilter: "nearest" }); - return { texture, view, sampler, width, height }; -} - -function regionFromCell(texture: GpuTexture, col: number, row: number): AtlasRegion { +function regionFromCell(texture: AtlasRegion["texture"], col: number, row: number): AtlasRegion { const x = col * CELL; const y = row * CELL; return { @@ -42,7 +20,7 @@ function regionFromCell(texture: GpuTexture, col: number, row: number): AtlasReg } /** Procedural demo atlas — no external image files required. */ -export function createDemoAtlas(device: GPUDevice): DemoAtlas { +export function createDemoAtlas(engine: Engine): DemoAtlas { const pixels = new Uint8ClampedArray(SIZE * SIZE * 4); const fills: Array<[col: number, row: number, r: number, g: number, b: number, a: number]> = [ @@ -68,7 +46,7 @@ export function createDemoAtlas(device: GPUDevice): DemoAtlas { } } - const texture = createTextureFromRgba(device, pixels, SIZE, SIZE); + const texture = uploadRgbaTexture(engine, pixels, SIZE, SIZE); return { player: regionFromCell(texture, 0, 0), enemy: regionFromCell(texture, 1, 0), diff --git a/examples/demo/src/demoConstants.ts b/examples/demo/src/demoConstants.ts new file mode 100644 index 0000000..9e7ff43 --- /dev/null +++ b/examples/demo/src/demoConstants.ts @@ -0,0 +1,27 @@ +import type { AtlasRegion } from "teengine"; + +/** Demo-specific atlas layout — your game defines its own atlas shape. */ +export type DemoAtlas = { + player: AtlasRegion; + enemy: AtlasRegion; + coin: AtlasRegion; + uiHeart: AtlasRegion; +}; + +/** Tags used by the demo game systems. */ +export const DemoTags = { + player: "player", + coin: "coin", + cameraTarget: "cameraTarget", +} as const; + +/** + * Demo collision layer bits — games define their own constants. + * Must be distinct powers of two so masks compose cleanly. + */ +export const DemoCollisionGroups = { + PLAYER: 1 << 1, + PICKUP: 1 << 2, + GROUND: 1 << 3, + ENEMY: 1 << 4, +} as const; diff --git a/examples/demo/src/main.ts b/examples/demo/src/main.ts index 58f42cd..d5c1daa 100644 --- a/examples/demo/src/main.ts +++ b/examples/demo/src/main.ts @@ -1,6 +1,4 @@ -import { Engine } from "teengine"; -import { PhysicsBridge } from "teengine"; -import { PhysicsWorld } from "teengine"; +import { Engine, PhysicsBridge, PhysicsWorld } from "teengine"; import { createDemoAtlas } from "./createDemoAtlas.js"; import { bindDemoLoop, createDemoScene } from "./DemoScene.js"; @@ -16,7 +14,7 @@ async function main(): Promise { const engine = await Engine.create({ canvas }); const physicsWorld = await PhysicsWorld.create({ gravityY: 980 }); const physics = new PhysicsBridge(physicsWorld); - const atlas = createDemoAtlas(engine.device); + const atlas = createDemoAtlas(engine); const scene = createDemoScene(engine, physics, atlas); bindDemoLoop(scene); diff --git a/legacy/README.md b/legacy/README.md deleted file mode 100644 index 6455737..0000000 --- a/legacy/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Legacy Canvas 2D prototype (2017) - -These files are the original TeEngine prototype: - -- `main.js` — Canvas 2D render loop -- `particle.js` — Custom particle physics (springs, gravity) -- `utils.js` — Math/collision helpers - -Superseded by the TypeScript + WebGPU engine in `src/`. Useful math from `utils.js` will be ported to `src/math/` over time. diff --git a/package-lock.json b/package-lock.json index fd8340e..edb8603 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,16 @@ "examples/*" ], "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.62.1", + "@typescript-eslint/parser": "^8.62.1", "@webgpu/types": "^0.1.60", + "eslint": "^10.6.0", "typescript": "^5.8.3" } }, "examples/demo": { "dependencies": { - "teengine": "file:../../packages/teengine" + "teengine": "*" }, "devDependencies": { "@webgpu/types": "^0.1.60", @@ -472,6 +475,166 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1166,6 +1329,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1173,6 +1343,246 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitest/expect": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", @@ -1308,6 +1718,33 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1325,6 +1762,29 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1421,6 +1881,21 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1449,6 +1924,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/demo": { "resolved": "examples/demo", "link": true @@ -1502,6 +1984,200 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1512,6 +2188,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1522,6 +2208,27 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1540,6 +2247,36 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1552,6 +2289,27 @@ "rollup": "^4.34.8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1567,6 +2325,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/happy-dom": { "version": "17.6.3", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.6.3.tgz", @@ -1581,6 +2352,56 @@ "node": ">=20.0.0" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1598,6 +2419,51 @@ "dev": true, "license": "MIT" }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1628,6 +2494,22 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1645,6 +2527,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -1696,6 +2594,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1706,6 +2611,76 @@ "node": ">=0.10.0" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1837,6 +2812,26 @@ } } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1906,6 +2901,42 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2081,6 +3112,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -2141,6 +3185,19 @@ } } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2162,6 +3219,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -2878,6 +3945,22 @@ "node": ">=12" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2895,6 +3978,29 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/teengine": { "version": "0.3.0", "license": "MIT", diff --git a/package.json b/package.json index 6b3e5d8..67aa5af 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,14 @@ "build:demo": "npm run build -w teengine && npm run build -w demo", "dev": "npm run build -w teengine && npm run dev -w demo", "typecheck": "npm run typecheck -w teengine && npm run typecheck -w demo", + "lint": "eslint .", "test": "npm run test -w teengine" }, "devDependencies": { + "@typescript-eslint/eslint-plugin": "^8.62.1", + "@typescript-eslint/parser": "^8.62.1", "@webgpu/types": "^0.1.60", + "eslint": "^10.6.0", "typescript": "^5.8.3" } } diff --git a/packages/teengine/README.md b/packages/teengine/README.md deleted file mode 100644 index 6f937dc..0000000 --- a/packages/teengine/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# teengine - -2D TypeScript game engine — WebGPU rendering, systems ECS, Rapier physics. - -```bash -npm install teengine -``` - -```ts -import { Engine, World, PhysicsBridge, PhysicsWorld, Layers, Color } from "teengine"; -``` - -See the [repository](https://github.com/dioveath/teengine-js) for docs and examples. diff --git a/packages/teengine/src/assets/Atlas.ts b/packages/teengine/src/assets/Atlas.ts index 141a68a..f3ae16b 100644 --- a/packages/teengine/src/assets/Atlas.ts +++ b/packages/teengine/src/assets/Atlas.ts @@ -1,3 +1,4 @@ +/** GPU texture bundle used internally by atlas regions and the renderer. */ export type GpuTexture = { texture: GPUTexture; view: GPUTextureView; @@ -16,10 +17,3 @@ export type AtlasRegion = { width: number; height: number; }; - -export type DemoAtlas = { - player: AtlasRegion; - enemy: AtlasRegion; - coin: AtlasRegion; - uiHeart: AtlasRegion; -}; diff --git a/packages/teengine/src/assets/loadAtlas.test.ts b/packages/teengine/src/assets/loadAtlas.test.ts new file mode 100644 index 0000000..89d0e14 --- /dev/null +++ b/packages/teengine/src/assets/loadAtlas.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Engine } from "../engine/Engine.js"; +import { loadAtlasFromJson } from "./loadAtlas.js"; + +function createMockEngine(): Engine { + return { + getGpuDevice: () => + ({ + createTexture: vi.fn(() => ({ + createView: vi.fn(() => ({})), + })), + createSampler: vi.fn(() => ({})), + queue: { + copyExternalImageToTexture: vi.fn(), + }, + }) as unknown as GPUDevice, + } as Engine; +} + +describe("loadAtlasFromJson", () => { + const fetchMock = vi.fn(); + const createImageBitmapMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + createImageBitmapMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("createImageBitmap", createImageBitmapMock); + vi.stubGlobal("GPUTextureUsage", { + TEXTURE_BINDING: 0x04, + COPY_DST: 0x02, + RENDER_ATTACHMENT: 0x10, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("loads atlas JSON and image, returning named regions", async () => { + const atlasJson = { + meta: { image: "sprites.png", size: { w: 64, h: 32 } }, + frames: { + hero: { frame: { x: 0, y: 0, w: 32, h: 32 } }, + coin: { frame: { x: 32, y: 0, w: 16, h: 16 } }, + }, + }; + + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => atlasJson, + }) + .mockResolvedValueOnce({ + ok: true, + blob: async () => new Blob(), + }); + + createImageBitmapMock.mockResolvedValue({ width: 64, height: 32 }); + + const engine = createMockEngine(); + const regions = await loadAtlasFromJson(engine, "/assets/sprites.json"); + + expect(regions.hero?.width).toBe(32); + expect(regions.hero?.u0).toBe(0); + expect(regions.hero?.u1).toBe(0.5); + expect(regions.coin?.height).toBe(16); + expect(fetchMock).toHaveBeenCalledWith("/assets/sprites.json"); + }); + + it("throws when atlas JSON fetch fails", async () => { + fetchMock.mockResolvedValueOnce({ ok: false }); + const engine = createMockEngine(); + + await expect(loadAtlasFromJson(engine, "/missing.json")).rejects.toThrow( + /Failed to load atlas JSON/, + ); + }); + + it("throws when atlas image fetch fails", async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + meta: { image: "sprites.png", size: { w: 64, h: 32 } }, + frames: {}, + }), + }) + .mockResolvedValueOnce({ ok: false }); + + const engine = createMockEngine(); + await expect(loadAtlasFromJson(engine, "/assets/sprites.json")).rejects.toThrow( + /Failed to load atlas image/, + ); + }); +}); diff --git a/packages/teengine/src/assets/loadAtlas.ts b/packages/teengine/src/assets/loadAtlas.ts index c7fb224..ecfb0ed 100644 --- a/packages/teengine/src/assets/loadAtlas.ts +++ b/packages/teengine/src/assets/loadAtlas.ts @@ -1,3 +1,4 @@ +import type { Engine } from "../engine/Engine.js"; import type { AtlasRegion, GpuTexture } from "./Atlas.js"; /** JSON atlas descriptor (Aseprite / TexturePacker-style minimal subset). */ @@ -21,10 +22,11 @@ export type LoadedAtlas = Record; * Returns named regions ready for drawSprite(). */ export async function loadAtlasFromJson( - device: GPUDevice, + engine: Engine, jsonUrl: string, imageUrl?: string, ): Promise { + const device = engine.getGpuDevice(); const response = await fetch(jsonUrl); if (!response.ok) { throw new Error(`Failed to load atlas JSON: ${jsonUrl}`); diff --git a/packages/teengine/src/assets/uploadTexture.ts b/packages/teengine/src/assets/uploadTexture.ts new file mode 100644 index 0000000..49da33f --- /dev/null +++ b/packages/teengine/src/assets/uploadTexture.ts @@ -0,0 +1,31 @@ +import type { Engine } from "../engine/Engine.js"; +import type { GpuTexture } from "./Atlas.js"; + +/** Upload RGBA pixel data to a GPU texture (procedural art, runtime-generated sprites). */ +export function uploadRgbaTexture( + engine: Engine, + pixels: Uint8ClampedArray | Uint8Array, + width: number, + height: number, +): GpuTexture { + const device = engine.getGpuDevice(); + const data = new Uint8Array(pixels); + + const texture = device.createTexture({ + size: { width, height }, + format: "rgba8unorm", + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, + }); + + device.queue.writeTexture( + { texture }, + data, + { bytesPerRow: width * 4 }, + { width, height }, + ); + + const view = texture.createView(); + const sampler = device.createSampler({ magFilter: "nearest", minFilter: "nearest" }); + + return { texture, view, sampler, width, height }; +} diff --git a/packages/teengine/src/ecs/Entity.test.ts b/packages/teengine/src/ecs/Entity.test.ts new file mode 100644 index 0000000..64fce3c --- /dev/null +++ b/packages/teengine/src/ecs/Entity.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { createEntity, hasPhysics, isSimulatedBody } from "./Entity.js"; + +describe("createEntity", () => { + it("assigns defaults and copies spawn config", () => { + const entity = createEntity(1, { + name: "Hero", + transform: { x: 10, y: 20 }, + tags: ["player", "hero"], + }); + + expect(entity.id).toBe(1); + expect(entity.name).toBe("Hero"); + expect(entity.active).toBe(true); + expect(entity.transform.x).toBe(10); + expect(entity.transform.y).toBe(20); + expect(entity.tags.has("player")).toBe(true); + expect(entity.tags.has("hero")).toBe(true); + }); + + it("starts with an empty tag set when none are provided", () => { + const entity = createEntity(2, {}); + expect(entity.tags.size).toBe(0); + }); +}); + +describe("hasPhysics", () => { + it("is true when a collider component is present", () => { + const entity = createEntity(3, { + collider: { shape: { kind: "box", width: 10, height: 10 } }, + }); + expect(hasPhysics(entity)).toBe(true); + }); + + it("is false without a collider", () => { + expect(hasPhysics(createEntity(4, {}))).toBe(false); + }); +}); + +describe("isSimulatedBody", () => { + it("is true for dynamic and kinematic bodies", () => { + const dynamic = createEntity(5, { + collider: { shape: { kind: "box", width: 10, height: 10 } }, + rigidBody: { type: "dynamic" }, + }); + const kinematic = createEntity(6, { + collider: { shape: { kind: "box", width: 10, height: 10 } }, + rigidBody: { type: "kinematicPosition" }, + }); + + expect(isSimulatedBody(dynamic)).toBe(true); + expect(isSimulatedBody(kinematic)).toBe(true); + }); + + it("is false for fixed bodies and entities without rigidBody", () => { + const fixed = createEntity(7, { + collider: { shape: { kind: "box", width: 10, height: 10 } }, + rigidBody: { type: "fixed" }, + }); + + expect(isSimulatedBody(fixed)).toBe(false); + expect(isSimulatedBody(createEntity(8, {}))).toBe(false); + }); +}); diff --git a/packages/teengine/src/ecs/Entity.ts b/packages/teengine/src/ecs/Entity.ts index be2b795..020f989 100644 --- a/packages/teengine/src/ecs/Entity.ts +++ b/packages/teengine/src/ecs/Entity.ts @@ -70,18 +70,6 @@ export type ShapeComponent = (ShapeRect | ShapeCircle | ShapeLine) & { layer: LayerName; }; -/** Marker: entity is the player character. */ -export type PlayerTag = { readonly _tag: "player" }; - -/** Marker: camera follows this entity. */ -export type CameraTargetTag = { readonly _tag: "cameraTarget" }; - -/** Marker: collectable pickup (demo / game-specific pattern). */ -export type CoinTag = { readonly _tag: "coin" }; - -/** Marker: receive collision events as `self` when `emitEvents` is enabled. */ -export type CollisionListenerTag = { readonly _tag: "collisionListener" }; - /** Rotates entity over time (radians per second). */ export type SpinComponent = { speed: number }; @@ -90,30 +78,25 @@ export type Entity = { name: string; active: boolean; transform: TransformData; + /** Game-defined labels for systems to filter on (e.g. `"player"`, `"pickup"`). */ + tags: Set; sprite?: SpriteComponent; shape?: ShapeComponent; collider?: ColliderComponent; collision?: CollisionComponent; rigidBody?: RigidBodyComponent; - player?: PlayerTag; - cameraTarget?: CameraTargetTag; - coin?: CoinTag; - collisionListener?: CollisionListenerTag; spin?: SpinComponent; }; export type SpawnConfig = { name?: string; transform?: Partial; + tags?: Iterable; sprite?: SpriteComponent; shape?: ShapeComponent; collider?: ColliderComponent; collision?: CollisionComponent; rigidBody?: RigidBodyComponent; - player?: PlayerTag; - cameraTarget?: CameraTargetTag; - coin?: CoinTag; - collisionListener?: CollisionListenerTag; spin?: SpinComponent; }; @@ -123,15 +106,12 @@ export function createEntity(id: EntityId, config: SpawnConfig): Entity { name: config.name ?? `Entity ${id}`, active: true, transform: Transform.create(config.transform), + tags: new Set(config.tags ?? []), sprite: config.sprite, shape: config.shape, collider: config.collider, collision: config.collision, rigidBody: config.rigidBody, - player: config.player, - cameraTarget: config.cameraTarget, - coin: config.coin, - collisionListener: config.collisionListener, spin: config.spin, }; } diff --git a/packages/teengine/src/ecs/World.test.ts b/packages/teengine/src/ecs/World.test.ts new file mode 100644 index 0000000..b106b8e --- /dev/null +++ b/packages/teengine/src/ecs/World.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { Layers } from "../graphics/Layers.js"; +import { World } from "../ecs/World.js"; +import { Input } from "../input/Input.js"; + +function createTestInput(): Input { + const canvas = document.createElement("canvas"); + canvas.width = 800; + canvas.height = 600; + return new Input(canvas); +} + +describe("World", () => { + it("spawns entities with monotonically increasing ids", () => { + const world = new World(); + const a = world.spawn({ name: "A" }); + const b = world.spawn({ name: "B" }); + expect(b).toBe(a + 1); + }); + + it("removes entities and returns undefined from get", () => { + const world = new World(); + const id = world.spawn({ transform: { x: 1, y: 2 } }); + expect(world.get(id)?.transform.x).toBe(1); + + world.remove(id); + expect(world.get(id)).toBeUndefined(); + }); + + it("filters entities with query", () => { + const world = new World(); + world.spawn({ tags: ["player"], rigidBody: { type: "dynamic" } }); + world.spawn({ tags: ["coin"] }); + world.spawn({ tags: ["player"], spin: { speed: 1 } }); + + const players = world.query({ withTags: ["player"] }); + expect(players).toHaveLength(2); + + const movers = world.query({ withTags: ["player"], with: ["rigidBody"] }); + expect(movers).toHaveLength(1); + expect(movers[0]?.rigidBody?.type).toBe("dynamic"); + }); + + it("returns raw transforms for non-physics entities during render", () => { + const world = new World(); + const id = world.spawn({ transform: { x: 42, y: 84 } }); + const entity = world.get(id)!; + + const renderTransform = world.getRenderTransform(entity, 0.5); + expect(renderTransform.x).toBe(42); + expect(renderTransform.y).toBe(84); + }); + + it("collects renderables into layer buckets and skips inactive entities", () => { + const world = new World(); + const activeId = world.spawn({ + sprite: { + region: { + texture: {} as never, + u0: 0, + v0: 0, + u1: 1, + v1: 1, + width: 16, + height: 16, + }, + layer: Layers.world, + }, + }); + const inactiveId = world.spawn({ + sprite: { + region: { + texture: {} as never, + u0: 0, + v0: 0, + u1: 1, + v1: 1, + width: 16, + height: 16, + }, + layer: Layers.world, + }, + }); + + world.deactivate(inactiveId); + + const buckets = world.collectRenderables(new Map()); + const worldBucket = buckets.get(Layers.world); + expect(worldBucket?.sprites).toHaveLength(1); + expect(worldBucket?.sprites[0]?.id).toBe(activeId); + }); + + it("runs fixed systems before post-physics systems", () => { + const world = new World(); + const input = createTestInput(); + const order: string[] = []; + + world.addFixedSystem({ + name: "Fixed", + fixedUpdate: () => { + order.push("fixed"); + }, + }); + world.addPostPhysicsSystem({ + name: "Post", + fixedUpdate: () => { + order.push("post"); + }, + }); + + world.fixedUpdate({ dt: 1 / 60, tick: 0, time: 0, input, physics: null }); + expect(order).toEqual(["fixed", "post"]); + }); + + it("invokes render systems", () => { + const world = new World(); + const input = createTestInput(); + let rendered = false; + + world.addRenderSystem({ + name: "Render", + render: () => { + rendered = true; + }, + }); + + world.render({ + dt: 1 / 60, + tick: 0, + time: 0, + input, + physics: null, + alpha: 1, + width: 800, + height: 600, + }); + + expect(rendered).toBe(true); + }); + + it("tracks elapsed simulation time across fixed updates", () => { + const world = new World(); + const input = createTestInput(); + + world.fixedUpdate({ dt: 1 / 60, tick: 0, time: 0, input, physics: null }); + world.fixedUpdate({ dt: 1 / 60, tick: 1, time: 1 / 60, input, physics: null }); + + expect(world.elapsed).toBeCloseTo(2 / 60); + }); + + it("activate and deactivate toggle entity.active", () => { + const world = new World(); + const id = world.spawn({ name: "Toggle" }); + + expect(world.get(id)?.active).toBe(true); + + world.deactivate(id); + expect(world.get(id)?.active).toBe(false); + + world.activate(id); + expect(world.get(id)?.active).toBe(true); + }); + + it("activate and deactivate throw when the entity is missing", () => { + const world = new World(); + expect(() => world.activate(999)).toThrow(/not found/); + expect(() => world.deactivate(999)).toThrow(/not found/); + }); +}); + +describe("sortEntitiesForLayer", () => { + it("sorts entities by a custom key when mode is not none", async () => { + const { sortEntitiesForLayer } = await import("../ecs/World.js"); + const world = new World(); + const low = world.spawn({ transform: { y: 10 } }); + const high = world.spawn({ transform: { y: 100 } }); + const entities = [world.get(high)!, world.get(low)!]; + + sortEntitiesForLayer(entities, "y", (entity) => entity.transform.y); + expect(entities.map((e) => e.id)).toEqual([low, high]); + }); +}); diff --git a/packages/teengine/src/ecs/World.ts b/packages/teengine/src/ecs/World.ts index 63b747a..011338d 100644 --- a/packages/teengine/src/ecs/World.ts +++ b/packages/teengine/src/ecs/World.ts @@ -2,6 +2,7 @@ import type { LayerSortMode } from "../graphics/LayerRegistry.js"; import type { PhysicsBridge } from "../physics/PhysicsBridge.js"; import type { TransformSnapshot } from "./interpolation.js"; import { createEntity, hasPhysics, type Entity, type EntityId, type SpawnConfig } from "./Entity.js"; +import { matchesEntityQuery, type EntityQuery } from "./query.js"; import type { FixedSystem, RenderSystem } from "./System.js"; type LayerBucket = { @@ -64,11 +65,36 @@ export class World { return [...this.entities.values()]; } + /** Filter entities by tags and component presence. */ + query(filter: EntityQuery): readonly Entity[] { + const results: Entity[] = []; + for (const entity of this.entities.values()) { + if (matchesEntityQuery(entity, filter)) { + results.push(entity); + } + } + return results; + } + remove(id: EntityId): void { this.physics?.unregister(id); this.entities.delete(id); } + /** Enable an entity for simulation, rendering, and query matching. */ + activate(id: EntityId): void { + const entity = this.entities.get(id); + if (!entity) throw new Error(`Entity ${id} not found.`); + entity.active = true; + } + + /** Disable an entity without removing it from the world. */ + deactivate(id: EntityId): void { + const entity = this.entities.get(id); + if (!entity) throw new Error(`Entity ${id} not found.`); + entity.active = false; + } + fixedUpdate(ctx: Omit): void { this.time += ctx.dt; @@ -95,10 +121,6 @@ export class World { } } - get physicsBridge(): PhysicsBridge | null { - return this.physics; - } - get elapsed(): number { return this.time; } diff --git a/packages/teengine/src/ecs/index.ts b/packages/teengine/src/ecs/index.ts index 89959e5..9d8374a 100644 --- a/packages/teengine/src/ecs/index.ts +++ b/packages/teengine/src/ecs/index.ts @@ -14,12 +14,10 @@ export type { ShapeRect, ShapeCircle, ShapeLine, - PlayerTag, - CameraTargetTag, - CoinTag, - CollisionListenerTag, SpinComponent, } from "./Entity.js"; -export { World, sortEntitiesForLayer } from "./World.js"; +export { World } from "./World.js"; export { hasPhysics, isSimulatedBody } from "./Entity.js"; +export { matchesEntityQuery } from "./query.js"; +export type { EntityQuery, EntityComponentKey } from "./query.js"; export type { FixedSystem, RenderSystem, FixedSystemContext, RenderSystemContext } from "./System.js"; diff --git a/packages/teengine/src/ecs/interpolation.test.ts b/packages/teengine/src/ecs/interpolation.test.ts new file mode 100644 index 0000000..9c489fa --- /dev/null +++ b/packages/teengine/src/ecs/interpolation.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { Transform } from "./Transform.js"; +import { lerpTransform, snapshotTransform } from "./interpolation.js"; + +describe("snapshotTransform", () => { + it("copies transform fields into a plain snapshot", () => { + const transform = Transform.create({ x: 5, y: 10, rotation: 0.5, scaleX: 2, scaleY: 3 }); + expect(snapshotTransform(transform)).toEqual({ + x: 5, + y: 10, + rotation: 0.5, + scaleX: 2, + scaleY: 3, + }); + }); +}); + +describe("lerpTransform", () => { + const prev = { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 }; + const current = { x: 100, y: 40, rotation: 1, scaleX: 2, scaleY: 2 }; + + it("returns the previous snapshot at alpha 0", () => { + const out = lerpTransform(prev, current, 0); + expect(out.x).toBe(0); + expect(out.y).toBe(0); + expect(out.rotation).toBe(0); + }); + + it("returns the current snapshot at alpha 1", () => { + const out = lerpTransform(prev, current, 1); + expect(out.x).toBe(100); + expect(out.y).toBe(40); + expect(out.rotation).toBe(1); + }); + + it("interpolates at alpha 0.5", () => { + const out = lerpTransform(prev, current, 0.5); + expect(out.x).toBeCloseTo(50); + expect(out.y).toBeCloseTo(20); + expect(out.rotation).toBeCloseTo(0.5); + expect(out.scaleX).toBeCloseTo(1.5); + expect(out.scaleY).toBeCloseTo(1.5); + }); +}); diff --git a/packages/teengine/src/ecs/query.test.ts b/packages/teengine/src/ecs/query.test.ts new file mode 100644 index 0000000..1f4af24 --- /dev/null +++ b/packages/teengine/src/ecs/query.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { createEntity } from "./Entity.js"; +import { matchesEntityQuery } from "./query.js"; + +describe("matchesEntityQuery", () => { + const player = createEntity(1, { + tags: ["player"], + rigidBody: { type: "dynamic" }, + collider: { shape: { kind: "box", width: 10, height: 10 } }, + }); + + const coin = createEntity(2, { + tags: ["coin"], + collider: { shape: { kind: "ball", radius: 8 } }, + collision: { response: "sensor" }, + }); + + it("matches by required tags", () => { + expect(matchesEntityQuery(player, { withTags: ["player"] })).toBe(true); + expect(matchesEntityQuery(coin, { withTags: ["player"] })).toBe(false); + }); + + it("excludes entities with forbidden tags", () => { + expect(matchesEntityQuery(player, { withoutTags: ["coin"] })).toBe(true); + expect(matchesEntityQuery(coin, { withoutTags: ["coin"] })).toBe(false); + }); + + it("matches by component presence", () => { + expect(matchesEntityQuery(player, { with: ["rigidBody", "collider"] })).toBe(true); + expect(matchesEntityQuery(coin, { with: ["rigidBody"] })).toBe(false); + }); + + it("excludes entities with forbidden components", () => { + expect(matchesEntityQuery(coin, { without: ["rigidBody"] })).toBe(true); + expect(matchesEntityQuery(player, { without: ["rigidBody"] })).toBe(false); + }); + + it("filters by active flag when specified", () => { + player.active = false; + expect(matchesEntityQuery(player, { active: true })).toBe(false); + expect(matchesEntityQuery(player, { active: false })).toBe(true); + player.active = true; + }); +}); diff --git a/packages/teengine/src/ecs/query.ts b/packages/teengine/src/ecs/query.ts new file mode 100644 index 0000000..660b677 --- /dev/null +++ b/packages/teengine/src/ecs/query.ts @@ -0,0 +1,55 @@ +import type { Entity } from "./Entity.js"; + +/** Component fields that can be used in entity queries. */ +export type EntityComponentKey = + | "sprite" + | "shape" + | "collider" + | "collision" + | "rigidBody" + | "spin"; + +export type EntityQuery = { + /** Entity must have every listed tag. */ + withTags?: Iterable; + /** Entity must have none of these tags. */ + withoutTags?: Iterable; + /** Entity must have all of these components. */ + with?: readonly EntityComponentKey[]; + /** Entity must not have any of these components. */ + without?: readonly EntityComponentKey[]; + /** When set, match only entities with this active flag. */ + active?: boolean; +}; + +export function matchesEntityQuery(entity: Entity, query: EntityQuery): boolean { + if (query.active !== undefined && entity.active !== query.active) { + return false; + } + + if (query.withTags) { + for (const tag of query.withTags) { + if (!entity.tags.has(tag)) return false; + } + } + + if (query.withoutTags) { + for (const tag of query.withoutTags) { + if (entity.tags.has(tag)) return false; + } + } + + if (query.with) { + for (const key of query.with) { + if (entity[key] === undefined) return false; + } + } + + if (query.without) { + for (const key of query.without) { + if (entity[key] !== undefined) return false; + } + } + + return true; +} diff --git a/packages/teengine/src/ecs/systems/CameraFollowSystem.ts b/packages/teengine/src/ecs/systems/CameraFollowSystem.ts index e490479..90deb63 100644 --- a/packages/teengine/src/ecs/systems/CameraFollowSystem.ts +++ b/packages/teengine/src/ecs/systems/CameraFollowSystem.ts @@ -4,11 +4,14 @@ import type { RenderSystem } from "../System.js"; export class CameraFollowSystem implements RenderSystem { readonly name = "CameraFollowSystem"; - constructor(private readonly camera: Camera2D) {} + constructor( + private readonly camera: Camera2D, + private readonly followTag: string, + ) {} render(ctx: import("../System.js").RenderSystemContext): void { for (const entity of ctx.world.getAll()) { - if (!entity.active || !entity.cameraTarget) continue; + if (!entity.active || !entity.tags.has(this.followTag)) continue; const t = ctx.world.getRenderTransform(entity, ctx.alpha); this.camera.lookAt(t.x, t.y); return; diff --git a/packages/teengine/src/engine/Engine.test.ts b/packages/teengine/src/engine/Engine.test.ts new file mode 100644 index 0000000..9907fc6 --- /dev/null +++ b/packages/teengine/src/engine/Engine.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockResizeToDisplaySize = vi.fn(() => ({ width: 640, height: 480 })); +const mockGraphicsResize = vi.fn(); + +vi.mock("../gpu/WebGPUContext.js", () => ({ + WebGPUContext: { + create: vi.fn(async (options: { canvas: HTMLCanvasElement }) => ({ + canvas: options.canvas, + device: {}, + resizeToDisplaySize: mockResizeToDisplaySize, + })), + }, +})); + +vi.mock("../graphics/Graphics.js", () => ({ + Graphics: { + create: vi.fn(async () => ({ + viewport: { width: 640, height: 480 }, + resize: mockGraphicsResize, + })), + }, +})); + +import { Engine } from "./Engine.js"; + +describe("Engine", () => { + let canvas: HTMLCanvasElement; + let rafCallback: FrameRequestCallback | null; + let rafId: number; + + beforeEach(() => { + canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 480; + rafCallback = null; + rafId = 1; + + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + rafCallback = cb; + return rafId++; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.stubGlobal( + "ResizeObserver", + class { + observe = vi.fn(); + disconnect = vi.fn(); + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("creates with graphics, input, and default fixed timestep", async () => { + const engine = await Engine.create({ canvas }); + expect(engine.graphics.viewport.width).toBe(640); + expect(engine.fixedTimestep).toBeCloseTo(1 / 60); + engine.stop(); + }); + + it("invokes fixedUpdate and render when the loop ticks", async () => { + const engine = await Engine.create({ canvas }); + const fixedUpdate = vi.fn(); + const render = vi.fn(); + + engine.setLoop({ fixedUpdate, render }); + engine.start(); + + expect(rafCallback).toBeTypeOf("function"); + const startTime = performance.now(); + rafCallback!(startTime + 1000 / 60); + + expect(fixedUpdate).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(1); + expect(render.mock.calls[0]?.[0].width).toBe(640); + + engine.stop(); + }); + + it("skips fixed updates while paused but still renders", async () => { + const engine = await Engine.create({ canvas }); + const fixedUpdate = vi.fn(); + const render = vi.fn(); + + engine.setLoop({ fixedUpdate, render }); + engine.setPaused(true); + engine.start(); + + rafCallback!(2000); + + expect(fixedUpdate).not.toHaveBeenCalled(); + expect(render).toHaveBeenCalledTimes(1); + + engine.stop(); + }); + + it("resizes graphics when the display size changes", async () => { + mockResizeToDisplaySize.mockReturnValueOnce({ width: 1024, height: 768 }); + const engine = await Engine.create({ canvas }); + expect(mockGraphicsResize).toHaveBeenCalledWith(1024, 768); + engine.stop(); + }); + + it("stop prevents further loop ticks", async () => { + const engine = await Engine.create({ canvas }); + const render = vi.fn(); + engine.setLoop({ fixedUpdate: vi.fn(), render }); + engine.start(); + engine.stop(); + + rafCallback!(3000); + expect(render).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/teengine/src/engine/Engine.ts b/packages/teengine/src/engine/Engine.ts index e206d31..bf172f8 100644 --- a/packages/teengine/src/engine/Engine.ts +++ b/packages/teengine/src/engine/Engine.ts @@ -96,7 +96,8 @@ export class Engine { return this.paused; } - get device(): GPUDevice { + /** @internal Used by asset upload helpers in this package. */ + getGpuDevice(): GPUDevice { return this.gpu.device; } diff --git a/packages/teengine/src/gpu/SpriteBatcher.ts b/packages/teengine/src/gpu/SpriteBatcher.ts index 2f7cc2d..8eebb37 100644 --- a/packages/teengine/src/gpu/SpriteBatcher.ts +++ b/packages/teengine/src/gpu/SpriteBatcher.ts @@ -36,10 +36,6 @@ export class SpriteBatcher { return new SpriteBatcher(gpu, pipeline, vertexBuffer); } - clear(): void { - this.vertices = []; - } - /** Draw sprites grouped by texture to minimize bind-group changes. */ drawSorted(pass: GPURenderPassEncoder, commands: SpriteDrawCommand[], viewProjection: Mat3): void { if (commands.length === 0) return; diff --git a/packages/teengine/src/graphics/DrawQueue.test.ts b/packages/teengine/src/graphics/DrawQueue.test.ts new file mode 100644 index 0000000..ec8a19f --- /dev/null +++ b/packages/teengine/src/graphics/DrawQueue.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import type { AtlasRegion } from "../assets/Atlas.js"; +import { Color } from "../math/index.js"; +import { + DrawQueue, + resolveDrawOptions, + resolveShapeZ, +} from "./DrawQueue.js"; + +const region: AtlasRegion = { + texture: {} as never, + u0: 0, + v0: 0, + u1: 0.5, + v1: 0.5, + width: 32, + height: 16, +}; + +describe("resolveDrawOptions", () => { + it("defaults origin to region center and z from y when sorting by y", () => { + const resolved = resolveDrawOptions(region, { x: 100, y: 200 }, "y"); + expect(resolved.originX).toBe(16); + expect(resolved.originY).toBe(8); + expect(resolved.z).toBe(200 + 16); + }); + + it("respects explicit z, origin, tint, and flip flags", () => { + const tint = Color.rgb(0.2, 0.4, 0.6, 0.8); + const resolved = resolveDrawOptions( + region, + { + x: 10, + y: 20, + z: 99, + origin: { x: 0, y: 0 }, + tint, + flipX: true, + flipY: true, + scale: { x: 2, y: 3 }, + rotation: 0.5, + }, + "none", + ); + + expect(resolved.z).toBe(99); + expect(resolved.originX).toBe(0); + expect(resolved.tint).toEqual(tint); + expect(resolved.flipX).toBe(true); + expect(resolved.flipY).toBe(true); + expect(resolved.scaleX).toBe(2); + expect(resolved.scaleY).toBe(3); + expect(resolved.rotation).toBe(0.5); + }); +}); + +describe("resolveShapeZ", () => { + it("uses y + height for y-sort mode when z is omitted", () => { + expect(resolveShapeZ(50, 20, "y")).toBe(70); + }); + + it("returns explicit z unchanged", () => { + expect(resolveShapeZ(50, 20, "y", 5)).toBe(5); + }); +}); + +describe("DrawQueue", () => { + it("groups commands by registered layer names and ignores unknown layers", () => { + const queue = new DrawQueue(); + queue.push({ + kind: "shapeRect", + layer: "world", + z: 1, + x: 0, + y: 0, + width: 10, + height: 10, + color: Color.rgb(1, 1, 1), + }); + queue.push({ + kind: "shapeRect", + layer: "ui", + z: 2, + x: 0, + y: 0, + width: 10, + height: 10, + color: Color.rgb(1, 0, 0), + }); + queue.push({ + kind: "shapeRect", + layer: "missing", + z: 3, + x: 0, + y: 0, + width: 10, + height: 10, + color: Color.rgb(0, 1, 0), + }); + + const grouped = queue.byLayer(["world", "ui"]); + expect(grouped.get("world")).toHaveLength(1); + expect(grouped.get("ui")).toHaveLength(1); + expect(grouped.get("missing")).toBeUndefined(); + }); + + it("clears queued commands", () => { + const queue = new DrawQueue(); + queue.push({ + kind: "shapeLine", + layer: "world", + z: 0, + x0: 0, + y0: 0, + x1: 10, + y1: 10, + width: 2, + color: Color.rgb(1, 1, 1), + }); + queue.clear(); + expect(queue.byLayer(["world"]).get("world")).toHaveLength(0); + }); +}); diff --git a/packages/teengine/src/graphics/LayerRegistry.test.ts b/packages/teengine/src/graphics/LayerRegistry.test.ts new file mode 100644 index 0000000..026780e --- /dev/null +++ b/packages/teengine/src/graphics/LayerRegistry.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createUiCamera } from "./Camera2D.js"; +import { LayerRegistry } from "./LayerRegistry.js"; + +describe("LayerRegistry", () => { + it("registers layers in order and returns their config", () => { + const registry = new LayerRegistry(); + const worldCam = createUiCamera(800, 600); + + registry.register("world", { camera: worldCam, sort: "y" }); + registry.register("ui", { camera: worldCam, sort: "z" }); + + expect(registry.drawOrder).toEqual(["world", "ui"]); + expect(registry.get("world").sort).toBe("y"); + }); + + it("throws when registering a duplicate layer name", () => { + const registry = new LayerRegistry(); + const camera = createUiCamera(800, 600); + registry.register("world", { camera, sort: "y" }); + + expect(() => registry.register("world", { camera, sort: "none" })).toThrow( + /already registered/, + ); + }); + + it("throws when getting an unknown layer", () => { + const registry = new LayerRegistry(); + expect(() => registry.get("missing")).toThrow(/not registered/); + }); +}); diff --git a/packages/teengine/src/graphics/Layers.ts b/packages/teengine/src/graphics/Layers.ts index 9d2a4e8..1217ff7 100644 --- a/packages/teengine/src/graphics/Layers.ts +++ b/packages/teengine/src/graphics/Layers.ts @@ -2,7 +2,6 @@ export const Layers = { world: "world", ui: "ui", - editor: "editor", } as const; export type LayerName = (typeof Layers)[keyof typeof Layers]; diff --git a/packages/teengine/src/index.ts b/packages/teengine/src/index.ts index fa4adcc..5d7320a 100644 --- a/packages/teengine/src/index.ts +++ b/packages/teengine/src/index.ts @@ -15,30 +15,33 @@ export { export { Layers } from "./graphics/Layers.js"; export type { LayerName } from "./graphics/Layers.js"; export type { LayerSortMode, ShapeOptions } from "./graphics/Graphics.js"; -export type { AtlasRegion, GpuTexture, DemoAtlas } from "./assets/Atlas.js"; +export type { AtlasRegion } from "./assets/Atlas.js"; export { loadAtlasFromJson } from "./assets/loadAtlas.js"; export type { AtlasJson, LoadedAtlas } from "./assets/loadAtlas.js"; -export { World, Transform } from "./ecs/index.js"; +export { uploadRgbaTexture } from "./assets/uploadTexture.js"; +export { World, Transform, hasPhysics, isSimulatedBody } from "./ecs/index.js"; export type { Entity, EntityId, SpawnConfig, + TransformData, SpriteComponent, ShapeComponent, + ShapeRect, + ShapeCircle, + ShapeLine, ColliderShape, ColliderComponent, CollisionComponent, CollisionResponse, RigidBodyComponent, - PlayerTag, - CameraTargetTag, - CoinTag, - CollisionListenerTag, SpinComponent, FixedSystem, RenderSystem, FixedSystemContext, RenderSystemContext, + EntityQuery, + EntityComponentKey, } from "./ecs/index.js"; export { SpinSystem } from "./ecs/systems/SpinSystem.js"; export { CameraFollowSystem } from "./ecs/systems/CameraFollowSystem.js"; @@ -49,9 +52,8 @@ export { PhysicsWorld, PhysicsBridge } from "./physics/index.js"; export type { PhysicsWorldOptions, RigidBodyHandle, - ColliderHandle, CollisionEvent, CollisionEventKind, CollisionLayers, } from "./physics/index.js"; -export { CollisionGroups, COLLIDE_ALL, layers, toInteractionGroups } from "./physics/index.js"; +export { COLLIDE_ALL, layers, toInteractionGroups } from "./physics/index.js"; diff --git a/packages/teengine/src/input/ActionMap.ts b/packages/teengine/src/input/ActionMap.ts index 4b7a834..579f6cb 100644 --- a/packages/teengine/src/input/ActionMap.ts +++ b/packages/teengine/src/input/ActionMap.ts @@ -6,16 +6,8 @@ export class ActionMap { this.bindings.set(action, new Set(codes)); } - unbind(action: string): void { - this.bindings.delete(action); - } - getCodes(action: string): readonly string[] { const codes = this.bindings.get(action); return codes ? [...codes] : []; } - - hasAction(action: string): boolean { - return this.bindings.has(action); - } } diff --git a/packages/teengine/src/math/index.ts b/packages/teengine/src/math/index.ts index 9f2e850..d062499 100644 --- a/packages/teengine/src/math/index.ts +++ b/packages/teengine/src/math/index.ts @@ -19,6 +19,11 @@ export const Color = { .map((c) => c + c) .join("") : value; + + if (full.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(full)) { + throw new Error(`Invalid hex color: "${hex}"`); + } + const n = Number.parseInt(full, 16); return { r: ((n >> 16) & 0xff) / 255, diff --git a/packages/teengine/src/math/math.test.ts b/packages/teengine/src/math/math.test.ts new file mode 100644 index 0000000..f104297 --- /dev/null +++ b/packages/teengine/src/math/math.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { Color, Mat3 } from "./index.js"; + +describe("Color", () => { + it("creates rgb colors with optional alpha", () => { + const color = Color.rgb(0.5, 0.25, 0.75, 0.8); + expect(color).toEqual({ r: 0.5, g: 0.25, b: 0.75, a: 0.8 }); + }); + + it("parses 6-digit hex colors", () => { + const color = Color.hex("#ff8040"); + expect(color.r).toBeCloseTo(1); + expect(color.g).toBeCloseTo(0.50196, 4); + expect(color.b).toBeCloseTo(0.25098, 4); + expect(color.a).toBe(1); + }); + + it("parses 3-digit shorthand hex colors", () => { + const color = Color.hex("#f80"); + expect(color.r).toBeCloseTo(1); + expect(color.g).toBeCloseTo(0.53333, 4); + expect(color.b).toBeCloseTo(0); + }); + + it("throws on invalid hex input", () => { + expect(() => Color.hex("not-a-color")).toThrow(/Invalid hex color/); + expect(() => Color.hex("#gggggg")).toThrow(/Invalid hex color/); + }); + + it("converts to vec4", () => { + expect(Color.toVec4(Color.rgb(1, 0.5, 0.25, 0.5))).toEqual([1, 0.5, 0.25, 0.5]); + }); +}); + +describe("Mat3", () => { + it("builds an orthographic projection matrix", () => { + const m = Mat3.ortho(0, 800, 600, 0); + expect(m[0]).toBeCloseTo(2 / 800); + expect(m[4]).toBeCloseTo(2 / -600); + expect(m[6]).toBeCloseTo(-1); + expect(m[7]).toBeCloseTo(1); + }); + + it("transforms a point through translate * identity", () => { + const m = Mat3.create(); + Mat3.translate(m, m, 100, 50); + const out = { x: 0, y: 0 }; + Mat3.transformPoint(out, m, 10, 20); + expect(out.x).toBeCloseTo(110); + expect(out.y).toBeCloseTo(70); + }); + + it("inverts an affine matrix", () => { + const m = Mat3.create(); + Mat3.translate(m, m, 40, 80); + const inv = Mat3.create(); + expect(Mat3.invert(inv, m)).toBe(true); + + const out = { x: 0, y: 0 }; + Mat3.transformPoint(out, inv, 40, 80); + expect(out.x).toBeCloseTo(0, 4); + expect(out.y).toBeCloseTo(0, 4); + }); + + it("returns false when the matrix is singular", () => { + const singular = Mat3.create(); + singular[0] = 0; + singular[4] = 0; + expect(Mat3.invert(Mat3.create(), singular)).toBe(false); + }); +}); diff --git a/packages/teengine/src/physics/CollisionEvents.test.ts b/packages/teengine/src/physics/CollisionEvents.test.ts index a18f7d7..a2f98fb 100644 --- a/packages/teengine/src/physics/CollisionEvents.test.ts +++ b/packages/teengine/src/physics/CollisionEvents.test.ts @@ -2,7 +2,11 @@ import { beforeAll, describe, expect, it } from "vitest"; import { createEntity } from "../ecs/Entity.js"; import { PhysicsBridge } from "./PhysicsBridge.js"; import { PhysicsWorld } from "./PhysicsWorld.js"; -import { CollisionGroups, layers } from "./CollisionLayers.js"; +import { layers } from "./CollisionLayers.js"; + +/** Test-local collision layer bits (games define their own). */ +const TEST_PLAYER = 1 << 1; +const TEST_PICKUP = 1 << 2; describe("Collision events", () => { let physics: PhysicsWorld; @@ -19,10 +23,9 @@ describe("Collision events", () => { collider: { shape: { kind: "box", width: 32, height: 32 } }, collision: { response: "solid", - layers: layers(CollisionGroups.PLAYER, CollisionGroups.PICKUP), + layers: layers(TEST_PLAYER, TEST_PICKUP), }, rigidBody: { type: "dynamic" }, - player: { _tag: "player" }, }); const coin = createEntity(2, { @@ -30,9 +33,8 @@ describe("Collision events", () => { collider: { shape: { kind: "ball", radius: 12 } }, collision: { response: "sensor", - layers: layers(CollisionGroups.PICKUP, CollisionGroups.PLAYER), + layers: layers(TEST_PICKUP, TEST_PLAYER), }, - coin: { _tag: "coin" }, }); bridge.register(player); diff --git a/packages/teengine/src/physics/CollisionLayers.test.ts b/packages/teengine/src/physics/CollisionLayers.test.ts new file mode 100644 index 0000000..36db025 --- /dev/null +++ b/packages/teengine/src/physics/CollisionLayers.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { COLLIDE_ALL, layers, toInteractionGroups } from "./CollisionLayers.js"; + +describe("toInteractionGroups", () => { + it("packs category into the high 16 bits and mask into the low 16 bits", () => { + const category = 1 << 1; + const mask = 1 << 4; + const packed = toInteractionGroups(layers(category, mask)); + expect(packed >>> 16).toBe(category); + expect(packed & 0xffff).toBe(mask); + }); + + it("COLLIDE_ALL collides with every category and mask bit", () => { + const packed = toInteractionGroups(COLLIDE_ALL); + expect(packed >>> 16).toBe(0xffff); + expect(packed & 0xffff).toBe(0xffff); + }); +}); + +describe("layers", () => { + it("returns a category/mask pair", () => { + expect(layers(2, 6)).toEqual({ category: 2, mask: 6 }); + }); +}); diff --git a/packages/teengine/src/physics/CollisionLayers.ts b/packages/teengine/src/physics/CollisionLayers.ts index 4602730..c85603d 100644 --- a/packages/teengine/src/physics/CollisionLayers.ts +++ b/packages/teengine/src/physics/CollisionLayers.ts @@ -1,6 +1,9 @@ /** * Collision filtering bitmasks (Rapier interaction groups). * + * Define your own category/mask bit constants in game code — compose with + * bitwise OR and pass to `layers()`. + * * @see https://rapier.rs/docs/user_guides/javascript/interaction_groups */ export type CollisionLayers = { @@ -21,15 +24,6 @@ export const COLLIDE_ALL: CollisionLayers = { mask: 0xffff, }; -/** Preset layer bits — compose with bitwise OR. */ -export const CollisionGroups = { - DEFAULT: 1 << 0, - PLAYER: 1 << 0, - PICKUP: 1 << 1, - GROUND: 1 << 2, - ENEMY: 1 << 3, -} as const; - export function layers( category: number, mask: number, diff --git a/packages/teengine/src/physics/PhysicsBridge.ts b/packages/teengine/src/physics/PhysicsBridge.ts index e72806a..6c83526 100644 --- a/packages/teengine/src/physics/PhysicsBridge.ts +++ b/packages/teengine/src/physics/PhysicsBridge.ts @@ -1,6 +1,6 @@ import type { Entity, EntityId } from "../ecs/Entity.js"; import { hasPhysics, isSimulatedBody } from "../ecs/Entity.js"; -import { snapshotTransform, type TransformSnapshot } from "../ecs/interpolation.js"; +import { lerpTransform, snapshotTransform, type TransformSnapshot } from "../ecs/interpolation.js"; import type { Transform } from "../ecs/Transform.js"; import type { CollisionEvent } from "./CollisionEvents.js"; import type { PhysicsWorld, RigidBodyHandle } from "./PhysicsWorld.js"; @@ -49,10 +49,6 @@ export class PhysicsBridge { return this.bodies.get(entityId)?.simulates ?? false; } - getHandle(entityId: EntityId): RigidBodyHandle | undefined { - return this.bodies.get(entityId)?.handle; - } - /** Snapshot transforms before physics step for interpolation. */ snapshotPreviousTransforms(getTransform: (id: EntityId) => Transform | undefined): void { for (const [id, entry] of this.bodies) { @@ -80,7 +76,10 @@ export class PhysicsBridge { const entity = getEntity(id); if (!entity) continue; - const t = this.physics.getTransform(entry.handle); + // Uses the entity-aware lookup (not `getTransform(entry.handle)`) so any + // `collider.offset` baked into the body's translation is subtracted back out. + const t = this.physics.getTransformForEntity(id); + if (!t) continue; entity.transform.x = t.x; entity.transform.y = t.y; entity.transform.rotation = t.rotation; @@ -104,13 +103,7 @@ export class PhysicsBridge { return out; } - const snap = snapshotTransform(current); - out.x = entry.prev.x + (snap.x - entry.prev.x) * alpha; - out.y = entry.prev.y + (snap.y - entry.prev.y) * alpha; - out.rotation = entry.prev.rotation + (snap.rotation - entry.prev.rotation) * alpha; - out.scaleX = snap.scaleX; - out.scaleY = snap.scaleY; - return out; + return lerpTransform(entry.prev, snapshotTransform(current), alpha, out); } get world(): PhysicsWorld { diff --git a/packages/teengine/src/physics/PhysicsWorld.test.ts b/packages/teengine/src/physics/PhysicsWorld.test.ts index 3c9377b..6b4cfd8 100644 --- a/packages/teengine/src/physics/PhysicsWorld.test.ts +++ b/packages/teengine/src/physics/PhysicsWorld.test.ts @@ -45,6 +45,29 @@ describe("PhysicsWorld", () => { physics.removeEntity(entity.id); }); + it("keeps entity transform stable when collider has a non-zero offset", () => { + // Regression: getTransformForEntity must subtract the offset baked into + // the body's translation at creation, or the entity would appear to jump + // by `offset` the instant physics starts syncing. + const entity = createEntity(4, { + transform: { x: 150, y: 60 }, + collider: { + shape: { kind: "box", width: 20, height: 20 }, + offset: { x: 15, y: -8 }, + }, + rigidBody: { type: "fixed" }, + }); + + physics.createPhysicsForEntity(entity); + + const transform = physics.getTransformForEntity(entity.id); + expect(transform).not.toBeNull(); + expect(transform!.x).toBeCloseTo(150); + expect(transform!.y).toBeCloseTo(60); + + physics.removeEntity(entity.id); + }); + it("rests a falling body on a static floor", () => { physics.createStaticBox(0, 300, 400, 20); diff --git a/packages/teengine/src/physics/PhysicsWorld.ts b/packages/teengine/src/physics/PhysicsWorld.ts index 7f75afd..010c115 100644 --- a/packages/teengine/src/physics/PhysicsWorld.ts +++ b/packages/teengine/src/physics/PhysicsWorld.ts @@ -23,6 +23,8 @@ type EntityPhysicsEntry = { bodyHandle: RigidBodyHandle; colliderHandle: ColliderHandle; simulates: boolean; + /** Engine-space offset baked into the body's translation at creation; subtracted back out on read. */ + offset: { x: number; y: number }; }; export class PhysicsWorld { @@ -111,6 +113,7 @@ export class PhysicsWorld { bodyHandle, colliderHandle, simulates: isSimulatedBody(entity), + offset, }); return bodyHandle; @@ -131,23 +134,6 @@ export class PhysicsWorld { this.entities.delete(entityId); } - removeBody(handle: RigidBodyHandle): void { - for (const [entityId, entry] of this.entityPhysics) { - if (entry.bodyHandle === handle) { - this.removeEntity(entityId); - return; - } - } - } - - hasEntity(entityId: EntityId): boolean { - return this.entityPhysics.has(entityId); - } - - simulatesEntity(entityId: EntityId): boolean { - return this.entityPhysics.get(entityId)?.simulates ?? false; - } - getTransform(handle: RigidBodyHandle): { x: number; y: number; rotation: number } { const body = this.bodies.get(handle); if (!body) { @@ -157,10 +143,20 @@ export class PhysicsWorld { return rapierToEngine(t.x, t.y, body.rotation()); } + /** + * Entity's transform derived from its body, with the creation-time + * `collider.offset` subtracted back out so the entity's own transform + * (not the collider's) is returned. Returns `null` if the entity has no body. + */ getTransformForEntity(entityId: EntityId): { x: number; y: number; rotation: number } | null { const entry = this.entityPhysics.get(entityId); if (!entry) return null; - return this.getTransform(entry.bodyHandle); + const body = this.getTransform(entry.bodyHandle); + return { + x: body.x - entry.offset.x, + y: body.y - entry.offset.y, + rotation: body.rotation, + }; } setLinearVelocity(handle: RigidBodyHandle, vx: number, vy: number): void { diff --git a/packages/teengine/src/physics/collisionDefaults.test.ts b/packages/teengine/src/physics/collisionDefaults.test.ts new file mode 100644 index 0000000..957d6c9 --- /dev/null +++ b/packages/teengine/src/physics/collisionDefaults.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { createEntity } from "../ecs/Entity.js"; +import { resolveCollision, shouldEmitAsSelf } from "./collisionDefaults.js"; + +describe("resolveCollision", () => { + it("returns null when the entity has no collider", () => { + const entity = createEntity(1, {}); + expect(resolveCollision(entity)).toBeNull(); + }); + + it("defaults solids (with a rigid body, no explicit collision component) to silent", () => { + const entity = createEntity(2, { + collider: { shape: { kind: "box", width: 10, height: 10 } }, + rigidBody: { type: "dynamic" }, + }); + const resolved = resolveCollision(entity); + expect(resolved).toEqual({ response: "solid", layers: expect.anything(), emitEvents: false }); + }); + + it("defaults a bare collider (no rigid body, no collision component) to an emitting sensor", () => { + const entity = createEntity(3, { + collider: { shape: { kind: "box", width: 10, height: 10 } }, + }); + const resolved = resolveCollision(entity); + expect(resolved).toEqual({ response: "sensor", layers: expect.anything(), emitEvents: true }); + }); + + it("respects an explicit collision component over the rigid-body default", () => { + const entity = createEntity(4, { + collider: { shape: { kind: "box", width: 10, height: 10 } }, + collision: { response: "solid", emitEvents: true }, + rigidBody: { type: "dynamic" }, + }); + const resolved = resolveCollision(entity); + expect(resolved?.emitEvents).toBe(true); + }); +}); + +describe("shouldEmitAsSelf", () => { + it("never emits when the resolved collision has emitEvents: false", () => { + const entity = createEntity(5, {}); + expect(shouldEmitAsSelf(entity, { response: "solid", layers: { category: 0, mask: 0 }, emitEvents: false })).toBe(false); + }); + + it("emits for a sensor with emitEvents: true", () => { + const entity = createEntity(6, {}); + expect(shouldEmitAsSelf(entity, { response: "sensor", layers: { category: 0, mask: 0 }, emitEvents: true })).toBe(true); + }); + + it("emits for a solid that explicitly opted into emitEvents: true", () => { + const entity = createEntity(7, {}); + expect(shouldEmitAsSelf(entity, { response: "solid", layers: { category: 0, mask: 0 }, emitEvents: true })).toBe(true); + }); +}); diff --git a/packages/teengine/src/physics/collisionDefaults.ts b/packages/teengine/src/physics/collisionDefaults.ts index e992f9c..340d882 100644 --- a/packages/teengine/src/physics/collisionDefaults.ts +++ b/packages/teengine/src/physics/collisionDefaults.ts @@ -29,9 +29,13 @@ export function resolveCollision(entity: Entity): ResolvedCollision | null { }; } -export function shouldEmitAsSelf(entity: Entity, collision: ResolvedCollision): boolean { - if (!collision.emitEvents) return false; - if (entity.collisionListener) return true; - if (collision.response === "sensor") return true; - return entity.collision?.emitEvents === true; +/** + * True if this entity should appear as `event.self` for a collision it's involved in. + * + * Rapier only queues collision events for a collider whose resolved `emitEvents` + * is true in the first place (see `PhysicsWorld.createPhysicsForEntity`), so once + * that guard passes, the entity is always the `self` side. + */ +export function shouldEmitAsSelf(_entity: Entity, collision: ResolvedCollision): boolean { + return collision.emitEvents; } diff --git a/packages/teengine/src/physics/index.ts b/packages/teengine/src/physics/index.ts index 20121d6..2a3eb16 100644 --- a/packages/teengine/src/physics/index.ts +++ b/packages/teengine/src/physics/index.ts @@ -1,16 +1,6 @@ export { PhysicsWorld } from "./PhysicsWorld.js"; -export type { PhysicsWorldOptions, RigidBodyHandle, ColliderHandle } from "./PhysicsWorld.js"; +export type { PhysicsWorldOptions, RigidBodyHandle } from "./PhysicsWorld.js"; export { PhysicsBridge } from "./PhysicsBridge.js"; export type { CollisionEvent, CollisionEventKind } from "./CollisionEvents.js"; -export { - CollisionGroups, - COLLIDE_ALL, - layers, - toInteractionGroups, -} from "./CollisionLayers.js"; +export { COLLIDE_ALL, layers, toInteractionGroups } from "./CollisionLayers.js"; export type { CollisionLayers } from "./CollisionLayers.js"; -export { - engineToRapier, - rapierToEngine, - engineGravityToRapier, -} from "./coords.js";