diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3687e67..9903a22 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,8 @@ # 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. +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 [MODULES.md](./MODULES.md) for what belongs in the engine vs your game vs examples. ## Layer stack @@ -26,7 +28,7 @@ PhysicsBridge (Rapier 2D) ←→ World.fixedUpdate() ## ECS + Systems -Entities are component bags. Behavior lives in **systems**: +Entities are component bags. Behavior lives in **systems** you write: ```ts world.addFixedSystem(new SpinSystem()); @@ -34,7 +36,7 @@ world.addRenderSystem(new WorldEntityRenderSystem(graphics)); world.addRenderSystem(new CameraFollowSystem(worldCamera)); ``` -Game-specific systems (e.g. `PlayerControllerSystem`) live in your app, not the package. +Built-in systems (`SpinSystem`, `CameraFollowSystem`, `WorldEntityRenderSystem`) are small rendering/utility helpers. **Movement, AI, game rules** — your systems. See `examples/demo/PlayerControllerSystem.ts` for one approach. ## Game loop @@ -72,6 +74,8 @@ packages/teengine/src/ ## Roadmap +### Done + - [x] WebGPU + cameras + layers + sprites - [x] Entity system + fixed timestep + systems - [x] Input system @@ -81,4 +85,14 @@ packages/teengine/src/ - [x] JSON atlas loader - [x] npm package layout - [x] Collision events / sensors -- [ ] Kinematic character controller + +### Engine quality (next) + +- [x] Remove demo tags from core entity types (`tags: Set` + demo constants in examples) +- [x] Seal public API — no demo atlas types, game-named collision presets, or coord internals +- [ ] ECS query helpers +- [ ] Small math exports (`Vec2`, utilities) +- [ ] Asset load cache + GPU release +- [ ] Physics stepping performance + +See [MODULES.md](./MODULES.md) for scope boundaries. diff --git a/docs/MODULES.md b/docs/MODULES.md new file mode 100644 index 0000000..00f065d --- /dev/null +++ b/docs/MODULES.md @@ -0,0 +1,122 @@ +# What belongs in TeEngine + +TeEngine is the **simplest useful 2D game engine** — not a platformer kit, not a grab bag of game recipes. + +**One rule:** the package gives you primitives to **run, draw, simulate, and input**. Everything that decides *how a game plays* is yours to build on top. + +--- + +## In the engine (`packages/teengine`) + +These are the only subsystems. If it does not fit here, it does not ship in the package. + +| Module | Responsibility | +|--------|----------------| +| **engine** | Fixed timestep loop, pause, resize | +| **graphics** | Cameras, layers, sprites, shapes | +| **gpu** | WebGPU batching (internal, not exported) | +| **ecs** | Entities, components, systems, render interpolation | +| **input** | Keyboard, mouse, action map | +| **physics** | Rapier bridge — bodies, colliders, sensors, collision events, sync | +| **assets** | Load atlases onto the GPU | +| **math** | Small shared types (`Color`; grow only when the engine itself needs them) | + +### What physics gives you (and nothing more) + +- Create bodies: `dynamic`, `fixed`, `kinematicPosition` +- Set velocity, apply impulse, step the world +- Collision layers (`layers()`, `COLLIDE_ALL`) — you define your own bit constants +- Enter/exit events, sensors +- Engine Y-down ↔ Rapier Y-up handled for you (not exported) + +That is enough to build **any** movement style — platformer, top-down, vehicle, point-and-click — in your own systems. The engine does not pick one. + +### What the engine deliberately omits + +| Not in engine | Why | +|---------------|-----| +| Character controller | Gameplay. You write a `FixedSystem`. | +| Animation / state machines | Gameplay / content pipeline. | +| Scene manager | App structure. You wire `Engine.setLoop`. | +| Tilemaps | Content format + renderer. Add when a game needs it. | +| Audio | Separate concern. | +| UI / text | Use DOM or your UI library. | +| Game-specific tags / atlas shapes | Demo defines its own in `examples/demo/` | + +--- + +## Public API (`teengine`) + +Single entry point — `packages/teengine/src/index.ts` is the only contract: + +- **Run:** `Engine`, loop callbacks, fixed timestep constants +- **Draw:** `Graphics`, `Camera2D`, `Layers`, `Color`, shape/sprite draw types +- **Simulate:** `World`, `Entity`, `Transform`, `hasPhysics`, `isSimulatedBody`, physics bridge + collision helpers +- **Input:** `Input`, `ActionMap` +- **Assets:** `loadAtlasFromJson(engine, …)`, `uploadRgbaTexture(engine, …)`, `AtlasRegion` + +Not exported: GPU device accessor, coordinate conversion helpers, demo-specific types, preset collision group names. + +--- + +## In examples (`examples/*`) + +Reference games and **copy-paste starting points** — not second-class engine modules. + +``` +examples/demo/ + demoConstants.ts ← DemoTags, DemoAtlas, DemoCollisionGroups + PlayerControllerSystem.ts ← velocity + jump: one way to move a dynamic body + CoinPickupSystem.ts ← sensor collision handling + DemoScene.ts ← how to wire engine + world + systems +``` + +A default working player controller belongs **here** (or in docs as a snippet), so developers can read it, fork it, or ignore it. It is never imported from `"teengine"`. + +--- + +## Internal (`src/gpu/`, unexported helpers) + +Implementation details. No stability guarantee. Not part of the engine's promise. + +--- + +## The boundary test + +Before adding anything to `packages/teengine`, ask: + +> **Does every 2D game need this, regardless of genre?** + +| Answer | Verdict | +|--------|---------| +| Yes — e.g. draw a sprite, step physics, poll input | **Engine** | +| No — e.g. platformer jump, walk cycle, inventory | **Developer's code or examples** | + +When unsure, leave it out. The engine stays smaller; games stay flexible. + +--- + +## Roadmap (engine only) + +| Item | Status | +|------|--------| +| Remove demo tags from `Entity` | ✅ `tags: Set` | +| Seal public API | ✅ Phase 2 | +| ECS query helpers | Planned | +| Export `Vec2` / small math helpers | Planned | +| Asset cache / lifecycle | Planned | +| Physics perf (buffer reuse) | Planned | + +**Not on engine roadmap:** character controller, animation module, scene stack, tilemaps. + +--- + +## Summary + +| Layer | Location | Role | +|-------|----------|------| +| Engine | `teengine` npm package | Run, draw, simulate, input | +| Examples | `examples/*` | Working reference implementations you own | +| Your game | Your repo | Systems, scenes, content, feel | + +The best simplest 2D engine is one that **does less, clearly** — and gets out of the way. diff --git a/docs/PACKAGE.md b/docs/PACKAGE.md index 481f634..f532faa 100644 --- a/docs/PACKAGE.md +++ b/docs/PACKAGE.md @@ -31,12 +31,15 @@ teengine-js/ ## Design principles +See [MODULES.md](./MODULES.md) for the engine boundary. + | Concern | Where it lives | |---------|----------------| -| **Reusable engine** | `packages/teengine` | -| **Game-specific logic** | `examples/*` (PlayerController, demo atlas, scenes) | +| **Engine** | `packages/teengine` — run, draw, simulate, input | +| **Reference implementations** | `examples/*` (player controller, pickup, scenes) — copy and adapt | +| **Your game** | Your app — systems, content, feel | | **GPU internals** | `packages/teengine/src/gpu` — private, not exported | -| **Editor / UI** | Out of scope — use your own UI framework in the app | +| **Editor / UI** | Out of scope — use your own UI framework | ## Public API (`teengine`) @@ -46,32 +49,27 @@ Single entry point today: import { Engine, World, - Graphics, PhysicsBridge, PhysicsWorld, Layers, + layers, loadAtlasFromJson, - SpinSystem, + uploadRgbaTexture, CameraFollowSystem, WorldEntityRenderSystem, } from "teengine"; -``` -### Future subpath exports (optional) +// Tags and collision layer bits are game-defined — see examples/demo/src/demoConstants.ts +world.spawn({ + tags: ["player"], + collision: { response: "solid", layers: layers(MY_PLAYER_LAYER, MY_GROUND_LAYER) }, +}); -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" - } -} +const atlas = await loadAtlasFromJson(engine, "/assets/sprites.json"); +const procedural = uploadRgbaTexture(engine, pixels, width, height); ``` -Only split when consumers need tree-shaking or clearer boundaries — not required yet. +Single entry point. No subpath exports unless the core API genuinely outgrows one bundle. ## Consuming the package diff --git a/docs/PHYSICS.md b/docs/PHYSICS.md index 7a75368..8e6921c 100644 --- a/docs/PHYSICS.md +++ b/docs/PHYSICS.md @@ -25,30 +25,49 @@ All conversion happens in `PhysicsWorld` — game code stays in engine coordinat ## Usage +`PhysicsWorld` wraps Rapier; `PhysicsBridge` is the entity-facing layer `World` uses. +`World` takes the bridge in its constructor — there is no `attachPhysics()` call. + ```ts -const physics = await PhysicsWorld.create({ gravityY: 980 }); -world.attachPhysics(physics); +const physicsWorld = await PhysicsWorld.create({ gravityY: 980 }); +const physics = new PhysicsBridge(physicsWorld); +const world = new World(physics); // Static ground (engine coords: x,y = top-left) physics.createStaticBox(0, 300, 2000, 40); -world.spawn({ +const playerId = world.spawn({ transform: { x: 400, y: 286 }, - rigidBody: { - type: "dynamic", - collider: { kind: "box", width: 28, height: 28 }, - lockRotation: true, - }, + // collider, collision, and rigidBody are separate sibling components — + // collider is never nested inside rigidBody. + collider: { shape: { kind: "box", width: 28, height: 28 } }, + collision: { response: "solid" }, + rigidBody: { type: "dynamic", lockRotation: true }, }); -fixedUpdate: ({ dt, input }) => { - physics.setLinearVelocity(handle, dx * speed, vy); - physics.applyImpulse(handle, 0, jumpImpulse); - physics.step(dt); - world.syncFromPhysics(); -}; +// Movement reads/writes velocity by entity id, through the bridge — +// never through a raw Rapier handle. +engine.setLoop({ + fixedUpdate: (ctx) => { + const dx = ctx.input.actionAxis("move_left", "move_right"); + const vel = physics.getLinearVelocity(playerId); + physics.setLinearVelocity(playerId, dx * 220, vel.y); + if (ctx.input.actionPressed("jump")) physics.applyImpulse(playerId, 0, 280); + + // World.fixedUpdate runs snapshot → your fixed systems → physics.step() + // → sync → your post-physics systems. Do not call physics.step() yourself. + world.fixedUpdate({ ...ctx, physics }); + }, + render: (ctx) => { + engine.graphics.beginFrame(Color.hex("#0d1117")); + world.render({ ...ctx, physics }); + engine.graphics.endFrame(); + }, +}); ``` +See `examples/demo/src/PlayerControllerSystem.ts` and `DemoScene.ts` for the same pattern as a reusable `FixedSystem`. + ## RigidBodyComponent | Field | Purpose | @@ -60,18 +79,17 @@ Collider and collision policy live on separate components (`collider`, `collisio ## Next phases -### Phase 2 — Events -- `PhysicsWorld.onCollisionEnter` / `Exit` via Rapier event queue +### Events ✅ +- Collision enter/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 +### Performance - Reuse translation buffers (avoid alloc per body per frame) - Optional Web Worker for `world.step()` +### Movement (not engine scope) +Platformer / character movement is built in **your** `FixedSystem` using the physics API above (`setLinearVelocity`, `applyImpulse`, `kinematicPosition`, etc.). See `examples/demo/PlayerControllerSystem.ts` for a starting point. + ## Vite config ```ts 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..db467e0 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,12 @@ 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; + if (!entity.active || !entity.tags.has(DemoTags.player) || !entity.rigidBody) continue; 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/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.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.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/index.ts b/packages/teengine/src/ecs/index.ts index 89959e5..f67d993 100644 --- a/packages/teengine/src/ecs/index.ts +++ b/packages/teengine/src/ecs/index.ts @@ -14,12 +14,8 @@ 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 type { FixedSystem, RenderSystem, FixedSystemContext, RenderSystemContext } from "./System.js"; 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.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/index.ts b/packages/teengine/src/index.ts index fa4adcc..3a9be15 100644 --- a/packages/teengine/src/index.ts +++ b/packages/teengine/src/index.ts @@ -15,25 +15,26 @@ 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, @@ -49,9 +50,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/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..d4b31bb 100644 --- a/packages/teengine/src/physics/PhysicsBridge.ts +++ b/packages/teengine/src/physics/PhysicsBridge.ts @@ -80,7 +80,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; 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..bb4c283 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; @@ -157,10 +160,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";