ecspresso 0.17.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/dist/asset-manager.d.ts +8 -8
- package/dist/asset-types.d.ts +9 -5
- package/dist/ecspresso-builder.d.ts +5 -5
- package/dist/ecspresso.d.ts +13 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/index.js.map +10 -10
- package/dist/plugins/ai/behavior-tree.d.ts +2 -2
- package/dist/plugins/ai/behavior-tree.js.map +2 -2
- package/dist/plugins/ai/detection.d.ts +3 -3
- package/dist/plugins/ai/detection.js.map +2 -2
- package/dist/plugins/ai/flocking.d.ts +3 -3
- package/dist/plugins/ai/flocking.js.map +1 -1
- package/dist/plugins/ai/pathfinding.d.ts +3 -4
- package/dist/plugins/ai/pathfinding.js.map +2 -2
- package/dist/plugins/audio/audio.js +2 -2
- package/dist/plugins/audio/audio.js.map +3 -3
- package/dist/plugins/combat/health.d.ts +2 -2
- package/dist/plugins/combat/health.js.map +2 -2
- package/dist/plugins/combat/projectile.d.ts +3 -3
- package/dist/plugins/combat/projectile.js.map +2 -2
- package/dist/plugins/input/selection.d.ts +3 -3
- package/dist/plugins/input/selection.js.map +3 -3
- package/dist/plugins/isometric/depth-sort.d.ts +2 -2
- package/dist/plugins/isometric/depth-sort.js.map +2 -2
- package/dist/plugins/isometric/projection.d.ts +2 -2
- package/dist/plugins/isometric/projection.js.map +2 -2
- package/dist/plugins/physics/steering.d.ts +2 -2
- package/dist/plugins/physics/steering.js.map +2 -2
- package/dist/plugins/rendering/particles.d.ts +2 -2
- package/dist/plugins/rendering/particles.js +2 -2
- package/dist/plugins/rendering/particles.js.map +3 -3
- package/dist/plugins/rendering/renderer2D.d.ts +6 -5
- package/dist/plugins/rendering/renderer2D.js +2 -2
- package/dist/plugins/rendering/renderer2D.js.map +3 -3
- package/dist/plugins/rendering/renderer3D.d.ts +3 -2
- package/dist/plugins/rendering/renderer3D.js +2 -2
- package/dist/plugins/rendering/renderer3D.js.map +3 -3
- package/dist/plugins/rendering/tilemap.d.ts +3 -3
- package/dist/plugins/rendering/tilemap.js.map +3 -3
- package/dist/plugins/spatial/camera.js.map +2 -2
- package/dist/plugins/spatial/camera3D.d.ts +3 -3
- package/dist/plugins/spatial/camera3D.js.map +2 -2
- package/dist/plugins/spatial/transform.d.ts +2 -2
- package/dist/plugins/spatial/transform.js.map +1 -1
- package/dist/plugins/spatial/transform3D.d.ts +2 -2
- package/dist/plugins/spatial/transform3D.js.map +1 -1
- package/dist/plugins/ui/ui.d.ts +2 -2
- package/dist/plugins/ui/ui.js +2 -2
- package/dist/plugins/ui/ui.js.map +4 -4
- package/dist/reactive-query-manager.d.ts +26 -11
- package/dist/screen-types.d.ts +21 -3
- package/dist/system-builder.d.ts +5 -5
- package/dist/type-utils.d.ts +20 -0
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/plugins/spatial/camera.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * Camera / Viewport Plugin for ECSpresso\n *\n * Provides a declarative camera with world/screen coordinate conversion, smooth follow,\n * trauma-based shake, bounds clamping, cursor-centered zoom, and logical viewport dimensions.\n *\n * This plugin is renderer-agnostic. PixiJS or other renderer integration (applying\n * cameraState to a container/stage transform) is the consumer's responsibility.\n *\n * Camera uses its own x/y/zoom/rotation rather than localTransform/worldTransform.\n * It reads the target entity's worldTransform for follow, but doesn't participate\n * in the transform hierarchy itself.\n */\n\nimport { definePlugin } from 'ecspresso';\nimport type { SystemPhase } from 'ecspresso';\nimport type ECSpresso from 'ecspresso';\nimport type { WorldConfigFrom } from '../../type-utils';\nimport type { TransformWorldConfig } from './transform';\n\n// ==================== Component Types ====================\n\nexport interface Camera {\n\tx: number;\n\ty: number;\n\tzoom: number;\n\trotation: number;\n}\n\nexport interface CameraFollow {\n\ttarget: number;\n\tsmoothing: number;\n\tdeadzoneX: number;\n\tdeadzoneY: number;\n\toffsetX: number;\n\toffsetY: number;\n}\n\nexport interface CameraShake {\n\ttrauma: number;\n\ttraumaDecay: number;\n\tmaxOffsetX: number;\n\tmaxOffsetY: number;\n\tmaxRotation: number;\n}\n\nexport interface CameraBounds {\n\tminX: number;\n\tminY: number;\n\tmaxX: number;\n\tmaxY: number;\n}\n\nexport interface CameraComponentTypes {\n\tcamera: Camera;\n\tcameraFollow: CameraFollow;\n\tcameraShake: CameraShake;\n\tcameraBounds: CameraBounds;\n}\n\n// ==================== Resource Types ====================\n\nexport interface FollowOptions {\n\tsmoothing?: number;\n\tdeadzoneX?: number;\n\tdeadzoneY?: number;\n\toffsetX?: number;\n\toffsetY?: number;\n}\n\nexport type EntityHandle = { id: number };\n\nexport interface CameraState {\n\t// Read-only data (synced from camera entity each frame)\n\tx: number;\n\ty: number;\n\tzoom: number;\n\trotation: number;\n\tshakeOffsetX: number;\n\tshakeOffsetY: number;\n\tshakeRotation: number;\n\tviewportWidth: number;\n\tviewportHeight: number;\n\tentityId: number;\n\n\t// Mutation methods\n\tfollow(target: number | EntityHandle, options?: FollowOptions): void;\n\tunfollow(): void;\n\tsetPosition(x: number, y: number): void;\n\tsetZoom(zoom: number): void;\n\tsetRotation(rotation: number): void;\n\tsetBounds(minX: number, minY: number, maxX: number, maxY: number): void;\n\tclearBounds(): void;\n\taddTrauma(amount: number): void;\n}\n\nexport interface CameraResourceTypes {\n\tcameraState: CameraState;\n}\n\n// ==================== Plugin Options ====================\n\nexport interface CameraPluginOptions<G extends string = 'camera'> {\n\tviewportWidth?: number;\n\tviewportHeight?: number;\n\tinitial?: {\n\t\tx?: number;\n\t\ty?: number;\n\t\tzoom?: number;\n\t\trotation?: number;\n\t};\n\tfollow?: FollowOptions;\n\tshake?: boolean | Partial<Omit<CameraShake, 'trauma'>>;\n\tbounds?:\n\t\t| { minX: number; minY: number; maxX: number; maxY: number }\n\t\t| [number, number, number, number];\n\tzoom?: {\n\t\tzoomStep?: number;\n\t\tminZoom?: number;\n\t\tmaxZoom?: number;\n\t};\n\tpan?: {\n\t\tspeed: number;\n\t\tactions?: {\n\t\t\tup?: string;\n\t\t\tdown?: string;\n\t\t\tleft?: string;\n\t\t\tright?: string;\n\t\t};\n\t};\n\tsystemGroup?: G;\n\tphase?: SystemPhase;\n\trandomFn?: () => number;\n}\n\n// ==================== Default Values ====================\n\nconst DEFAULT_SHAKE: Readonly<Omit<CameraShake, 'trauma'>> = {\n\ttraumaDecay: 1,\n\tmaxOffsetX: 10,\n\tmaxOffsetY: 10,\n\tmaxRotation: 0.05,\n};\n\nconst DEFAULT_FOLLOW: Readonly<Omit<CameraFollow, 'target'>> = {\n\tsmoothing: 5,\n\tdeadzoneX: 0,\n\tdeadzoneY: 0,\n\toffsetX: 0,\n\toffsetY: 0,\n};\n\n// ==================== Coordinate Conversion ====================\n\nexport function worldToScreen(\n\tworldX: number,\n\tworldY: number,\n\tstate: CameraState,\n): { x: number; y: number } {\n\tconst dx = worldX - (state.x + state.shakeOffsetX);\n\tconst dy = worldY - (state.y + state.shakeOffsetY);\n\n\tconst angle = -(state.rotation + state.shakeRotation);\n\tconst cos = Math.cos(angle);\n\tconst sin = Math.sin(angle);\n\tconst rx = dx * cos - dy * sin;\n\tconst ry = dx * sin + dy * cos;\n\n\treturn {\n\t\tx: rx * state.zoom + state.viewportWidth / 2,\n\t\ty: ry * state.zoom + state.viewportHeight / 2,\n\t};\n}\n\nexport function screenToWorld(\n\tscreenX: number,\n\tscreenY: number,\n\tstate: CameraState,\n): { x: number; y: number } {\n\tconst cx = (screenX - state.viewportWidth / 2) / state.zoom;\n\tconst cy = (screenY - state.viewportHeight / 2) / state.zoom;\n\n\tconst angle = state.rotation + state.shakeRotation;\n\tconst cos = Math.cos(angle);\n\tconst sin = Math.sin(angle);\n\tconst rx = cx * cos - cy * sin;\n\tconst ry = cx * sin + cy * cos;\n\n\treturn {\n\t\tx: rx + state.x + state.shakeOffsetX,\n\t\ty: ry + state.y + state.shakeOffsetY,\n\t};\n}\n\n// ==================== Internal Helpers ====================\n\nfunction resolveTarget(target: number | EntityHandle): number {\n\treturn typeof target === 'number' ? target : target.id;\n}\n\nfunction resolveShakeOptions(shake: true | Partial<Omit<CameraShake, 'trauma'>>): CameraShake {\n\tconst opts = shake === true ? {} : shake;\n\treturn {\n\t\ttrauma: 0,\n\t\ttraumaDecay: opts.traumaDecay ?? DEFAULT_SHAKE.traumaDecay,\n\t\tmaxOffsetX: opts.maxOffsetX ?? DEFAULT_SHAKE.maxOffsetX,\n\t\tmaxOffsetY: opts.maxOffsetY ?? DEFAULT_SHAKE.maxOffsetY,\n\t\tmaxRotation: opts.maxRotation ?? DEFAULT_SHAKE.maxRotation,\n\t};\n}\n\nfunction resolveBounds(\n\tbounds: { minX: number; minY: number; maxX: number; maxY: number } | [number, number, number, number],\n): CameraBounds {\n\tif (Array.isArray(bounds)) {\n\t\treturn { minX: bounds[0], minY: bounds[1], maxX: bounds[2], maxY: bounds[3] };\n\t}\n\treturn { ...bounds };\n}\n\nfunction resolveFollowOptions(options?: FollowOptions): Omit<CameraFollow, 'target'> {\n\treturn {\n\t\tsmoothing: options?.smoothing ?? DEFAULT_FOLLOW.smoothing,\n\t\tdeadzoneX: options?.deadzoneX ?? DEFAULT_FOLLOW.deadzoneX,\n\t\tdeadzoneY: options?.deadzoneY ?? DEFAULT_FOLLOW.deadzoneY,\n\t\toffsetX: options?.offsetX ?? DEFAULT_FOLLOW.offsetX,\n\t\toffsetY: options?.offsetY ?? DEFAULT_FOLLOW.offsetY,\n\t};\n}\n\n// ==================== Plugin Factory ====================\n\ntype CameraWorldConfig = WorldConfigFrom<CameraComponentTypes, {}, CameraResourceTypes>;\n\ntype CameraLabels =\n\t| 'camera-init'\n\t| 'camera-follow'\n\t| 'camera-shake-update'\n\t| 'camera-bounds'\n\t| 'camera-state-sync'\n\t| 'camera-zoom'\n\t| 'camera-pan';\n\nexport function createCameraPlugin<G extends string = 'camera'>(\n\toptions?: CameraPluginOptions<G>,\n) {\n\tconst {\n\t\tviewportWidth = 800,\n\t\tviewportHeight = 600,\n\t\tinitial,\n\t\tfollow: followConfig,\n\t\tshake: shakeConfig,\n\t\tbounds: boundsConfig,\n\t\tzoom: zoomConfig,\n\t\tpan: panConfig,\n\t\tsystemGroup = 'camera',\n\t\tphase = 'postUpdate',\n\t\trandomFn = Math.random,\n\t} = options ?? {};\n\n\treturn definePlugin('camera')\n\t\t.withComponentTypes<CameraComponentTypes>()\n\t\t.withResourceTypes<CameraResourceTypes>()\n\t\t.withLabels<CameraLabels>()\n\t\t.withGroups<G>()\n\t\t.requires<TransformWorldConfig>()\n\t\t.install((world) => {\n\t\t\t// Build mutation methods as closures over the world reference.\n\t\t\t// The cameraState resource is created immediately with placeholder methods,\n\t\t\t// then the init system populates entityId and wires up real methods.\n\n\t\t\tconst cameraState: CameraState = {\n\t\t\t\tx: initial?.x ?? 0,\n\t\t\t\ty: initial?.y ?? 0,\n\t\t\t\tzoom: initial?.zoom ?? 1,\n\t\t\t\trotation: initial?.rotation ?? 0,\n\t\t\t\tshakeOffsetX: 0,\n\t\t\t\tshakeOffsetY: 0,\n\t\t\t\tshakeRotation: 0,\n\t\t\t\tviewportWidth,\n\t\t\t\tviewportHeight,\n\t\t\t\tentityId: -1,\n\n\t\t\t\t// Mutation methods — wired up after camera entity is spawned\n\t\t\t\tfollow: () => {},\n\t\t\t\tunfollow: () => {},\n\t\t\t\tsetPosition: () => {},\n\t\t\t\tsetZoom: () => {},\n\t\t\t\tsetRotation: () => {},\n\t\t\t\tsetBounds: () => {},\n\t\t\t\tclearBounds: () => {},\n\t\t\t\taddTrauma: () => {},\n\t\t\t};\n\n\t\t\tworld.addResource('cameraState', cameraState);\n\n\t\t\t// camera-init: spawns camera entity and wires up mutation closures\n\t\t\tworld\n\t\t\t\t.addSystem('camera-init')\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setOnInitialize((ecs: ECSpresso<CameraWorldConfig & TransformWorldConfig>) => {\n\t\t\t\t\t// Spawn with required camera component\n\t\t\t\t\tconst entity = ecs.spawn({\n\t\t\t\t\t\tcamera: {\n\t\t\t\t\t\t\tx: initial?.x ?? 0,\n\t\t\t\t\t\t\ty: initial?.y ?? 0,\n\t\t\t\t\t\t\tzoom: initial?.zoom ?? 1,\n\t\t\t\t\t\t\trotation: initial?.rotation ?? 0,\n\t\t\t\t\t\t},\n\t\t\t\t\t});\n\n\t\t\t\t\t// Conditionally add optional components\n\t\t\t\t\tif (followConfig) {\n\t\t\t\t\t\tecs.addComponent(entity.id, 'cameraFollow', {\n\t\t\t\t\t\t\ttarget: -1,\n\t\t\t\t\t\t\t...resolveFollowOptions(followConfig),\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\n\t\t\t\t\tif (shakeConfig) {\n\t\t\t\t\t\tecs.addComponent(entity.id, 'cameraShake', resolveShakeOptions(shakeConfig));\n\t\t\t\t\t}\n\n\t\t\t\t\tif (boundsConfig) {\n\t\t\t\t\t\tecs.addComponent(entity.id, 'cameraBounds', resolveBounds(boundsConfig));\n\t\t\t\t\t}\n\t\t\t\t\tcameraState.entityId = entity.id;\n\n\t\t\t\t\t// Wire up mutation methods\n\t\t\t\t\tcameraState.follow = (target: number | EntityHandle, opts?: FollowOptions) => {\n\t\t\t\t\t\tconst targetId = resolveTarget(target);\n\t\t\t\t\t\tconst followData: CameraFollow = {\n\t\t\t\t\t\t\ttarget: targetId,\n\t\t\t\t\t\t\t...resolveFollowOptions(opts),\n\t\t\t\t\t\t};\n\t\t\t\t\t\tconst existing = ecs.getComponent(cameraState.entityId, 'cameraFollow');\n\t\t\t\t\t\tif (existing) {\n\t\t\t\t\t\t\texisting.target = followData.target;\n\t\t\t\t\t\t\texisting.smoothing = followData.smoothing;\n\t\t\t\t\t\t\texisting.deadzoneX = followData.deadzoneX;\n\t\t\t\t\t\t\texisting.deadzoneY = followData.deadzoneY;\n\t\t\t\t\t\t\texisting.offsetX = followData.offsetX;\n\t\t\t\t\t\t\texisting.offsetY = followData.offsetY;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tecs.addComponent(cameraState.entityId, 'cameraFollow', followData);\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.unfollow = () => {\n\t\t\t\t\t\tconst existing = ecs.getComponent(cameraState.entityId, 'cameraFollow');\n\t\t\t\t\t\tif (existing) {\n\t\t\t\t\t\t\tecs.removeComponent(cameraState.entityId, 'cameraFollow');\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.setPosition = (x: number, y: number) => {\n\t\t\t\t\t\tconst camera = ecs.getComponent(cameraState.entityId, 'camera');\n\t\t\t\t\t\tif (!camera) return;\n\t\t\t\t\t\tcamera.x = x;\n\t\t\t\t\t\tcamera.y = y;\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.setZoom = (zoom: number) => {\n\t\t\t\t\t\tconst camera = ecs.getComponent(cameraState.entityId, 'camera');\n\t\t\t\t\t\tif (!camera) return;\n\t\t\t\t\t\tcamera.zoom = zoom;\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.setRotation = (rotation: number) => {\n\t\t\t\t\t\tconst camera = ecs.getComponent(cameraState.entityId, 'camera');\n\t\t\t\t\t\tif (!camera) return;\n\t\t\t\t\t\tcamera.rotation = rotation;\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.setBounds = (minX: number, minY: number, maxX: number, maxY: number) => {\n\t\t\t\t\t\tconst existing = ecs.getComponent(cameraState.entityId, 'cameraBounds');\n\t\t\t\t\t\tif (existing) {\n\t\t\t\t\t\t\texisting.minX = minX;\n\t\t\t\t\t\t\texisting.minY = minY;\n\t\t\t\t\t\t\texisting.maxX = maxX;\n\t\t\t\t\t\t\texisting.maxY = maxY;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tecs.addComponent(cameraState.entityId, 'cameraBounds', { minX, minY, maxX, maxY });\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.clearBounds = () => {\n\t\t\t\t\t\tconst existing = ecs.getComponent(cameraState.entityId, 'cameraBounds');\n\t\t\t\t\t\tif (existing) {\n\t\t\t\t\t\t\tecs.removeComponent(cameraState.entityId, 'cameraBounds');\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.addTrauma = (amount: number) => {\n\t\t\t\t\t\tconst shake = ecs.getComponent(cameraState.entityId, 'cameraShake');\n\t\t\t\t\t\tif (shake) {\n\t\t\t\t\t\t\tshake.trauma = Math.min(1, Math.max(0, shake.trauma + amount));\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tecs.addComponent(cameraState.entityId, 'cameraShake', {\n\t\t\t\t\t\t\t\t...resolveShakeOptions(true),\n\t\t\t\t\t\t\t\ttrauma: Math.min(1, Math.max(0, amount)),\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\t\t\t\t});\n\n\t\t\t// camera-follow: priority 400 (after transform propagation at 500)\n\t\t\tworld\n\t\t\t\t.addSystem('camera-follow')\n\t\t\t\t.setPriority(400)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.addQuery('cameras', {\n\t\t\t\t\twith: ['camera', 'cameraFollow'],\n\t\t\t\t})\n\t\t\t\t.setProcess(({ queries, dt, ecs }) => {\n\t\t\t\t\tconst t = Math.min(1, dt);\n\t\t\t\t\tfor (const entity of queries.cameras) {\n\t\t\t\t\t\tconst { camera, cameraFollow } = entity.components;\n\t\t\t\t\t\tif (cameraFollow.target < 0) continue;\n\n\t\t\t\t\t\tconst targetWorld = ecs.getComponent(cameraFollow.target, 'worldTransform');\n\t\t\t\t\t\tif (!targetWorld) continue;\n\n\t\t\t\t\t\tconst goalX = targetWorld.x + cameraFollow.offsetX;\n\t\t\t\t\t\tconst goalY = targetWorld.y + cameraFollow.offsetY;\n\t\t\t\t\t\tconst dx = goalX - camera.x;\n\t\t\t\t\t\tconst dy = goalY - camera.y;\n\n\t\t\t\t\t\tif (Math.abs(dx) > cameraFollow.deadzoneX) {\n\t\t\t\t\t\t\tconst sign = dx > 0 ? 1 : -1;\n\t\t\t\t\t\t\tconst excessX = dx - sign * cameraFollow.deadzoneX;\n\t\t\t\t\t\t\tconst factor = Math.min(1, cameraFollow.smoothing * t);\n\t\t\t\t\t\t\tcamera.x += excessX * factor;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (Math.abs(dy) > cameraFollow.deadzoneY) {\n\t\t\t\t\t\t\tconst sign = dy > 0 ? 1 : -1;\n\t\t\t\t\t\t\tconst excessY = dy - sign * cameraFollow.deadzoneY;\n\t\t\t\t\t\t\tconst factor = Math.min(1, cameraFollow.smoothing * t);\n\t\t\t\t\t\t\tcamera.y += excessY * factor;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t// camera-shake-update: priority 390\n\t\t\tworld\n\t\t\t\t.addSystem('camera-shake-update')\n\t\t\t\t.setPriority(390)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.addQuery('shakeCameras', {\n\t\t\t\t\twith: ['camera', 'cameraShake'],\n\t\t\t\t})\n\t\t\t\t.setProcess(({ queries, dt }) => {\n\t\t\t\t\tfor (const entity of queries.shakeCameras) {\n\t\t\t\t\t\tconst { cameraShake } = entity.components;\n\t\t\t\t\t\tcameraShake.trauma = Math.max(0, cameraShake.trauma - cameraShake.traumaDecay * dt);\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t// camera-bounds: priority 380\n\t\t\tworld\n\t\t\t\t.addSystem('camera-bounds')\n\t\t\t\t.setPriority(380)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.addQuery('boundedCameras', {\n\t\t\t\t\twith: ['camera', 'cameraBounds'],\n\t\t\t\t})\n\t\t\t\t.setProcess(({ queries }) => {\n\t\t\t\t\tfor (const entity of queries.boundedCameras) {\n\t\t\t\t\t\tconst { camera, cameraBounds } = entity.components;\n\t\t\t\t\t\tconst halfW = cameraState.viewportWidth / (2 * camera.zoom);\n\t\t\t\t\t\tconst halfH = cameraState.viewportHeight / (2 * camera.zoom);\n\n\t\t\t\t\t\tconst effectiveMinX = cameraBounds.minX + halfW;\n\t\t\t\t\t\tconst effectiveMaxX = cameraBounds.maxX - halfW;\n\t\t\t\t\t\tconst effectiveMinY = cameraBounds.minY + halfH;\n\t\t\t\t\t\tconst effectiveMaxY = cameraBounds.maxY - halfH;\n\n\t\t\t\t\t\tif (effectiveMinX > effectiveMaxX) {\n\t\t\t\t\t\t\tcamera.x = (cameraBounds.minX + cameraBounds.maxX) / 2;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcamera.x = Math.max(effectiveMinX, Math.min(effectiveMaxX, camera.x));\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (effectiveMinY > effectiveMaxY) {\n\t\t\t\t\t\t\tcamera.y = (cameraBounds.minY + cameraBounds.maxY) / 2;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcamera.y = Math.max(effectiveMinY, Math.min(effectiveMaxY, camera.y));\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t// camera-state-sync: priority 370\n\t\t\tworld\n\t\t\t\t.addSystem('camera-state-sync')\n\t\t\t\t.setPriority(370)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ ecs }) => {\n\t\t\t\t\tconst camera = ecs.getComponent(cameraState.entityId, 'camera');\n\t\t\t\t\tif (!camera) {\n\t\t\t\t\t\tcameraState.x = 0;\n\t\t\t\t\t\tcameraState.y = 0;\n\t\t\t\t\t\tcameraState.zoom = 1;\n\t\t\t\t\t\tcameraState.rotation = 0;\n\t\t\t\t\t\tcameraState.shakeOffsetX = 0;\n\t\t\t\t\t\tcameraState.shakeOffsetY = 0;\n\t\t\t\t\t\tcameraState.shakeRotation = 0;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tcameraState.x = camera.x;\n\t\t\t\t\tcameraState.y = camera.y;\n\t\t\t\t\tcameraState.zoom = camera.zoom;\n\t\t\t\t\tcameraState.rotation = camera.rotation;\n\n\t\t\t\t\tconst shake = ecs.getComponent(cameraState.entityId, 'cameraShake');\n\t\t\t\t\tif (shake && shake.trauma > 0) {\n\t\t\t\t\t\tconst intensity = shake.trauma * shake.trauma;\n\t\t\t\t\t\tcameraState.shakeOffsetX = shake.maxOffsetX * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\t\tcameraState.shakeOffsetY = shake.maxOffsetY * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\t\tcameraState.shakeRotation = shake.maxRotation * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcameraState.shakeOffsetX = 0;\n\t\t\t\t\t\tcameraState.shakeOffsetY = 0;\n\t\t\t\t\t\tcameraState.shakeRotation = 0;\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t// camera-zoom: conditionally registered when zoom option is provided\n\t\t\tif (zoomConfig) {\n\t\t\t\tconst {\n\t\t\t\t\tzoomStep = 0.1,\n\t\t\t\t\tminZoom = 0.1,\n\t\t\t\t\tmaxZoom = 10,\n\t\t\t\t} = zoomConfig;\n\n\t\t\t\ttype ZoomInputState = { pointer: { position: { x: number; y: number } } };\n\n\t\t\t\tlet pendingSteps = 0;\n\t\t\t\tlet zoomActive = false;\n\t\t\t\tlet canvas: HTMLCanvasElement | undefined;\n\t\t\t\tlet isoState: { tileWidth: number; tileHeight: number; originX: number; originY: number } | undefined;\n\n\t\t\t\tfunction onWheel(e: WheelEvent) {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tpendingSteps += Math.sign(e.deltaY);\n\t\t\t\t}\n\n\t\t\t\tworld\n\t\t\t\t\t.addSystem('camera-zoom')\n\t\t\t\t\t.setPriority(410)\n\t\t\t\t\t.inPhase('preUpdate')\n\t\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t\t.addQuery('cameras', {\n\t\t\t\t\t\twith: ['camera'],\n\t\t\t\t\t})\n\t\t\t\t\t.setOnInitialize((ecs) => {\n\t\t\t\t\t\t// Check for required dependencies\n\t\t\t\t\t\tconst inputState = ecs.tryGetResource<ZoomInputState>('inputState');\n\t\t\t\t\t\tconst pixiApp = ecs.tryGetResource<{ canvas: HTMLCanvasElement }>('pixiApp');\n\n\t\t\t\t\t\tif (!inputState || !pixiApp) {\n\t\t\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\t\t'[camera] zoom requires the input plugin and renderer2D plugin. ' +\n\t\t\t\t\t\t\t\t'Zoom will be disabled.',\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tcanvas = pixiApp.canvas;\n\t\t\t\t\t\tcanvas.addEventListener('wheel', onWheel as EventListener, { passive: false });\n\n\t\t\t\t\t\t// Detect isometric projection for iso-aware cursor-centered zoom\n\t\t\t\t\t\tisoState = ecs.tryGetResource<{\n\t\t\t\t\t\t\ttileWidth: number; tileHeight: number;\n\t\t\t\t\t\t\toriginX: number; originY: number;\n\t\t\t\t\t\t}>('isoProjection');\n\n\t\t\t\t\t\tzoomActive = true;\n\t\t\t\t\t})\n\t\t\t\t\t.setOnDetach(() => {\n\t\t\t\t\t\tif (!zoomActive || !canvas) return;\n\t\t\t\t\t\tcanvas.removeEventListener('wheel', onWheel as EventListener);\n\t\t\t\t\t})\n\t\t\t\t\t.setProcess(({ queries, ecs }) => {\n\t\t\t\t\t\tif (!zoomActive || pendingSteps === 0) return;\n\n\t\t\t\t\t\tconst steps = pendingSteps;\n\t\t\t\t\t\tpendingSteps = 0;\n\n\t\t\t\t\t\tconst [cameraEntity] = queries.cameras;\n\t\t\t\t\t\tif (!cameraEntity) return;\n\n\t\t\t\t\t\tconst cam = cameraEntity.components.camera;\n\t\t\t\t\t\tconst inputState = ecs.tryGetResource<ZoomInputState>('inputState');\n\t\t\t\t\t\tif (!inputState) return;\n\n\t\t\t\t\t\t// Apply zoom — proportional to number of wheel steps\n\t\t\t\t\t\tconst direction = steps > 0 ? (1 - zoomStep) : (1 + zoomStep);\n\t\t\t\t\t\tconst newZoom = Math.max(minZoom, Math.min(maxZoom, cam.zoom * Math.pow(direction, Math.abs(steps))));\n\n\t\t\t\t\t\tif (isoState && canvas) {\n\t\t\t\t\t\t\t// Iso-aware cursor-centered zoom: work in iso-screen space\n\t\t\t\t\t\t\tconst rect = canvas.getBoundingClientRect();\n\t\t\t\t\t\t\tconst screenOffX = inputState.pointer.position.x - (rect.left + rect.width / 2);\n\t\t\t\t\t\t\tconst screenOffY = inputState.pointer.position.y - (rect.top + rect.height / 2);\n\n\t\t\t\t\t\t\t// Inlined worldToIso — avoids cross-plugin import\n\t\t\t\t\t\t\tconst halfW = isoState.tileWidth / 2;\n\t\t\t\t\t\t\tconst halfH = isoState.tileHeight / 2;\n\t\t\t\t\t\t\tconst camIsoX = (cam.x - cam.y) * halfW + isoState.originX;\n\t\t\t\t\t\t\tconst camIsoY = (cam.x + cam.y) * halfH + isoState.originY;\n\t\t\t\t\t\t\tconst isoBeforeX = camIsoX + screenOffX / cam.zoom;\n\t\t\t\t\t\t\tconst isoBeforeY = camIsoY + screenOffY / cam.zoom;\n\n\t\t\t\t\t\t\tcam.zoom = newZoom;\n\n\t\t\t\t\t\t\t// New camera iso position so the same point stays under cursor\n\t\t\t\t\t\t\tconst newCamIsoX = isoBeforeX - screenOffX / newZoom;\n\t\t\t\t\t\t\tconst newCamIsoY = isoBeforeY - screenOffY / newZoom;\n\n\t\t\t\t\t\t\t// Inlined isoToWorld\n\t\t\t\t\t\t\tconst relX = newCamIsoX - isoState.originX;\n\t\t\t\t\t\t\tconst relY = newCamIsoY - isoState.originY;\n\t\t\t\t\t\t\tcam.x = relX / isoState.tileWidth + relY / isoState.tileHeight;\n\t\t\t\t\t\t\tcam.y = -relX / isoState.tileWidth + relY / isoState.tileHeight;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Pixel-space cursor-centered zoom\n\t\t\t\t\t\t\tconst worldBefore = screenToWorld(\n\t\t\t\t\t\t\t\tinputState.pointer.position.x,\n\t\t\t\t\t\t\t\tinputState.pointer.position.y,\n\t\t\t\t\t\t\t\tcameraState,\n\t\t\t\t\t\t\t);\n\n\t\t\t\t\t\t\tcam.zoom = newZoom;\n\n\t\t\t\t\t\t\tcam.x = worldBefore.x - (inputState.pointer.position.x - cameraState.viewportWidth / 2) / newZoom;\n\t\t\t\t\t\t\tcam.y = worldBefore.y - (inputState.pointer.position.y - cameraState.viewportHeight / 2) / newZoom;\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t}\n\n\t\t\t// camera-pan: conditionally registered when pan option is provided\n\t\t\tif (panConfig) {\n\t\t\t\ttype PanInputState = { actions: { isActive(action: string): boolean } };\n\n\t\t\t\tconst {\n\t\t\t\t\tspeed,\n\t\t\t\t\tactions: panActions,\n\t\t\t\t} = panConfig;\n\n\t\t\t\tconst actionUp = panActions?.up ?? 'panUp';\n\t\t\t\tconst actionDown = panActions?.down ?? 'panDown';\n\t\t\t\tconst actionLeft = panActions?.left ?? 'panLeft';\n\t\t\t\tconst actionRight = panActions?.right ?? 'panRight';\n\n\t\t\t\tlet panActive = false;\n\n\t\t\t\tworld\n\t\t\t\t\t.addSystem('camera-pan')\n\t\t\t\t\t.setPriority(420)\n\t\t\t\t\t.inPhase('preUpdate')\n\t\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t\t.setOnInitialize((ecs) => {\n\t\t\t\t\t\tconst inputState = ecs.tryGetResource<PanInputState>('inputState');\n\t\t\t\t\t\tif (!inputState) {\n\t\t\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\t\t'[camera] pan requires the input plugin. Pan will be disabled.',\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpanActive = true;\n\t\t\t\t\t})\n\t\t\t\t\t.setProcess(({ ecs, dt }) => {\n\t\t\t\t\t\tif (!panActive) return;\n\n\t\t\t\t\t\tconst inputState = ecs.tryGetResource<PanInputState>('inputState');\n\t\t\t\t\t\tif (!inputState) return;\n\n\t\t\t\t\t\tconst delta = (speed / cameraState.zoom) * dt;\n\t\t\t\t\t\tconst dx = (inputState.actions.isActive(actionRight) ? 1 : 0)\n\t\t\t\t\t\t\t- (inputState.actions.isActive(actionLeft) ? 1 : 0);\n\t\t\t\t\t\tconst dy = (inputState.actions.isActive(actionDown) ? 1 : 0)\n\t\t\t\t\t\t\t- (inputState.actions.isActive(actionUp) ? 1 : 0);\n\n\t\t\t\t\t\tif (dx !== 0 || dy !== 0) {\n\t\t\t\t\t\t\tcameraState.setPosition(\n\t\t\t\t\t\t\t\tcameraState.x + dx * delta,\n\t\t\t\t\t\t\t\tcameraState.y + dy * delta,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t}\n\t\t});\n}\n"
|
|
5
|
+
"/**\n * Camera / Viewport Plugin for ECSpresso\n *\n * Provides a declarative camera with world/screen coordinate conversion, smooth follow,\n * trauma-based shake, bounds clamping, cursor-centered zoom, and logical viewport dimensions.\n *\n * This plugin is renderer-agnostic. PixiJS or other renderer integration (applying\n * cameraState to a container/stage transform) is the consumer's responsibility.\n *\n * Camera uses its own x/y/zoom/rotation rather than localTransform/worldTransform.\n * It reads the target entity's worldTransform for follow, but doesn't participate\n * in the transform hierarchy itself.\n */\n\nimport { definePlugin } from 'ecspresso';\nimport type { SystemPhase } from 'ecspresso';\nimport type ECSpresso from 'ecspresso';\nimport type { ComponentsConfig, ResourcesConfig } from '../../type-utils';\nimport type { TransformWorldConfig } from './transform';\n\n// ==================== Component Types ====================\n\nexport interface Camera {\n\tx: number;\n\ty: number;\n\tzoom: number;\n\trotation: number;\n}\n\nexport interface CameraFollow {\n\ttarget: number;\n\tsmoothing: number;\n\tdeadzoneX: number;\n\tdeadzoneY: number;\n\toffsetX: number;\n\toffsetY: number;\n}\n\nexport interface CameraShake {\n\ttrauma: number;\n\ttraumaDecay: number;\n\tmaxOffsetX: number;\n\tmaxOffsetY: number;\n\tmaxRotation: number;\n}\n\nexport interface CameraBounds {\n\tminX: number;\n\tminY: number;\n\tmaxX: number;\n\tmaxY: number;\n}\n\nexport interface CameraComponentTypes {\n\tcamera: Camera;\n\tcameraFollow: CameraFollow;\n\tcameraShake: CameraShake;\n\tcameraBounds: CameraBounds;\n}\n\n// ==================== Resource Types ====================\n\nexport interface FollowOptions {\n\tsmoothing?: number;\n\tdeadzoneX?: number;\n\tdeadzoneY?: number;\n\toffsetX?: number;\n\toffsetY?: number;\n}\n\nexport type EntityHandle = { id: number };\n\nexport interface CameraState {\n\t// Read-only data (synced from camera entity each frame)\n\tx: number;\n\ty: number;\n\tzoom: number;\n\trotation: number;\n\tshakeOffsetX: number;\n\tshakeOffsetY: number;\n\tshakeRotation: number;\n\tviewportWidth: number;\n\tviewportHeight: number;\n\tentityId: number;\n\n\t// Mutation methods\n\tfollow(target: number | EntityHandle, options?: FollowOptions): void;\n\tunfollow(): void;\n\tsetPosition(x: number, y: number): void;\n\tsetZoom(zoom: number): void;\n\tsetRotation(rotation: number): void;\n\tsetBounds(minX: number, minY: number, maxX: number, maxY: number): void;\n\tclearBounds(): void;\n\taddTrauma(amount: number): void;\n}\n\nexport interface CameraResourceTypes {\n\tcameraState: CameraState;\n}\n\n// ==================== Plugin Options ====================\n\nexport interface CameraPluginOptions<G extends string = 'camera'> {\n\tviewportWidth?: number;\n\tviewportHeight?: number;\n\tinitial?: {\n\t\tx?: number;\n\t\ty?: number;\n\t\tzoom?: number;\n\t\trotation?: number;\n\t};\n\tfollow?: FollowOptions;\n\tshake?: boolean | Partial<Omit<CameraShake, 'trauma'>>;\n\tbounds?:\n\t\t| { minX: number; minY: number; maxX: number; maxY: number }\n\t\t| [number, number, number, number];\n\tzoom?: {\n\t\tzoomStep?: number;\n\t\tminZoom?: number;\n\t\tmaxZoom?: number;\n\t};\n\tpan?: {\n\t\tspeed: number;\n\t\tactions?: {\n\t\t\tup?: string;\n\t\t\tdown?: string;\n\t\t\tleft?: string;\n\t\t\tright?: string;\n\t\t};\n\t};\n\tsystemGroup?: G;\n\tphase?: SystemPhase;\n\trandomFn?: () => number;\n}\n\n// ==================== Default Values ====================\n\nconst DEFAULT_SHAKE: Readonly<Omit<CameraShake, 'trauma'>> = {\n\ttraumaDecay: 1,\n\tmaxOffsetX: 10,\n\tmaxOffsetY: 10,\n\tmaxRotation: 0.05,\n};\n\nconst DEFAULT_FOLLOW: Readonly<Omit<CameraFollow, 'target'>> = {\n\tsmoothing: 5,\n\tdeadzoneX: 0,\n\tdeadzoneY: 0,\n\toffsetX: 0,\n\toffsetY: 0,\n};\n\n// ==================== Coordinate Conversion ====================\n\nexport function worldToScreen(\n\tworldX: number,\n\tworldY: number,\n\tstate: CameraState,\n): { x: number; y: number } {\n\tconst dx = worldX - (state.x + state.shakeOffsetX);\n\tconst dy = worldY - (state.y + state.shakeOffsetY);\n\n\tconst angle = -(state.rotation + state.shakeRotation);\n\tconst cos = Math.cos(angle);\n\tconst sin = Math.sin(angle);\n\tconst rx = dx * cos - dy * sin;\n\tconst ry = dx * sin + dy * cos;\n\n\treturn {\n\t\tx: rx * state.zoom + state.viewportWidth / 2,\n\t\ty: ry * state.zoom + state.viewportHeight / 2,\n\t};\n}\n\nexport function screenToWorld(\n\tscreenX: number,\n\tscreenY: number,\n\tstate: CameraState,\n): { x: number; y: number } {\n\tconst cx = (screenX - state.viewportWidth / 2) / state.zoom;\n\tconst cy = (screenY - state.viewportHeight / 2) / state.zoom;\n\n\tconst angle = state.rotation + state.shakeRotation;\n\tconst cos = Math.cos(angle);\n\tconst sin = Math.sin(angle);\n\tconst rx = cx * cos - cy * sin;\n\tconst ry = cx * sin + cy * cos;\n\n\treturn {\n\t\tx: rx + state.x + state.shakeOffsetX,\n\t\ty: ry + state.y + state.shakeOffsetY,\n\t};\n}\n\n// ==================== Internal Helpers ====================\n\nfunction resolveTarget(target: number | EntityHandle): number {\n\treturn typeof target === 'number' ? target : target.id;\n}\n\nfunction resolveShakeOptions(shake: true | Partial<Omit<CameraShake, 'trauma'>>): CameraShake {\n\tconst opts = shake === true ? {} : shake;\n\treturn {\n\t\ttrauma: 0,\n\t\ttraumaDecay: opts.traumaDecay ?? DEFAULT_SHAKE.traumaDecay,\n\t\tmaxOffsetX: opts.maxOffsetX ?? DEFAULT_SHAKE.maxOffsetX,\n\t\tmaxOffsetY: opts.maxOffsetY ?? DEFAULT_SHAKE.maxOffsetY,\n\t\tmaxRotation: opts.maxRotation ?? DEFAULT_SHAKE.maxRotation,\n\t};\n}\n\nfunction resolveBounds(\n\tbounds: { minX: number; minY: number; maxX: number; maxY: number } | [number, number, number, number],\n): CameraBounds {\n\tif (Array.isArray(bounds)) {\n\t\treturn { minX: bounds[0], minY: bounds[1], maxX: bounds[2], maxY: bounds[3] };\n\t}\n\treturn { ...bounds };\n}\n\nfunction resolveFollowOptions(options?: FollowOptions): Omit<CameraFollow, 'target'> {\n\treturn {\n\t\tsmoothing: options?.smoothing ?? DEFAULT_FOLLOW.smoothing,\n\t\tdeadzoneX: options?.deadzoneX ?? DEFAULT_FOLLOW.deadzoneX,\n\t\tdeadzoneY: options?.deadzoneY ?? DEFAULT_FOLLOW.deadzoneY,\n\t\toffsetX: options?.offsetX ?? DEFAULT_FOLLOW.offsetX,\n\t\toffsetY: options?.offsetY ?? DEFAULT_FOLLOW.offsetY,\n\t};\n}\n\n// ==================== Plugin Factory ====================\n\ntype CameraWorldConfig =\n\tComponentsConfig<CameraComponentTypes>\n\t& ResourcesConfig<CameraResourceTypes>;\n\ntype CameraLabels =\n\t| 'camera-init'\n\t| 'camera-follow'\n\t| 'camera-shake-update'\n\t| 'camera-bounds'\n\t| 'camera-state-sync'\n\t| 'camera-zoom'\n\t| 'camera-pan';\n\nexport function createCameraPlugin<G extends string = 'camera'>(\n\toptions?: CameraPluginOptions<G>,\n) {\n\tconst {\n\t\tviewportWidth = 800,\n\t\tviewportHeight = 600,\n\t\tinitial,\n\t\tfollow: followConfig,\n\t\tshake: shakeConfig,\n\t\tbounds: boundsConfig,\n\t\tzoom: zoomConfig,\n\t\tpan: panConfig,\n\t\tsystemGroup = 'camera',\n\t\tphase = 'postUpdate',\n\t\trandomFn = Math.random,\n\t} = options ?? {};\n\n\treturn definePlugin('camera')\n\t\t.withComponentTypes<CameraComponentTypes>()\n\t\t.withResourceTypes<CameraResourceTypes>()\n\t\t.withLabels<CameraLabels>()\n\t\t.withGroups<G>()\n\t\t.requires<TransformWorldConfig>()\n\t\t.install((world) => {\n\t\t\t// Build mutation methods as closures over the world reference.\n\t\t\t// The cameraState resource is created immediately with placeholder methods,\n\t\t\t// then the init system populates entityId and wires up real methods.\n\n\t\t\tconst cameraState: CameraState = {\n\t\t\t\tx: initial?.x ?? 0,\n\t\t\t\ty: initial?.y ?? 0,\n\t\t\t\tzoom: initial?.zoom ?? 1,\n\t\t\t\trotation: initial?.rotation ?? 0,\n\t\t\t\tshakeOffsetX: 0,\n\t\t\t\tshakeOffsetY: 0,\n\t\t\t\tshakeRotation: 0,\n\t\t\t\tviewportWidth,\n\t\t\t\tviewportHeight,\n\t\t\t\tentityId: -1,\n\n\t\t\t\t// Mutation methods — wired up after camera entity is spawned\n\t\t\t\tfollow: () => {},\n\t\t\t\tunfollow: () => {},\n\t\t\t\tsetPosition: () => {},\n\t\t\t\tsetZoom: () => {},\n\t\t\t\tsetRotation: () => {},\n\t\t\t\tsetBounds: () => {},\n\t\t\t\tclearBounds: () => {},\n\t\t\t\taddTrauma: () => {},\n\t\t\t};\n\n\t\t\tworld.addResource('cameraState', cameraState);\n\n\t\t\t// camera-init: spawns camera entity and wires up mutation closures\n\t\t\tworld\n\t\t\t\t.addSystem('camera-init')\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setOnInitialize((ecs: ECSpresso<CameraWorldConfig & TransformWorldConfig>) => {\n\t\t\t\t\t// Spawn with required camera component\n\t\t\t\t\tconst entity = ecs.spawn({\n\t\t\t\t\t\tcamera: {\n\t\t\t\t\t\t\tx: initial?.x ?? 0,\n\t\t\t\t\t\t\ty: initial?.y ?? 0,\n\t\t\t\t\t\t\tzoom: initial?.zoom ?? 1,\n\t\t\t\t\t\t\trotation: initial?.rotation ?? 0,\n\t\t\t\t\t\t},\n\t\t\t\t\t});\n\n\t\t\t\t\t// Conditionally add optional components\n\t\t\t\t\tif (followConfig) {\n\t\t\t\t\t\tecs.addComponent(entity.id, 'cameraFollow', {\n\t\t\t\t\t\t\ttarget: -1,\n\t\t\t\t\t\t\t...resolveFollowOptions(followConfig),\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\n\t\t\t\t\tif (shakeConfig) {\n\t\t\t\t\t\tecs.addComponent(entity.id, 'cameraShake', resolveShakeOptions(shakeConfig));\n\t\t\t\t\t}\n\n\t\t\t\t\tif (boundsConfig) {\n\t\t\t\t\t\tecs.addComponent(entity.id, 'cameraBounds', resolveBounds(boundsConfig));\n\t\t\t\t\t}\n\t\t\t\t\tcameraState.entityId = entity.id;\n\n\t\t\t\t\t// Wire up mutation methods\n\t\t\t\t\tcameraState.follow = (target: number | EntityHandle, opts?: FollowOptions) => {\n\t\t\t\t\t\tconst targetId = resolveTarget(target);\n\t\t\t\t\t\tconst followData: CameraFollow = {\n\t\t\t\t\t\t\ttarget: targetId,\n\t\t\t\t\t\t\t...resolveFollowOptions(opts),\n\t\t\t\t\t\t};\n\t\t\t\t\t\tconst existing = ecs.getComponent(cameraState.entityId, 'cameraFollow');\n\t\t\t\t\t\tif (existing) {\n\t\t\t\t\t\t\texisting.target = followData.target;\n\t\t\t\t\t\t\texisting.smoothing = followData.smoothing;\n\t\t\t\t\t\t\texisting.deadzoneX = followData.deadzoneX;\n\t\t\t\t\t\t\texisting.deadzoneY = followData.deadzoneY;\n\t\t\t\t\t\t\texisting.offsetX = followData.offsetX;\n\t\t\t\t\t\t\texisting.offsetY = followData.offsetY;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tecs.addComponent(cameraState.entityId, 'cameraFollow', followData);\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.unfollow = () => {\n\t\t\t\t\t\tconst existing = ecs.getComponent(cameraState.entityId, 'cameraFollow');\n\t\t\t\t\t\tif (existing) {\n\t\t\t\t\t\t\tecs.removeComponent(cameraState.entityId, 'cameraFollow');\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.setPosition = (x: number, y: number) => {\n\t\t\t\t\t\tconst camera = ecs.getComponent(cameraState.entityId, 'camera');\n\t\t\t\t\t\tif (!camera) return;\n\t\t\t\t\t\tcamera.x = x;\n\t\t\t\t\t\tcamera.y = y;\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.setZoom = (zoom: number) => {\n\t\t\t\t\t\tconst camera = ecs.getComponent(cameraState.entityId, 'camera');\n\t\t\t\t\t\tif (!camera) return;\n\t\t\t\t\t\tcamera.zoom = zoom;\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.setRotation = (rotation: number) => {\n\t\t\t\t\t\tconst camera = ecs.getComponent(cameraState.entityId, 'camera');\n\t\t\t\t\t\tif (!camera) return;\n\t\t\t\t\t\tcamera.rotation = rotation;\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.setBounds = (minX: number, minY: number, maxX: number, maxY: number) => {\n\t\t\t\t\t\tconst existing = ecs.getComponent(cameraState.entityId, 'cameraBounds');\n\t\t\t\t\t\tif (existing) {\n\t\t\t\t\t\t\texisting.minX = minX;\n\t\t\t\t\t\t\texisting.minY = minY;\n\t\t\t\t\t\t\texisting.maxX = maxX;\n\t\t\t\t\t\t\texisting.maxY = maxY;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tecs.addComponent(cameraState.entityId, 'cameraBounds', { minX, minY, maxX, maxY });\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.clearBounds = () => {\n\t\t\t\t\t\tconst existing = ecs.getComponent(cameraState.entityId, 'cameraBounds');\n\t\t\t\t\t\tif (existing) {\n\t\t\t\t\t\t\tecs.removeComponent(cameraState.entityId, 'cameraBounds');\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\n\t\t\t\t\tcameraState.addTrauma = (amount: number) => {\n\t\t\t\t\t\tconst shake = ecs.getComponent(cameraState.entityId, 'cameraShake');\n\t\t\t\t\t\tif (shake) {\n\t\t\t\t\t\t\tshake.trauma = Math.min(1, Math.max(0, shake.trauma + amount));\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tecs.addComponent(cameraState.entityId, 'cameraShake', {\n\t\t\t\t\t\t\t\t...resolveShakeOptions(true),\n\t\t\t\t\t\t\t\ttrauma: Math.min(1, Math.max(0, amount)),\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t};\n\t\t\t\t});\n\n\t\t\t// camera-follow: priority 400 (after transform propagation at 500)\n\t\t\tworld\n\t\t\t\t.addSystem('camera-follow')\n\t\t\t\t.setPriority(400)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.addQuery('cameras', {\n\t\t\t\t\twith: ['camera', 'cameraFollow'],\n\t\t\t\t})\n\t\t\t\t.setProcess(({ queries, dt, ecs }) => {\n\t\t\t\t\tconst t = Math.min(1, dt);\n\t\t\t\t\tfor (const entity of queries.cameras) {\n\t\t\t\t\t\tconst { camera, cameraFollow } = entity.components;\n\t\t\t\t\t\tif (cameraFollow.target < 0) continue;\n\n\t\t\t\t\t\tconst targetWorld = ecs.getComponent(cameraFollow.target, 'worldTransform');\n\t\t\t\t\t\tif (!targetWorld) continue;\n\n\t\t\t\t\t\tconst goalX = targetWorld.x + cameraFollow.offsetX;\n\t\t\t\t\t\tconst goalY = targetWorld.y + cameraFollow.offsetY;\n\t\t\t\t\t\tconst dx = goalX - camera.x;\n\t\t\t\t\t\tconst dy = goalY - camera.y;\n\n\t\t\t\t\t\tif (Math.abs(dx) > cameraFollow.deadzoneX) {\n\t\t\t\t\t\t\tconst sign = dx > 0 ? 1 : -1;\n\t\t\t\t\t\t\tconst excessX = dx - sign * cameraFollow.deadzoneX;\n\t\t\t\t\t\t\tconst factor = Math.min(1, cameraFollow.smoothing * t);\n\t\t\t\t\t\t\tcamera.x += excessX * factor;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (Math.abs(dy) > cameraFollow.deadzoneY) {\n\t\t\t\t\t\t\tconst sign = dy > 0 ? 1 : -1;\n\t\t\t\t\t\t\tconst excessY = dy - sign * cameraFollow.deadzoneY;\n\t\t\t\t\t\t\tconst factor = Math.min(1, cameraFollow.smoothing * t);\n\t\t\t\t\t\t\tcamera.y += excessY * factor;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t// camera-shake-update: priority 390\n\t\t\tworld\n\t\t\t\t.addSystem('camera-shake-update')\n\t\t\t\t.setPriority(390)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.addQuery('shakeCameras', {\n\t\t\t\t\twith: ['camera', 'cameraShake'],\n\t\t\t\t})\n\t\t\t\t.setProcess(({ queries, dt }) => {\n\t\t\t\t\tfor (const entity of queries.shakeCameras) {\n\t\t\t\t\t\tconst { cameraShake } = entity.components;\n\t\t\t\t\t\tcameraShake.trauma = Math.max(0, cameraShake.trauma - cameraShake.traumaDecay * dt);\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t// camera-bounds: priority 380\n\t\t\tworld\n\t\t\t\t.addSystem('camera-bounds')\n\t\t\t\t.setPriority(380)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.addQuery('boundedCameras', {\n\t\t\t\t\twith: ['camera', 'cameraBounds'],\n\t\t\t\t})\n\t\t\t\t.setProcess(({ queries }) => {\n\t\t\t\t\tfor (const entity of queries.boundedCameras) {\n\t\t\t\t\t\tconst { camera, cameraBounds } = entity.components;\n\t\t\t\t\t\tconst halfW = cameraState.viewportWidth / (2 * camera.zoom);\n\t\t\t\t\t\tconst halfH = cameraState.viewportHeight / (2 * camera.zoom);\n\n\t\t\t\t\t\tconst effectiveMinX = cameraBounds.minX + halfW;\n\t\t\t\t\t\tconst effectiveMaxX = cameraBounds.maxX - halfW;\n\t\t\t\t\t\tconst effectiveMinY = cameraBounds.minY + halfH;\n\t\t\t\t\t\tconst effectiveMaxY = cameraBounds.maxY - halfH;\n\n\t\t\t\t\t\tif (effectiveMinX > effectiveMaxX) {\n\t\t\t\t\t\t\tcamera.x = (cameraBounds.minX + cameraBounds.maxX) / 2;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcamera.x = Math.max(effectiveMinX, Math.min(effectiveMaxX, camera.x));\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (effectiveMinY > effectiveMaxY) {\n\t\t\t\t\t\t\tcamera.y = (cameraBounds.minY + cameraBounds.maxY) / 2;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcamera.y = Math.max(effectiveMinY, Math.min(effectiveMaxY, camera.y));\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t// camera-state-sync: priority 370\n\t\t\tworld\n\t\t\t\t.addSystem('camera-state-sync')\n\t\t\t\t.setPriority(370)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ ecs }) => {\n\t\t\t\t\tconst camera = ecs.getComponent(cameraState.entityId, 'camera');\n\t\t\t\t\tif (!camera) {\n\t\t\t\t\t\tcameraState.x = 0;\n\t\t\t\t\t\tcameraState.y = 0;\n\t\t\t\t\t\tcameraState.zoom = 1;\n\t\t\t\t\t\tcameraState.rotation = 0;\n\t\t\t\t\t\tcameraState.shakeOffsetX = 0;\n\t\t\t\t\t\tcameraState.shakeOffsetY = 0;\n\t\t\t\t\t\tcameraState.shakeRotation = 0;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tcameraState.x = camera.x;\n\t\t\t\t\tcameraState.y = camera.y;\n\t\t\t\t\tcameraState.zoom = camera.zoom;\n\t\t\t\t\tcameraState.rotation = camera.rotation;\n\n\t\t\t\t\tconst shake = ecs.getComponent(cameraState.entityId, 'cameraShake');\n\t\t\t\t\tif (shake && shake.trauma > 0) {\n\t\t\t\t\t\tconst intensity = shake.trauma * shake.trauma;\n\t\t\t\t\t\tcameraState.shakeOffsetX = shake.maxOffsetX * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\t\tcameraState.shakeOffsetY = shake.maxOffsetY * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\t\tcameraState.shakeRotation = shake.maxRotation * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcameraState.shakeOffsetX = 0;\n\t\t\t\t\t\tcameraState.shakeOffsetY = 0;\n\t\t\t\t\t\tcameraState.shakeRotation = 0;\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t// camera-zoom: conditionally registered when zoom option is provided\n\t\t\tif (zoomConfig) {\n\t\t\t\tconst {\n\t\t\t\t\tzoomStep = 0.1,\n\t\t\t\t\tminZoom = 0.1,\n\t\t\t\t\tmaxZoom = 10,\n\t\t\t\t} = zoomConfig;\n\n\t\t\t\ttype ZoomInputState = { pointer: { position: { x: number; y: number } } };\n\n\t\t\t\tlet pendingSteps = 0;\n\t\t\t\tlet zoomActive = false;\n\t\t\t\tlet canvas: HTMLCanvasElement | undefined;\n\t\t\t\tlet isoState: { tileWidth: number; tileHeight: number; originX: number; originY: number } | undefined;\n\n\t\t\t\tfunction onWheel(e: WheelEvent) {\n\t\t\t\t\te.preventDefault();\n\t\t\t\t\tpendingSteps += Math.sign(e.deltaY);\n\t\t\t\t}\n\n\t\t\t\tworld\n\t\t\t\t\t.addSystem('camera-zoom')\n\t\t\t\t\t.setPriority(410)\n\t\t\t\t\t.inPhase('preUpdate')\n\t\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t\t.addQuery('cameras', {\n\t\t\t\t\t\twith: ['camera'],\n\t\t\t\t\t})\n\t\t\t\t\t.setOnInitialize((ecs) => {\n\t\t\t\t\t\t// Check for required dependencies\n\t\t\t\t\t\tconst inputState = ecs.tryGetResource<ZoomInputState>('inputState');\n\t\t\t\t\t\tconst pixiApp = ecs.tryGetResource<{ canvas: HTMLCanvasElement }>('pixiApp');\n\n\t\t\t\t\t\tif (!inputState || !pixiApp) {\n\t\t\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\t\t'[camera] zoom requires the input plugin and renderer2D plugin. ' +\n\t\t\t\t\t\t\t\t'Zoom will be disabled.',\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tcanvas = pixiApp.canvas;\n\t\t\t\t\t\tcanvas.addEventListener('wheel', onWheel as EventListener, { passive: false });\n\n\t\t\t\t\t\t// Detect isometric projection for iso-aware cursor-centered zoom\n\t\t\t\t\t\tisoState = ecs.tryGetResource<{\n\t\t\t\t\t\t\ttileWidth: number; tileHeight: number;\n\t\t\t\t\t\t\toriginX: number; originY: number;\n\t\t\t\t\t\t}>('isoProjection');\n\n\t\t\t\t\t\tzoomActive = true;\n\t\t\t\t\t})\n\t\t\t\t\t.setOnDetach(() => {\n\t\t\t\t\t\tif (!zoomActive || !canvas) return;\n\t\t\t\t\t\tcanvas.removeEventListener('wheel', onWheel as EventListener);\n\t\t\t\t\t})\n\t\t\t\t\t.setProcess(({ queries, ecs }) => {\n\t\t\t\t\t\tif (!zoomActive || pendingSteps === 0) return;\n\n\t\t\t\t\t\tconst steps = pendingSteps;\n\t\t\t\t\t\tpendingSteps = 0;\n\n\t\t\t\t\t\tconst [cameraEntity] = queries.cameras;\n\t\t\t\t\t\tif (!cameraEntity) return;\n\n\t\t\t\t\t\tconst cam = cameraEntity.components.camera;\n\t\t\t\t\t\tconst inputState = ecs.tryGetResource<ZoomInputState>('inputState');\n\t\t\t\t\t\tif (!inputState) return;\n\n\t\t\t\t\t\t// Apply zoom — proportional to number of wheel steps\n\t\t\t\t\t\tconst direction = steps > 0 ? (1 - zoomStep) : (1 + zoomStep);\n\t\t\t\t\t\tconst newZoom = Math.max(minZoom, Math.min(maxZoom, cam.zoom * Math.pow(direction, Math.abs(steps))));\n\n\t\t\t\t\t\tif (isoState && canvas) {\n\t\t\t\t\t\t\t// Iso-aware cursor-centered zoom: work in iso-screen space\n\t\t\t\t\t\t\tconst rect = canvas.getBoundingClientRect();\n\t\t\t\t\t\t\tconst screenOffX = inputState.pointer.position.x - (rect.left + rect.width / 2);\n\t\t\t\t\t\t\tconst screenOffY = inputState.pointer.position.y - (rect.top + rect.height / 2);\n\n\t\t\t\t\t\t\t// Inlined worldToIso — avoids cross-plugin import\n\t\t\t\t\t\t\tconst halfW = isoState.tileWidth / 2;\n\t\t\t\t\t\t\tconst halfH = isoState.tileHeight / 2;\n\t\t\t\t\t\t\tconst camIsoX = (cam.x - cam.y) * halfW + isoState.originX;\n\t\t\t\t\t\t\tconst camIsoY = (cam.x + cam.y) * halfH + isoState.originY;\n\t\t\t\t\t\t\tconst isoBeforeX = camIsoX + screenOffX / cam.zoom;\n\t\t\t\t\t\t\tconst isoBeforeY = camIsoY + screenOffY / cam.zoom;\n\n\t\t\t\t\t\t\tcam.zoom = newZoom;\n\n\t\t\t\t\t\t\t// New camera iso position so the same point stays under cursor\n\t\t\t\t\t\t\tconst newCamIsoX = isoBeforeX - screenOffX / newZoom;\n\t\t\t\t\t\t\tconst newCamIsoY = isoBeforeY - screenOffY / newZoom;\n\n\t\t\t\t\t\t\t// Inlined isoToWorld\n\t\t\t\t\t\t\tconst relX = newCamIsoX - isoState.originX;\n\t\t\t\t\t\t\tconst relY = newCamIsoY - isoState.originY;\n\t\t\t\t\t\t\tcam.x = relX / isoState.tileWidth + relY / isoState.tileHeight;\n\t\t\t\t\t\t\tcam.y = -relX / isoState.tileWidth + relY / isoState.tileHeight;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Pixel-space cursor-centered zoom\n\t\t\t\t\t\t\tconst worldBefore = screenToWorld(\n\t\t\t\t\t\t\t\tinputState.pointer.position.x,\n\t\t\t\t\t\t\t\tinputState.pointer.position.y,\n\t\t\t\t\t\t\t\tcameraState,\n\t\t\t\t\t\t\t);\n\n\t\t\t\t\t\t\tcam.zoom = newZoom;\n\n\t\t\t\t\t\t\tcam.x = worldBefore.x - (inputState.pointer.position.x - cameraState.viewportWidth / 2) / newZoom;\n\t\t\t\t\t\t\tcam.y = worldBefore.y - (inputState.pointer.position.y - cameraState.viewportHeight / 2) / newZoom;\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t}\n\n\t\t\t// camera-pan: conditionally registered when pan option is provided\n\t\t\tif (panConfig) {\n\t\t\t\ttype PanInputState = { actions: { isActive(action: string): boolean } };\n\n\t\t\t\tconst {\n\t\t\t\t\tspeed,\n\t\t\t\t\tactions: panActions,\n\t\t\t\t} = panConfig;\n\n\t\t\t\tconst actionUp = panActions?.up ?? 'panUp';\n\t\t\t\tconst actionDown = panActions?.down ?? 'panDown';\n\t\t\t\tconst actionLeft = panActions?.left ?? 'panLeft';\n\t\t\t\tconst actionRight = panActions?.right ?? 'panRight';\n\n\t\t\t\tlet panActive = false;\n\n\t\t\t\tworld\n\t\t\t\t\t.addSystem('camera-pan')\n\t\t\t\t\t.setPriority(420)\n\t\t\t\t\t.inPhase('preUpdate')\n\t\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t\t.setOnInitialize((ecs) => {\n\t\t\t\t\t\tconst inputState = ecs.tryGetResource<PanInputState>('inputState');\n\t\t\t\t\t\tif (!inputState) {\n\t\t\t\t\t\t\tconsole.error(\n\t\t\t\t\t\t\t\t'[camera] pan requires the input plugin. Pan will be disabled.',\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpanActive = true;\n\t\t\t\t\t})\n\t\t\t\t\t.setProcess(({ ecs, dt }) => {\n\t\t\t\t\t\tif (!panActive) return;\n\n\t\t\t\t\t\tconst inputState = ecs.tryGetResource<PanInputState>('inputState');\n\t\t\t\t\t\tif (!inputState) return;\n\n\t\t\t\t\t\tconst delta = (speed / cameraState.zoom) * dt;\n\t\t\t\t\t\tconst dx = (inputState.actions.isActive(actionRight) ? 1 : 0)\n\t\t\t\t\t\t\t- (inputState.actions.isActive(actionLeft) ? 1 : 0);\n\t\t\t\t\t\tconst dy = (inputState.actions.isActive(actionDown) ? 1 : 0)\n\t\t\t\t\t\t\t- (inputState.actions.isActive(actionUp) ? 1 : 0);\n\n\t\t\t\t\t\tif (dx !== 0 || dy !== 0) {\n\t\t\t\t\t\t\tcameraState.setPosition(\n\t\t\t\t\t\t\t\tcameraState.x + dx * delta,\n\t\t\t\t\t\t\t\tcameraState.y + dy * delta,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t}\n\t\t});\n}\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": "2PAcA,uBAAS,kBA2HT,IAAM,EAAuD,CAC5D,YAAa,EACb,WAAY,GACZ,WAAY,GACZ,YAAa,IACd,EAEM,EAAyD,CAC9D,UAAW,EACX,UAAW,EACX,UAAW,EACX,QAAS,EACT,QAAS,CACV,EAIO,SAAS,EAAa,CAC5B,EACA,EACA,EAC2B,CAC3B,IAAM,EAAK,GAAU,EAAM,EAAI,EAAM,cAC/B,EAAK,GAAU,EAAM,EAAI,EAAM,cAE/B,EAAQ,EAAE,EAAM,SAAW,EAAM,eACjC,EAAM,KAAK,IAAI,CAAK,EACpB,EAAM,KAAK,IAAI,CAAK,EACpB,EAAK,EAAK,EAAM,EAAK,EACrB,EAAK,EAAK,EAAM,EAAK,EAE3B,MAAO,CACN,EAAG,EAAK,EAAM,KAAO,EAAM,cAAgB,EAC3C,EAAG,EAAK,EAAM,KAAO,EAAM,eAAiB,CAC7C,EAGM,SAAS,CAAa,CAC5B,EACA,EACA,EAC2B,CAC3B,IAAM,GAAM,EAAU,EAAM,cAAgB,GAAK,EAAM,KACjD,GAAM,EAAU,EAAM,eAAiB,GAAK,EAAM,KAElD,EAAQ,EAAM,SAAW,EAAM,cAC/B,EAAM,KAAK,IAAI,CAAK,EACpB,EAAM,KAAK,IAAI,CAAK,EACpB,EAAK,EAAK,EAAM,EAAK,EACrB,EAAK,EAAK,EAAM,EAAK,EAE3B,MAAO,CACN,EAAG,EAAK,EAAM,EAAI,EAAM,aACxB,EAAG,EAAK,EAAM,EAAI,EAAM,YACzB,EAKD,SAAS,CAAa,CAAC,EAAuC,CAC7D,OAAO,OAAO,IAAW,SAAW,EAAS,EAAO,GAGrD,SAAS,CAAmB,CAAC,EAAiE,CAC7F,IAAM,EAAO,IAAU,GAAO,CAAC,EAAI,EACnC,MAAO,CACN,OAAQ,EACR,YAAa,EAAK,aAAe,EAAc,YAC/C,WAAY,EAAK,YAAc,EAAc,WAC7C,WAAY,EAAK,YAAc,EAAc,WAC7C,YAAa,EAAK,aAAe,EAAc,WAChD,EAGD,SAAS,CAAa,CACrB,EACe,CACf,GAAI,MAAM,QAAQ,CAAM,EACvB,MAAO,CAAE,KAAM,EAAO,GAAI,KAAM,EAAO,GAAI,KAAM,EAAO,GAAI,KAAM,EAAO,EAAG,EAE7E,MAAO,IAAK,CAAO,EAGpB,SAAS,CAAoB,CAAC,EAAuD,CACpF,MAAO,CACN,UAAW,GAAS,WAAa,EAAe,UAChD,UAAW,GAAS,WAAa,EAAe,UAChD,UAAW,GAAS,WAAa,EAAe,UAChD,QAAS,GAAS,SAAW,EAAe,QAC5C,QAAS,GAAS,SAAW,EAAe,OAC7C,
|
|
7
|
+
"mappings": "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",
|
|
8
8
|
"debugId": "25B2568457374CA064756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* Import from 'ecspresso/plugins/spatial/camera3D'
|
|
15
15
|
*/
|
|
16
16
|
import type { SystemPhase } from 'ecspresso';
|
|
17
|
-
import type {
|
|
17
|
+
import type { ComponentsConfig, ResourcesConfig } from '../../type-utils';
|
|
18
18
|
import type { Transform3DComponentTypes } from './transform3D';
|
|
19
19
|
import type { Renderer3DResourceTypes } from '../rendering/renderer3D';
|
|
20
|
-
type Camera3DRequiredConfig =
|
|
20
|
+
type Camera3DRequiredConfig = ComponentsConfig<Transform3DComponentTypes> & ResourcesConfig<Renderer3DResourceTypes>;
|
|
21
21
|
export interface Camera3DFollowOptions {
|
|
22
22
|
smoothing?: number;
|
|
23
23
|
offsetX?: number;
|
|
@@ -69,7 +69,7 @@ export type Camera3DState = PerspectiveCamera3DState | OrthographicCamera3DState
|
|
|
69
69
|
export interface Camera3DResourceTypes {
|
|
70
70
|
camera3DState: Camera3DState;
|
|
71
71
|
}
|
|
72
|
-
export type Camera3DWorldConfig =
|
|
72
|
+
export type Camera3DWorldConfig = ResourcesConfig<Camera3DResourceTypes>;
|
|
73
73
|
export interface Camera3DBasePluginOptions<G extends string = 'camera3d'> {
|
|
74
74
|
systemGroup?: G;
|
|
75
75
|
phase?: SystemPhase;
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/plugins/spatial/camera3D.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * Camera 3D Plugin for ECSpresso\n *\n * Orbit/follow/shake camera controls for a Three.js PerspectiveCamera or\n * OrthographicCamera managed by renderer3D. Purely resource-based (no camera\n * entity). The renderer3D `camera` resource is the single camera target.\n * Orbit via pointer drag + scroll wheel, follow via entity tracking, shake\n * via trauma-based offsets.\n *\n * The plugin's `projection` option must match the underlying camera's kind;\n * a mismatch throws at init. State is a discriminated union — perspective\n * cameras expose `fov` / `setFov`, orthographic cameras expose `zoom` / `setZoom`.\n *\n * Import from 'ecspresso/plugins/spatial/camera3D'\n */\n\nimport { definePlugin } from 'ecspresso';\nimport type { SystemPhase } from 'ecspresso';\nimport type { WorldConfigFrom } from '../../type-utils';\nimport type { Transform3DComponentTypes } from './transform3D';\nimport type { Renderer3DResourceTypes } from '../rendering/renderer3D';\nimport type { OrthographicCamera, PerspectiveCamera } from 'three';\n\n// ==================== Dependency Types ====================\n\ntype Camera3DRequiredConfig = WorldConfigFrom<\n\tTransform3DComponentTypes,\n\t{},\n\tRenderer3DResourceTypes\n>;\n\n// ==================== Resource Types ====================\n\nexport interface Camera3DFollowOptions {\n\tsmoothing?: number;\n\toffsetX?: number;\n\toffsetY?: number;\n\toffsetZ?: number;\n}\n\nexport interface Camera3DShakeOptions {\n\ttraumaDecay?: number;\n\tmaxOffsetX?: number;\n\tmaxOffsetY?: number;\n\tmaxOffsetZ?: number;\n}\n\nexport interface Camera3DBaseState {\n\t// Orbit / spherical state\n\ttargetX: number;\n\ttargetY: number;\n\ttargetZ: number;\n\tazimuth: number;\n\televation: number;\n\tdistance: number;\n\n\t// Follow\n\tfollowTarget: number;\n\tfollowSmoothing: number;\n\tfollowOffsetX: number;\n\tfollowOffsetY: number;\n\tfollowOffsetZ: number;\n\n\t// Shake (read by sync, written by shake system)\n\ttrauma: number;\n\tshakeOffsetX: number;\n\tshakeOffsetY: number;\n\tshakeOffsetZ: number;\n\n\t// Mutation methods\n\tfollow(target: number | { id: number }, options?: Camera3DFollowOptions): void;\n\tunfollow(): void;\n\tsetTarget(x: number, y: number, z: number): void;\n\tsetOrbit(azimuth: number, elevation: number, distance: number): void;\n\tsetDistance(distance: number): void;\n\taddTrauma(amount: number): void;\n}\n\nexport interface PerspectiveCamera3DState extends Camera3DBaseState {\n\tprojection: 'perspective';\n\tfov: number;\n\tsetFov(fov: number): void;\n}\n\nexport interface OrthographicCamera3DState extends Camera3DBaseState {\n\tprojection: 'orthographic';\n\tzoom: number;\n\tsetZoom(zoom: number): void;\n}\n\nexport type Camera3DState = PerspectiveCamera3DState | OrthographicCamera3DState;\n\nexport interface Camera3DResourceTypes {\n\tcamera3DState: Camera3DState;\n}\n\nexport type Camera3DWorldConfig = WorldConfigFrom<{}, {}, Camera3DResourceTypes>;\n\n// ==================== Plugin Options ====================\n\nexport interface Camera3DBasePluginOptions<G extends string = 'camera3d'> {\n\tsystemGroup?: G;\n\tphase?: SystemPhase;\n\n\t// Initial orbit state\n\tazimuth?: number;\n\televation?: number;\n\tdistance?: number;\n\ttarget?: { x: number; y: number; z: number };\n\n\t// Orbit constraints\n\tminDistance?: number;\n\tmaxDistance?: number;\n\tminElevation?: number;\n\tmaxElevation?: number;\n\n\t// Sensitivity\n\torbitSensitivity?: number;\n\tdollySensitivity?: number;\n\n\t// Disable pointer-drag orbit (zoom/dolly via wheel is unaffected)\n\tenableOrbit?: boolean;\n\n\t// Follow\n\tfollow?: Camera3DFollowOptions;\n\n\t// Shake\n\tshake?: boolean | Partial<Camera3DShakeOptions>;\n\n\t// Injectable RNG for deterministic shake\n\trandomFn?: () => number;\n}\n\nexport type Camera3DPluginOptions<G extends string = 'camera3d'> =\n\tCamera3DBasePluginOptions<G> & (\n\t\t| { projection?: 'perspective'; fov?: number }\n\t\t| { projection: 'orthographic'; zoom?: number }\n\t);\n\n// ==================== Labels ====================\n\nexport type Camera3DLabels =\n\t| 'camera3d-init'\n\t| 'camera3d-follow'\n\t| 'camera3d-shake'\n\t| 'camera3d-sync';\n\n// ==================== Constants ====================\n\nconst DEFAULT_FOLLOW: Readonly<Required<Camera3DFollowOptions>> = {\n\tsmoothing: 5,\n\toffsetX: 0,\n\toffsetY: 0,\n\toffsetZ: 0,\n};\n\nconst DEFAULT_SHAKE: Readonly<Required<Camera3DShakeOptions>> = {\n\ttraumaDecay: 1,\n\tmaxOffsetX: 0.3,\n\tmaxOffsetY: 0.3,\n\tmaxOffsetZ: 0.3,\n};\n\nconst HALF_PI = Math.PI / 2;\nconst ELEVATION_EPSILON = 0.001;\n\n// ==================== Scratch Objects ====================\n\nconst _camPos = { x: 0, y: 0, z: 0 };\n\n// ==================== Helpers ====================\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.max(min, Math.min(max, value));\n}\n\nfunction resolveShakeOptions(config: true | Partial<Camera3DShakeOptions>): Required<Camera3DShakeOptions> {\n\tif (config === true) return { ...DEFAULT_SHAKE };\n\treturn {\n\t\ttraumaDecay: config.traumaDecay ?? DEFAULT_SHAKE.traumaDecay,\n\t\tmaxOffsetX: config.maxOffsetX ?? DEFAULT_SHAKE.maxOffsetX,\n\t\tmaxOffsetY: config.maxOffsetY ?? DEFAULT_SHAKE.maxOffsetY,\n\t\tmaxOffsetZ: config.maxOffsetZ ?? DEFAULT_SHAKE.maxOffsetZ,\n\t};\n}\n\n/**\n * Convert spherical coordinates to cartesian. Y-up convention (Three.js default).\n * Azimuth rotates in the XZ plane; elevation goes from XZ plane toward +Y.\n */\nexport function sphericalToCartesian(\n\tazimuth: number,\n\televation: number,\n\tdistance: number,\n\tout: { x: number; y: number; z: number },\n): void {\n\tconst cosElev = Math.cos(elevation);\n\tout.x = distance * cosElev * Math.sin(azimuth);\n\tout.y = distance * Math.sin(elevation);\n\tout.z = distance * cosElev * Math.cos(azimuth);\n}\n\n// ==================== Plugin Factory ====================\n\nexport function createCamera3DPlugin<G extends string = 'camera3d'>(\n\toptions?: Camera3DPluginOptions<G>,\n) {\n\tconst {\n\t\tsystemGroup = 'camera3d',\n\t\tphase = 'postUpdate',\n\t\tazimuth: initialAzimuth = 0,\n\t\televation: initialElevation = 0.5,\n\t\tdistance: initialDistance = 10,\n\t\ttarget: initialTarget,\n\t\tminDistance = 1,\n\t\tmaxDistance = 100,\n\t\tminElevation = -HALF_PI + ELEVATION_EPSILON,\n\t\tmaxElevation = HALF_PI - ELEVATION_EPSILON,\n\t\torbitSensitivity = 0.003,\n\t\tdollySensitivity = 1.1,\n\t\tenableOrbit = true,\n\t\tfollow: followConfig,\n\t\tshake: shakeConfig,\n\t\trandomFn = Math.random,\n\t} = options ?? {};\n\n\tconst projection: 'perspective' | 'orthographic' = options?.projection ?? 'perspective';\n\tconst initialFov = options?.projection !== 'orthographic' ? (options?.fov ?? 75) : 75;\n\tconst initialZoom = options?.projection === 'orthographic' ? (options.zoom ?? 1) : 1;\n\n\tconst resolvedShake = shakeConfig ? resolveShakeOptions(shakeConfig) : DEFAULT_SHAKE;\n\tconst shakeDecay = resolvedShake.traumaDecay;\n\tconst shakeMaxX = resolvedShake.maxOffsetX;\n\tconst shakeMaxY = resolvedShake.maxOffsetY;\n\tconst shakeMaxZ = resolvedShake.maxOffsetZ;\n\n\t// Base fields + mutators shared between variants. Mutators use an explicit `this`\n\t// parameter so they type-check against `Camera3DBaseState` regardless of variant.\n\tconst baseFields = {\n\t\ttargetX: initialTarget?.x ?? 0,\n\t\ttargetY: initialTarget?.y ?? 0,\n\t\ttargetZ: initialTarget?.z ?? 0,\n\t\tazimuth: initialAzimuth,\n\t\televation: clamp(initialElevation, minElevation, maxElevation),\n\t\tdistance: clamp(initialDistance, minDistance, maxDistance),\n\n\t\tfollowTarget: -1,\n\t\tfollowSmoothing: followConfig?.smoothing ?? DEFAULT_FOLLOW.smoothing,\n\t\tfollowOffsetX: followConfig?.offsetX ?? DEFAULT_FOLLOW.offsetX,\n\t\tfollowOffsetY: followConfig?.offsetY ?? DEFAULT_FOLLOW.offsetY,\n\t\tfollowOffsetZ: followConfig?.offsetZ ?? DEFAULT_FOLLOW.offsetZ,\n\n\t\ttrauma: 0,\n\t\tshakeOffsetX: 0,\n\t\tshakeOffsetY: 0,\n\t\tshakeOffsetZ: 0,\n\t};\n\n\tconst baseMutators = {\n\t\tfollow(this: Camera3DBaseState, target: number | { id: number }, opts?: Camera3DFollowOptions) {\n\t\t\tconst targetId = typeof target === 'number' ? target : target.id;\n\t\t\tthis.followTarget = targetId;\n\t\t\tthis.followSmoothing = opts?.smoothing ?? followConfig?.smoothing ?? DEFAULT_FOLLOW.smoothing;\n\t\t\tthis.followOffsetX = opts?.offsetX ?? followConfig?.offsetX ?? DEFAULT_FOLLOW.offsetX;\n\t\t\tthis.followOffsetY = opts?.offsetY ?? followConfig?.offsetY ?? DEFAULT_FOLLOW.offsetY;\n\t\t\tthis.followOffsetZ = opts?.offsetZ ?? followConfig?.offsetZ ?? DEFAULT_FOLLOW.offsetZ;\n\t\t},\n\t\tunfollow(this: Camera3DBaseState) {\n\t\t\tthis.followTarget = -1;\n\t\t},\n\t\tsetTarget(this: Camera3DBaseState, x: number, y: number, z: number) {\n\t\t\tthis.targetX = x;\n\t\t\tthis.targetY = y;\n\t\t\tthis.targetZ = z;\n\t\t},\n\t\tsetOrbit(this: Camera3DBaseState, az: number, el: number, dist: number) {\n\t\t\tthis.azimuth = az;\n\t\t\tthis.elevation = clamp(el, minElevation, maxElevation);\n\t\t\tthis.distance = clamp(dist, minDistance, maxDistance);\n\t\t},\n\t\tsetDistance(this: Camera3DBaseState, d: number) {\n\t\t\tthis.distance = clamp(d, minDistance, maxDistance);\n\t\t},\n\t\taddTrauma(this: Camera3DBaseState, amount: number) {\n\t\t\tthis.trauma = clamp(this.trauma + amount, 0, 1);\n\t\t},\n\t};\n\n\treturn definePlugin('camera3d')\n\t\t.withResourceTypes<Camera3DResourceTypes>()\n\t\t.withLabels<Camera3DLabels>()\n\t\t.withGroups<G>()\n\t\t.requires<Camera3DRequiredConfig>()\n\t\t.install((world) => {\n\n\t\t\t// ==================== DOM State ====================\n\n\t\t\tconst drag = { active: false, prevX: 0, prevY: 0, pendingDolly: 0, el: null as HTMLElement | null };\n\n\t\t\t// ==================== Resource ====================\n\n\t\t\tconst variantFields = projection === 'orthographic'\n\t\t\t\t? {\n\t\t\t\t\tprojection: 'orthographic' as const,\n\t\t\t\t\tzoom: initialZoom,\n\t\t\t\t\tsetZoom(this: OrthographicCamera3DState, z: number) { this.zoom = z; },\n\t\t\t\t}\n\t\t\t\t: {\n\t\t\t\t\tprojection: 'perspective' as const,\n\t\t\t\t\tfov: initialFov,\n\t\t\t\t\tsetFov(this: PerspectiveCamera3DState, f: number) { this.fov = f; },\n\t\t\t\t};\n\n\t\t\tconst state: Camera3DState = {\n\t\t\t\t...baseFields,\n\t\t\t\t...baseMutators,\n\t\t\t\t...variantFields,\n\t\t\t};\n\n\t\t\tworld.addResource('camera3DState', state);\n\n\t\t\t// ==================== DOM Handlers ====================\n\n\t\t\tfunction onPointerDown(e: PointerEvent) {\n\t\t\t\tdrag.active = true;\n\t\t\t\tdrag.prevX = e.clientX;\n\t\t\t\tdrag.prevY = e.clientY;\n\t\t\t\tdrag.el?.setPointerCapture(e.pointerId);\n\t\t\t}\n\n\t\t\tfunction onPointerMove(e: PointerEvent) {\n\t\t\t\tif (!drag.active) return;\n\t\t\t\tconst deltaX = e.clientX - drag.prevX;\n\t\t\t\tconst deltaY = e.clientY - drag.prevY;\n\t\t\t\tdrag.prevX = e.clientX;\n\t\t\t\tdrag.prevY = e.clientY;\n\n\t\t\t\tstate.azimuth -= deltaX * orbitSensitivity;\n\t\t\t\tstate.elevation = clamp(\n\t\t\t\t\tstate.elevation + deltaY * orbitSensitivity,\n\t\t\t\t\tminElevation,\n\t\t\t\t\tmaxElevation,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tfunction onPointerUp(e: PointerEvent) {\n\t\t\t\tdrag.active = false;\n\t\t\t\tdrag.el?.releasePointerCapture(e.pointerId);\n\t\t\t}\n\n\t\t\tfunction onWheel(e: WheelEvent) {\n\t\t\t\te.preventDefault();\n\t\t\t\tdrag.pendingDolly += Math.sign(e.deltaY);\n\t\t\t}\n\n\t\t\t// ==================== Init System ====================\n\n\t\t\t// Camera ref cached once at init — never changes at runtime\n\t\t\tlet cachedCamera: Renderer3DResourceTypes['camera'] | null = null;\n\t\t\tlet cachedPerspCamera: PerspectiveCamera | null = null;\n\t\t\tlet cachedOrthoCamera: OrthographicCamera | null = null;\n\n\t\t\tworld\n\t\t\t\t.addSystem('camera3d-init')\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setOnInitialize((ecs) => {\n\t\t\t\t\tconst threeRenderer = ecs.getResource('threeRenderer');\n\t\t\t\t\tcachedCamera = ecs.getResource('camera');\n\n\t\t\t\t\t// Narrow to the concrete camera variant once\n\t\t\t\t\tif ((cachedCamera as PerspectiveCamera).isPerspectiveCamera) {\n\t\t\t\t\t\tcachedPerspCamera = cachedCamera as PerspectiveCamera;\n\t\t\t\t\t} else if ((cachedCamera as OrthographicCamera).isOrthographicCamera) {\n\t\t\t\t\t\tcachedOrthoCamera = cachedCamera as OrthographicCamera;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Guard: plugin `projection` option must match the resolved camera kind\n\t\t\t\t\tif (state.projection === 'perspective' && !cachedPerspCamera) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'createCamera3DPlugin: configured as \\'perspective\\' but the renderer\\'s camera is not a PerspectiveCamera.',\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\tif (state.projection === 'orthographic' && !cachedOrthoCamera) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'createCamera3DPlugin: configured as \\'orthographic\\' but the renderer\\'s camera is not an OrthographicCamera.',\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Sync initial variant-specific value from the actual camera\n\t\t\t\t\tif (state.projection === 'perspective' && cachedPerspCamera) {\n\t\t\t\t\t\tstate.fov = cachedPerspCamera.fov;\n\t\t\t\t\t} else if (state.projection === 'orthographic' && cachedOrthoCamera) {\n\t\t\t\t\t\tstate.zoom = cachedOrthoCamera.zoom;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Attach DOM listeners\n\t\t\t\t\tdrag.el = threeRenderer.domElement;\n\t\t\t\t\tif (enableOrbit) {\n\t\t\t\t\t\tdrag.el.addEventListener('pointerdown', onPointerDown);\n\t\t\t\t\t\tdrag.el.addEventListener('pointermove', onPointerMove);\n\t\t\t\t\t\tdrag.el.addEventListener('pointerup', onPointerUp);\n\t\t\t\t\t}\n\t\t\t\t\tdrag.el.addEventListener('wheel', onWheel as EventListener, { passive: false });\n\n\t\t\t\t\t// Initial camera position sync\n\t\t\t\t\tsphericalToCartesian(state.azimuth, state.elevation, state.distance, _camPos);\n\t\t\t\t\tcachedCamera.position.set(\n\t\t\t\t\t\tstate.targetX + _camPos.x,\n\t\t\t\t\t\tstate.targetY + _camPos.y,\n\t\t\t\t\t\tstate.targetZ + _camPos.z,\n\t\t\t\t\t);\n\t\t\t\t\tcachedCamera.lookAt(state.targetX, state.targetY, state.targetZ);\n\t\t\t\t})\n\t\t\t\t.setOnDetach(() => {\n\t\t\t\t\tif (!drag.el) return;\n\t\t\t\t\tif (enableOrbit) {\n\t\t\t\t\t\tdrag.el.removeEventListener('pointerdown', onPointerDown);\n\t\t\t\t\t\tdrag.el.removeEventListener('pointermove', onPointerMove);\n\t\t\t\t\t\tdrag.el.removeEventListener('pointerup', onPointerUp);\n\t\t\t\t\t}\n\t\t\t\t\tdrag.el.removeEventListener('wheel', onWheel as EventListener);\n\t\t\t\t\tdrag.el = null;\n\t\t\t\t\tcachedCamera = null;\n\t\t\t\t\tcachedPerspCamera = null;\n\t\t\t\t\tcachedOrthoCamera = null;\n\t\t\t\t});\n\n\t\t\t// ==================== Follow System ====================\n\n\t\t\tworld\n\t\t\t\t.addSystem('camera3d-follow')\n\t\t\t\t.setPriority(400)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ ecs, dt }) => {\n\t\t\t\t\tif (state.followTarget < 0) return;\n\n\t\t\t\t\tif (!ecs.getEntity(state.followTarget)) {\n\t\t\t\t\t\tstate.followTarget = -1;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst worldTransform = ecs.getComponent(state.followTarget, 'worldTransform3D');\n\t\t\t\t\tif (!worldTransform) return;\n\n\t\t\t\t\tconst goalX = worldTransform.x + state.followOffsetX;\n\t\t\t\t\tconst goalY = worldTransform.y + state.followOffsetY;\n\t\t\t\t\tconst goalZ = worldTransform.z + state.followOffsetZ;\n\n\t\t\t\t\tconst factor = Math.min(1, state.followSmoothing * dt);\n\t\t\t\t\tstate.targetX += (goalX - state.targetX) * factor;\n\t\t\t\t\tstate.targetY += (goalY - state.targetY) * factor;\n\t\t\t\t\tstate.targetZ += (goalZ - state.targetZ) * factor;\n\t\t\t\t});\n\n\t\t\t// ==================== Shake System ====================\n\n\t\t\tworld\n\t\t\t\t.addSystem('camera3d-shake')\n\t\t\t\t.setPriority(390)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ dt }) => {\n\t\t\t\t\tif (state.trauma <= 0) {\n\t\t\t\t\t\tstate.shakeOffsetX = 0;\n\t\t\t\t\t\tstate.shakeOffsetY = 0;\n\t\t\t\t\t\tstate.shakeOffsetZ = 0;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tstate.trauma = Math.max(0, state.trauma - shakeDecay * dt);\n\n\t\t\t\t\tconst intensity = state.trauma * state.trauma;\n\t\t\t\t\tstate.shakeOffsetX = shakeMaxX * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\tstate.shakeOffsetY = shakeMaxY * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\tstate.shakeOffsetZ = shakeMaxZ * intensity * (randomFn() * 2 - 1);\n\t\t\t\t});\n\n\t\t\t// ==================== Sync System ====================\n\n\t\t\tworld\n\t\t\t\t.addSystem('camera3d-sync')\n\t\t\t\t.setPriority(380)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(() => {\n\t\t\t\t\tif (!cachedCamera) return;\n\n\t\t\t\t\t// Process pending dolly\n\t\t\t\t\tif (drag.pendingDolly !== 0) {\n\t\t\t\t\t\tstate.distance = clamp(\n\t\t\t\t\t\t\tstate.distance * Math.pow(dollySensitivity, drag.pendingDolly),\n\t\t\t\t\t\t\tminDistance,\n\t\t\t\t\t\t\tmaxDistance,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tdrag.pendingDolly = 0;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Compute camera position from spherical coords. Shake is applied as a\n\t\t\t\t\t// pure view translation — both position and lookAt target shift by the\n\t\t\t\t\t// same offset so the view pans instead of rotating. This keeps the effect\n\t\t\t\t\t// visible under orthographic projection (which has no parallax) and also\n\t\t\t\t\t// makes perspective shake magnitudes feel consistent regardless of distance.\n\t\t\t\t\tsphericalToCartesian(state.azimuth, state.elevation, state.distance, _camPos);\n\t\t\t\t\tcachedCamera.position.set(\n\t\t\t\t\t\tstate.targetX + _camPos.x + state.shakeOffsetX,\n\t\t\t\t\t\tstate.targetY + _camPos.y + state.shakeOffsetY,\n\t\t\t\t\t\tstate.targetZ + _camPos.z + state.shakeOffsetZ,\n\t\t\t\t\t);\n\t\t\t\t\tcachedCamera.lookAt(\n\t\t\t\t\t\tstate.targetX + state.shakeOffsetX,\n\t\t\t\t\t\tstate.targetY + state.shakeOffsetY,\n\t\t\t\t\t\tstate.targetZ + state.shakeOffsetZ,\n\t\t\t\t\t);\n\n\t\t\t\t\tif (state.projection === 'perspective' && cachedPerspCamera && cachedPerspCamera.fov !== state.fov) {\n\t\t\t\t\t\tcachedPerspCamera.fov = state.fov;\n\t\t\t\t\t\tcachedPerspCamera.updateProjectionMatrix();\n\t\t\t\t\t} else if (state.projection === 'orthographic' && cachedOrthoCamera && cachedOrthoCamera.zoom !== state.zoom) {\n\t\t\t\t\t\tcachedOrthoCamera.zoom = state.zoom;\n\t\t\t\t\t\tcachedOrthoCamera.updateProjectionMatrix();\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t});\n}\n"
|
|
5
|
+
"/**\n * Camera 3D Plugin for ECSpresso\n *\n * Orbit/follow/shake camera controls for a Three.js PerspectiveCamera or\n * OrthographicCamera managed by renderer3D. Purely resource-based (no camera\n * entity). The renderer3D `camera` resource is the single camera target.\n * Orbit via pointer drag + scroll wheel, follow via entity tracking, shake\n * via trauma-based offsets.\n *\n * The plugin's `projection` option must match the underlying camera's kind;\n * a mismatch throws at init. State is a discriminated union — perspective\n * cameras expose `fov` / `setFov`, orthographic cameras expose `zoom` / `setZoom`.\n *\n * Import from 'ecspresso/plugins/spatial/camera3D'\n */\n\nimport { definePlugin } from 'ecspresso';\nimport type { SystemPhase } from 'ecspresso';\nimport type { ComponentsConfig, ResourcesConfig } from '../../type-utils';\nimport type { Transform3DComponentTypes } from './transform3D';\nimport type { Renderer3DResourceTypes } from '../rendering/renderer3D';\nimport type { OrthographicCamera, PerspectiveCamera } from 'three';\n\n// ==================== Dependency Types ====================\n\ntype Camera3DRequiredConfig =\n\tComponentsConfig<Transform3DComponentTypes>\n\t& ResourcesConfig<Renderer3DResourceTypes>;\n\n// ==================== Resource Types ====================\n\nexport interface Camera3DFollowOptions {\n\tsmoothing?: number;\n\toffsetX?: number;\n\toffsetY?: number;\n\toffsetZ?: number;\n}\n\nexport interface Camera3DShakeOptions {\n\ttraumaDecay?: number;\n\tmaxOffsetX?: number;\n\tmaxOffsetY?: number;\n\tmaxOffsetZ?: number;\n}\n\nexport interface Camera3DBaseState {\n\t// Orbit / spherical state\n\ttargetX: number;\n\ttargetY: number;\n\ttargetZ: number;\n\tazimuth: number;\n\televation: number;\n\tdistance: number;\n\n\t// Follow\n\tfollowTarget: number;\n\tfollowSmoothing: number;\n\tfollowOffsetX: number;\n\tfollowOffsetY: number;\n\tfollowOffsetZ: number;\n\n\t// Shake (read by sync, written by shake system)\n\ttrauma: number;\n\tshakeOffsetX: number;\n\tshakeOffsetY: number;\n\tshakeOffsetZ: number;\n\n\t// Mutation methods\n\tfollow(target: number | { id: number }, options?: Camera3DFollowOptions): void;\n\tunfollow(): void;\n\tsetTarget(x: number, y: number, z: number): void;\n\tsetOrbit(azimuth: number, elevation: number, distance: number): void;\n\tsetDistance(distance: number): void;\n\taddTrauma(amount: number): void;\n}\n\nexport interface PerspectiveCamera3DState extends Camera3DBaseState {\n\tprojection: 'perspective';\n\tfov: number;\n\tsetFov(fov: number): void;\n}\n\nexport interface OrthographicCamera3DState extends Camera3DBaseState {\n\tprojection: 'orthographic';\n\tzoom: number;\n\tsetZoom(zoom: number): void;\n}\n\nexport type Camera3DState = PerspectiveCamera3DState | OrthographicCamera3DState;\n\nexport interface Camera3DResourceTypes {\n\tcamera3DState: Camera3DState;\n}\n\nexport type Camera3DWorldConfig = ResourcesConfig<Camera3DResourceTypes>;\n\n// ==================== Plugin Options ====================\n\nexport interface Camera3DBasePluginOptions<G extends string = 'camera3d'> {\n\tsystemGroup?: G;\n\tphase?: SystemPhase;\n\n\t// Initial orbit state\n\tazimuth?: number;\n\televation?: number;\n\tdistance?: number;\n\ttarget?: { x: number; y: number; z: number };\n\n\t// Orbit constraints\n\tminDistance?: number;\n\tmaxDistance?: number;\n\tminElevation?: number;\n\tmaxElevation?: number;\n\n\t// Sensitivity\n\torbitSensitivity?: number;\n\tdollySensitivity?: number;\n\n\t// Disable pointer-drag orbit (zoom/dolly via wheel is unaffected)\n\tenableOrbit?: boolean;\n\n\t// Follow\n\tfollow?: Camera3DFollowOptions;\n\n\t// Shake\n\tshake?: boolean | Partial<Camera3DShakeOptions>;\n\n\t// Injectable RNG for deterministic shake\n\trandomFn?: () => number;\n}\n\nexport type Camera3DPluginOptions<G extends string = 'camera3d'> =\n\tCamera3DBasePluginOptions<G> & (\n\t\t| { projection?: 'perspective'; fov?: number }\n\t\t| { projection: 'orthographic'; zoom?: number }\n\t);\n\n// ==================== Labels ====================\n\nexport type Camera3DLabels =\n\t| 'camera3d-init'\n\t| 'camera3d-follow'\n\t| 'camera3d-shake'\n\t| 'camera3d-sync';\n\n// ==================== Constants ====================\n\nconst DEFAULT_FOLLOW: Readonly<Required<Camera3DFollowOptions>> = {\n\tsmoothing: 5,\n\toffsetX: 0,\n\toffsetY: 0,\n\toffsetZ: 0,\n};\n\nconst DEFAULT_SHAKE: Readonly<Required<Camera3DShakeOptions>> = {\n\ttraumaDecay: 1,\n\tmaxOffsetX: 0.3,\n\tmaxOffsetY: 0.3,\n\tmaxOffsetZ: 0.3,\n};\n\nconst HALF_PI = Math.PI / 2;\nconst ELEVATION_EPSILON = 0.001;\n\n// ==================== Scratch Objects ====================\n\nconst _camPos = { x: 0, y: 0, z: 0 };\n\n// ==================== Helpers ====================\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.max(min, Math.min(max, value));\n}\n\nfunction resolveShakeOptions(config: true | Partial<Camera3DShakeOptions>): Required<Camera3DShakeOptions> {\n\tif (config === true) return { ...DEFAULT_SHAKE };\n\treturn {\n\t\ttraumaDecay: config.traumaDecay ?? DEFAULT_SHAKE.traumaDecay,\n\t\tmaxOffsetX: config.maxOffsetX ?? DEFAULT_SHAKE.maxOffsetX,\n\t\tmaxOffsetY: config.maxOffsetY ?? DEFAULT_SHAKE.maxOffsetY,\n\t\tmaxOffsetZ: config.maxOffsetZ ?? DEFAULT_SHAKE.maxOffsetZ,\n\t};\n}\n\n/**\n * Convert spherical coordinates to cartesian. Y-up convention (Three.js default).\n * Azimuth rotates in the XZ plane; elevation goes from XZ plane toward +Y.\n */\nexport function sphericalToCartesian(\n\tazimuth: number,\n\televation: number,\n\tdistance: number,\n\tout: { x: number; y: number; z: number },\n): void {\n\tconst cosElev = Math.cos(elevation);\n\tout.x = distance * cosElev * Math.sin(azimuth);\n\tout.y = distance * Math.sin(elevation);\n\tout.z = distance * cosElev * Math.cos(azimuth);\n}\n\n// ==================== Plugin Factory ====================\n\nexport function createCamera3DPlugin<G extends string = 'camera3d'>(\n\toptions?: Camera3DPluginOptions<G>,\n) {\n\tconst {\n\t\tsystemGroup = 'camera3d',\n\t\tphase = 'postUpdate',\n\t\tazimuth: initialAzimuth = 0,\n\t\televation: initialElevation = 0.5,\n\t\tdistance: initialDistance = 10,\n\t\ttarget: initialTarget,\n\t\tminDistance = 1,\n\t\tmaxDistance = 100,\n\t\tminElevation = -HALF_PI + ELEVATION_EPSILON,\n\t\tmaxElevation = HALF_PI - ELEVATION_EPSILON,\n\t\torbitSensitivity = 0.003,\n\t\tdollySensitivity = 1.1,\n\t\tenableOrbit = true,\n\t\tfollow: followConfig,\n\t\tshake: shakeConfig,\n\t\trandomFn = Math.random,\n\t} = options ?? {};\n\n\tconst projection: 'perspective' | 'orthographic' = options?.projection ?? 'perspective';\n\tconst initialFov = options?.projection !== 'orthographic' ? (options?.fov ?? 75) : 75;\n\tconst initialZoom = options?.projection === 'orthographic' ? (options.zoom ?? 1) : 1;\n\n\tconst resolvedShake = shakeConfig ? resolveShakeOptions(shakeConfig) : DEFAULT_SHAKE;\n\tconst shakeDecay = resolvedShake.traumaDecay;\n\tconst shakeMaxX = resolvedShake.maxOffsetX;\n\tconst shakeMaxY = resolvedShake.maxOffsetY;\n\tconst shakeMaxZ = resolvedShake.maxOffsetZ;\n\n\t// Base fields + mutators shared between variants. Mutators use an explicit `this`\n\t// parameter so they type-check against `Camera3DBaseState` regardless of variant.\n\tconst baseFields = {\n\t\ttargetX: initialTarget?.x ?? 0,\n\t\ttargetY: initialTarget?.y ?? 0,\n\t\ttargetZ: initialTarget?.z ?? 0,\n\t\tazimuth: initialAzimuth,\n\t\televation: clamp(initialElevation, minElevation, maxElevation),\n\t\tdistance: clamp(initialDistance, minDistance, maxDistance),\n\n\t\tfollowTarget: -1,\n\t\tfollowSmoothing: followConfig?.smoothing ?? DEFAULT_FOLLOW.smoothing,\n\t\tfollowOffsetX: followConfig?.offsetX ?? DEFAULT_FOLLOW.offsetX,\n\t\tfollowOffsetY: followConfig?.offsetY ?? DEFAULT_FOLLOW.offsetY,\n\t\tfollowOffsetZ: followConfig?.offsetZ ?? DEFAULT_FOLLOW.offsetZ,\n\n\t\ttrauma: 0,\n\t\tshakeOffsetX: 0,\n\t\tshakeOffsetY: 0,\n\t\tshakeOffsetZ: 0,\n\t};\n\n\tconst baseMutators = {\n\t\tfollow(this: Camera3DBaseState, target: number | { id: number }, opts?: Camera3DFollowOptions) {\n\t\t\tconst targetId = typeof target === 'number' ? target : target.id;\n\t\t\tthis.followTarget = targetId;\n\t\t\tthis.followSmoothing = opts?.smoothing ?? followConfig?.smoothing ?? DEFAULT_FOLLOW.smoothing;\n\t\t\tthis.followOffsetX = opts?.offsetX ?? followConfig?.offsetX ?? DEFAULT_FOLLOW.offsetX;\n\t\t\tthis.followOffsetY = opts?.offsetY ?? followConfig?.offsetY ?? DEFAULT_FOLLOW.offsetY;\n\t\t\tthis.followOffsetZ = opts?.offsetZ ?? followConfig?.offsetZ ?? DEFAULT_FOLLOW.offsetZ;\n\t\t},\n\t\tunfollow(this: Camera3DBaseState) {\n\t\t\tthis.followTarget = -1;\n\t\t},\n\t\tsetTarget(this: Camera3DBaseState, x: number, y: number, z: number) {\n\t\t\tthis.targetX = x;\n\t\t\tthis.targetY = y;\n\t\t\tthis.targetZ = z;\n\t\t},\n\t\tsetOrbit(this: Camera3DBaseState, az: number, el: number, dist: number) {\n\t\t\tthis.azimuth = az;\n\t\t\tthis.elevation = clamp(el, minElevation, maxElevation);\n\t\t\tthis.distance = clamp(dist, minDistance, maxDistance);\n\t\t},\n\t\tsetDistance(this: Camera3DBaseState, d: number) {\n\t\t\tthis.distance = clamp(d, minDistance, maxDistance);\n\t\t},\n\t\taddTrauma(this: Camera3DBaseState, amount: number) {\n\t\t\tthis.trauma = clamp(this.trauma + amount, 0, 1);\n\t\t},\n\t};\n\n\treturn definePlugin('camera3d')\n\t\t.withResourceTypes<Camera3DResourceTypes>()\n\t\t.withLabels<Camera3DLabels>()\n\t\t.withGroups<G>()\n\t\t.requires<Camera3DRequiredConfig>()\n\t\t.install((world) => {\n\n\t\t\t// ==================== DOM State ====================\n\n\t\t\tconst drag = { active: false, prevX: 0, prevY: 0, pendingDolly: 0, el: null as HTMLElement | null };\n\n\t\t\t// ==================== Resource ====================\n\n\t\t\tconst variantFields = projection === 'orthographic'\n\t\t\t\t? {\n\t\t\t\t\tprojection: 'orthographic' as const,\n\t\t\t\t\tzoom: initialZoom,\n\t\t\t\t\tsetZoom(this: OrthographicCamera3DState, z: number) { this.zoom = z; },\n\t\t\t\t}\n\t\t\t\t: {\n\t\t\t\t\tprojection: 'perspective' as const,\n\t\t\t\t\tfov: initialFov,\n\t\t\t\t\tsetFov(this: PerspectiveCamera3DState, f: number) { this.fov = f; },\n\t\t\t\t};\n\n\t\t\tconst state: Camera3DState = {\n\t\t\t\t...baseFields,\n\t\t\t\t...baseMutators,\n\t\t\t\t...variantFields,\n\t\t\t};\n\n\t\t\tworld.addResource('camera3DState', state);\n\n\t\t\t// ==================== DOM Handlers ====================\n\n\t\t\tfunction onPointerDown(e: PointerEvent) {\n\t\t\t\tdrag.active = true;\n\t\t\t\tdrag.prevX = e.clientX;\n\t\t\t\tdrag.prevY = e.clientY;\n\t\t\t\tdrag.el?.setPointerCapture(e.pointerId);\n\t\t\t}\n\n\t\t\tfunction onPointerMove(e: PointerEvent) {\n\t\t\t\tif (!drag.active) return;\n\t\t\t\tconst deltaX = e.clientX - drag.prevX;\n\t\t\t\tconst deltaY = e.clientY - drag.prevY;\n\t\t\t\tdrag.prevX = e.clientX;\n\t\t\t\tdrag.prevY = e.clientY;\n\n\t\t\t\tstate.azimuth -= deltaX * orbitSensitivity;\n\t\t\t\tstate.elevation = clamp(\n\t\t\t\t\tstate.elevation + deltaY * orbitSensitivity,\n\t\t\t\t\tminElevation,\n\t\t\t\t\tmaxElevation,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tfunction onPointerUp(e: PointerEvent) {\n\t\t\t\tdrag.active = false;\n\t\t\t\tdrag.el?.releasePointerCapture(e.pointerId);\n\t\t\t}\n\n\t\t\tfunction onWheel(e: WheelEvent) {\n\t\t\t\te.preventDefault();\n\t\t\t\tdrag.pendingDolly += Math.sign(e.deltaY);\n\t\t\t}\n\n\t\t\t// ==================== Init System ====================\n\n\t\t\t// Camera ref cached once at init — never changes at runtime\n\t\t\tlet cachedCamera: Renderer3DResourceTypes['camera'] | null = null;\n\t\t\tlet cachedPerspCamera: PerspectiveCamera | null = null;\n\t\t\tlet cachedOrthoCamera: OrthographicCamera | null = null;\n\n\t\t\tworld\n\t\t\t\t.addSystem('camera3d-init')\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setOnInitialize((ecs) => {\n\t\t\t\t\tconst threeRenderer = ecs.getResource('threeRenderer');\n\t\t\t\t\tcachedCamera = ecs.getResource('camera');\n\n\t\t\t\t\t// Narrow to the concrete camera variant once\n\t\t\t\t\tif ((cachedCamera as PerspectiveCamera).isPerspectiveCamera) {\n\t\t\t\t\t\tcachedPerspCamera = cachedCamera as PerspectiveCamera;\n\t\t\t\t\t} else if ((cachedCamera as OrthographicCamera).isOrthographicCamera) {\n\t\t\t\t\t\tcachedOrthoCamera = cachedCamera as OrthographicCamera;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Guard: plugin `projection` option must match the resolved camera kind\n\t\t\t\t\tif (state.projection === 'perspective' && !cachedPerspCamera) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'createCamera3DPlugin: configured as \\'perspective\\' but the renderer\\'s camera is not a PerspectiveCamera.',\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\tif (state.projection === 'orthographic' && !cachedOrthoCamera) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'createCamera3DPlugin: configured as \\'orthographic\\' but the renderer\\'s camera is not an OrthographicCamera.',\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Sync initial variant-specific value from the actual camera\n\t\t\t\t\tif (state.projection === 'perspective' && cachedPerspCamera) {\n\t\t\t\t\t\tstate.fov = cachedPerspCamera.fov;\n\t\t\t\t\t} else if (state.projection === 'orthographic' && cachedOrthoCamera) {\n\t\t\t\t\t\tstate.zoom = cachedOrthoCamera.zoom;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Attach DOM listeners\n\t\t\t\t\tdrag.el = threeRenderer.domElement;\n\t\t\t\t\tif (enableOrbit) {\n\t\t\t\t\t\tdrag.el.addEventListener('pointerdown', onPointerDown);\n\t\t\t\t\t\tdrag.el.addEventListener('pointermove', onPointerMove);\n\t\t\t\t\t\tdrag.el.addEventListener('pointerup', onPointerUp);\n\t\t\t\t\t}\n\t\t\t\t\tdrag.el.addEventListener('wheel', onWheel as EventListener, { passive: false });\n\n\t\t\t\t\t// Initial camera position sync\n\t\t\t\t\tsphericalToCartesian(state.azimuth, state.elevation, state.distance, _camPos);\n\t\t\t\t\tcachedCamera.position.set(\n\t\t\t\t\t\tstate.targetX + _camPos.x,\n\t\t\t\t\t\tstate.targetY + _camPos.y,\n\t\t\t\t\t\tstate.targetZ + _camPos.z,\n\t\t\t\t\t);\n\t\t\t\t\tcachedCamera.lookAt(state.targetX, state.targetY, state.targetZ);\n\t\t\t\t})\n\t\t\t\t.setOnDetach(() => {\n\t\t\t\t\tif (!drag.el) return;\n\t\t\t\t\tif (enableOrbit) {\n\t\t\t\t\t\tdrag.el.removeEventListener('pointerdown', onPointerDown);\n\t\t\t\t\t\tdrag.el.removeEventListener('pointermove', onPointerMove);\n\t\t\t\t\t\tdrag.el.removeEventListener('pointerup', onPointerUp);\n\t\t\t\t\t}\n\t\t\t\t\tdrag.el.removeEventListener('wheel', onWheel as EventListener);\n\t\t\t\t\tdrag.el = null;\n\t\t\t\t\tcachedCamera = null;\n\t\t\t\t\tcachedPerspCamera = null;\n\t\t\t\t\tcachedOrthoCamera = null;\n\t\t\t\t});\n\n\t\t\t// ==================== Follow System ====================\n\n\t\t\tworld\n\t\t\t\t.addSystem('camera3d-follow')\n\t\t\t\t.setPriority(400)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ ecs, dt }) => {\n\t\t\t\t\tif (state.followTarget < 0) return;\n\n\t\t\t\t\tif (!ecs.getEntity(state.followTarget)) {\n\t\t\t\t\t\tstate.followTarget = -1;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst worldTransform = ecs.getComponent(state.followTarget, 'worldTransform3D');\n\t\t\t\t\tif (!worldTransform) return;\n\n\t\t\t\t\tconst goalX = worldTransform.x + state.followOffsetX;\n\t\t\t\t\tconst goalY = worldTransform.y + state.followOffsetY;\n\t\t\t\t\tconst goalZ = worldTransform.z + state.followOffsetZ;\n\n\t\t\t\t\tconst factor = Math.min(1, state.followSmoothing * dt);\n\t\t\t\t\tstate.targetX += (goalX - state.targetX) * factor;\n\t\t\t\t\tstate.targetY += (goalY - state.targetY) * factor;\n\t\t\t\t\tstate.targetZ += (goalZ - state.targetZ) * factor;\n\t\t\t\t});\n\n\t\t\t// ==================== Shake System ====================\n\n\t\t\tworld\n\t\t\t\t.addSystem('camera3d-shake')\n\t\t\t\t.setPriority(390)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ dt }) => {\n\t\t\t\t\tif (state.trauma <= 0) {\n\t\t\t\t\t\tstate.shakeOffsetX = 0;\n\t\t\t\t\t\tstate.shakeOffsetY = 0;\n\t\t\t\t\t\tstate.shakeOffsetZ = 0;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tstate.trauma = Math.max(0, state.trauma - shakeDecay * dt);\n\n\t\t\t\t\tconst intensity = state.trauma * state.trauma;\n\t\t\t\t\tstate.shakeOffsetX = shakeMaxX * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\tstate.shakeOffsetY = shakeMaxY * intensity * (randomFn() * 2 - 1);\n\t\t\t\t\tstate.shakeOffsetZ = shakeMaxZ * intensity * (randomFn() * 2 - 1);\n\t\t\t\t});\n\n\t\t\t// ==================== Sync System ====================\n\n\t\t\tworld\n\t\t\t\t.addSystem('camera3d-sync')\n\t\t\t\t.setPriority(380)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(() => {\n\t\t\t\t\tif (!cachedCamera) return;\n\n\t\t\t\t\t// Process pending dolly\n\t\t\t\t\tif (drag.pendingDolly !== 0) {\n\t\t\t\t\t\tstate.distance = clamp(\n\t\t\t\t\t\t\tstate.distance * Math.pow(dollySensitivity, drag.pendingDolly),\n\t\t\t\t\t\t\tminDistance,\n\t\t\t\t\t\t\tmaxDistance,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tdrag.pendingDolly = 0;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Compute camera position from spherical coords. Shake is applied as a\n\t\t\t\t\t// pure view translation — both position and lookAt target shift by the\n\t\t\t\t\t// same offset so the view pans instead of rotating. This keeps the effect\n\t\t\t\t\t// visible under orthographic projection (which has no parallax) and also\n\t\t\t\t\t// makes perspective shake magnitudes feel consistent regardless of distance.\n\t\t\t\t\tsphericalToCartesian(state.azimuth, state.elevation, state.distance, _camPos);\n\t\t\t\t\tcachedCamera.position.set(\n\t\t\t\t\t\tstate.targetX + _camPos.x + state.shakeOffsetX,\n\t\t\t\t\t\tstate.targetY + _camPos.y + state.shakeOffsetY,\n\t\t\t\t\t\tstate.targetZ + _camPos.z + state.shakeOffsetZ,\n\t\t\t\t\t);\n\t\t\t\t\tcachedCamera.lookAt(\n\t\t\t\t\t\tstate.targetX + state.shakeOffsetX,\n\t\t\t\t\t\tstate.targetY + state.shakeOffsetY,\n\t\t\t\t\t\tstate.targetZ + state.shakeOffsetZ,\n\t\t\t\t\t);\n\n\t\t\t\t\tif (state.projection === 'perspective' && cachedPerspCamera && cachedPerspCamera.fov !== state.fov) {\n\t\t\t\t\t\tcachedPerspCamera.fov = state.fov;\n\t\t\t\t\t\tcachedPerspCamera.updateProjectionMatrix();\n\t\t\t\t\t} else if (state.projection === 'orthographic' && cachedOrthoCamera && cachedOrthoCamera.zoom !== state.zoom) {\n\t\t\t\t\t\tcachedOrthoCamera.zoom = state.zoom;\n\t\t\t\t\t\tcachedOrthoCamera.updateProjectionMatrix();\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t});\n}\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": "4PAgBA,uBAAS,
|
|
7
|
+
"mappings": "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",
|
|
8
8
|
"debugId": "E7D2111F51674CD764756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @see https://docs.rs/bevy/latest/bevy/transform/components/struct.GlobalTransform.html
|
|
8
8
|
*/
|
|
9
9
|
import { type BasePluginOptions } from 'ecspresso';
|
|
10
|
-
import type {
|
|
10
|
+
import type { ComponentsConfig } from '../../type-utils';
|
|
11
11
|
/**
|
|
12
12
|
* Local transform relative to parent (or world if no parent).
|
|
13
13
|
* This is the transform you modify directly.
|
|
@@ -50,7 +50,7 @@ export interface TransformComponentTypes {
|
|
|
50
50
|
* WorldConfig representing the transform plugin's provided components.
|
|
51
51
|
* Used as the `Requires` type parameter by plugins that depend on transform.
|
|
52
52
|
*/
|
|
53
|
-
export type TransformWorldConfig =
|
|
53
|
+
export type TransformWorldConfig = ComponentsConfig<TransformComponentTypes>;
|
|
54
54
|
/**
|
|
55
55
|
* Configuration options for the transform plugin.
|
|
56
56
|
*/
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/plugins/spatial/transform.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * Transform Plugin for ECSpresso\n *\n * Provides hierarchical transform propagation following Bevy's Transform/GlobalTransform pattern.\n * LocalTransform is modified by user code; WorldTransform is computed automatically.\n *\n * @see https://docs.rs/bevy/latest/bevy/transform/components/struct.GlobalTransform.html\n */\n\nimport { definePlugin, type BasePluginOptions } from 'ecspresso';\nimport type ECSpresso from 'ecspresso';\nimport type { WorldConfigFrom } from '../../type-utils';\n\n// ==================== Component Types ====================\n\n/**\n * Local transform relative to parent (or world if no parent).\n * This is the transform you modify directly.\n */\nexport interface LocalTransform {\n\tx: number;\n\ty: number;\n\trotation: number;\n\tscaleX: number;\n\tscaleY: number;\n}\n\n/**\n * Computed world transform (accumulated from parent chain).\n * Read-only - managed by the transform propagation system.\n */\nexport interface WorldTransform {\n\tx: number;\n\ty: number;\n\trotation: number;\n\tscaleX: number;\n\tscaleY: number;\n}\n\n/**\n * Component types provided by the transform plugin.\n * Included automatically via `.withPlugin(createTransformPlugin())`.\n *\n * @example\n * ```typescript\n * const ecs = ECSpresso.create()\n * .withPlugin(createTransformPlugin())\n * .withComponentTypes<{ sprite: Sprite; velocity: { x: number; y: number } }>()\n * .build();\n * ```\n */\nexport interface TransformComponentTypes {\n\tlocalTransform: LocalTransform;\n\tworldTransform: WorldTransform;\n}\n\n/**\n * WorldConfig representing the transform plugin's provided components.\n * Used as the `Requires` type parameter by plugins that depend on transform.\n */\nexport type TransformWorldConfig = WorldConfigFrom<TransformComponentTypes>;\n\n// ==================== Plugin Options ====================\n\n/**\n * Configuration options for the transform plugin.\n */\nexport interface TransformPluginOptions<G extends string = 'transform'> extends BasePluginOptions<G> {}\n\n// ==================== Default Values ====================\n\n/**\n * Default local transform values.\n */\nexport const DEFAULT_LOCAL_TRANSFORM: Readonly<LocalTransform> = {\n\tx: 0,\n\ty: 0,\n\trotation: 0,\n\tscaleX: 1,\n\tscaleY: 1,\n};\n\n/**\n * Default world transform values.\n */\nexport const DEFAULT_WORLD_TRANSFORM: Readonly<WorldTransform> = {\n\tx: 0,\n\ty: 0,\n\trotation: 0,\n\tscaleX: 1,\n\tscaleY: 1,\n};\n\n// ==================== Helper Functions ====================\n\n/**\n * Create a local transform component with position only.\n * Uses default rotation (0) and scale (1, 1).\n *\n * @param x The x coordinate\n * @param y The y coordinate\n * @returns Component object suitable for spreading into spawn()\n *\n * @example\n * ```typescript\n * ecs.spawn({\n * ...createLocalTransform(100, 200),\n * sprite,\n * });\n * ```\n */\nexport function createLocalTransform(x: number, y: number): Pick<TransformComponentTypes, 'localTransform'> {\n\treturn {\n\t\tlocalTransform: {\n\t\t\tx,\n\t\t\ty,\n\t\t\trotation: 0,\n\t\t\tscaleX: 1,\n\t\t\tscaleY: 1,\n\t\t},\n\t};\n}\n\n/**\n * Create a world transform component with position only.\n * Typically used alongside createLocalTransform for initial state.\n *\n * @param x The x coordinate\n * @param y The y coordinate\n * @returns Component object suitable for spreading into spawn()\n */\nexport function createWorldTransform(x: number, y: number): Pick<TransformComponentTypes, 'worldTransform'> {\n\treturn {\n\t\tworldTransform: {\n\t\t\tx,\n\t\t\ty,\n\t\t\trotation: 0,\n\t\t\tscaleX: 1,\n\t\t\tscaleY: 1,\n\t\t},\n\t};\n}\n\n/**\n * Options for creating a full transform.\n */\nexport interface TransformOptions {\n\trotation?: number;\n\tscaleX?: number;\n\tscaleY?: number;\n\t/** Uniform scale (overrides scaleX/scaleY if provided) */\n\tscale?: number;\n}\n\n/**\n * Create both local and world transform components.\n * World transform is initialized to match local transform.\n *\n * @param x The x coordinate\n * @param y The y coordinate\n * @param options Optional rotation and scale\n * @returns Component object suitable for spreading into spawn()\n *\n * @example\n * ```typescript\n * ecs.spawn({\n * ...createTransform(100, 200),\n * sprite,\n * });\n *\n * // With rotation and scale\n * ecs.spawn({\n * ...createTransform(100, 200, { rotation: Math.PI / 4, scale: 2 }),\n * sprite,\n * });\n * ```\n */\nexport function createTransform(\n\tx: number,\n\ty: number,\n\toptions?: TransformOptions\n): TransformComponentTypes {\n\tconst scaleX = options?.scale ?? options?.scaleX ?? 1;\n\tconst scaleY = options?.scale ?? options?.scaleY ?? 1;\n\tconst rotation = options?.rotation ?? 0;\n\n\tconst transform = {\n\t\tx,\n\t\ty,\n\t\trotation,\n\t\tscaleX,\n\t\tscaleY,\n\t};\n\n\treturn {\n\t\tlocalTransform: { ...transform },\n\t\tworldTransform: { ...transform },\n\t};\n}\n\n// ==================== Plugin Factory ====================\n\n/**\n * Create a transform plugin for ECSpresso.\n *\n * This plugin provides:\n * - Transform propagation system that computes world transforms from local transforms\n * - Parent-first traversal ensures parents are processed before children\n * - Supports full transform hierarchy (position, rotation, scale)\n *\n * @example\n * ```typescript\n * const ecs = ECSpresso\n * .create<Components, Events, Resources>()\n * .withPlugin(createTransformPlugin())\n * .withPlugin(createPhysics2DPlugin())\n * .build();\n *\n * // Spawn entity with transform\n * ecs.spawn({\n * ...createTransform(100, 200),\n * velocity: { x: 50, y: 0 },\n * });\n * ```\n */\nexport function createTransformPlugin<G extends string = 'transform'>(\n\toptions?: TransformPluginOptions<G>\n) {\n\tconst {\n\t\tsystemGroup = 'transform',\n\t\tpriority = 500,\n\t\tphase = 'postUpdate',\n\t} = options ?? {};\n\n\treturn definePlugin('transform')\n\t\t.withComponentTypes<TransformComponentTypes>()\n\t\t.withLabels<'transform-propagation'>()\n\t\t.withGroups<G>()\n\t\t.install((world) => {\n\t\t\t// localTransform requires worldTransform — initialize from localTransform values\n\t\t\tworld.registerRequired('localTransform', 'worldTransform', (lt) => ({\n\t\t\t\tx: lt.x, y: lt.y, rotation: lt.rotation, scaleX: lt.scaleX, scaleY: lt.scaleY,\n\t\t\t}));\n\n\t\t\tconst orphanBuffer: Array<import('../../types').FilteredEntity<TransformComponentTypes, 'localTransform' | 'worldTransform'>> = [];\n\t\t\tconst hierarchyVisited = new Set<number>();\n\n\t\t\tworld\n\t\t\t\t.addSystem('transform-propagation')\n\t\t\t\t.setPriority(priority)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ ecs }) => {\n\t\t\t\t\tpropagateTransforms(ecs, orphanBuffer, hierarchyVisited);\n\t\t\t\t});\n\t\t});\n}\n\n/**\n * Propagate transforms through the hierarchy.\n * Parent-first traversal ensures parents are computed before children.\n *\n * Runs unconditionally for all entities with transforms — user code can\n * freely mutate localTransform without needing to call markChanged.\n * Only marks worldTransform as changed when values actually differ,\n * so downstream systems (e.g. renderer sync) can skip static entities.\n */\nfunction propagateTransforms(\n\tecs: ECSpresso<WorldConfigFrom<TransformComponentTypes>>,\n\torphanBuffer: Array<import('../../types').FilteredEntity<TransformComponentTypes, 'localTransform' | 'worldTransform'>>,\n\thierarchyVisited: Set<number>,\n): void {\n\tconst em = ecs.entityManager;\n\n\t// Fast path: no hierarchy relationships exist — all entities are flat\n\tif (!em.hasHierarchy) {\n\t\tem.getEntitiesWithQueryInto(orphanBuffer, ['localTransform', 'worldTransform']);\n\t\tfor (const entity of orphanBuffer) {\n\t\t\tconst { localTransform, worldTransform } = entity.components;\n\t\t\tif (copyTransform(localTransform, worldTransform)) {\n\t\t\t\tecs.markChanged(entity.id, 'worldTransform');\n\t\t\t}\n\t\t}\n\t\treturn;\n\t}\n\n\t// Hierarchy exists — use parent-first traversal then process remaining orphans\n\thierarchyVisited.clear();\n\n\tecs.forEachInHierarchy((entityId, parentId) => {\n\t\thierarchyVisited.add(entityId);\n\t\tconst localTransform = em.getComponent(entityId, 'localTransform');\n\t\tconst worldTransform = em.getComponent(entityId, 'worldTransform');\n\n\t\tif (!localTransform || !worldTransform) return;\n\n\t\tconst parentWorld = parentId !== null\n\t\t\t? em.getComponent(parentId, 'worldTransform')\n\t\t\t: null;\n\n\t\tconst changed = parentWorld\n\t\t\t? combineTransforms(parentWorld, localTransform, worldTransform)\n\t\t\t: copyTransform(localTransform, worldTransform);\n\n\t\tif (changed) ecs.markChanged(entityId, 'worldTransform');\n\t});\n\n\tem.getEntitiesWithQueryInto(orphanBuffer, ['localTransform', 'worldTransform']);\n\tfor (const entity of orphanBuffer) {\n\t\tif (hierarchyVisited.has(entity.id)) continue;\n\t\tconst { localTransform, worldTransform } = entity.components;\n\t\tif (copyTransform(localTransform, worldTransform)) {\n\t\t\tecs.markChanged(entity.id, 'worldTransform');\n\t\t}\n\t}\n}\n\n/**\n * Copy transform values from source to destination.\n * Returns true if the destination was actually modified.\n */\nfunction copyTransform(src: LocalTransform, dest: WorldTransform): boolean {\n\tif (dest.x === src.x && dest.y === src.y &&\n\t\tdest.rotation === src.rotation &&\n\t\tdest.scaleX === src.scaleX && dest.scaleY === src.scaleY) {\n\t\treturn false;\n\t}\n\tdest.x = src.x;\n\tdest.y = src.y;\n\tdest.rotation = src.rotation;\n\tdest.scaleX = src.scaleX;\n\tdest.scaleY = src.scaleY;\n\treturn true;\n}\n\n/**\n * Combine parent world transform with child local transform into child world transform.\n * Returns true if the destination was actually modified.\n */\nfunction combineTransforms(\n\tparent: WorldTransform,\n\tlocal: LocalTransform,\n\tworld: WorldTransform\n): boolean {\n\t// Apply parent's scale to local position\n\tconst scaledLocalX = local.x * parent.scaleX;\n\tconst scaledLocalY = local.y * parent.scaleY;\n\n\t// Rotate local position by parent's rotation\n\tconst cos = Math.cos(parent.rotation);\n\tconst sin = Math.sin(parent.rotation);\n\tconst rotatedX = scaledLocalX * cos - scaledLocalY * sin;\n\tconst rotatedY = scaledLocalX * sin + scaledLocalY * cos;\n\n\t// Add to parent's position\n\tconst newX = parent.x + rotatedX;\n\tconst newY = parent.y + rotatedY;\n\tconst newRotation = parent.rotation + local.rotation;\n\tconst newScaleX = parent.scaleX * local.scaleX;\n\tconst newScaleY = parent.scaleY * local.scaleY;\n\n\tif (world.x === newX && world.y === newY &&\n\t\tworld.rotation === newRotation &&\n\t\tworld.scaleX === newScaleX && world.scaleY === newScaleY) {\n\t\treturn false;\n\t}\n\n\tworld.x = newX;\n\tworld.y = newY;\n\tworld.rotation = newRotation;\n\tworld.scaleX = newScaleX;\n\tworld.scaleY = newScaleY;\n\treturn true;\n}\n"
|
|
5
|
+
"/**\n * Transform Plugin for ECSpresso\n *\n * Provides hierarchical transform propagation following Bevy's Transform/GlobalTransform pattern.\n * LocalTransform is modified by user code; WorldTransform is computed automatically.\n *\n * @see https://docs.rs/bevy/latest/bevy/transform/components/struct.GlobalTransform.html\n */\n\nimport { definePlugin, type BasePluginOptions } from 'ecspresso';\nimport type ECSpresso from 'ecspresso';\nimport type { ComponentsConfig } from '../../type-utils';\n\n// ==================== Component Types ====================\n\n/**\n * Local transform relative to parent (or world if no parent).\n * This is the transform you modify directly.\n */\nexport interface LocalTransform {\n\tx: number;\n\ty: number;\n\trotation: number;\n\tscaleX: number;\n\tscaleY: number;\n}\n\n/**\n * Computed world transform (accumulated from parent chain).\n * Read-only - managed by the transform propagation system.\n */\nexport interface WorldTransform {\n\tx: number;\n\ty: number;\n\trotation: number;\n\tscaleX: number;\n\tscaleY: number;\n}\n\n/**\n * Component types provided by the transform plugin.\n * Included automatically via `.withPlugin(createTransformPlugin())`.\n *\n * @example\n * ```typescript\n * const ecs = ECSpresso.create()\n * .withPlugin(createTransformPlugin())\n * .withComponentTypes<{ sprite: Sprite; velocity: { x: number; y: number } }>()\n * .build();\n * ```\n */\nexport interface TransformComponentTypes {\n\tlocalTransform: LocalTransform;\n\tworldTransform: WorldTransform;\n}\n\n/**\n * WorldConfig representing the transform plugin's provided components.\n * Used as the `Requires` type parameter by plugins that depend on transform.\n */\nexport type TransformWorldConfig = ComponentsConfig<TransformComponentTypes>;\n\n// ==================== Plugin Options ====================\n\n/**\n * Configuration options for the transform plugin.\n */\nexport interface TransformPluginOptions<G extends string = 'transform'> extends BasePluginOptions<G> {}\n\n// ==================== Default Values ====================\n\n/**\n * Default local transform values.\n */\nexport const DEFAULT_LOCAL_TRANSFORM: Readonly<LocalTransform> = {\n\tx: 0,\n\ty: 0,\n\trotation: 0,\n\tscaleX: 1,\n\tscaleY: 1,\n};\n\n/**\n * Default world transform values.\n */\nexport const DEFAULT_WORLD_TRANSFORM: Readonly<WorldTransform> = {\n\tx: 0,\n\ty: 0,\n\trotation: 0,\n\tscaleX: 1,\n\tscaleY: 1,\n};\n\n// ==================== Helper Functions ====================\n\n/**\n * Create a local transform component with position only.\n * Uses default rotation (0) and scale (1, 1).\n *\n * @param x The x coordinate\n * @param y The y coordinate\n * @returns Component object suitable for spreading into spawn()\n *\n * @example\n * ```typescript\n * ecs.spawn({\n * ...createLocalTransform(100, 200),\n * sprite,\n * });\n * ```\n */\nexport function createLocalTransform(x: number, y: number): Pick<TransformComponentTypes, 'localTransform'> {\n\treturn {\n\t\tlocalTransform: {\n\t\t\tx,\n\t\t\ty,\n\t\t\trotation: 0,\n\t\t\tscaleX: 1,\n\t\t\tscaleY: 1,\n\t\t},\n\t};\n}\n\n/**\n * Create a world transform component with position only.\n * Typically used alongside createLocalTransform for initial state.\n *\n * @param x The x coordinate\n * @param y The y coordinate\n * @returns Component object suitable for spreading into spawn()\n */\nexport function createWorldTransform(x: number, y: number): Pick<TransformComponentTypes, 'worldTransform'> {\n\treturn {\n\t\tworldTransform: {\n\t\t\tx,\n\t\t\ty,\n\t\t\trotation: 0,\n\t\t\tscaleX: 1,\n\t\t\tscaleY: 1,\n\t\t},\n\t};\n}\n\n/**\n * Options for creating a full transform.\n */\nexport interface TransformOptions {\n\trotation?: number;\n\tscaleX?: number;\n\tscaleY?: number;\n\t/** Uniform scale (overrides scaleX/scaleY if provided) */\n\tscale?: number;\n}\n\n/**\n * Create both local and world transform components.\n * World transform is initialized to match local transform.\n *\n * @param x The x coordinate\n * @param y The y coordinate\n * @param options Optional rotation and scale\n * @returns Component object suitable for spreading into spawn()\n *\n * @example\n * ```typescript\n * ecs.spawn({\n * ...createTransform(100, 200),\n * sprite,\n * });\n *\n * // With rotation and scale\n * ecs.spawn({\n * ...createTransform(100, 200, { rotation: Math.PI / 4, scale: 2 }),\n * sprite,\n * });\n * ```\n */\nexport function createTransform(\n\tx: number,\n\ty: number,\n\toptions?: TransformOptions\n): TransformComponentTypes {\n\tconst scaleX = options?.scale ?? options?.scaleX ?? 1;\n\tconst scaleY = options?.scale ?? options?.scaleY ?? 1;\n\tconst rotation = options?.rotation ?? 0;\n\n\tconst transform = {\n\t\tx,\n\t\ty,\n\t\trotation,\n\t\tscaleX,\n\t\tscaleY,\n\t};\n\n\treturn {\n\t\tlocalTransform: { ...transform },\n\t\tworldTransform: { ...transform },\n\t};\n}\n\n// ==================== Plugin Factory ====================\n\n/**\n * Create a transform plugin for ECSpresso.\n *\n * This plugin provides:\n * - Transform propagation system that computes world transforms from local transforms\n * - Parent-first traversal ensures parents are processed before children\n * - Supports full transform hierarchy (position, rotation, scale)\n *\n * @example\n * ```typescript\n * const ecs = ECSpresso\n * .create<Components, Events, Resources>()\n * .withPlugin(createTransformPlugin())\n * .withPlugin(createPhysics2DPlugin())\n * .build();\n *\n * // Spawn entity with transform\n * ecs.spawn({\n * ...createTransform(100, 200),\n * velocity: { x: 50, y: 0 },\n * });\n * ```\n */\nexport function createTransformPlugin<G extends string = 'transform'>(\n\toptions?: TransformPluginOptions<G>\n) {\n\tconst {\n\t\tsystemGroup = 'transform',\n\t\tpriority = 500,\n\t\tphase = 'postUpdate',\n\t} = options ?? {};\n\n\treturn definePlugin('transform')\n\t\t.withComponentTypes<TransformComponentTypes>()\n\t\t.withLabels<'transform-propagation'>()\n\t\t.withGroups<G>()\n\t\t.install((world) => {\n\t\t\t// localTransform requires worldTransform — initialize from localTransform values\n\t\t\tworld.registerRequired('localTransform', 'worldTransform', (lt) => ({\n\t\t\t\tx: lt.x, y: lt.y, rotation: lt.rotation, scaleX: lt.scaleX, scaleY: lt.scaleY,\n\t\t\t}));\n\n\t\t\tconst orphanBuffer: Array<import('../../types').FilteredEntity<TransformComponentTypes, 'localTransform' | 'worldTransform'>> = [];\n\t\t\tconst hierarchyVisited = new Set<number>();\n\n\t\t\tworld\n\t\t\t\t.addSystem('transform-propagation')\n\t\t\t\t.setPriority(priority)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ ecs }) => {\n\t\t\t\t\tpropagateTransforms(ecs, orphanBuffer, hierarchyVisited);\n\t\t\t\t});\n\t\t});\n}\n\n/**\n * Propagate transforms through the hierarchy.\n * Parent-first traversal ensures parents are computed before children.\n *\n * Runs unconditionally for all entities with transforms — user code can\n * freely mutate localTransform without needing to call markChanged.\n * Only marks worldTransform as changed when values actually differ,\n * so downstream systems (e.g. renderer sync) can skip static entities.\n */\nfunction propagateTransforms(\n\tecs: ECSpresso<ComponentsConfig<TransformComponentTypes>>,\n\torphanBuffer: Array<import('../../types').FilteredEntity<TransformComponentTypes, 'localTransform' | 'worldTransform'>>,\n\thierarchyVisited: Set<number>,\n): void {\n\tconst em = ecs.entityManager;\n\n\t// Fast path: no hierarchy relationships exist — all entities are flat\n\tif (!em.hasHierarchy) {\n\t\tem.getEntitiesWithQueryInto(orphanBuffer, ['localTransform', 'worldTransform']);\n\t\tfor (const entity of orphanBuffer) {\n\t\t\tconst { localTransform, worldTransform } = entity.components;\n\t\t\tif (copyTransform(localTransform, worldTransform)) {\n\t\t\t\tecs.markChanged(entity.id, 'worldTransform');\n\t\t\t}\n\t\t}\n\t\treturn;\n\t}\n\n\t// Hierarchy exists — use parent-first traversal then process remaining orphans\n\thierarchyVisited.clear();\n\n\tecs.forEachInHierarchy((entityId, parentId) => {\n\t\thierarchyVisited.add(entityId);\n\t\tconst localTransform = em.getComponent(entityId, 'localTransform');\n\t\tconst worldTransform = em.getComponent(entityId, 'worldTransform');\n\n\t\tif (!localTransform || !worldTransform) return;\n\n\t\tconst parentWorld = parentId !== null\n\t\t\t? em.getComponent(parentId, 'worldTransform')\n\t\t\t: null;\n\n\t\tconst changed = parentWorld\n\t\t\t? combineTransforms(parentWorld, localTransform, worldTransform)\n\t\t\t: copyTransform(localTransform, worldTransform);\n\n\t\tif (changed) ecs.markChanged(entityId, 'worldTransform');\n\t});\n\n\tem.getEntitiesWithQueryInto(orphanBuffer, ['localTransform', 'worldTransform']);\n\tfor (const entity of orphanBuffer) {\n\t\tif (hierarchyVisited.has(entity.id)) continue;\n\t\tconst { localTransform, worldTransform } = entity.components;\n\t\tif (copyTransform(localTransform, worldTransform)) {\n\t\t\tecs.markChanged(entity.id, 'worldTransform');\n\t\t}\n\t}\n}\n\n/**\n * Copy transform values from source to destination.\n * Returns true if the destination was actually modified.\n */\nfunction copyTransform(src: LocalTransform, dest: WorldTransform): boolean {\n\tif (dest.x === src.x && dest.y === src.y &&\n\t\tdest.rotation === src.rotation &&\n\t\tdest.scaleX === src.scaleX && dest.scaleY === src.scaleY) {\n\t\treturn false;\n\t}\n\tdest.x = src.x;\n\tdest.y = src.y;\n\tdest.rotation = src.rotation;\n\tdest.scaleX = src.scaleX;\n\tdest.scaleY = src.scaleY;\n\treturn true;\n}\n\n/**\n * Combine parent world transform with child local transform into child world transform.\n * Returns true if the destination was actually modified.\n */\nfunction combineTransforms(\n\tparent: WorldTransform,\n\tlocal: LocalTransform,\n\tworld: WorldTransform\n): boolean {\n\t// Apply parent's scale to local position\n\tconst scaledLocalX = local.x * parent.scaleX;\n\tconst scaledLocalY = local.y * parent.scaleY;\n\n\t// Rotate local position by parent's rotation\n\tconst cos = Math.cos(parent.rotation);\n\tconst sin = Math.sin(parent.rotation);\n\tconst rotatedX = scaledLocalX * cos - scaledLocalY * sin;\n\tconst rotatedY = scaledLocalX * sin + scaledLocalY * cos;\n\n\t// Add to parent's position\n\tconst newX = parent.x + rotatedX;\n\tconst newY = parent.y + rotatedY;\n\tconst newRotation = parent.rotation + local.rotation;\n\tconst newScaleX = parent.scaleX * local.scaleX;\n\tconst newScaleY = parent.scaleY * local.scaleY;\n\n\tif (world.x === newX && world.y === newY &&\n\t\tworld.rotation === newRotation &&\n\t\tworld.scaleX === newScaleX && world.scaleY === newScaleY) {\n\t\treturn false;\n\t}\n\n\tworld.x = newX;\n\tworld.y = newY;\n\tworld.rotation = newRotation;\n\tworld.scaleX = newScaleX;\n\tworld.scaleY = newScaleY;\n\treturn true;\n}\n"
|
|
6
6
|
],
|
|
7
7
|
"mappings": "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",
|
|
8
8
|
"debugId": "DD4C20CA5FEDDFB464756E2164756E21",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* then converts back to Euler for storage.
|
|
10
10
|
*/
|
|
11
11
|
import { type BasePluginOptions } from 'ecspresso';
|
|
12
|
-
import type {
|
|
12
|
+
import type { ComponentsConfig } from '../../type-utils';
|
|
13
13
|
/**
|
|
14
14
|
* 3D local transform relative to parent (or world if no parent).
|
|
15
15
|
* This is the transform you modify directly.
|
|
@@ -54,7 +54,7 @@ export interface Transform3DComponentTypes {
|
|
|
54
54
|
* WorldConfig representing the 3D transform plugin's provided components.
|
|
55
55
|
* Used as the `Requires` type parameter by plugins that depend on transform3D.
|
|
56
56
|
*/
|
|
57
|
-
export type Transform3DWorldConfig =
|
|
57
|
+
export type Transform3DWorldConfig = ComponentsConfig<Transform3DComponentTypes>;
|
|
58
58
|
/**
|
|
59
59
|
* Configuration options for the 3D transform plugin.
|
|
60
60
|
*/
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/plugins/spatial/transform3D.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"/**\n * 3D Transform Plugin for ECSpresso\n *\n * Provides hierarchical 3D transform propagation following Bevy's Transform/GlobalTransform pattern.\n * LocalTransform3D is modified by user code; WorldTransform3D is computed automatically.\n *\n * Rotation is stored as Euler angles (radians, XYZ intrinsic order matching Three.js defaults).\n * Hierarchical composition converts to quaternions internally for correct rotation composition,\n * then converts back to Euler for storage.\n */\n\nimport { definePlugin, type BasePluginOptions } from 'ecspresso';\nimport type ECSpresso from 'ecspresso';\nimport type { WorldConfigFrom } from '../../type-utils';\n\n// ==================== Component Types ====================\n\n/**\n * 3D local transform relative to parent (or world if no parent).\n * This is the transform you modify directly.\n *\n * Rotation is in radians, using XYZ intrinsic Euler order (Three.js default).\n */\nexport interface LocalTransform3D {\n\tx: number;\n\ty: number;\n\tz: number;\n\trx: number;\n\try: number;\n\trz: number;\n\tsx: number;\n\tsy: number;\n\tsz: number;\n}\n\n/**\n * Computed 3D world transform (accumulated from parent chain).\n * Read-only — managed by the transform propagation system.\n */\nexport interface WorldTransform3D {\n\tx: number;\n\ty: number;\n\tz: number;\n\trx: number;\n\try: number;\n\trz: number;\n\tsx: number;\n\tsy: number;\n\tsz: number;\n}\n\n/**\n * Component types provided by the 3D transform plugin.\n * Included automatically via `.withPlugin(createTransform3DPlugin())`.\n */\nexport interface Transform3DComponentTypes {\n\tlocalTransform3D: LocalTransform3D;\n\tworldTransform3D: WorldTransform3D;\n}\n\n/**\n * WorldConfig representing the 3D transform plugin's provided components.\n * Used as the `Requires` type parameter by plugins that depend on transform3D.\n */\nexport type Transform3DWorldConfig = WorldConfigFrom<Transform3DComponentTypes>;\n\n// ==================== Plugin Options ====================\n\n/**\n * Configuration options for the 3D transform plugin.\n */\nexport interface Transform3DPluginOptions<G extends string = 'transform3d'> extends BasePluginOptions<G> {}\n\n// ==================== Default Values ====================\n\n/**\n * Default local 3D transform values.\n */\nexport const DEFAULT_LOCAL_TRANSFORM_3D: Readonly<LocalTransform3D> = {\n\tx: 0, y: 0, z: 0,\n\trx: 0, ry: 0, rz: 0,\n\tsx: 1, sy: 1, sz: 1,\n};\n\n/**\n * Default world 3D transform values.\n */\nexport const DEFAULT_WORLD_TRANSFORM_3D: Readonly<WorldTransform3D> = {\n\tx: 0, y: 0, z: 0,\n\trx: 0, ry: 0, rz: 0,\n\tsx: 1, sy: 1, sz: 1,\n};\n\n// ==================== Helper Functions ====================\n\n/**\n * Create a local 3D transform component with position only.\n * Uses default rotation (0, 0, 0) and scale (1, 1, 1).\n *\n * @example\n * ```typescript\n * ecs.spawn({\n * ...createLocalTransform3D(10, 5, -20),\n * mesh: myMesh,\n * });\n * ```\n */\nexport function createLocalTransform3D(\n\tx: number,\n\ty: number,\n\tz: number,\n): Pick<Transform3DComponentTypes, 'localTransform3D'> {\n\treturn {\n\t\tlocalTransform3D: { x, y, z, rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1 },\n\t};\n}\n\n/**\n * Create a world 3D transform component with position only.\n * Typically used alongside createLocalTransform3D for initial state.\n */\nexport function createWorldTransform3D(\n\tx: number,\n\ty: number,\n\tz: number,\n): Pick<Transform3DComponentTypes, 'worldTransform3D'> {\n\treturn {\n\t\tworldTransform3D: { x, y, z, rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1 },\n\t};\n}\n\n/**\n * Options for creating a full 3D transform.\n */\nexport interface Transform3DOptions {\n\trotation?: { x?: number; y?: number; z?: number };\n\tscaleX?: number;\n\tscaleY?: number;\n\tscaleZ?: number;\n\t/** Uniform scale (overrides scaleX/scaleY/scaleZ if provided) */\n\tscale?: number;\n}\n\n/**\n * Create both local and world 3D transform components.\n * World transform is initialized to match local transform.\n *\n * @example\n * ```typescript\n * ecs.spawn({\n * ...createTransform3D(10, 5, -20),\n * mesh: myMesh,\n * });\n *\n * // With rotation and scale\n * ecs.spawn({\n * ...createTransform3D(10, 5, -20, {\n * rotation: { y: Math.PI / 4 },\n * scale: 2,\n * }),\n * mesh: myMesh,\n * });\n * ```\n */\nexport function createTransform3D(\n\tx: number,\n\ty: number,\n\tz: number,\n\toptions?: Transform3DOptions,\n): Transform3DComponentTypes {\n\tconst sx = options?.scale ?? options?.scaleX ?? 1;\n\tconst sy = options?.scale ?? options?.scaleY ?? 1;\n\tconst sz = options?.scale ?? options?.scaleZ ?? 1;\n\tconst rx = options?.rotation?.x ?? 0;\n\tconst ry = options?.rotation?.y ?? 0;\n\tconst rz = options?.rotation?.z ?? 0;\n\n\tconst transform = { x, y, z, rx, ry, rz, sx, sy, sz };\n\n\treturn {\n\t\tlocalTransform3D: { ...transform },\n\t\tworldTransform3D: { ...transform },\n\t};\n}\n\n// ==================== Quaternion Math (Internal) ====================\n// Inlined quaternion operations to avoid Three.js dependency in the transform plugin.\n// Uses XYZ intrinsic Euler order to match Three.js Object3D.rotation defaults.\n// Pre-allocated scratch arrays avoid GC pressure in the hot path.\n\n// Scratch quaternion struct to avoid allocation in hot path\ninterface Quat { x: number; y: number; z: number; w: number }\nconst qP: Quat = { x: 0, y: 0, z: 0, w: 1 }; // parent\nconst qL: Quat = { x: 0, y: 0, z: 0, w: 1 }; // local\nconst qW: Quat = { x: 0, y: 0, z: 0, w: 1 }; // world (result)\n\n/**\n * Convert Euler angles (XYZ intrinsic order) to quaternion.\n * Writes result into the `out` object.\n */\nfunction eulerToQuat(rx: number, ry: number, rz: number, out: Quat): void {\n\tconst cx = Math.cos(rx * 0.5);\n\tconst srx = Math.sin(rx * 0.5);\n\tconst cy = Math.cos(ry * 0.5);\n\tconst sy = Math.sin(ry * 0.5);\n\tconst cz = Math.cos(rz * 0.5);\n\tconst sz = Math.sin(rz * 0.5);\n\n\t// XYZ intrinsic = ZYX extrinsic\n\tout.x = srx * cy * cz + cx * sy * sz;\n\tout.y = cx * sy * cz - srx * cy * sz;\n\tout.z = cx * cy * sz + srx * sy * cz;\n\tout.w = cx * cy * cz - srx * sy * sz;\n}\n\n/**\n * Multiply two quaternions: out = a * b\n */\nfunction quatMultiply(a: Quat, b: Quat, out: Quat): void {\n\tout.x = a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y;\n\tout.y = a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x;\n\tout.z = a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w;\n\tout.w = a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z;\n}\n\n// Scratch vec3 for quatRotateVec to avoid per-call allocation\ninterface Vec3 { x: number; y: number; z: number }\nconst vecOut: Vec3 = { x: 0, y: 0, z: 0 };\n\n// Scratch euler for quatToEuler to avoid per-call allocation\ninterface Euler3 { rx: number; ry: number; rz: number }\nconst eulerOut: Euler3 = { rx: 0, ry: 0, rz: 0 };\n\n/**\n * Rotate a vector by quaternion q. Writes result into module-scoped `vecOut`.\n */\nfunction quatRotateVec(\n\tq: Quat,\n\tvx: number,\n\tvy: number,\n\tvz: number,\n): Vec3 {\n\tconst tx = 2 * (q.y * vz - q.z * vy);\n\tconst ty = 2 * (q.z * vx - q.x * vz);\n\tconst tz = 2 * (q.x * vy - q.y * vx);\n\n\tvecOut.x = vx + q.w * tx + (q.y * tz - q.z * ty);\n\tvecOut.y = vy + q.w * ty + (q.z * tx - q.x * tz);\n\tvecOut.z = vz + q.w * tz + (q.x * ty - q.y * tx);\n\treturn vecOut;\n}\n\n/**\n * Convert quaternion to Euler angles (XYZ intrinsic order).\n * Writes result into module-scoped `eulerOut`.\n */\nfunction quatToEuler(q: Quat): Euler3 {\n\tconst x2 = q.x + q.x, y2 = q.y + q.y, z2 = q.z + q.z;\n\tconst xx = q.x * x2, xy = q.x * y2, xz = q.x * z2;\n\tconst yy = q.y * y2, yz = q.y * z2, zz = q.z * z2;\n\tconst wx = q.w * x2, wy = q.w * y2, wz = q.w * z2;\n\n\tconst m11 = 1 - (yy + zz);\n\tconst m12 = xy - wz;\n\tconst m13 = xz + wy;\n\tconst m22 = 1 - (xx + zz);\n\tconst m23 = yz - wx;\n\tconst m33 = 1 - (xx + yy);\n\n\teulerOut.ry = Math.asin(Math.max(-1, Math.min(1, m13)));\n\tconst cosY = Math.cos(eulerOut.ry);\n\n\tif (Math.abs(cosY) > 1e-6) {\n\t\teulerOut.rx = Math.atan2(-m23, m33);\n\t\teulerOut.rz = Math.atan2(-m12, m11);\n\t} else {\n\t\t// Gimbal lock fallback\n\t\tconst m21 = xy + wz;\n\t\teulerOut.rx = Math.atan2(m21, m22);\n\t\teulerOut.rz = 0;\n\t}\n\treturn eulerOut;\n}\n\n// ==================== Plugin Factory ====================\n\n/**\n * Create a 3D transform plugin for ECSpresso.\n *\n * This plugin provides:\n * - 3D transform propagation system that computes world transforms from local transforms\n * - Parent-first traversal ensures parents are processed before children\n * - Supports full 3D transform hierarchy (position, rotation, scale)\n * - Rotation composed via quaternions internally for correctness\n *\n * @example\n * ```typescript\n * const ecs = ECSpresso.create()\n * .withPlugin(createTransform3DPlugin())\n * .withComponentTypes<{ velocity: { x: number; y: number; z: number } }>()\n * .build();\n *\n * ecs.spawn({\n * ...createTransform3D(10, 5, -20),\n * velocity: { x: 1, y: 0, z: 0 },\n * });\n * ```\n */\nexport function createTransform3DPlugin<G extends string = 'transform3d'>(\n\toptions?: Transform3DPluginOptions<G>,\n) {\n\tconst {\n\t\tsystemGroup = 'transform3d',\n\t\tpriority = 500,\n\t\tphase = 'postUpdate',\n\t} = options ?? {};\n\n\treturn definePlugin('transform3d')\n\t\t.withComponentTypes<Transform3DComponentTypes>()\n\t\t.withLabels<'transform3d-propagation'>()\n\t\t.withGroups<G>()\n\t\t.install((world) => {\n\t\t\t// localTransform3D requires worldTransform3D — initialize from localTransform3D values\n\t\t\tworld.registerRequired('localTransform3D', 'worldTransform3D', (lt) => ({\n\t\t\t\tx: lt.x, y: lt.y, z: lt.z,\n\t\t\t\trx: lt.rx, ry: lt.ry, rz: lt.rz,\n\t\t\t\tsx: lt.sx, sy: lt.sy, sz: lt.sz,\n\t\t\t}));\n\n\t\t\tconst orphanBuffer: Array<import('../../types').FilteredEntity<Transform3DComponentTypes, 'localTransform3D' | 'worldTransform3D'>> = [];\n\t\t\tconst hierarchyVisited = new Set<number>();\n\n\t\t\tworld\n\t\t\t\t.addSystem('transform3d-propagation')\n\t\t\t\t.setPriority(priority)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ ecs }) => {\n\t\t\t\t\tpropagateTransforms3D(ecs, orphanBuffer, hierarchyVisited);\n\t\t\t\t});\n\t\t});\n}\n\n// ==================== Propagation ====================\n\n/**\n * Propagate 3D transforms through the hierarchy.\n * Parent-first traversal ensures parents are computed before children.\n */\nfunction propagateTransforms3D(\n\tecs: ECSpresso<WorldConfigFrom<Transform3DComponentTypes>>,\n\torphanBuffer: Array<import('../../types').FilteredEntity<Transform3DComponentTypes, 'localTransform3D' | 'worldTransform3D'>>,\n\thierarchyVisited: Set<number>,\n): void {\n\tconst em = ecs.entityManager;\n\n\t// Fast path: no hierarchy relationships exist — all entities are flat\n\tif (!em.hasHierarchy) {\n\t\tem.getEntitiesWithQueryInto(orphanBuffer, ['localTransform3D', 'worldTransform3D']);\n\t\tfor (const entity of orphanBuffer) {\n\t\t\tconst { localTransform3D, worldTransform3D } = entity.components;\n\t\t\tif (copyTransform3D(localTransform3D, worldTransform3D)) {\n\t\t\t\tecs.markChanged(entity.id, 'worldTransform3D');\n\t\t\t}\n\t\t}\n\t\treturn;\n\t}\n\n\t// Hierarchy exists — use parent-first traversal then process remaining orphans\n\thierarchyVisited.clear();\n\n\tecs.forEachInHierarchy((entityId, parentId) => {\n\t\thierarchyVisited.add(entityId);\n\t\tconst localTransform3D = em.getComponent(entityId, 'localTransform3D');\n\t\tconst worldTransform3D = em.getComponent(entityId, 'worldTransform3D');\n\n\t\tif (!localTransform3D || !worldTransform3D) return;\n\n\t\tconst parentWorld = parentId !== null\n\t\t\t? em.getComponent(parentId, 'worldTransform3D')\n\t\t\t: null;\n\n\t\tconst changed = parentWorld\n\t\t\t? combineTransforms3D(parentWorld, localTransform3D, worldTransform3D)\n\t\t\t: copyTransform3D(localTransform3D, worldTransform3D);\n\n\t\tif (changed) ecs.markChanged(entityId, 'worldTransform3D');\n\t});\n\n\tem.getEntitiesWithQueryInto(orphanBuffer, ['localTransform3D', 'worldTransform3D']);\n\tfor (const entity of orphanBuffer) {\n\t\tif (hierarchyVisited.has(entity.id)) continue;\n\t\tconst { localTransform3D, worldTransform3D } = entity.components;\n\t\tif (copyTransform3D(localTransform3D, worldTransform3D)) {\n\t\t\tecs.markChanged(entity.id, 'worldTransform3D');\n\t\t}\n\t}\n}\n\n/**\n * Copy 3D transform values from source to destination.\n * Returns true if the destination was actually modified.\n */\nfunction copyTransform3D(src: LocalTransform3D, dest: WorldTransform3D): boolean {\n\tif (\n\t\tdest.x === src.x && dest.y === src.y && dest.z === src.z &&\n\t\tdest.rx === src.rx && dest.ry === src.ry && dest.rz === src.rz &&\n\t\tdest.sx === src.sx && dest.sy === src.sy && dest.sz === src.sz\n\t) {\n\t\treturn false;\n\t}\n\tdest.x = src.x;\n\tdest.y = src.y;\n\tdest.z = src.z;\n\tdest.rx = src.rx;\n\tdest.ry = src.ry;\n\tdest.rz = src.rz;\n\tdest.sx = src.sx;\n\tdest.sy = src.sy;\n\tdest.sz = src.sz;\n\treturn true;\n}\n\n/**\n * Combine parent world transform with child local transform into child world transform.\n * Uses quaternion math internally for correct rotation composition.\n * Returns true if the destination was actually modified.\n */\nfunction combineTransforms3D(\n\tparent: WorldTransform3D,\n\tlocal: LocalTransform3D,\n\tworld: WorldTransform3D,\n): boolean {\n\t// Convert parent and local rotations to quaternions\n\teulerToQuat(parent.rx, parent.ry, parent.rz, qP);\n\teulerToQuat(local.rx, local.ry, local.rz, qL);\n\n\t// Compose rotations: worldQuat = parentQuat * localQuat\n\tquatMultiply(qP, qL, qW);\n\n\t// Convert back to Euler (writes into eulerOut scratch)\n\tconst euler = quatToEuler(qW);\n\n\t// Apply parent scale to local position, rotate by parent rotation (writes into vecOut scratch)\n\tconst rotated = quatRotateVec(qP, local.x * parent.sx, local.y * parent.sy, local.z * parent.sz);\n\n\t// Compute final world values\n\tconst newX = parent.x + rotated.x;\n\tconst newY = parent.y + rotated.y;\n\tconst newZ = parent.z + rotated.z;\n\tconst newSx = parent.sx * local.sx;\n\tconst newSy = parent.sy * local.sy;\n\tconst newSz = parent.sz * local.sz;\n\n\tif (\n\t\tworld.x === newX && world.y === newY && world.z === newZ &&\n\t\tworld.rx === euler.rx && world.ry === euler.ry && world.rz === euler.rz &&\n\t\tworld.sx === newSx && world.sy === newSy && world.sz === newSz\n\t) {\n\t\treturn false;\n\t}\n\n\tworld.x = newX;\n\tworld.y = newY;\n\tworld.z = newZ;\n\tworld.rx = euler.rx;\n\tworld.ry = euler.ry;\n\tworld.rz = euler.rz;\n\tworld.sx = newSx;\n\tworld.sy = newSy;\n\tworld.sz = newSz;\n\treturn true;\n}\n"
|
|
5
|
+
"/**\n * 3D Transform Plugin for ECSpresso\n *\n * Provides hierarchical 3D transform propagation following Bevy's Transform/GlobalTransform pattern.\n * LocalTransform3D is modified by user code; WorldTransform3D is computed automatically.\n *\n * Rotation is stored as Euler angles (radians, XYZ intrinsic order matching Three.js defaults).\n * Hierarchical composition converts to quaternions internally for correct rotation composition,\n * then converts back to Euler for storage.\n */\n\nimport { definePlugin, type BasePluginOptions } from 'ecspresso';\nimport type ECSpresso from 'ecspresso';\nimport type { ComponentsConfig } from '../../type-utils';\n\n// ==================== Component Types ====================\n\n/**\n * 3D local transform relative to parent (or world if no parent).\n * This is the transform you modify directly.\n *\n * Rotation is in radians, using XYZ intrinsic Euler order (Three.js default).\n */\nexport interface LocalTransform3D {\n\tx: number;\n\ty: number;\n\tz: number;\n\trx: number;\n\try: number;\n\trz: number;\n\tsx: number;\n\tsy: number;\n\tsz: number;\n}\n\n/**\n * Computed 3D world transform (accumulated from parent chain).\n * Read-only — managed by the transform propagation system.\n */\nexport interface WorldTransform3D {\n\tx: number;\n\ty: number;\n\tz: number;\n\trx: number;\n\try: number;\n\trz: number;\n\tsx: number;\n\tsy: number;\n\tsz: number;\n}\n\n/**\n * Component types provided by the 3D transform plugin.\n * Included automatically via `.withPlugin(createTransform3DPlugin())`.\n */\nexport interface Transform3DComponentTypes {\n\tlocalTransform3D: LocalTransform3D;\n\tworldTransform3D: WorldTransform3D;\n}\n\n/**\n * WorldConfig representing the 3D transform plugin's provided components.\n * Used as the `Requires` type parameter by plugins that depend on transform3D.\n */\nexport type Transform3DWorldConfig = ComponentsConfig<Transform3DComponentTypes>;\n\n// ==================== Plugin Options ====================\n\n/**\n * Configuration options for the 3D transform plugin.\n */\nexport interface Transform3DPluginOptions<G extends string = 'transform3d'> extends BasePluginOptions<G> {}\n\n// ==================== Default Values ====================\n\n/**\n * Default local 3D transform values.\n */\nexport const DEFAULT_LOCAL_TRANSFORM_3D: Readonly<LocalTransform3D> = {\n\tx: 0, y: 0, z: 0,\n\trx: 0, ry: 0, rz: 0,\n\tsx: 1, sy: 1, sz: 1,\n};\n\n/**\n * Default world 3D transform values.\n */\nexport const DEFAULT_WORLD_TRANSFORM_3D: Readonly<WorldTransform3D> = {\n\tx: 0, y: 0, z: 0,\n\trx: 0, ry: 0, rz: 0,\n\tsx: 1, sy: 1, sz: 1,\n};\n\n// ==================== Helper Functions ====================\n\n/**\n * Create a local 3D transform component with position only.\n * Uses default rotation (0, 0, 0) and scale (1, 1, 1).\n *\n * @example\n * ```typescript\n * ecs.spawn({\n * ...createLocalTransform3D(10, 5, -20),\n * mesh: myMesh,\n * });\n * ```\n */\nexport function createLocalTransform3D(\n\tx: number,\n\ty: number,\n\tz: number,\n): Pick<Transform3DComponentTypes, 'localTransform3D'> {\n\treturn {\n\t\tlocalTransform3D: { x, y, z, rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1 },\n\t};\n}\n\n/**\n * Create a world 3D transform component with position only.\n * Typically used alongside createLocalTransform3D for initial state.\n */\nexport function createWorldTransform3D(\n\tx: number,\n\ty: number,\n\tz: number,\n): Pick<Transform3DComponentTypes, 'worldTransform3D'> {\n\treturn {\n\t\tworldTransform3D: { x, y, z, rx: 0, ry: 0, rz: 0, sx: 1, sy: 1, sz: 1 },\n\t};\n}\n\n/**\n * Options for creating a full 3D transform.\n */\nexport interface Transform3DOptions {\n\trotation?: { x?: number; y?: number; z?: number };\n\tscaleX?: number;\n\tscaleY?: number;\n\tscaleZ?: number;\n\t/** Uniform scale (overrides scaleX/scaleY/scaleZ if provided) */\n\tscale?: number;\n}\n\n/**\n * Create both local and world 3D transform components.\n * World transform is initialized to match local transform.\n *\n * @example\n * ```typescript\n * ecs.spawn({\n * ...createTransform3D(10, 5, -20),\n * mesh: myMesh,\n * });\n *\n * // With rotation and scale\n * ecs.spawn({\n * ...createTransform3D(10, 5, -20, {\n * rotation: { y: Math.PI / 4 },\n * scale: 2,\n * }),\n * mesh: myMesh,\n * });\n * ```\n */\nexport function createTransform3D(\n\tx: number,\n\ty: number,\n\tz: number,\n\toptions?: Transform3DOptions,\n): Transform3DComponentTypes {\n\tconst sx = options?.scale ?? options?.scaleX ?? 1;\n\tconst sy = options?.scale ?? options?.scaleY ?? 1;\n\tconst sz = options?.scale ?? options?.scaleZ ?? 1;\n\tconst rx = options?.rotation?.x ?? 0;\n\tconst ry = options?.rotation?.y ?? 0;\n\tconst rz = options?.rotation?.z ?? 0;\n\n\tconst transform = { x, y, z, rx, ry, rz, sx, sy, sz };\n\n\treturn {\n\t\tlocalTransform3D: { ...transform },\n\t\tworldTransform3D: { ...transform },\n\t};\n}\n\n// ==================== Quaternion Math (Internal) ====================\n// Inlined quaternion operations to avoid Three.js dependency in the transform plugin.\n// Uses XYZ intrinsic Euler order to match Three.js Object3D.rotation defaults.\n// Pre-allocated scratch arrays avoid GC pressure in the hot path.\n\n// Scratch quaternion struct to avoid allocation in hot path\ninterface Quat { x: number; y: number; z: number; w: number }\nconst qP: Quat = { x: 0, y: 0, z: 0, w: 1 }; // parent\nconst qL: Quat = { x: 0, y: 0, z: 0, w: 1 }; // local\nconst qW: Quat = { x: 0, y: 0, z: 0, w: 1 }; // world (result)\n\n/**\n * Convert Euler angles (XYZ intrinsic order) to quaternion.\n * Writes result into the `out` object.\n */\nfunction eulerToQuat(rx: number, ry: number, rz: number, out: Quat): void {\n\tconst cx = Math.cos(rx * 0.5);\n\tconst srx = Math.sin(rx * 0.5);\n\tconst cy = Math.cos(ry * 0.5);\n\tconst sy = Math.sin(ry * 0.5);\n\tconst cz = Math.cos(rz * 0.5);\n\tconst sz = Math.sin(rz * 0.5);\n\n\t// XYZ intrinsic = ZYX extrinsic\n\tout.x = srx * cy * cz + cx * sy * sz;\n\tout.y = cx * sy * cz - srx * cy * sz;\n\tout.z = cx * cy * sz + srx * sy * cz;\n\tout.w = cx * cy * cz - srx * sy * sz;\n}\n\n/**\n * Multiply two quaternions: out = a * b\n */\nfunction quatMultiply(a: Quat, b: Quat, out: Quat): void {\n\tout.x = a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y;\n\tout.y = a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x;\n\tout.z = a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w;\n\tout.w = a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z;\n}\n\n// Scratch vec3 for quatRotateVec to avoid per-call allocation\ninterface Vec3 { x: number; y: number; z: number }\nconst vecOut: Vec3 = { x: 0, y: 0, z: 0 };\n\n// Scratch euler for quatToEuler to avoid per-call allocation\ninterface Euler3 { rx: number; ry: number; rz: number }\nconst eulerOut: Euler3 = { rx: 0, ry: 0, rz: 0 };\n\n/**\n * Rotate a vector by quaternion q. Writes result into module-scoped `vecOut`.\n */\nfunction quatRotateVec(\n\tq: Quat,\n\tvx: number,\n\tvy: number,\n\tvz: number,\n): Vec3 {\n\tconst tx = 2 * (q.y * vz - q.z * vy);\n\tconst ty = 2 * (q.z * vx - q.x * vz);\n\tconst tz = 2 * (q.x * vy - q.y * vx);\n\n\tvecOut.x = vx + q.w * tx + (q.y * tz - q.z * ty);\n\tvecOut.y = vy + q.w * ty + (q.z * tx - q.x * tz);\n\tvecOut.z = vz + q.w * tz + (q.x * ty - q.y * tx);\n\treturn vecOut;\n}\n\n/**\n * Convert quaternion to Euler angles (XYZ intrinsic order).\n * Writes result into module-scoped `eulerOut`.\n */\nfunction quatToEuler(q: Quat): Euler3 {\n\tconst x2 = q.x + q.x, y2 = q.y + q.y, z2 = q.z + q.z;\n\tconst xx = q.x * x2, xy = q.x * y2, xz = q.x * z2;\n\tconst yy = q.y * y2, yz = q.y * z2, zz = q.z * z2;\n\tconst wx = q.w * x2, wy = q.w * y2, wz = q.w * z2;\n\n\tconst m11 = 1 - (yy + zz);\n\tconst m12 = xy - wz;\n\tconst m13 = xz + wy;\n\tconst m22 = 1 - (xx + zz);\n\tconst m23 = yz - wx;\n\tconst m33 = 1 - (xx + yy);\n\n\teulerOut.ry = Math.asin(Math.max(-1, Math.min(1, m13)));\n\tconst cosY = Math.cos(eulerOut.ry);\n\n\tif (Math.abs(cosY) > 1e-6) {\n\t\teulerOut.rx = Math.atan2(-m23, m33);\n\t\teulerOut.rz = Math.atan2(-m12, m11);\n\t} else {\n\t\t// Gimbal lock fallback\n\t\tconst m21 = xy + wz;\n\t\teulerOut.rx = Math.atan2(m21, m22);\n\t\teulerOut.rz = 0;\n\t}\n\treturn eulerOut;\n}\n\n// ==================== Plugin Factory ====================\n\n/**\n * Create a 3D transform plugin for ECSpresso.\n *\n * This plugin provides:\n * - 3D transform propagation system that computes world transforms from local transforms\n * - Parent-first traversal ensures parents are processed before children\n * - Supports full 3D transform hierarchy (position, rotation, scale)\n * - Rotation composed via quaternions internally for correctness\n *\n * @example\n * ```typescript\n * const ecs = ECSpresso.create()\n * .withPlugin(createTransform3DPlugin())\n * .withComponentTypes<{ velocity: { x: number; y: number; z: number } }>()\n * .build();\n *\n * ecs.spawn({\n * ...createTransform3D(10, 5, -20),\n * velocity: { x: 1, y: 0, z: 0 },\n * });\n * ```\n */\nexport function createTransform3DPlugin<G extends string = 'transform3d'>(\n\toptions?: Transform3DPluginOptions<G>,\n) {\n\tconst {\n\t\tsystemGroup = 'transform3d',\n\t\tpriority = 500,\n\t\tphase = 'postUpdate',\n\t} = options ?? {};\n\n\treturn definePlugin('transform3d')\n\t\t.withComponentTypes<Transform3DComponentTypes>()\n\t\t.withLabels<'transform3d-propagation'>()\n\t\t.withGroups<G>()\n\t\t.install((world) => {\n\t\t\t// localTransform3D requires worldTransform3D — initialize from localTransform3D values\n\t\t\tworld.registerRequired('localTransform3D', 'worldTransform3D', (lt) => ({\n\t\t\t\tx: lt.x, y: lt.y, z: lt.z,\n\t\t\t\trx: lt.rx, ry: lt.ry, rz: lt.rz,\n\t\t\t\tsx: lt.sx, sy: lt.sy, sz: lt.sz,\n\t\t\t}));\n\n\t\t\tconst orphanBuffer: Array<import('../../types').FilteredEntity<Transform3DComponentTypes, 'localTransform3D' | 'worldTransform3D'>> = [];\n\t\t\tconst hierarchyVisited = new Set<number>();\n\n\t\t\tworld\n\t\t\t\t.addSystem('transform3d-propagation')\n\t\t\t\t.setPriority(priority)\n\t\t\t\t.inPhase(phase)\n\t\t\t\t.inGroup(systemGroup)\n\t\t\t\t.setProcess(({ ecs }) => {\n\t\t\t\t\tpropagateTransforms3D(ecs, orphanBuffer, hierarchyVisited);\n\t\t\t\t});\n\t\t});\n}\n\n// ==================== Propagation ====================\n\n/**\n * Propagate 3D transforms through the hierarchy.\n * Parent-first traversal ensures parents are computed before children.\n */\nfunction propagateTransforms3D(\n\tecs: ECSpresso<ComponentsConfig<Transform3DComponentTypes>>,\n\torphanBuffer: Array<import('../../types').FilteredEntity<Transform3DComponentTypes, 'localTransform3D' | 'worldTransform3D'>>,\n\thierarchyVisited: Set<number>,\n): void {\n\tconst em = ecs.entityManager;\n\n\t// Fast path: no hierarchy relationships exist — all entities are flat\n\tif (!em.hasHierarchy) {\n\t\tem.getEntitiesWithQueryInto(orphanBuffer, ['localTransform3D', 'worldTransform3D']);\n\t\tfor (const entity of orphanBuffer) {\n\t\t\tconst { localTransform3D, worldTransform3D } = entity.components;\n\t\t\tif (copyTransform3D(localTransform3D, worldTransform3D)) {\n\t\t\t\tecs.markChanged(entity.id, 'worldTransform3D');\n\t\t\t}\n\t\t}\n\t\treturn;\n\t}\n\n\t// Hierarchy exists — use parent-first traversal then process remaining orphans\n\thierarchyVisited.clear();\n\n\tecs.forEachInHierarchy((entityId, parentId) => {\n\t\thierarchyVisited.add(entityId);\n\t\tconst localTransform3D = em.getComponent(entityId, 'localTransform3D');\n\t\tconst worldTransform3D = em.getComponent(entityId, 'worldTransform3D');\n\n\t\tif (!localTransform3D || !worldTransform3D) return;\n\n\t\tconst parentWorld = parentId !== null\n\t\t\t? em.getComponent(parentId, 'worldTransform3D')\n\t\t\t: null;\n\n\t\tconst changed = parentWorld\n\t\t\t? combineTransforms3D(parentWorld, localTransform3D, worldTransform3D)\n\t\t\t: copyTransform3D(localTransform3D, worldTransform3D);\n\n\t\tif (changed) ecs.markChanged(entityId, 'worldTransform3D');\n\t});\n\n\tem.getEntitiesWithQueryInto(orphanBuffer, ['localTransform3D', 'worldTransform3D']);\n\tfor (const entity of orphanBuffer) {\n\t\tif (hierarchyVisited.has(entity.id)) continue;\n\t\tconst { localTransform3D, worldTransform3D } = entity.components;\n\t\tif (copyTransform3D(localTransform3D, worldTransform3D)) {\n\t\t\tecs.markChanged(entity.id, 'worldTransform3D');\n\t\t}\n\t}\n}\n\n/**\n * Copy 3D transform values from source to destination.\n * Returns true if the destination was actually modified.\n */\nfunction copyTransform3D(src: LocalTransform3D, dest: WorldTransform3D): boolean {\n\tif (\n\t\tdest.x === src.x && dest.y === src.y && dest.z === src.z &&\n\t\tdest.rx === src.rx && dest.ry === src.ry && dest.rz === src.rz &&\n\t\tdest.sx === src.sx && dest.sy === src.sy && dest.sz === src.sz\n\t) {\n\t\treturn false;\n\t}\n\tdest.x = src.x;\n\tdest.y = src.y;\n\tdest.z = src.z;\n\tdest.rx = src.rx;\n\tdest.ry = src.ry;\n\tdest.rz = src.rz;\n\tdest.sx = src.sx;\n\tdest.sy = src.sy;\n\tdest.sz = src.sz;\n\treturn true;\n}\n\n/**\n * Combine parent world transform with child local transform into child world transform.\n * Uses quaternion math internally for correct rotation composition.\n * Returns true if the destination was actually modified.\n */\nfunction combineTransforms3D(\n\tparent: WorldTransform3D,\n\tlocal: LocalTransform3D,\n\tworld: WorldTransform3D,\n): boolean {\n\t// Convert parent and local rotations to quaternions\n\teulerToQuat(parent.rx, parent.ry, parent.rz, qP);\n\teulerToQuat(local.rx, local.ry, local.rz, qL);\n\n\t// Compose rotations: worldQuat = parentQuat * localQuat\n\tquatMultiply(qP, qL, qW);\n\n\t// Convert back to Euler (writes into eulerOut scratch)\n\tconst euler = quatToEuler(qW);\n\n\t// Apply parent scale to local position, rotate by parent rotation (writes into vecOut scratch)\n\tconst rotated = quatRotateVec(qP, local.x * parent.sx, local.y * parent.sy, local.z * parent.sz);\n\n\t// Compute final world values\n\tconst newX = parent.x + rotated.x;\n\tconst newY = parent.y + rotated.y;\n\tconst newZ = parent.z + rotated.z;\n\tconst newSx = parent.sx * local.sx;\n\tconst newSy = parent.sy * local.sy;\n\tconst newSz = parent.sz * local.sz;\n\n\tif (\n\t\tworld.x === newX && world.y === newY && world.z === newZ &&\n\t\tworld.rx === euler.rx && world.ry === euler.ry && world.rz === euler.rz &&\n\t\tworld.sx === newSx && world.sy === newSy && world.sz === newSz\n\t) {\n\t\treturn false;\n\t}\n\n\tworld.x = newX;\n\tworld.y = newY;\n\tworld.z = newZ;\n\tworld.rx = euler.rx;\n\tworld.ry = euler.ry;\n\tworld.rz = euler.rz;\n\tworld.sx = newSx;\n\tworld.sy = newSy;\n\tworld.sz = newSz;\n\treturn true;\n}\n"
|
|
6
6
|
],
|
|
7
7
|
"mappings": "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",
|
|
8
8
|
"debugId": "BF0602ADE0E3736C64756E2164756E21",
|
package/dist/plugins/ui/ui.d.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Future phases will add the message log (Phase 3).
|
|
21
21
|
*/
|
|
22
22
|
import { type BasePluginOptions } from 'ecspresso';
|
|
23
|
-
import type {
|
|
23
|
+
import type { ComponentsConfig, ResourcesConfig } from '../../type-utils';
|
|
24
24
|
import type { Vector2D } from '../../utils/math';
|
|
25
25
|
import { type TransformComponentTypes } from '../spatial/transform';
|
|
26
26
|
import type { BoundsResourceTypes } from '../spatial/bounds';
|
|
@@ -177,7 +177,7 @@ export interface MessageLogWorld {
|
|
|
177
177
|
* listeners. Safe to call from inside a system process callback.
|
|
178
178
|
*/
|
|
179
179
|
export declare function appendLogLine(ecs: MessageLogWorld, entityId: number, line: LogFragment[]): void;
|
|
180
|
-
type UIRequires =
|
|
180
|
+
type UIRequires = ComponentsConfig<TransformComponentTypes> & ResourcesConfig<BoundsResourceTypes & InputResourceTypes>;
|
|
181
181
|
type UILabels = 'ui-anchor-resolve' | 'ui-interaction' | 'ui-label-sync' | 'ui-panel-sync' | 'ui-progress-sync' | 'ui-message-log-sync';
|
|
182
182
|
export interface UIPluginOptions<G extends string = 'ui'> extends BasePluginOptions<G> {
|
|
183
183
|
/** Priority for the anchor-resolve system in preUpdate (default: 0). */
|