From c4f61953b2d283d0191fac5f40b6ef653b3927eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 21:13:14 +0000 Subject: [PATCH 1/4] fix(common): apply parent rotation and scale to child position offsets composeWithParent inherited rotation and scale correctly but composed a child's world position by plain addition, so a child's local offset was never rotated or scaled by the parent's world transform - a child of a rotating parent spun in place instead of orbiting it. Rotate and scale the local offset by the parent's world rotation/scale before adding it to the parent's world position. Also deletes parent-position-system.ts, parent-rotation-system.ts, and parent-scale-system.ts, which carried the same additive-position bug and were superseded by createTransformEcsSystem but never removed - they were not exported from src/common/systems/index.ts and referenced only by their own test files. Fixes #581 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M42FeAcwfsHYR3btuySY5U --- CHANGELOG.md | 1 + .../systems/parent-position-system.test.ts | 174 ---------------- src/common/systems/parent-position-system.ts | 88 --------- .../systems/parent-rotation-system.test.ts | 149 -------------- src/common/systems/parent-rotation-system.ts | 82 -------- .../systems/parent-scale-system.test.ts | 186 ------------------ src/common/systems/parent-scale-system.ts | 86 -------- src/common/systems/transform-system.test.ts | 107 +++++++++- src/common/systems/transform-system.ts | 99 +++++++--- 9 files changed, 179 insertions(+), 793 deletions(-) delete mode 100644 src/common/systems/parent-position-system.test.ts delete mode 100644 src/common/systems/parent-position-system.ts delete mode 100644 src/common/systems/parent-rotation-system.test.ts delete mode 100644 src/common/systems/parent-rotation-system.ts delete mode 100644 src/common/systems/parent-scale-system.test.ts delete mode 100644 src/common/systems/parent-scale-system.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0b4a6d..2a5410fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Fixed +- **common:** Fix `createTransformEcsSystem` composing a child's world position by plain addition of the parent's world position, ignoring the parent's world rotation and scale entirely - a child parented to a rotated and/or scaled entity now orbits/scales with it instead of spinning or resizing in place at an un-rotated, un-scaled offset. **Behaviour change** for any existing content that parents a positioned entity to a rotated or scaled one and was authored against the previous (incorrect) composition. - **utilities:** Fix `Game`'s render loop occasionally producing a negative `deltaTime` for one frame (e.g. right after constructing hundreds of entities in a single tick), by reading `performance.now()` at the point the frame callback runs instead of trusting `requestAnimationFrame`'s supplied timestamp, which is not guaranteed to be monotonic relative to the previous frame ## [0.24.2] - 2026-08-03 diff --git a/src/common/systems/parent-position-system.test.ts b/src/common/systems/parent-position-system.test.ts deleted file mode 100644 index 92f2a41e..00000000 --- a/src/common/systems/parent-position-system.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { addPositionComponent } from '../components'; -import { EcsWorld } from '../../ecs'; - -import { addParentComponent } from '../components/parent-component'; -import { createParentPositionEcsSystem } from './parent-position-system'; - -describe('parent-position-system', () => { - let world: EcsWorld; - - beforeEach(() => { - world = new EcsWorld(); - world.addSystem(createParentPositionEcsSystem()); - }); - - it('root transforms should have the same world and local values', () => { - const entity = world.createEntity(); - - const positionComponent = addPositionComponent(world, entity, { - local: { x: 10, y: 20 }, - }); - - world.update(); - - expect(positionComponent.world.x).toBe(10); - expect(positionComponent.local.x).toBe(10); - expect(positionComponent.world.y).toBe(20); - expect(positionComponent.local.y).toBe(20); - }); - - it('should compute world positions for a parent-child hierarchy', () => { - const parent = world.createEntity(); - const child = world.createEntity(); - - const parentPosition = addPositionComponent(world, parent, { - local: { x: 10, y: 20 }, - }); - - const childPosition = addPositionComponent(world, child, { - local: { x: 5, y: 5 }, - }); - - addParentComponent(world, child, { parent }); - world.update(); - - expect(parentPosition.world.x).toBe(10); - expect(parentPosition.local.x).toBe(10); - expect(parentPosition.world.y).toBe(20); - expect(parentPosition.local.y).toBe(20); - - expect(childPosition.world.x).toBe(15); - expect(childPosition.local.x).toBe(5); - expect(childPosition.world.y).toBe(25); - expect(childPosition.local.y).toBe(5); - }); - - it('should compute world positions for a parent-child-grandchild hierarchy', () => { - const parent = world.createEntity(); - const child = world.createEntity(); - const grandchild = world.createEntity(); - - const parentPosition = addPositionComponent(world, parent, { - local: { x: 10, y: 20 }, - }); - - const childPosition = addPositionComponent(world, child, { - local: { x: 5, y: 5 }, - }); - - const grandchildPosition = addPositionComponent(world, grandchild, { - local: { x: 2, y: 2 }, - }); - - addParentComponent(world, child, { parent }); - addParentComponent(world, grandchild, { parent: child }); - - world.update(); - - expect(parentPosition.world.x).toBe(10); - expect(parentPosition.local.x).toBe(10); - expect(parentPosition.world.y).toBe(20); - expect(parentPosition.local.y).toBe(20); - - expect(childPosition.world.x).toBe(15); - expect(childPosition.local.x).toBe(5); - expect(childPosition.world.y).toBe(25); - expect(childPosition.local.y).toBe(5); - - expect(grandchildPosition.world.x).toBe(17); - expect(grandchildPosition.local.x).toBe(2); - expect(grandchildPosition.world.y).toBe(27); - expect(grandchildPosition.local.y).toBe(2); - }); - - it('should compute world positions for a parent-child-grandchild hierarchy (out-of-order registration)', () => { - const parent = world.createEntity(); - const grandchild = world.createEntity(); - const child = world.createEntity(); - - const grandchildPosition = addPositionComponent(world, grandchild, { - local: { x: 2, y: 2 }, - }); - - const parentPosition = addPositionComponent(world, parent, { - local: { x: 10, y: 20 }, - }); - - const childPosition = addPositionComponent(world, child, { - local: { x: 5, y: 5 }, - }); - - addParentComponent(world, grandchild, { parent: child }); - addParentComponent(world, child, { parent }); - - world.update(); - - expect(parentPosition.world.x).toBe(10); - expect(parentPosition.local.x).toBe(10); - expect(parentPosition.world.y).toBe(20); - expect(parentPosition.local.y).toBe(20); - - expect(childPosition.world.x).toBe(15); - expect(childPosition.local.x).toBe(5); - expect(childPosition.world.y).toBe(25); - expect(childPosition.local.y).toBe(5); - - expect(grandchildPosition.world.x).toBe(17); - expect(grandchildPosition.local.x).toBe(2); - expect(grandchildPosition.world.y).toBe(27); - expect(grandchildPosition.local.y).toBe(2); - }); - - it('should compute world positions for a parent-child-grandchild hierarchy after they move', () => { - const parent = world.createEntity(); - const child = world.createEntity(); - const grandchild = world.createEntity(); - - const parentPosition = addPositionComponent(world, parent, { - local: { x: 10, y: 20 }, - }); - - const childPosition = addPositionComponent(world, child, { - local: { x: 5, y: 5 }, - }); - - const grandchildPosition = addPositionComponent(world, grandchild, { - local: { x: 2, y: 2 }, - }); - - addParentComponent(world, child, { parent }); - addParentComponent(world, grandchild, { parent: child }); - - world.update(); - - childPosition.local.x = 120; - - world.update(); - - expect(parentPosition.world.x).toBe(10); - expect(parentPosition.local.x).toBe(10); - expect(parentPosition.world.y).toBe(20); - expect(parentPosition.local.y).toBe(20); - - expect(childPosition.world.x).toBe(130); - expect(childPosition.local.x).toBe(120); - expect(childPosition.world.y).toBe(25); - expect(childPosition.local.y).toBe(5); - - expect(grandchildPosition.world.x).toBe(132); - expect(grandchildPosition.local.x).toBe(2); - expect(grandchildPosition.world.y).toBe(27); - expect(grandchildPosition.local.y).toBe(2); - }); -}); diff --git a/src/common/systems/parent-position-system.ts b/src/common/systems/parent-position-system.ts deleted file mode 100644 index 30244cb4..00000000 --- a/src/common/systems/parent-position-system.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { EcsSystem } from '../../ecs/ecs-system.js'; -import { EcsWorld } from '../../ecs/ecs-world.js'; -import { PositionEcsComponent, positionId } from '../components/index.js'; -import { parentId } from '../components/parent-component.js'; -import { - createTransformCache, - resetTransformCache, -} from './transform-cache.js'; - -const cache = createTransformCache(); - -function computeWorld(entity: number, world: EcsWorld): void { - if (cache.computed.has(entity)) { - return; - } - - // Cycle detection: if we re-enter an entity, break the cycle by treating it as a root. - if (cache.visiting.has(entity)) { - const positionComponent = world.getComponent(entity, positionId); - - if (positionComponent) { - positionComponent.world.x = positionComponent.local.x; - positionComponent.world.y = positionComponent.local.y; - } - - cache.computed.add(entity); - - return; - } - - cache.visiting.add(entity); - - const positionComponent = world.getComponent(entity, positionId); - - if (!positionComponent) { - cache.visiting.delete(entity); - - return; - } - - const parentComponent = world.getComponent(entity, parentId); - - if (!parentComponent) { - if (positionComponent) { - positionComponent.world.x = positionComponent.local.x; - positionComponent.world.y = positionComponent.local.y; - } - - cache.visiting.delete(entity); - cache.computed.add(entity); - - return; - } - - const parentEntity = parentComponent.parent; - - computeWorld(parentEntity, world); - - const parentPosition = world.getComponent(parentEntity, positionId); - - if (positionComponent) { - if (parentPosition) { - positionComponent.world.x = - parentPosition.world.x + positionComponent.local.x; - positionComponent.world.y = - parentPosition.world.y + positionComponent.local.y; - } else { - positionComponent.world.x = positionComponent.local.x; - positionComponent.world.y = positionComponent.local.y; - } - } - - cache.visiting.delete(entity); - cache.computed.add(entity); -} - -export const createParentPositionEcsSystem = (): EcsSystem< - [PositionEcsComponent] -> => ({ - query: [positionId], - update: (world, { entities }) => { - resetTransformCache(cache); - - for (const entity of entities) { - computeWorld(entity, world); - } - }, -}); diff --git a/src/common/systems/parent-rotation-system.test.ts b/src/common/systems/parent-rotation-system.test.ts deleted file mode 100644 index 21af41ba..00000000 --- a/src/common/systems/parent-rotation-system.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { addRotationComponent } from '../components'; -import { EcsWorld } from '../../ecs'; -import { addParentComponent } from '../components/parent-component'; -import { createParentRotationEcsSystem } from './parent-rotation-system'; - -describe('parent-rotation-system', () => { - let world: EcsWorld; - - beforeEach(() => { - world = new EcsWorld(); - world.addSystem(createParentRotationEcsSystem()); - }); - - it('root rotations should have the same world and local values', () => { - const entity = world.createEntity(); - - const rotationComponent = addRotationComponent(world, entity, { - local: 10, - }); - - world.update(); - - expect(rotationComponent.world).toBe(10); - expect(rotationComponent.local).toBe(10); - }); - - it('should compute world rotations for a parent-child hierarchy', () => { - const parent = world.createEntity(); - const child = world.createEntity(); - - const parentRotation = addRotationComponent(world, parent, { - local: 10, - }); - - const childRotation = addRotationComponent(world, child, { - local: 5, - }); - - addParentComponent(world, child, { parent }); - world.update(); - - expect(parentRotation.world).toBe(10); - expect(parentRotation.local).toBe(10); - - expect(childRotation.world).toBe(15); - expect(childRotation.local).toBe(5); - }); - - it('should compute world rotations for a parent-child-grandchild hierarchy', () => { - const parent = world.createEntity(); - const child = world.createEntity(); - const grandchild = world.createEntity(); - - const parentRotation = addRotationComponent(world, parent, { - local: 10, - }); - - const childRotation = addRotationComponent(world, child, { - local: 5, - }); - - const grandchildRotation = addRotationComponent(world, grandchild, { - local: 2, - }); - - addParentComponent(world, child, { parent }); - addParentComponent(world, grandchild, { parent: child }); - - world.update(); - - expect(parentRotation.world).toBe(10); - expect(parentRotation.local).toBe(10); - - expect(childRotation.world).toBe(15); - expect(childRotation.local).toBe(5); - - expect(grandchildRotation.world).toBe(17); - expect(grandchildRotation.local).toBe(2); - }); - - it('should compute world rotations for a parent-child-grandchild hierarchy (out-of-order registration)', () => { - const parent = world.createEntity(); - const grandchild = world.createEntity(); - const child = world.createEntity(); - - const grandchildRotation = addRotationComponent(world, grandchild, { - local: 2, - }); - - const parentRotation = addRotationComponent(world, parent, { - local: 10, - }); - - const childRotation = addRotationComponent(world, child, { - local: 5, - }); - - addParentComponent(world, grandchild, { parent: child }); - addParentComponent(world, child, { parent }); - - world.update(); - - expect(parentRotation.world).toBe(10); - expect(parentRotation.local).toBe(10); - - expect(childRotation.world).toBe(15); - expect(childRotation.local).toBe(5); - - expect(grandchildRotation.world).toBe(17); - expect(grandchildRotation.local).toBe(2); - }); - - it('should compute world rotations for a parent-child-grandchild hierarchy after they rotate', () => { - const parent = world.createEntity(); - const child = world.createEntity(); - const grandchild = world.createEntity(); - - const parentRotation = addRotationComponent(world, parent, { - local: 10, - }); - - const childRotation = addRotationComponent(world, child, { - local: 5, - }); - - const grandchildRotation = addRotationComponent(world, grandchild, { - local: 2, - }); - - addParentComponent(world, child, { parent }); - addParentComponent(world, grandchild, { parent: child }); - - world.update(); - - childRotation.local = 7; - - world.update(); - - expect(parentRotation.world).toBe(10); - expect(parentRotation.local).toBe(10); - - expect(childRotation.world).toBe(17); - expect(childRotation.local).toBe(7); - - expect(grandchildRotation.world).toBe(19); - expect(grandchildRotation.local).toBe(2); - }); -}); diff --git a/src/common/systems/parent-rotation-system.ts b/src/common/systems/parent-rotation-system.ts deleted file mode 100644 index 94d0b21c..00000000 --- a/src/common/systems/parent-rotation-system.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { EcsSystem } from '../../ecs/ecs-system.js'; -import { EcsWorld } from '../../ecs/ecs-world.js'; -import { RotationEcsComponent, rotationId } from '../components/index.js'; -import { parentId } from '../components/parent-component.js'; -import { - createTransformCache, - resetTransformCache, -} from './transform-cache.js'; - -const cache = createTransformCache(); - -function computeWorld(entity: number, world: EcsWorld): void { - if (cache.computed.has(entity)) { - return; - } - - // Cycle detection: if we re-enter an entity, break the cycle by treating it as a root. - if (cache.visiting.has(entity)) { - const rotationComponent = world.getComponent(entity, rotationId); - - if (rotationComponent) { - rotationComponent.world = rotationComponent.local; - } - - cache.computed.add(entity); - - return; - } - - cache.visiting.add(entity); - - const rotationComponent = world.getComponent(entity, rotationId); - - if (!rotationComponent) { - cache.visiting.delete(entity); - - return; - } - - const parentComponent = world.getComponent(entity, parentId); - - if (!parentComponent) { - if (rotationComponent) { - rotationComponent.world = rotationComponent.local; - } - - cache.visiting.delete(entity); - cache.computed.add(entity); - - return; - } - - const parentEntity = parentComponent.parent; - - computeWorld(parentEntity, world); - - const parentRotation = world.getComponent(parentEntity, rotationId); - - if (rotationComponent) { - if (parentRotation) { - rotationComponent.world = parentRotation.world + rotationComponent.local; - } else { - rotationComponent.world = rotationComponent.local; - } - } - - cache.visiting.delete(entity); - cache.computed.add(entity); -} - -export const createParentRotationEcsSystem = (): EcsSystem< - [RotationEcsComponent] -> => ({ - query: [rotationId], - update: (world, { entities }) => { - resetTransformCache(cache); - - for (const entity of entities) { - computeWorld(entity, world); - } - }, -}); diff --git a/src/common/systems/parent-scale-system.test.ts b/src/common/systems/parent-scale-system.test.ts deleted file mode 100644 index 522f2475..00000000 --- a/src/common/systems/parent-scale-system.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { addScaleComponent } from '../components'; -import { EcsWorld } from '../../ecs'; - -import { addParentComponent } from '../components/parent-component'; -import { createParentScaleEcsSystem } from './parent-scale-system'; - -describe('parent-scale-system', () => { - let world: EcsWorld; - - beforeEach(() => { - world = new EcsWorld(); - world.addSystem(createParentScaleEcsSystem()); - }); - - it('root transforms should have the same world and local values', () => { - const entity = world.createEntity(); - - const scaleComponent = addScaleComponent(world, entity, { - local: { x: 10, y: 20 }, - world: { x: 0, y: 0 }, - }); - - world.update(); - - expect(scaleComponent.world.x).toBe(10); - expect(scaleComponent.local.x).toBe(10); - expect(scaleComponent.world.y).toBe(20); - expect(scaleComponent.local.y).toBe(20); - }); - - it('should compute world scales for a parent-child hierarchy', () => { - const parent = world.createEntity(); - const child = world.createEntity(); - - const parentScale = addScaleComponent(world, parent, { - local: { x: 10, y: 20 }, - world: { x: 0, y: 0 }, - }); - - const childScale = addScaleComponent(world, child, { - local: { x: 5, y: 5 }, - world: { x: 0, y: 0 }, - }); - - addParentComponent(world, child, { parent }); - world.update(); - - expect(parentScale.world.x).toBe(10); - expect(parentScale.local.x).toBe(10); - expect(parentScale.world.y).toBe(20); - expect(parentScale.local.y).toBe(20); - - expect(childScale.world.x).toBe(50); - expect(childScale.local.x).toBe(5); - expect(childScale.world.y).toBe(100); - expect(childScale.local.y).toBe(5); - }); - - it('should compute world scales for a parent-child-grandchild hierarchy', () => { - const parent = world.createEntity(); - const child = world.createEntity(); - const grandchild = world.createEntity(); - - const parentScale = addScaleComponent(world, parent, { - local: { x: 10, y: 20 }, - world: { x: 0, y: 0 }, - }); - - const childScale = addScaleComponent(world, child, { - local: { x: 5, y: 5 }, - world: { x: 0, y: 0 }, - }); - - const grandchildScale = addScaleComponent(world, grandchild, { - local: { x: 2, y: 2 }, - world: { x: 0, y: 0 }, - }); - - addParentComponent(world, child, { parent }); - addParentComponent(world, grandchild, { parent: child }); - - world.update(); - - expect(parentScale.world.x).toBe(10); - expect(parentScale.local.x).toBe(10); - expect(parentScale.world.y).toBe(20); - expect(parentScale.local.y).toBe(20); - - expect(childScale.world.x).toBe(50); - expect(childScale.local.x).toBe(5); - expect(childScale.world.y).toBe(100); - expect(childScale.local.y).toBe(5); - - expect(grandchildScale.world.x).toBe(100); - expect(grandchildScale.local.x).toBe(2); - expect(grandchildScale.world.y).toBe(200); - expect(grandchildScale.local.y).toBe(2); - }); - - it('should compute world scales for a parent-child-grandchild hierarchy (out-of-order registration)', () => { - const parent = world.createEntity(); - const grandchild = world.createEntity(); - const child = world.createEntity(); - - const grandchildScale = addScaleComponent(world, grandchild, { - local: { x: 2, y: 2 }, - world: { x: 0, y: 0 }, - }); - - const parentScale = addScaleComponent(world, parent, { - local: { x: 10, y: 20 }, - world: { x: 0, y: 0 }, - }); - - const childScale = addScaleComponent(world, child, { - local: { x: 5, y: 5 }, - world: { x: 0, y: 0 }, - }); - - addParentComponent(world, grandchild, { parent: child }); - addParentComponent(world, child, { parent }); - - world.update(); - - expect(parentScale.world.x).toBe(10); - expect(parentScale.local.x).toBe(10); - expect(parentScale.world.y).toBe(20); - expect(parentScale.local.y).toBe(20); - - expect(childScale.world.x).toBe(50); - expect(childScale.local.x).toBe(5); - expect(childScale.world.y).toBe(100); - expect(childScale.local.y).toBe(5); - - expect(grandchildScale.world.x).toBe(100); - expect(grandchildScale.local.x).toBe(2); - expect(grandchildScale.world.y).toBe(200); - expect(grandchildScale.local.y).toBe(2); - }); - - it('should compute world scales for a parent-child-grandchild hierarchy after they scale', () => { - const parent = world.createEntity(); - const child = world.createEntity(); - const grandchild = world.createEntity(); - - const parentScale = addScaleComponent(world, parent, { - local: { x: 10, y: 20 }, - world: { x: 0, y: 0 }, - }); - - const childScale = addScaleComponent(world, child, { - local: { x: 5, y: 5 }, - world: { x: 0, y: 0 }, - }); - - const grandchildScale = addScaleComponent(world, grandchild, { - local: { x: 2, y: 2 }, - world: { x: 0, y: 0 }, - }); - - addParentComponent(world, child, { parent }); - addParentComponent(world, grandchild, { parent: child }); - - world.update(); - - childScale.local.x = 120; - - world.update(); - - expect(parentScale.world.x).toBe(10); - expect(parentScale.local.x).toBe(10); - expect(parentScale.world.y).toBe(20); - expect(parentScale.local.y).toBe(20); - - expect(childScale.world.x).toBe(1200); - expect(childScale.local.x).toBe(120); - expect(childScale.world.y).toBe(100); - expect(childScale.local.y).toBe(5); - - expect(grandchildScale.world.x).toBe(2400); - expect(grandchildScale.local.x).toBe(2); - expect(grandchildScale.world.y).toBe(200); - expect(grandchildScale.local.y).toBe(2); - }); -}); diff --git a/src/common/systems/parent-scale-system.ts b/src/common/systems/parent-scale-system.ts deleted file mode 100644 index 17293890..00000000 --- a/src/common/systems/parent-scale-system.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { EcsSystem } from '../../ecs/ecs-system.js'; -import { EcsWorld } from '../../ecs/ecs-world.js'; -import { ScaleEcsComponent, scaleId } from '../components/index.js'; -import { parentId } from '../components/parent-component.js'; -import { - createTransformCache, - resetTransformCache, -} from './transform-cache.js'; - -const cache = createTransformCache(); - -function computeWorld(entity: number, world: EcsWorld): void { - if (cache.computed.has(entity)) { - return; - } - - // Cycle detection: if we re-enter an entity, break the cycle by treating it as a root. - if (cache.visiting.has(entity)) { - const scaleComponent = world.getComponent(entity, scaleId); - - if (scaleComponent) { - scaleComponent.world.x = scaleComponent.local.x; - scaleComponent.world.y = scaleComponent.local.y; - } - - cache.computed.add(entity); - - return; - } - - cache.visiting.add(entity); - - const scaleComponent = world.getComponent(entity, scaleId); - - if (!scaleComponent) { - cache.visiting.delete(entity); - - return; - } - - const parentComponent = world.getComponent(entity, parentId); - - if (!parentComponent) { - if (scaleComponent) { - scaleComponent.world.x = scaleComponent.local.x; - scaleComponent.world.y = scaleComponent.local.y; - } - - cache.visiting.delete(entity); - cache.computed.add(entity); - - return; - } - - const parentEntity = parentComponent.parent; - - computeWorld(parentEntity, world); - - const parentScale = world.getComponent(parentEntity, scaleId); - - if (scaleComponent) { - if (parentScale) { - scaleComponent.world.x = parentScale.world.x * scaleComponent.local.x; - scaleComponent.world.y = parentScale.world.y * scaleComponent.local.y; - } else { - scaleComponent.world.x = scaleComponent.local.x; - scaleComponent.world.y = scaleComponent.local.y; - } - } - - cache.visiting.delete(entity); - cache.computed.add(entity); -} - -export const createParentScaleEcsSystem = (): EcsSystem< - [ScaleEcsComponent] -> => ({ - query: [scaleId], - update: (world, { entities }) => { - resetTransformCache(cache); - - for (const entity of entities) { - computeWorld(entity, world); - } - }, -}); diff --git a/src/common/systems/transform-system.test.ts b/src/common/systems/transform-system.test.ts index 1fe00b6f..dab687cd 100644 --- a/src/common/systems/transform-system.test.ts +++ b/src/common/systems/transform-system.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { EcsWorld } from '../../ecs/index.js'; -import { addPositionComponent } from '../components/index.js'; +import { + addPositionComponent, + addRotationComponent, + addScaleComponent, +} from '../components/index.js'; import { addParentComponent } from '../components/parent-component.js'; import { createTransformEcsSystem } from './transform-system.js'; @@ -168,4 +172,105 @@ describe('transform-system', () => { expect(recycledPosition.world.x).toBe(50); expect(recycledPosition.world.y).toBe(60); }); + + it('should rotate a child position offset by the parent rotation', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + + addPositionComponent(world, parent, { local: { x: 0, y: 0 } }); + addRotationComponent(world, parent, { local: Math.PI / 2 }); + + const childPosition = addPositionComponent(world, child, { + local: { x: 10, y: 0 }, + }); + + addParentComponent(world, child, { parent }); + + world.update(); + + expect(childPosition.world.x).toBeCloseTo(0); + expect(childPosition.world.y).toBeCloseTo(10); + }); + + it('should scale a child position offset by the parent scale', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + + addPositionComponent(world, parent, { local: { x: 0, y: 0 } }); + addScaleComponent(world, parent, { local: { x: 2, y: 2 } }); + + const childPosition = addPositionComponent(world, child, { + local: { x: 10, y: 0 }, + }); + + addParentComponent(world, child, { parent }); + + world.update(); + + expect(childPosition.world.x).toBeCloseTo(20); + expect(childPosition.world.y).toBeCloseTo(0); + }); + + it('should scale then rotate a child position offset by the parent transform', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + + addPositionComponent(world, parent, { local: { x: 5, y: 5 } }); + addRotationComponent(world, parent, { local: Math.PI / 2 }); + addScaleComponent(world, parent, { local: { x: 2, y: 2 } }); + + const childPosition = addPositionComponent(world, child, { + local: { x: 10, y: 0 }, + }); + + addParentComponent(world, child, { parent }); + + world.update(); + + expect(childPosition.world.x).toBeCloseTo(5); + expect(childPosition.world.y).toBeCloseTo(25); + }); + + it('should compose position, rotation and scale through a three-deep parent chain', () => { + const grandparent = world.createEntity(); + const parent = world.createEntity(); + const child = world.createEntity(); + + addPositionComponent(world, grandparent, { local: { x: 0, y: 0 } }); + addRotationComponent(world, grandparent, { local: Math.PI / 2 }); + addScaleComponent(world, grandparent, { local: { x: 2, y: 2 } }); + + const parentPosition = addPositionComponent(world, parent, { + local: { x: 10, y: 0 }, + }); + const parentRotation = addRotationComponent(world, parent, { + local: Math.PI / 2, + }); + const parentScale = addScaleComponent(world, parent, { + local: { x: 1, y: 1 }, + }); + + addParentComponent(world, parent, { parent: grandparent }); + + const childPosition = addPositionComponent(world, child, { + local: { x: 5, y: 0 }, + }); + + addParentComponent(world, child, { parent }); + + world.update(); + + // grandparent: position (0, 0), rotation pi/2, scale (2, 2) + // parent: local offset (10, 0) scaled by (2, 2) -> (20, 0), rotated by pi/2 -> (0, 20) + expect(parentPosition.world.x).toBeCloseTo(0); + expect(parentPosition.world.y).toBeCloseTo(20); + expect(parentRotation.world).toBeCloseTo(Math.PI); + expect(parentScale.world.x).toBeCloseTo(2); + expect(parentScale.world.y).toBeCloseTo(2); + + // child: local offset (5, 0) scaled by parent world scale (2, 2) -> (10, 0), + // rotated by parent world rotation pi -> (-10, 0), plus parent world position (0, 20) + expect(childPosition.world.x).toBeCloseTo(-10); + expect(childPosition.world.y).toBeCloseTo(20); + }); }); diff --git a/src/common/systems/transform-system.ts b/src/common/systems/transform-system.ts index 3a8a6daa..1d9e70c0 100644 --- a/src/common/systems/transform-system.ts +++ b/src/common/systems/transform-system.ts @@ -1,5 +1,6 @@ import { EcsSystem } from '../../ecs/ecs-system.js'; import { EcsWorld } from '../../ecs/ecs-world.js'; +import { Vec2 } from '../../math/index.js'; import { PositionEcsComponent, positionId, @@ -34,48 +35,92 @@ function setLocalAsWorldIfExists(entity: number, world: EcsWorld): void { } } -function composeWithParent( +function composePositionWithParent( entity: number, parentEntity: number, world: EcsWorld, ): void { const positionComponent = world.getComponent(entity, positionId); - const rotationComponent = world.getComponent(entity, rotationId); - const scaleComponent = world.getComponent(entity, scaleId); + + if (!positionComponent) { + return; + } const parentPosition = world.getComponent(parentEntity, positionId); + + if (!parentPosition) { + positionComponent.world.x = positionComponent.local.x; + positionComponent.world.y = positionComponent.local.y; + + return; + } + const parentRotation = world.getComponent(parentEntity, rotationId); const parentScale = world.getComponent(parentEntity, scaleId); + const offset = Vec2.clone(positionComponent.local); - if (positionComponent) { - if (parentPosition) { - positionComponent.world.x = - parentPosition.world.x + positionComponent.local.x; - positionComponent.world.y = - parentPosition.world.y + positionComponent.local.y; - } else { - positionComponent.world.x = positionComponent.local.x; - positionComponent.world.y = positionComponent.local.y; - } + if (parentScale) { + Vec2.multiplyComponents(offset, parentScale.world); } - if (rotationComponent) { - if (parentRotation) { - rotationComponent.world = parentRotation.world + rotationComponent.local; - } else { - rotationComponent.world = rotationComponent.local; - } + if (parentRotation) { + Vec2.rotate(offset, parentRotation.world); } - if (scaleComponent) { - if (parentScale) { - scaleComponent.world.x = parentScale.world.x * scaleComponent.local.x; - scaleComponent.world.y = parentScale.world.y * scaleComponent.local.y; - } else { - scaleComponent.world.x = scaleComponent.local.x; - scaleComponent.world.y = scaleComponent.local.y; - } + positionComponent.world.x = parentPosition.world.x + offset.x; + positionComponent.world.y = parentPosition.world.y + offset.y; +} + +function composeRotationWithParent( + entity: number, + parentEntity: number, + world: EcsWorld, +): void { + const rotationComponent = world.getComponent(entity, rotationId); + + if (!rotationComponent) { + return; } + + const parentRotation = world.getComponent(parentEntity, rotationId); + + rotationComponent.world = parentRotation + ? parentRotation.world + rotationComponent.local + : rotationComponent.local; +} + +function composeScaleWithParent( + entity: number, + parentEntity: number, + world: EcsWorld, +): void { + const scaleComponent = world.getComponent(entity, scaleId); + + if (!scaleComponent) { + return; + } + + const parentScale = world.getComponent(parentEntity, scaleId); + + if (!parentScale) { + scaleComponent.world.x = scaleComponent.local.x; + scaleComponent.world.y = scaleComponent.local.y; + + return; + } + + scaleComponent.world.x = parentScale.world.x * scaleComponent.local.x; + scaleComponent.world.y = parentScale.world.y * scaleComponent.local.y; +} + +function composeWithParent( + entity: number, + parentEntity: number, + world: EcsWorld, +): void { + composePositionWithParent(entity, parentEntity, world); + composeRotationWithParent(entity, parentEntity, world); + composeScaleWithParent(entity, parentEntity, world); } function computeWorld( From 814ca8b1fd4335e75de2a7bbff4c08a2ad95f9a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:22:31 +0000 Subject: [PATCH 2/4] chore: re-trigger CI Push/pull_request webhook events were dropped during a GitHub Actions incident (stuck ARC runner pods, resolved 2026-08-07 02:04 UTC), so CI never ran for this PR. Empty commit to re-fire the trigger per GitHub's incident notice. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M42FeAcwfsHYR3btuySY5U From 9c967343579f655c11dc54dc0fed5f64454b9b02 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:25:02 +0000 Subject: [PATCH 3/4] fix: use American spelling in CHANGELOG entry to satisfy cspell "Behaviour" isn't in the project's dictionary; the repo consistently uses American spelling ("behavior") elsewhere. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M42FeAcwfsHYR3btuySY5U --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a5410fe..646298bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Fixed -- **common:** Fix `createTransformEcsSystem` composing a child's world position by plain addition of the parent's world position, ignoring the parent's world rotation and scale entirely - a child parented to a rotated and/or scaled entity now orbits/scales with it instead of spinning or resizing in place at an un-rotated, un-scaled offset. **Behaviour change** for any existing content that parents a positioned entity to a rotated or scaled one and was authored against the previous (incorrect) composition. +- **common:** Fix `createTransformEcsSystem` composing a child's world position by plain addition of the parent's world position, ignoring the parent's world rotation and scale entirely - a child parented to a rotated and/or scaled entity now orbits/scales with it instead of spinning or resizing in place at an un-rotated, un-scaled offset. **Behavior change** for any existing content that parents a positioned entity to a rotated or scaled one and was authored against the previous (incorrect) composition. - **utilities:** Fix `Game`'s render loop occasionally producing a negative `deltaTime` for one frame (e.g. right after constructing hundreds of entities in a single tick), by reading `performance.now()` at the point the frame callback runs instead of trusting `requestAnimationFrame`'s supplied timestamp, which is not guaranteed to be monotonic relative to the previous frame ## [0.24.2] - 2026-08-03 From fee8d027de35e318369adceb50b1b47616655a34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:33:44 +0000 Subject: [PATCH 4/4] test(common): cover parent-missing-component branches in transform system The refactor split into composePositionWithParent/composeRotationWithParent/ composeScaleWithParent added branches for a component present on an entity but absent on its parent (or vice versa) that weren't exercised by the existing rotated/scaled/chain tests, dropping patch coverage below the project's Codecov target. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01M42FeAcwfsHYR3btuySY5U --- src/common/systems/transform-system.test.ts | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/common/systems/transform-system.test.ts b/src/common/systems/transform-system.test.ts index dab687cd..a4a29e50 100644 --- a/src/common/systems/transform-system.test.ts +++ b/src/common/systems/transform-system.test.ts @@ -273,4 +273,54 @@ describe('transform-system', () => { expect(childPosition.world.x).toBeCloseTo(-10); expect(childPosition.world.y).toBeCloseTo(20); }); + + it('should leave a child unaffected when an ancestor in the chain has no position or rotation component', () => { + const grandparent = world.createEntity(); + const middle = world.createEntity(); + const child = world.createEntity(); + + addPositionComponent(world, grandparent, { local: { x: 0, y: 0 } }); + + const middleRotation = addRotationComponent(world, middle, { + local: Math.PI / 2, + }); + + addParentComponent(world, middle, { parent: grandparent }); + + const childPosition = addPositionComponent(world, child, { + local: { x: 5, y: 5 }, + }); + + addParentComponent(world, child, { parent: middle }); + + world.update(); + + // middle has no PositionEcsComponent, so it composes rotation only, and + // since grandparent has no RotationEcsComponent, middle keeps its local rotation. + expect(middleRotation.world).toBeCloseTo(Math.PI / 2); + + // child's parent (middle) has no PositionEcsComponent, so child's world + // position falls back to its local position, untransformed. + expect(childPosition.world.x).toBe(5); + expect(childPosition.world.y).toBe(5); + }); + + it('should leave a child scale unaffected when its parent has no scale component', () => { + const parent = world.createEntity(); + const child = world.createEntity(); + + addPositionComponent(world, parent, { local: { x: 0, y: 0 } }); + + const childScale = addScaleComponent(world, child, { + local: { x: 3, y: 4 }, + }); + + addPositionComponent(world, child, { local: { x: 0, y: 0 } }); + addParentComponent(world, child, { parent }); + + world.update(); + + expect(childScale.world.x).toBe(3); + expect(childScale.world.y).toBe(4); + }); });