From eed1781e20ad7b10e69c105132922c2885d67553 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 16:17:31 +0000 Subject: [PATCH 1/8] docs: add module specification with character controller T3 spec Define Kernel/Core/Standard/Application tiers with classification rules, fixed-update pipeline phases, export map, and full character-controller module spec. Update architecture, package, and physics docs to reference the new source of truth. Co-authored-by: rAI --- docs/ARCHITECTURE.md | 16 +- docs/MODULES.md | 451 +++++++++++++++++++++++++++++++++++++++++++ docs/PACKAGE.md | 9 +- docs/PHYSICS.md | 11 +- 4 files changed, 476 insertions(+), 11 deletions(-) create mode 100644 docs/MODULES.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3687e67..9ba85ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # 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 [PACKAGE.md](./PACKAGE.md) for layout and [MODULES.md](./MODULES.md) for module tiers (Kernel / Core / Standard / Application). ## Layer stack @@ -34,7 +34,7 @@ world.addRenderSystem(new WorldEntityRenderSystem(graphics)); world.addRenderSystem(new CameraFollowSystem(worldCamera)); ``` -Game-specific systems (e.g. `PlayerControllerSystem`) live in your app, not the package. +Game-specific systems (e.g. `PlayerControllerSystem`) live in your app (T4 Application tier). Optional engine subsystems (e.g. character controller) are **Standard modules** — see [MODULES.md](./MODULES.md). ## Game loop @@ -72,6 +72,8 @@ packages/teengine/src/ ## Roadmap +### Kernel + Core (T1/T2) — done + - [x] WebGPU + cameras + layers + sprites - [x] Entity system + fixed timestep + systems - [x] Input system @@ -81,4 +83,12 @@ packages/teengine/src/ - [x] JSON atlas loader - [x] npm package layout - [x] Collision events / sensors -- [ ] Kinematic character controller +- [x] Module specification ([MODULES.md](./MODULES.md)) + +### Standard modules (T3) — planned + +- [ ] `teengine/character-controller` — full spec in [MODULES.md §8](./MODULES.md#8-character-controller-module-full-spec) +- [ ] `teengine/animation` +- [ ] `teengine/scene` +- [ ] `teengine/math` (Vec2 utilities) +- [ ] Migrate demo tags (`PlayerTag`, `CoinTag`) out of Core entity types diff --git a/docs/MODULES.md b/docs/MODULES.md new file mode 100644 index 0000000..f45183e --- /dev/null +++ b/docs/MODULES.md @@ -0,0 +1,451 @@ +# TeEngine Module Specification + +This document defines **what a module is**, **how modules are classified**, and **where every capability lives**. It is the source of truth for package boundaries — not ad‑hoc “core vs plugin” calls per feature. + +Related: [ARCHITECTURE.md](./ARCHITECTURE.md), [PACKAGE.md](./PACKAGE.md), [PHYSICS.md](./PHYSICS.md). + +--- + +## 1. Goals + +TeEngine targets a **refined minimal 2D engine**: enough to ship small games without becoming a general-purpose framework. + +The module spec exists to: + +1. **Prevent boundary drift** — demo code, physics internals, and reusable features stay in separate buckets. +2. **Make opt-in explicit** — games that never need platformer movement do not carry platformer assumptions in their mental model or public types. +3. **Scale the monorepo** — new capabilities get a tier and export path *before* implementation starts. +4. **Keep one npm package until split is justified** — tiers map to **directories and exports**, not necessarily separate published packages. + +--- + +## 2. Module tiers + +Every capability belongs to exactly one tier. + +| Tier | Name | Shipped in | npm export | Stability | Purpose | +|------|------|------------|------------|-----------|---------| +| **T0** | Internal | `packages/teengine` | None | None | Implementation detail. Breaking changes anytime. | +| **T1** | Kernel | `packages/teengine` | `teengine` | Stable | Without this, the product is not a game engine. | +| **T2** | Core | `packages/teengine` | `teengine` | Stable | Subsystems every typical game uses once the engine is running. | +| **T3** | Standard | `packages/teengine` | `teengine/` | Stable | Complete, optional subsystems. Opt in by import + registration. | +| **T4** | Application | `examples/*` | Never published | N/A | Single-game logic, assets, scenes. | + +### 2.1 Classification rules + +Use this decision tree for any new feature: + +``` +1. Is it required to create Engine, draw a frame, or tick World? + YES → T1 Kernel + NO → 2 + +2. Is it a general subsystem (ECS, input, rigid-body simulation, atlas load) + that most games enable without thinking? + YES → T2 Core + NO → 3 + +3. Is it a cohesive optional subsystem with its own component(s), systems, + lifecycle, and docs — usable without game-specific types? + YES → T3 Standard + NO → 4 + +4. Does it encode one game's rules, content, or presentation? + YES → T4 Application + NO → Revisit: likely T0 internal helper or belongs inside an existing module +``` + +### 2.2 What “Standard” (T3) is NOT + +- **Not a half-exported physics helper.** A Standard module owns its component types, systems, integration contract, tests, and documentation. +- **Not game code with engine branding.** If it references `player`, `coin`, or demo-specific action names as hard requirements, it is T4. +- **Not a separate npm package by default.** Split to `@teengine/` only when bundle size, release cadence, or third-party ownership demands it (see §7). + +### 2.3 What “Core” (T2) IS + +Core modules are **always imported from the main entry** and are part of the engine’s default story: + +- You do not “register” Core — you construct `Engine`, `World`, `PhysicsBridge`. +- Core may contain **optional per-entity features** (e.g. `rigidBody.type: "dynamic"`) as long as the subsystem itself is always present. + +--- + +## 3. Current inventory + +### T0 — Internal + +| Path | Role | +|------|------| +| `src/gpu/` | WebGPU device, batchers, shaders | +| `src/math/Mat3` | Affine math used by cameras/GPU (export decision: §6) | + +### T1 — Kernel + +| Path | Role | +|------|------| +| `src/engine/` | Game loop, fixed timestep, pause, resize | +| `src/graphics/` | Cameras, layers, draw API, draw queue | +| `src/math/Color` | Color type used by graphics | + +### T2 — Core + +| Path | Role | +|------|------| +| `src/ecs/` | `World`, `Entity`, `Transform`, system interfaces, interpolation | +| `src/input/` | Keyboard, mouse, `ActionMap` | +| `src/physics/` | Rapier world, `PhysicsBridge`, collision layers, events, coords | +| `src/assets/` | Atlas types, `loadAtlasFromJson` | + +### T3 — Standard (specified, not all implemented) + +| Module | Path (target) | Export | Status | +|--------|---------------|--------|--------| +| Character Controller | `src/character-controller/` | `teengine/character-controller` | Planned | +| Animation | `src/animation/` | `teengine/animation` | Planned | +| Scene | `src/scene/` | `teengine/scene` | Planned | +| Math | `src/math/` (Vec2 utilities) | `teengine/math` | Partial | +| Built-in systems pack | `src/systems/` | `teengine/systems` | Partial (`SpinSystem`, etc. live in `ecs/systems/` today — migrate when pack grows) | + +### T4 — Application + +| Path | Role | +|------|------| +| `examples/demo/src/PlayerControllerSystem.ts` | Demo movement tuning + input wiring | +| `examples/demo/src/CoinPickupSystem.ts` | Demo pickup rules | +| `examples/demo/src/DemoScene.ts` | Scene content | +| `examples/demo/src/createDemoAtlas.ts` | Procedural demo art | + +### Misplaced today (migration required) + +These violate the spec and must move or be generalized: + +| Item | Current | Target | +|------|---------|--------| +| `PlayerTag`, `CoinTag` | T2 `Entity.ts` | Remove from Core; use generic tags in T4 or `tags: string[]` on entity | +| `PlayerControllerSystem` | T4 (correct) | After CC module lands: thin wrapper over `CharacterMotor` + demo action names | +| `SpinSystem`, `CameraFollowSystem`, `WorldEntityRenderSystem` | T2 `ecs/systems/` | T3 `systems/` when subpath export is added (behavior unchanged) | + +--- + +## 4. Standard module contract + +Every T3 module MUST provide: + +``` +src// + index.ts # public exports only + types.ts # components, config, results + README.md # optional; user-facing usage (or section in docs/) + *.test.ts # unit tests +``` + +Every T3 module MUST document: + +1. **Dependencies** — which T1/T2 modules it uses. +2. **Registration** — what the game adds to `World` / `Engine`. +3. **Fixed-update phase** — when it runs relative to §5. +4. **Non-goals** — what the module explicitly does not do. + +Every T3 module MUST NOT: + +- Import from `examples/` +- Add game-specific marker components (`player`, `coin`, …) +- Read input action names unless configurable via constructor/options + +--- + +## 5. Fixed-update pipeline (integration contract) + +All modules hook into this ordered pipeline. **Do not invent parallel update paths.** + +| Phase | Owner | Work | +|-------|-------|------| +| **P0** | T2 `World` | `physics.snapshotPreviousTransforms()` | +| **P1** | T4 / custom | `FixedSystem`s — read input, AI, set **intent** on components | +| **P2** | T3 Character Controller | Apply intent → collision-resolved displacement for KCC entities | +| **P3** | T2 `PhysicsBridge` | `physics.step(dt)` — dynamic bodies, event queue | +| **P4** | T2 `World` | `physics.syncToEntities()` | +| **P5** | T4 / custom | `PostPhysicsSystem`s — triggers, gameplay reactions | + +Render path unchanged: T2 interpolation via `World.getRenderTransform()` → T3/render systems → T1 `Graphics`. + +**Rule:** Character Controller runs at **P2**, before `physics.step()`. It moves **kinematic** bodies; dynamic bodies are unaffected. + +--- + +## 6. Public export map + +### Today + +```json +{ + "exports": { + ".": "./dist/index.js" + } +} +``` + +Main entry exports **T1 + T2 only**. + +### Target (when first T3 module ships) + +```json +{ + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./character-controller": { + "types": "./dist/character-controller/index.d.ts", + "import": "./dist/character-controller/index.js" + }, + "./systems": { + "types": "./dist/systems/index.d.ts", + "import": "./dist/systems/index.js" + }, + "./math": { + "types": "./dist/math/index.d.ts", + "import": "./dist/math/index.js" + } + } +} +``` + +`tsup` entry array grows per T3 module. **T3 never re-exports through main `index.ts`** — that keeps tree-shaking and mental boundaries clean. + +--- + +## 7. When to split a separate npm package + +Stay in `teengine` until **all** of: + +- Module is >~15kB minified **and** most consumers omit it, **or** +- Independent versioning is required (third-party maintainer), **or** +- It introduces a new heavy dependency not wanted by core consumers + +Split format: `@teengine/` workspace package depending on `teengine`. + +Character Controller **does not qualify** — it uses existing `@dimforge/rapier2d` Core dependency. + +--- + +## 8. Character Controller module (full spec) + +**Tier:** T3 Standard +**Path:** `packages/teengine/src/character-controller/` +**Export:** `teengine/character-controller` +**Depends on:** T2 `physics`, T2 `ecs` + +### 8.1 Purpose + +Provide **collision-resolved kinematic movement** for controllable avatars (platformers, top-down with slide). Replaces dynamic-body velocity hacks for characters. + +### 8.2 Non-goals + +- Not input binding (T4 / game code) +- Not animation, root motion, or networked prediction +- Not a replacement for dynamic rigid bodies (crates, ragdolls) +- Not exported from main `teengine` entry + +### 8.3 Types + +```ts +/** Per-entity motor configuration (spawn-time). */ +export type CharacterMotorComponent = { + /** Max horizontal speed, engine units / sec. */ + moveSpeed: number; + /** Initial upward speed when jump requested, engine units / sec (Y-down: negative). */ + jumpSpeed: number; + /** Gravity applied when airborne, engine units / sec² (Y-down: positive). */ + gravity: number; + /** Rapier autostep max height, engine units. Default: 0 (disabled). */ + maxStepHeight?: number; + /** Snap to ground within this distance. Default: 0.5. */ + snapToGround?: number; +}; + +/** Written by game systems each tick before motor solve (P1 → P2). */ +export type CharacterIntent = { + /** -1..1, normalized horizontal desired direction. */ + moveX: number; + /** Request jump if grounded (or coyote — game sets flag). */ + jump: boolean; +}; + +/** Read-only result after P2 solve. */ +export type CharacterMotorState = { + grounded: boolean; + /** Vertical velocity after solve, engine space. */ + velocityY: number; +}; +``` + +Entity storage (Core change — generic optional fields): + +```ts +// ecs/Entity.ts — add to Entity + SpawnConfig +characterMotor?: CharacterMotorComponent; +characterIntent?: CharacterIntent; // cleared or overwritten each P1 +``` + +Core hosts **storage** only. All behavior lives in the T3 module. + +### 8.4 Public API + +```ts +// teengine/character-controller + +export type { CharacterMotorComponent, CharacterIntent, CharacterMotorState }; + +/** Runs P2 for all entities with characterMotor + kinematic rigid body. */ +export class CharacterMotorSystem implements FixedSystem { + constructor(options?: { gravityY?: number }); // default from PhysicsWorld if omitted +} + +/** Low-level access when games bypass the system (advanced). */ +export class CharacterMotor { + constructor(bridge: PhysicsBridge); + setIntent(entityId: EntityId, intent: CharacterIntent): void; + solve(entityId: EntityId, dt: number): CharacterMotorState; + getState(entityId: EntityId): CharacterMotorState; +} +``` + +`CharacterMotor` wraps Rapier `KinematicCharacterController` internally. Rapier types do not leak across the public boundary. + +### 8.5 Physics integration (inside module) + +The module extends physics behavior **without** bloating `PhysicsBridge`’s public Core API: + +``` +character-controller/ + RapierCharacterMotor.ts # one KCC instance per motor entity + CharacterMotorSystem.ts + index.ts +``` + +Registration lifecycle: + +| Event | Action | +|-------|--------| +| Entity spawned with `characterMotor` + `collider` + `rigidBody.type: "kinematicPosition"` | Create Rapier KCC + link to existing body/collider handles | +| Entity removed | Destroy KCC | +| `PhysicsBridge.unregister` | Module hook must run (via system-owned map or bridge callback list) | + +**Core change (minimal):** `PhysicsBridge` exposes `onRegister` / `onUnregister` callbacks OR `CharacterMotor` subscribes through `World.spawn`/`remove` wrappers. Pick one during implementation; do not duplicate body maps. + +### 8.6 Required spawn shape + +```ts +world.spawn({ + transform: { x, y }, + collider: { shape: { kind: "box", width, height }, friction: 0 }, + collision: { response: "solid", layers: ... }, + rigidBody: { type: "kinematicPosition", lockRotation: true }, + characterMotor: { moveSpeed: 220, jumpSpeed: 280, gravity: 980 }, +}); +``` + +Invalid combinations **throw at spawn** with explicit errors: + +- `characterMotor` + `rigidBody.type: "dynamic"` → error +- `characterMotor` without `collider` → error + +### 8.7 Application-layer usage (demo) + +```ts +import { CharacterMotorSystem } from "teengine/character-controller"; + +// P1 — demo system: input → intent (T4) +class DemoPlayerIntentSystem implements FixedSystem { + fixedUpdate({ world, input }) { + for (const e of world.getAll()) { + if (!e.characterMotor || !e.player) continue; // player tag stays in T4 until generic tags land + e.characterIntent = { + moveX: input.actionAxis("move_left", "move_right"), + jump: input.actionPressed("jump"), + }; + } + } +} + +world.addFixedSystem(new DemoPlayerIntentSystem()); +world.addFixedSystem(new CharacterMotorSystem()); +``` + +After migration, `PlayerControllerSystem.ts` is deleted or reduced to `DemoPlayerIntentSystem`. + +### 8.8 Tests (required before stable) + +- Grounded detection on flat surface +- No jump when airborne (without coyote flag from game) +- Horizontal slide along vertical wall +- Coordinate round-trip (engine Y-down ↔ Rapier Y-up) +- Interpolation still smooth (`isSimulatedBody` kinematic path) +- Sensor collision events still fire (coin pickup unchanged) + +### 8.9 Versioning + +Ships as **minor** bump (`0.4.0`): new subpath export, no breaking Core API. + +--- + +## 9. Future Standard modules (brief spec) + +### 9.1 Animation (T3) + +- `SpriteAnimationComponent` + `AnimationSystem` (P1 or render-adjacent) +- Frame sequences from atlas regions; optional Aseprite tag import +- Does not include state machines (T4) + +### 9.2 Scene (T3) + +- `Scene` interface: `enter(ctx)`, `exit()` +- `SceneStack`: push / pop / replace +- Does not include editor serialization + +### 9.3 Math (T3) + +- `Vec2` operations, `clamp`, `lerp`, distance +- Export `Mat3` for custom camera/transform work +- Does not include full linear algebra library + +--- + +## 10. Implementation checklist (Character Controller) + +- [ ] Add `docs/MODULES.md` (this file) +- [ ] Add `characterMotor` / `characterIntent` to Core entity storage +- [ ] Implement `src/character-controller/` per §8 +- [ ] Add `teengine/character-controller` export + tsup entry +- [ ] Wire P2 in `World.fixedUpdate` **or** document that `CharacterMotorSystem` must be registered last among P1 systems (prefer explicit P2 hook in `World` when motor entities exist) +- [ ] Migrate demo to intent system + `CharacterMotorSystem` +- [ ] Remove impulse-based jump from demo +- [ ] Deprecate `PlayerTag` / `CoinTag` from Core types (major bump when removed) +- [ ] Update `ARCHITECTURE.md` roadmap + `PHYSICS.md` phase status + +**P2 hook decision:** Prefer an explicit `World` phase over convention-based system ordering: + +```ts +// World.fixedUpdate — target shape +for (const system of this.fixedSystems) system.fixedUpdate(ctx); +this.characterMotor?.solveAll(ctx); // owned by optional module registration +this.physics?.step(ctx.dt); +``` + +`World.registerCharacterMotor(motor: CharacterMotor)` called when the game imports the module — Core knows the interface type via minimal callback, or motor registers as a special `FixedSystem` with guaranteed P2 slot. + +--- + +## 11. Summary + +| Question | Answer | +|----------|--------| +| Is Character Controller Core? | **No.** T3 Standard module. | +| Is it a plugin? | **No.** First-party module in `teengine`, subpath export. | +| Where does Rapier KCC live? | Inside `character-controller/`, not scattered in demo or `PhysicsBridge`. | +| Where does input → jump live? | T4 application (`DemoPlayerIntentSystem`). | +| What is Core’s job? | Entity field storage, pipeline phases, `PhysicsBridge.step/sync`. | + +**No half measures:** the Character Controller ships as a complete T3 module with types, system, tests, export path, and pipeline slot — or it does not ship. diff --git a/docs/PACKAGE.md b/docs/PACKAGE.md index 481f634..c629548 100644 --- a/docs/PACKAGE.md +++ b/docs/PACKAGE.md @@ -31,11 +31,14 @@ teengine-js/ ## Design principles +Module tiers (Kernel / Core / Standard / Application) are defined in [MODULES.md](./MODULES.md). + | 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 | +| **Kernel + Core (T1/T2)** | `packages/teengine` — main export `teengine` | +| **Standard modules (T3)** | `packages/teengine/src//` — subpath e.g. `teengine/character-controller` | +| **Application (T4)** | `examples/*` (PlayerController, demo atlas, scenes) | +| **GPU internals (T0)** | `packages/teengine/src/gpu` — private, not exported | | **Editor / UI** | Out of scope — use your own UI framework in the app | ## Public API (`teengine`) diff --git a/docs/PHYSICS.md b/docs/PHYSICS.md index 7a75368..c01cec2 100644 --- a/docs/PHYSICS.md +++ b/docs/PHYSICS.md @@ -60,13 +60,14 @@ Collider and collision policy live on separate components (`collider`, `collisio ## Next phases -### Phase 2 — Events -- `PhysicsWorld.onCollisionEnter` / `Exit` via Rapier event queue +### Phase 2 — 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 3 — Character controller (T3 Standard module) +- Spec: [MODULES.md §8](./MODULES.md#8-character-controller-module-full-spec) +- `teengine/character-controller` — Rapier KCC, `CharacterMotorSystem`, intent/motor components +- Demo keeps input wiring (T4); drops impulse-based jump ### Phase 4 — Performance - Reuse translation buffers (avoid alloc per body per frame) From 9807b55cb7a4c1f72b9a750484cee7af0f712a9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 16:23:54 +0000 Subject: [PATCH 2/8] =?UTF-8?q?docs:=20simplify=20scope=20=E2=80=94=20engi?= =?UTF-8?q?ne=20primitives=20only,=20gameplay=20in=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace tiered module spec with a single boundary rule: the package provides run/draw/simulate/input. Character controllers, animation, and scene management are developer or example concerns, not engine work. Co-authored-by: rAI --- docs/ARCHITECTURE.md | 25 ++- docs/MODULES.md | 473 +++++++------------------------------------ docs/PACKAGE.md | 28 +-- docs/PHYSICS.md | 12 +- 4 files changed, 95 insertions(+), 443 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9ba85ed..86c3f1f 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 and [MODULES.md](./MODULES.md) for module tiers (Kernel / Core / Standard / Application). +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 (T4 Application tier). Optional engine subsystems (e.g. character controller) are **Standard modules** — see [MODULES.md](./MODULES.md). +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,7 +74,7 @@ packages/teengine/src/ ## Roadmap -### Kernel + Core (T1/T2) — done +### Done - [x] WebGPU + cameras + layers + sprites - [x] Entity system + fixed timestep + systems @@ -83,12 +85,13 @@ packages/teengine/src/ - [x] JSON atlas loader - [x] npm package layout - [x] Collision events / sensors -- [x] Module specification ([MODULES.md](./MODULES.md)) -### Standard modules (T3) — planned +### Engine quality (next) + +- [ ] Remove demo tags (`PlayerTag`, `CoinTag`) from core entity types +- [ ] ECS query helpers +- [ ] Small math exports (`Vec2`, utilities) +- [ ] Asset load cache + GPU release +- [ ] Physics stepping performance -- [ ] `teengine/character-controller` — full spec in [MODULES.md §8](./MODULES.md#8-character-controller-module-full-spec) -- [ ] `teengine/animation` -- [ ] `teengine/scene` -- [ ] `teengine/math` (Vec2 utilities) -- [ ] Migrate demo tags (`PlayerTag`, `CoinTag`) out of Core entity types +See [MODULES.md](./MODULES.md) for scope boundaries. diff --git a/docs/MODULES.md b/docs/MODULES.md index f45183e..df5044e 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -1,451 +1,116 @@ -# TeEngine Module Specification +# What belongs in TeEngine -This document defines **what a module is**, **how modules are classified**, and **where every capability lives**. It is the source of truth for package boundaries — not ad‑hoc “core vs plugin” calls per feature. +TeEngine is the **simplest useful 2D game engine** — not a platformer kit, not a grab bag of game recipes. -Related: [ARCHITECTURE.md](./ARCHITECTURE.md), [PACKAGE.md](./PACKAGE.md), [PHYSICS.md](./PHYSICS.md). +**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. --- -## 1. Goals +## In the engine (`packages/teengine`) -TeEngine targets a **refined minimal 2D engine**: enough to ship small games without becoming a general-purpose framework. +These are the only subsystems. If it does not fit here, it does not ship in the package. -The module spec exists to: +| 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) | -1. **Prevent boundary drift** — demo code, physics internals, and reusable features stay in separate buckets. -2. **Make opt-in explicit** — games that never need platformer movement do not carry platformer assumptions in their mental model or public types. -3. **Scale the monorepo** — new capabilities get a tier and export path *before* implementation starts. -4. **Keep one npm package until split is justified** — tiers map to **directories and exports**, not necessarily separate published packages. +### What physics gives you (and nothing more) ---- - -## 2. Module tiers - -Every capability belongs to exactly one tier. - -| Tier | Name | Shipped in | npm export | Stability | Purpose | -|------|------|------------|------------|-----------|---------| -| **T0** | Internal | `packages/teengine` | None | None | Implementation detail. Breaking changes anytime. | -| **T1** | Kernel | `packages/teengine` | `teengine` | Stable | Without this, the product is not a game engine. | -| **T2** | Core | `packages/teengine` | `teengine` | Stable | Subsystems every typical game uses once the engine is running. | -| **T3** | Standard | `packages/teengine` | `teengine/` | Stable | Complete, optional subsystems. Opt in by import + registration. | -| **T4** | Application | `examples/*` | Never published | N/A | Single-game logic, assets, scenes. | - -### 2.1 Classification rules - -Use this decision tree for any new feature: - -``` -1. Is it required to create Engine, draw a frame, or tick World? - YES → T1 Kernel - NO → 2 - -2. Is it a general subsystem (ECS, input, rigid-body simulation, atlas load) - that most games enable without thinking? - YES → T2 Core - NO → 3 - -3. Is it a cohesive optional subsystem with its own component(s), systems, - lifecycle, and docs — usable without game-specific types? - YES → T3 Standard - NO → 4 - -4. Does it encode one game's rules, content, or presentation? - YES → T4 Application - NO → Revisit: likely T0 internal helper or belongs inside an existing module -``` - -### 2.2 What “Standard” (T3) is NOT - -- **Not a half-exported physics helper.** A Standard module owns its component types, systems, integration contract, tests, and documentation. -- **Not game code with engine branding.** If it references `player`, `coin`, or demo-specific action names as hard requirements, it is T4. -- **Not a separate npm package by default.** Split to `@teengine/` only when bundle size, release cadence, or third-party ownership demands it (see §7). - -### 2.3 What “Core” (T2) IS - -Core modules are **always imported from the main entry** and are part of the engine’s default story: - -- You do not “register” Core — you construct `Engine`, `World`, `PhysicsBridge`. -- Core may contain **optional per-entity features** (e.g. `rigidBody.type: "dynamic"`) as long as the subsystem itself is always present. - ---- - -## 3. Current inventory - -### T0 — Internal - -| Path | Role | -|------|------| -| `src/gpu/` | WebGPU device, batchers, shaders | -| `src/math/Mat3` | Affine math used by cameras/GPU (export decision: §6) | - -### T1 — Kernel - -| Path | Role | -|------|------| -| `src/engine/` | Game loop, fixed timestep, pause, resize | -| `src/graphics/` | Cameras, layers, draw API, draw queue | -| `src/math/Color` | Color type used by graphics | +- Create bodies: `dynamic`, `fixed`, `kinematicPosition` +- Set velocity, apply impulse, step the world +- Collision layers, enter/exit events, sensors +- Engine Y-down ↔ Rapier Y-up handled for you -### T2 — Core +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. -| Path | Role | -|------|------| -| `src/ecs/` | `World`, `Entity`, `Transform`, system interfaces, interpolation | -| `src/input/` | Keyboard, mouse, `ActionMap` | -| `src/physics/` | Rapier world, `PhysicsBridge`, collision layers, events, coords | -| `src/assets/` | Atlas types, `loadAtlasFromJson` | +### What the engine deliberately omits -### T3 — Standard (specified, not all implemented) - -| Module | Path (target) | Export | Status | -|--------|---------------|--------|--------| -| Character Controller | `src/character-controller/` | `teengine/character-controller` | Planned | -| Animation | `src/animation/` | `teengine/animation` | Planned | -| Scene | `src/scene/` | `teengine/scene` | Planned | -| Math | `src/math/` (Vec2 utilities) | `teengine/math` | Partial | -| Built-in systems pack | `src/systems/` | `teengine/systems` | Partial (`SpinSystem`, etc. live in `ecs/systems/` today — migrate when pack grows) | - -### T4 — Application - -| Path | Role | -|------|------| -| `examples/demo/src/PlayerControllerSystem.ts` | Demo movement tuning + input wiring | -| `examples/demo/src/CoinPickupSystem.ts` | Demo pickup rules | -| `examples/demo/src/DemoScene.ts` | Scene content | -| `examples/demo/src/createDemoAtlas.ts` | Procedural demo art | - -### Misplaced today (migration required) - -These violate the spec and must move or be generalized: - -| Item | Current | Target | -|------|---------|--------| -| `PlayerTag`, `CoinTag` | T2 `Entity.ts` | Remove from Core; use generic tags in T4 or `tags: string[]` on entity | -| `PlayerControllerSystem` | T4 (correct) | After CC module lands: thin wrapper over `CharacterMotor` + demo action names | -| `SpinSystem`, `CameraFollowSystem`, `WorldEntityRenderSystem` | T2 `ecs/systems/` | T3 `systems/` when subpath export is added (behavior unchanged) | +| 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. | +| `PlayerTag`, `CoinTag`, etc. | Demo concepts — [being removed from core types](#cleanup) | --- -## 4. Standard module contract +## In examples (`examples/*`) -Every T3 module MUST provide: +Reference games and **copy-paste starting points** — not second-class engine modules. ``` -src// - index.ts # public exports only - types.ts # components, config, results - README.md # optional; user-facing usage (or section in docs/) - *.test.ts # unit tests +examples/demo/ + PlayerControllerSystem.ts ← velocity + jump: one way to move a dynamic body + CoinPickupSystem.ts ← sensor collision handling + DemoScene.ts ← how to wire engine + world + systems ``` -Every T3 module MUST document: +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"`. -1. **Dependencies** — which T1/T2 modules it uses. -2. **Registration** — what the game adds to `World` / `Engine`. -3. **Fixed-update phase** — when it runs relative to §5. -4. **Non-goals** — what the module explicitly does not do. - -Every T3 module MUST NOT: - -- Import from `examples/` -- Add game-specific marker components (`player`, `coin`, …) -- Read input action names unless configurable via constructor/options +If we later publish recipes, they live as **examples or a separate repo** — not inside the engine package. --- -## 5. Fixed-update pipeline (integration contract) - -All modules hook into this ordered pipeline. **Do not invent parallel update paths.** - -| Phase | Owner | Work | -|-------|-------|------| -| **P0** | T2 `World` | `physics.snapshotPreviousTransforms()` | -| **P1** | T4 / custom | `FixedSystem`s — read input, AI, set **intent** on components | -| **P2** | T3 Character Controller | Apply intent → collision-resolved displacement for KCC entities | -| **P3** | T2 `PhysicsBridge` | `physics.step(dt)` — dynamic bodies, event queue | -| **P4** | T2 `World` | `physics.syncToEntities()` | -| **P5** | T4 / custom | `PostPhysicsSystem`s — triggers, gameplay reactions | - -Render path unchanged: T2 interpolation via `World.getRenderTransform()` → T3/render systems → T1 `Graphics`. +## Internal (`src/gpu/`, unexported helpers) -**Rule:** Character Controller runs at **P2**, before `physics.step()`. It moves **kinematic** bodies; dynamic bodies are unaffected. +Implementation details. No stability guarantee. Not part of the engine's promise. --- -## 6. Public export map +## The boundary test -### Today +Before adding anything to `packages/teengine`, ask: -```json -{ - "exports": { - ".": "./dist/index.js" - } -} -``` +> **Does every 2D game need this, regardless of genre?** -Main entry exports **T1 + T2 only**. - -### Target (when first T3 module ships) - -```json -{ - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./character-controller": { - "types": "./dist/character-controller/index.d.ts", - "import": "./dist/character-controller/index.js" - }, - "./systems": { - "types": "./dist/systems/index.d.ts", - "import": "./dist/systems/index.js" - }, - "./math": { - "types": "./dist/math/index.d.ts", - "import": "./dist/math/index.js" - } - } -} -``` +| 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** | -`tsup` entry array grows per T3 module. **T3 never re-exports through main `index.ts`** — that keeps tree-shaking and mental boundaries clean. +When unsure, leave it out. The engine stays smaller; games stay flexible. --- -## 7. When to split a separate npm package - -Stay in `teengine` until **all** of: - -- Module is >~15kB minified **and** most consumers omit it, **or** -- Independent versioning is required (third-party maintainer), **or** -- It introduces a new heavy dependency not wanted by core consumers - -Split format: `@teengine/` workspace package depending on `teengine`. +## Cleanup -Character Controller **does not qualify** — it uses existing `@dimforge/rapier2d` Core dependency. +Core entity types currently include demo markers (`PlayerTag`, `CoinTag`) that violate the boundary above. These will move to `examples/demo` (or become generic tags the game defines). The engine exposes **components and hooks**, not **roles in a specific game**. --- -## 8. Character Controller module (full spec) - -**Tier:** T3 Standard -**Path:** `packages/teengine/src/character-controller/` -**Export:** `teengine/character-controller` -**Depends on:** T2 `physics`, T2 `ecs` - -### 8.1 Purpose - -Provide **collision-resolved kinematic movement** for controllable avatars (platformers, top-down with slide). Replaces dynamic-body velocity hacks for characters. - -### 8.2 Non-goals - -- Not input binding (T4 / game code) -- Not animation, root motion, or networked prediction -- Not a replacement for dynamic rigid bodies (crates, ragdolls) -- Not exported from main `teengine` entry - -### 8.3 Types - -```ts -/** Per-entity motor configuration (spawn-time). */ -export type CharacterMotorComponent = { - /** Max horizontal speed, engine units / sec. */ - moveSpeed: number; - /** Initial upward speed when jump requested, engine units / sec (Y-down: negative). */ - jumpSpeed: number; - /** Gravity applied when airborne, engine units / sec² (Y-down: positive). */ - gravity: number; - /** Rapier autostep max height, engine units. Default: 0 (disabled). */ - maxStepHeight?: number; - /** Snap to ground within this distance. Default: 0.5. */ - snapToGround?: number; -}; - -/** Written by game systems each tick before motor solve (P1 → P2). */ -export type CharacterIntent = { - /** -1..1, normalized horizontal desired direction. */ - moveX: number; - /** Request jump if grounded (or coyote — game sets flag). */ - jump: boolean; -}; - -/** Read-only result after P2 solve. */ -export type CharacterMotorState = { - grounded: boolean; - /** Vertical velocity after solve, engine space. */ - velocityY: number; -}; -``` - -Entity storage (Core change — generic optional fields): - -```ts -// ecs/Entity.ts — add to Entity + SpawnConfig -characterMotor?: CharacterMotorComponent; -characterIntent?: CharacterIntent; // cleared or overwritten each P1 -``` - -Core hosts **storage** only. All behavior lives in the T3 module. +## Roadmap (engine only) -### 8.4 Public API +Work that makes the engine **simpler or more complete as a 2D foundation** — not game features. -```ts -// teengine/character-controller - -export type { CharacterMotorComponent, CharacterIntent, CharacterMotorState }; - -/** Runs P2 for all entities with characterMotor + kinematic rigid body. */ -export class CharacterMotorSystem implements FixedSystem { - constructor(options?: { gravityY?: number }); // default from PhysicsWorld if omitted -} - -/** Low-level access when games bypass the system (advanced). */ -export class CharacterMotor { - constructor(bridge: PhysicsBridge); - setIntent(entityId: EntityId, intent: CharacterIntent): void; - solve(entityId: EntityId, dt: number): CharacterMotorState; - getState(entityId: EntityId): CharacterMotorState; -} -``` - -`CharacterMotor` wraps Rapier `KinematicCharacterController` internally. Rapier types do not leak across the public boundary. - -### 8.5 Physics integration (inside module) - -The module extends physics behavior **without** bloating `PhysicsBridge`’s public Core API: - -``` -character-controller/ - RapierCharacterMotor.ts # one KCC instance per motor entity - CharacterMotorSystem.ts - index.ts -``` - -Registration lifecycle: - -| Event | Action | -|-------|--------| -| Entity spawned with `characterMotor` + `collider` + `rigidBody.type: "kinematicPosition"` | Create Rapier KCC + link to existing body/collider handles | -| Entity removed | Destroy KCC | -| `PhysicsBridge.unregister` | Module hook must run (via system-owned map or bridge callback list) | - -**Core change (minimal):** `PhysicsBridge` exposes `onRegister` / `onUnregister` callbacks OR `CharacterMotor` subscribes through `World.spawn`/`remove` wrappers. Pick one during implementation; do not duplicate body maps. - -### 8.6 Required spawn shape - -```ts -world.spawn({ - transform: { x, y }, - collider: { shape: { kind: "box", width, height }, friction: 0 }, - collision: { response: "solid", layers: ... }, - rigidBody: { type: "kinematicPosition", lockRotation: true }, - characterMotor: { moveSpeed: 220, jumpSpeed: 280, gravity: 980 }, -}); -``` - -Invalid combinations **throw at spawn** with explicit errors: - -- `characterMotor` + `rigidBody.type: "dynamic"` → error -- `characterMotor` without `collider` → error - -### 8.7 Application-layer usage (demo) - -```ts -import { CharacterMotorSystem } from "teengine/character-controller"; - -// P1 — demo system: input → intent (T4) -class DemoPlayerIntentSystem implements FixedSystem { - fixedUpdate({ world, input }) { - for (const e of world.getAll()) { - if (!e.characterMotor || !e.player) continue; // player tag stays in T4 until generic tags land - e.characterIntent = { - moveX: input.actionAxis("move_left", "move_right"), - jump: input.actionPressed("jump"), - }; - } - } -} - -world.addFixedSystem(new DemoPlayerIntentSystem()); -world.addFixedSystem(new CharacterMotorSystem()); -``` - -After migration, `PlayerControllerSystem.ts` is deleted or reduced to `DemoPlayerIntentSystem`. - -### 8.8 Tests (required before stable) - -- Grounded detection on flat surface -- No jump when airborne (without coyote flag from game) -- Horizontal slide along vertical wall -- Coordinate round-trip (engine Y-down ↔ Rapier Y-up) -- Interpolation still smooth (`isSimulatedBody` kinematic path) -- Sensor collision events still fire (coin pickup unchanged) - -### 8.9 Versioning - -Ships as **minor** bump (`0.4.0`): new subpath export, no breaking Core API. - ---- - -## 9. Future Standard modules (brief spec) - -### 9.1 Animation (T3) - -- `SpriteAnimationComponent` + `AnimationSystem` (P1 or render-adjacent) -- Frame sequences from atlas regions; optional Aseprite tag import -- Does not include state machines (T4) - -### 9.2 Scene (T3) - -- `Scene` interface: `enter(ctx)`, `exit()` -- `SceneStack`: push / pop / replace -- Does not include editor serialization - -### 9.3 Math (T3) - -- `Vec2` operations, `clamp`, `lerp`, distance -- Export `Mat3` for custom camera/transform work -- Does not include full linear algebra library - ---- - -## 10. Implementation checklist (Character Controller) - -- [ ] Add `docs/MODULES.md` (this file) -- [ ] Add `characterMotor` / `characterIntent` to Core entity storage -- [ ] Implement `src/character-controller/` per §8 -- [ ] Add `teengine/character-controller` export + tsup entry -- [ ] Wire P2 in `World.fixedUpdate` **or** document that `CharacterMotorSystem` must be registered last among P1 systems (prefer explicit P2 hook in `World` when motor entities exist) -- [ ] Migrate demo to intent system + `CharacterMotorSystem` -- [ ] Remove impulse-based jump from demo -- [ ] Deprecate `PlayerTag` / `CoinTag` from Core types (major bump when removed) -- [ ] Update `ARCHITECTURE.md` roadmap + `PHYSICS.md` phase status - -**P2 hook decision:** Prefer an explicit `World` phase over convention-based system ordering: - -```ts -// World.fixedUpdate — target shape -for (const system of this.fixedSystems) system.fixedUpdate(ctx); -this.characterMotor?.solveAll(ctx); // owned by optional module registration -this.physics?.step(ctx.dt); -``` +| Item | Rationale | +|------|-----------| +| Remove demo tags from `Entity` | Stop leaking example concepts into public types | +| ECS query helpers | Less `getAll()` + manual filtering in every system | +| Export `Vec2` / small math helpers | Shared primitives, not gameplay | +| Asset cache / lifecycle | Load once, release GPU resources cleanly | +| Physics perf (buffer reuse) | Engine quality, not new API surface | +| Docs: "building movement" guide | Point to example systems; document kinematic vs dynamic | -`World.registerCharacterMotor(motor: CharacterMotor)` called when the game imports the module — Core knows the interface type via minimal callback, or motor registers as a special `FixedSystem` with guaranteed P2 slot. +**Not on engine roadmap:** character controller, animation module, scene stack, tilemaps. --- -## 11. Summary +## Summary -| Question | Answer | -|----------|--------| -| Is Character Controller Core? | **No.** T3 Standard module. | -| Is it a plugin? | **No.** First-party module in `teengine`, subpath export. | -| Where does Rapier KCC live? | Inside `character-controller/`, not scattered in demo or `PhysicsBridge`. | -| Where does input → jump live? | T4 application (`DemoPlayerIntentSystem`). | -| What is Core’s job? | Entity field storage, pipeline phases, `PhysicsBridge.step/sync`. | +| 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 | -**No half measures:** the Character Controller ships as a complete T3 module with types, system, tests, export path, and pipeline slot — or it does not ship. +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 c629548..48bf87b 100644 --- a/docs/PACKAGE.md +++ b/docs/PACKAGE.md @@ -31,15 +31,15 @@ teengine-js/ ## Design principles -Module tiers (Kernel / Core / Standard / Application) are defined in [MODULES.md](./MODULES.md). +See [MODULES.md](./MODULES.md) for the engine boundary. | Concern | Where it lives | |---------|----------------| -| **Kernel + Core (T1/T2)** | `packages/teengine` — main export `teengine` | -| **Standard modules (T3)** | `packages/teengine/src//` — subpath e.g. `teengine/character-controller` | -| **Application (T4)** | `examples/*` (PlayerController, demo atlas, scenes) | -| **GPU internals (T0)** | `packages/teengine/src/gpu` — private, not exported | -| **Editor / UI** | Out of scope — use your own UI framework in the app | +| **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 | ## Public API (`teengine`) @@ -60,21 +60,7 @@ import { } 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. +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 c01cec2..cf3650a 100644 --- a/docs/PHYSICS.md +++ b/docs/PHYSICS.md @@ -60,19 +60,17 @@ Collider and collision policy live on separate components (`collider`, `collisio ## Next phases -### Phase 2 — Events ✅ +### Events ✅ - Collision enter/exit via Rapier event queue - Sensor colliders for triggers -### Phase 3 — Character controller (T3 Standard module) -- Spec: [MODULES.md §8](./MODULES.md#8-character-controller-module-full-spec) -- `teengine/character-controller` — Rapier KCC, `CharacterMotorSystem`, intent/motor components -- Demo keeps input wiring (T4); drops impulse-based jump - -### 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 From fa46cfffd57fc4d8f2fb504b50e53942c82d6a33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 16:39:50 +0000 Subject: [PATCH 3/8] fix(physics): subtract collider offset when syncing entity transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createPhysicsForEntity() bakes collider.offset into the body's initial translation, but syncToEntities() wrote the body position straight back to entity.transform without subtracting it — any entity with a non-zero offset would jump by that offset on the first physics sync. PhysicsWorld now stores the offset per entity and getTransformForEntity() subtracts it back out. PhysicsBridge.syncToEntities() uses that entity-aware lookup instead of the raw handle-based getTransform(). Co-authored-by: rAI --- .../teengine/src/physics/PhysicsBridge.ts | 5 +++- .../teengine/src/physics/PhysicsWorld.test.ts | 23 +++++++++++++++++++ packages/teengine/src/physics/PhysicsWorld.ts | 15 +++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) 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 { From 861311fe3d0c43f5008f997db62ce5015a045bbc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 16:39:56 +0000 Subject: [PATCH 4/8] fix(physics): give every CollisionGroups preset a distinct bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEFAULT and PLAYER were both 1 << 0 — composing DEFAULT into a mask silently also matched PLAYER and vice versa. Re-numbered so each preset owns a unique bit, and added a test that locks this invariant in going forward. Co-authored-by: rAI --- .../src/physics/CollisionLayers.test.ts | 30 +++++++++++++++++++ .../teengine/src/physics/CollisionLayers.ts | 13 ++++---- 2 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 packages/teengine/src/physics/CollisionLayers.test.ts diff --git a/packages/teengine/src/physics/CollisionLayers.test.ts b/packages/teengine/src/physics/CollisionLayers.test.ts new file mode 100644 index 0000000..a21db92 --- /dev/null +++ b/packages/teengine/src/physics/CollisionLayers.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { CollisionGroups, COLLIDE_ALL, layers, toInteractionGroups } from "./CollisionLayers.js"; + +describe("CollisionGroups", () => { + it("assigns every preset a distinct bit", () => { + const values = Object.values(CollisionGroups); + const unique = new Set(values); + expect(unique.size).toBe(values.length); + }); + + it("packs each preset into a single bit", () => { + for (const value of Object.values(CollisionGroups)) { + expect(value & (value - 1)).toBe(0); + } + }); +}); + +describe("toInteractionGroups", () => { + it("packs category into the high 16 bits and mask into the low 16 bits", () => { + const packed = toInteractionGroups(layers(CollisionGroups.PLAYER, CollisionGroups.ENEMY)); + expect(packed >>> 16).toBe(CollisionGroups.PLAYER); + expect(packed & 0xffff).toBe(CollisionGroups.ENEMY); + }); + + 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); + }); +}); diff --git a/packages/teengine/src/physics/CollisionLayers.ts b/packages/teengine/src/physics/CollisionLayers.ts index 4602730..ba8237f 100644 --- a/packages/teengine/src/physics/CollisionLayers.ts +++ b/packages/teengine/src/physics/CollisionLayers.ts @@ -21,13 +21,16 @@ export const COLLIDE_ALL: CollisionLayers = { mask: 0xffff, }; -/** Preset layer bits — compose with bitwise OR. */ +/** + * Preset layer bits — compose with bitwise OR. + * Each preset is a distinct bit; do not reuse a bit across presets. + */ export const CollisionGroups = { DEFAULT: 1 << 0, - PLAYER: 1 << 0, - PICKUP: 1 << 1, - GROUND: 1 << 2, - ENEMY: 1 << 3, + PLAYER: 1 << 1, + PICKUP: 1 << 2, + GROUND: 1 << 3, + ENEMY: 1 << 4, } as const; export function layers( From 7027cc37d747fb07d3cedd96ee7a797b88f30eef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 16:40:03 +0000 Subject: [PATCH 5/8] refactor(physics): simplify shouldEmitAsSelf, document collisionListener no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Traced the branches: Rapier only queues events for a collider whose resolved emitEvents is true in the first place, so by the time shouldEmitAsSelf's first guard passes, every remaining branch already evaluates to true (sensor, or a solid with emitEvents explicitly set — the only way a solid's resolved emitEvents becomes true). The function reduces to returning collision.emitEvents; collisionListener currently has no effect on the outcome, which is documented as a tracked Core API cleanup rather than silently changed here. Added collisionDefaults.test.ts covering resolveCollision defaults and shouldEmitAsSelf, since neither had direct unit tests before. Co-authored-by: rAI --- .../src/physics/collisionDefaults.test.ts | 54 +++++++++++++++++++ .../teengine/src/physics/collisionDefaults.ts | 20 +++++-- 2 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 packages/teengine/src/physics/collisionDefaults.test.ts 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..ea048fc 100644 --- a/packages/teengine/src/physics/collisionDefaults.ts +++ b/packages/teengine/src/physics/collisionDefaults.ts @@ -29,9 +29,19 @@ 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 — whether it's a sensor + * or a solid with `collision.emitEvents: true` explicitly set (the only way a + * solid's resolved `emitEvents` becomes true, since solids default to `false`). + * + * `entity` is unused today: `collisionListener` has no additional effect on this + * result. Tracked as a Core API cleanup (see `docs/MODULES.md`), not fixed here + * to avoid changing event-emission behavior as part of a "simplify" pass. + */ +export function shouldEmitAsSelf(_entity: Entity, collision: ResolvedCollision): boolean { + return collision.emitEvents; } From 23f55b4b96fe2c1fb3c793d6cb68acfce17e917a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 16:40:10 +0000 Subject: [PATCH 6/8] docs(physics): fix usage example to match the real World/PhysicsBridge API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example called world.attachPhysics() and world.syncFromPhysics(), neither of which exist — physics is constructor-injected into World, and sync happens internally inside World.fixedUpdate(). It also nested collider inside rigidBody, which contradicts the actual sibling-component schema. Replaced with a runnable example matching PlayerControllerSystem and DemoScene in examples/demo. Co-authored-by: rAI --- docs/PHYSICS.md | 47 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/PHYSICS.md b/docs/PHYSICS.md index cf3650a..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 | From cad0b05345cbaeee3f4d59c2193a0b1188d50dea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 16:44:38 +0000 Subject: [PATCH 7/8] =?UTF-8?q?refactor(api):=20seal=20public=20surface=20?= =?UTF-8?q?=E2=80=94=20generic=20tags,=20no=20demo=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace PlayerTag/CoinTag/CameraTargetTag/CollisionListenerTag with Entity.tags: Set - CameraFollowSystem takes followTag string (game-defined) - Remove DemoAtlas, CollisionGroups presets, ColliderHandle, coord helpers, and GpuTexture from public exports - Reconcile index.ts as the single API contract; export hasPhysics, isSimulatedBody, TransformData, shape component types Co-authored-by: rAI --- packages/teengine/src/assets/Atlas.ts | 8 +---- packages/teengine/src/assets/loadAtlas.ts | 4 ++- packages/teengine/src/assets/uploadTexture.ts | 31 +++++++++++++++++++ packages/teengine/src/ecs/Entity.ts | 28 +++-------------- packages/teengine/src/ecs/index.ts | 6 +--- .../src/ecs/systems/CameraFollowSystem.ts | 7 +++-- packages/teengine/src/index.ts | 16 +++++----- .../src/physics/CollisionEvents.test.ts | 12 ++++--- .../src/physics/CollisionLayers.test.ts | 30 +++++++----------- .../teengine/src/physics/CollisionLayers.ts | 15 ++------- packages/teengine/src/physics/index.ts | 14 ++------- 11 files changed, 77 insertions(+), 94 deletions(-) create mode 100644 packages/teengine/src/assets/uploadTexture.ts 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/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 index a21db92..36db025 100644 --- a/packages/teengine/src/physics/CollisionLayers.test.ts +++ b/packages/teengine/src/physics/CollisionLayers.test.ts @@ -1,25 +1,13 @@ import { describe, expect, it } from "vitest"; -import { CollisionGroups, COLLIDE_ALL, layers, toInteractionGroups } from "./CollisionLayers.js"; - -describe("CollisionGroups", () => { - it("assigns every preset a distinct bit", () => { - const values = Object.values(CollisionGroups); - const unique = new Set(values); - expect(unique.size).toBe(values.length); - }); - - it("packs each preset into a single bit", () => { - for (const value of Object.values(CollisionGroups)) { - expect(value & (value - 1)).toBe(0); - } - }); -}); +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 packed = toInteractionGroups(layers(CollisionGroups.PLAYER, CollisionGroups.ENEMY)); - expect(packed >>> 16).toBe(CollisionGroups.PLAYER); - expect(packed & 0xffff).toBe(CollisionGroups.ENEMY); + 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", () => { @@ -28,3 +16,9 @@ describe("toInteractionGroups", () => { 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 ba8237f..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,18 +24,6 @@ export const COLLIDE_ALL: CollisionLayers = { mask: 0xffff, }; -/** - * Preset layer bits — compose with bitwise OR. - * Each preset is a distinct bit; do not reuse a bit across presets. - */ -export const CollisionGroups = { - DEFAULT: 1 << 0, - PLAYER: 1 << 1, - PICKUP: 1 << 2, - GROUND: 1 << 3, - ENEMY: 1 << 4, -} as const; - export function layers( category: number, mask: number, 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"; From 2cd7bf5cc7891db206c668571f9628cc9f58847d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 16:44:41 +0000 Subject: [PATCH 8/8] refactor(assets): loadAtlasFromJson(engine) + uploadRgbaTexture; migrate demo - Remove public Engine.device; asset helpers use getGpuDevice() internally - Add uploadRgbaTexture(engine, pixels, w, h) for procedural textures - Demo owns DemoTags, DemoAtlas, DemoCollisionGroups in demoConstants.ts - Update docs (MODULES, ARCHITECTURE, PACKAGE) for Phase 2 completion Co-authored-by: rAI --- docs/ARCHITECTURE.md | 3 +- docs/MODULES.md | 48 +++++++++++-------- docs/PACKAGE.md | 13 ++++- examples/demo/src/CoinPickupSystem.ts | 7 +-- examples/demo/src/DemoScene.ts | 22 +++++---- examples/demo/src/PlayerControllerSystem.ts | 7 +-- examples/demo/src/createDemoAtlas.ts | 32 ++----------- examples/demo/src/demoConstants.ts | 27 +++++++++++ examples/demo/src/main.ts | 6 +-- packages/teengine/src/engine/Engine.ts | 3 +- .../teengine/src/physics/collisionDefaults.ts | 8 +--- 11 files changed, 98 insertions(+), 78 deletions(-) create mode 100644 examples/demo/src/demoConstants.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 86c3f1f..9903a22 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -88,7 +88,8 @@ packages/teengine/src/ ### Engine quality (next) -- [ ] Remove demo tags (`PlayerTag`, `CoinTag`) from core entity types +- [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 diff --git a/docs/MODULES.md b/docs/MODULES.md index df5044e..00f065d 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -25,8 +25,9 @@ These are the only subsystems. If it does not fit here, it does not ship in the - Create bodies: `dynamic`, `fixed`, `kinematicPosition` - Set velocity, apply impulse, step the world -- Collision layers, enter/exit events, sensors -- Engine Y-down ↔ Rapier Y-up handled for you +- 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. @@ -40,7 +41,21 @@ That is enough to build **any** movement style — platformer, top-down, vehicle | Tilemaps | Content format + renderer. Add when a game needs it. | | Audio | Separate concern. | | UI / text | Use DOM or your UI library. | -| `PlayerTag`, `CoinTag`, etc. | Demo concepts — [being removed from core types](#cleanup) | +| 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. --- @@ -50,6 +65,7 @@ Reference games and **copy-paste starting points** — not second-class engine m ``` 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 @@ -57,8 +73,6 @@ examples/demo/ 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"`. -If we later publish recipes, they live as **examples or a separate repo** — not inside the engine package. - --- ## Internal (`src/gpu/`, unexported helpers) @@ -82,24 +96,16 @@ When unsure, leave it out. The engine stays smaller; games stay flexible. --- -## Cleanup - -Core entity types currently include demo markers (`PlayerTag`, `CoinTag`) that violate the boundary above. These will move to `examples/demo` (or become generic tags the game defines). The engine exposes **components and hooks**, not **roles in a specific game**. - ---- - ## Roadmap (engine only) -Work that makes the engine **simpler or more complete as a 2D foundation** — not game features. - -| Item | Rationale | -|------|-----------| -| Remove demo tags from `Entity` | Stop leaking example concepts into public types | -| ECS query helpers | Less `getAll()` + manual filtering in every system | -| Export `Vec2` / small math helpers | Shared primitives, not gameplay | -| Asset cache / lifecycle | Load once, release GPU resources cleanly | -| Physics perf (buffer reuse) | Engine quality, not new API surface | -| Docs: "building movement" guide | Point to example systems; document kinematic vs dynamic | +| 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. diff --git a/docs/PACKAGE.md b/docs/PACKAGE.md index 48bf87b..f532faa 100644 --- a/docs/PACKAGE.md +++ b/docs/PACKAGE.md @@ -49,15 +49,24 @@ Single entry point today: import { Engine, World, - Graphics, PhysicsBridge, PhysicsWorld, Layers, + layers, loadAtlasFromJson, - SpinSystem, + uploadRgbaTexture, CameraFollowSystem, WorldEntityRenderSystem, } from "teengine"; + +// 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) }, +}); + +const atlas = await loadAtlasFromJson(engine, "/assets/sprites.json"); +const procedural = uploadRgbaTexture(engine, pixels, width, height); ``` Single entry point. No subpath exports unless the core API genuinely outgrows one bundle. 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/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/physics/collisionDefaults.ts b/packages/teengine/src/physics/collisionDefaults.ts index ea048fc..340d882 100644 --- a/packages/teengine/src/physics/collisionDefaults.ts +++ b/packages/teengine/src/physics/collisionDefaults.ts @@ -34,13 +34,7 @@ export function resolveCollision(entity: Entity): ResolvedCollision | null { * * 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 — whether it's a sensor - * or a solid with `collision.emitEvents: true` explicitly set (the only way a - * solid's resolved `emitEvents` becomes true, since solids default to `false`). - * - * `entity` is unused today: `collisionListener` has no additional effect on this - * result. Tracked as a Core API cleanup (see `docs/MODULES.md`), not fixed here - * to avoid changing event-emission behavior as part of a "simplify" pass. + * that guard passes, the entity is always the `self` side. */ export function shouldEmitAsSelf(_entity: Entity, collision: ResolvedCollision): boolean { return collision.emitEvents;