diff --git a/examples/samples/transform/2d/index.js b/examples/samples/transform/2d/index.js index bd4e2f5d..e2b2dfc1 100644 --- a/examples/samples/transform/2d/index.js +++ b/examples/samples/transform/2d/index.js @@ -2,6 +2,7 @@ const translate2d = new URL('./translate.js', import.meta.url) const rotate2d = new URL('./rotate.js', import.meta.url) const scale2d = new URL('./scale.js', import.meta.url) const propagate2d = new URL('./propagate.js', import.meta.url) +const reparent = new URL('./reparent.js', import.meta.url) const lookat2d = new URL('./lookat.js', import.meta.url) export default { @@ -9,5 +10,6 @@ export default { 'rotate2d': rotate2d, 'scale2d': scale2d, 'propagate2d': propagate2d, + 'reparent': reparent, 'lookat2d': lookat2d } diff --git a/examples/samples/transform/2d/reparent.js b/examples/samples/transform/2d/reparent.js new file mode 100644 index 00000000..7e814ae3 --- /dev/null +++ b/examples/samples/transform/2d/reparent.js @@ -0,0 +1,169 @@ +import { + App, + AppSchedule, + BasicMaterial, + BasicMaterialAssets, + BasicMaterialInstance, + Canvas2DRendererPlugin, + Color, + DefaultPlugin, + DOMWindowPlugin, + EntityCommands, + EntityHandle, + FPSDebugger, + KeyCode, + Keyboard, + Mesh, + MeshAssets, + Orientation2D, + Query, + Rotary, + VirtualClock, + createBasicMesh2D +} from 'wima' +import { addDefaultCamera2D, HackPlugin, setupViewport } from '../../utils.js' + +class LeftDock { } +class RightDock { } +class ShowcaseChild { } +class ActiveDock { } + +const idleDockColor = new Color(0.19, 0.23, 0.28) +const activeLeftColor = new Color(0.32, 0.90, 0.83) +const activeRightColor = new Color(0.98, 0.69, 0.29) +const childColor = new Color(0.97, 0.98, 1) +const dockSpacing = 0.62 +const dockSpinSpeed = 0.45 +const childSpinSpeed = 0.75 + +const app = new App() + +app + .registerPlugin(new HackPlugin()) + .registerPlugin(new DefaultPlugin()) + .registerPlugin(new DOMWindowPlugin()) + .registerPlugin(new Canvas2DRendererPlugin()) + .registerSystem({ schedule: AppSchedule.Startup, system: spawnShowcase }) + .registerSystem({ schedule: AppSchedule.Startup, system: addDefaultCamera2D }) + .registerSystem({ schedule: AppSchedule.Update, system: update }) + .registerSystem({ schedule: AppSchedule.Update, system: setupViewport }) + .registerDebugger(new FPSDebugger()) + .run() + +/** + * @param {import('@wimaengine/ecs').World} world + */ +function spawnShowcase(world) { + const commands = new EntityCommands(world) + const meshes = world.getResource(MeshAssets) + const materials = world.getResource(BasicMaterialAssets) + + const dockMesh = meshes.add(Mesh.quad2D(0.20, 0.20)) + const childMesh = meshes.add(Mesh.quad2D(0.12, 0.12)) + + const leftMaterial = materials.add(new BasicMaterial({ + color: activeLeftColor.clone() + })) + const rightMaterial = materials.add(new BasicMaterial({ + color: idleDockColor.clone() + })) + const childMaterial = materials.add(new BasicMaterial({ + color: childColor.clone() + })) + + commands + .spawn() + .insertPrefab([ + ...createBasicMesh2D(dockMesh, leftMaterial, -dockSpacing, 0, 0, 1, 1), + new LeftDock(), + new ActiveDock() + ]) + .build() + + commands + .spawn() + .insertPrefab([ + ...createBasicMesh2D(dockMesh, rightMaterial, dockSpacing, 0, 0, 1, 1), + new RightDock() + ]) + .build() + + commands + .spawn() + .insertPrefab([ + ...createBasicMesh2D(childMesh, childMaterial, 0, 0, 0, 1, 1), + new ShowcaseChild() + ]) + .build() +} + +/** + * @param {import('@wimaengine/ecs').World} world + */ +function update(world) { + const keyboard = world.getResource(Keyboard) + const materials = world.getResource(BasicMaterialAssets) + const clock = world.getResource(VirtualClock) + const delta = clock.getDelta() + const leftDock = new Query(world, [EntityHandle, LeftDock, Orientation2D, BasicMaterialInstance]).single() + const rightDock = new Query(world, [EntityHandle, RightDock, Orientation2D, BasicMaterialInstance]).single() + const child = new Query(world, [EntityHandle, ShowcaseChild, Orientation2D, BasicMaterialInstance]).single() + const activeDock = new Query(world, [EntityHandle, ActiveDock]).single() + let activeDockEntity = activeDock?.[0] + + if ( + keyboard.justPressed(KeyCode.Space) || + keyboard.justPressed(KeyCode.KeyR) + ) { + if (!leftDock || !rightDock || !child) { + return + } + + if (!activeDockEntity) { + activeDockEntity = leftDock[0] + } + + const nextDock = activeDockEntity.equals(leftDock[0]) ? rightDock[0] : leftDock[0] + + if (!nextDock.equals(activeDockEntity)) { + world.remove(activeDockEntity, [ActiveDock]) + world.insert(nextDock, [new ActiveDock()]) + activeDockEntity = nextDock + } + + new EntityCommands(world).reparent(child[0], nextDock) + } + + if (leftDock) { + leftDock[2].multiply(Rotary.fromAngle(delta * dockSpinSpeed)) + } + + if (rightDock) { + rightDock[2].multiply(Rotary.fromAngle(-delta * dockSpinSpeed)) + } + + if (child) { + child[2].multiply(Rotary.fromAngle(-delta * childSpinSpeed)) + } + + const left = leftDock ? materials.get(leftDock[3].handle) : null + const right = rightDock ? materials.get(rightDock[3].handle) : null + const childPaint = child ? materials.get(child[3].handle) : null + const isLeftActive = Boolean( + activeDockEntity && + leftDock && + activeDockEntity.equals(leftDock[0]) + ) + + if (left) { + left.color.copy(isLeftActive ? activeLeftColor : idleDockColor) + } + + if (right) { + right.color.copy(isLeftActive ? idleDockColor : activeRightColor) + } + + if (childPaint) { + childPaint.color.copy(childColor) + } +} diff --git a/examples/samples/transform/3d/index.js b/examples/samples/transform/3d/index.js index 3b4b697c..751237e5 100644 --- a/examples/samples/transform/3d/index.js +++ b/examples/samples/transform/3d/index.js @@ -3,11 +3,13 @@ const rotate3d = new URL('./rotate.js', import.meta.url) const scale3d = new URL('./scale.js', import.meta.url) const lookAt3d = new URL('./lookat.js', import.meta.url) const propagate3d = new URL('./propagate.js', import.meta.url) +const reparent = new URL('./reparent.js', import.meta.url) export default { 'translate3d': translate3d, 'rotate3d': rotate3d, 'scale3d': scale3d, 'lookAt3d': lookAt3d, - 'propagate3d': propagate3d + 'propagate3d': propagate3d, + 'reparent': reparent } diff --git a/examples/samples/transform/3d/reparent.js b/examples/samples/transform/3d/reparent.js new file mode 100644 index 00000000..1d966fd1 --- /dev/null +++ b/examples/samples/transform/3d/reparent.js @@ -0,0 +1,169 @@ +import { + App, + AppSchedule, + BasicMaterial, + BasicMaterialAssets, + BasicMaterialInstance, + Color, + DefaultPlugin, + DOMWindowPlugin, + EntityCommands, + EntityHandle, + FPSDebugger, + KeyCode, + Keyboard, + Mesh, + MeshAssets, + Orientation3D, + Query, + Quaternion, + VirtualClock, + WebglRendererPlugin, + createBasicMesh3D +} from 'wima' +import { addDefaultCamera3D, HackPlugin, setupViewportWebgl } from '../../utils.js' + +class LeftDock { } +class RightDock { } +class ShowcaseChild { } +class ActiveDock { } + +const idleDockColor = new Color(0.18, 0.22, 0.28) +const activeLeftColor = new Color(0.36, 0.91, 0.84) +const activeRightColor = new Color(0.99, 0.73, 0.31) +const childColor = new Color(0.98, 0.99, 1) +const dockSpacing = 0.72 +const dockSpinSpeed = 0.45 +const childSpinSpeed = 0.75 + +const app = new App() + +app + .registerPlugin(new HackPlugin()) + .registerPlugin(new WebglRendererPlugin()) + .registerPlugin(new DefaultPlugin()) + .registerPlugin(new DOMWindowPlugin()) + .registerSystem({ schedule: AppSchedule.Startup, system: spawnShowcase }) + .registerSystem({ schedule: AppSchedule.Startup, system: addDefaultCamera3D }) + .registerSystem({ schedule: AppSchedule.Update, system: update }) + .registerSystem({ schedule: AppSchedule.Update, system: setupViewportWebgl }) + .registerDebugger(new FPSDebugger()) + .run() + +/** + * @param {import('@wimaengine/ecs').World} world + */ +function spawnShowcase(world) { + const commands = new EntityCommands(world) + const meshes = world.getResource(MeshAssets) + const materials = world.getResource(BasicMaterialAssets) + + const dockMesh = meshes.add(Mesh.cube(0.22, 0.22, 0.22)) + const childMesh = meshes.add(Mesh.cube(0.14, 0.14, 0.14)) + + const leftMaterial = materials.add(new BasicMaterial({ + color: activeLeftColor.clone() + })) + const rightMaterial = materials.add(new BasicMaterial({ + color: idleDockColor.clone() + })) + const childMaterial = materials.add(new BasicMaterial({ + color: childColor.clone() + })) + + commands + .spawn() + .insertPrefab([ + ...createBasicMesh3D(dockMesh, leftMaterial, -dockSpacing, 0, 0), + new LeftDock(), + new ActiveDock() + ]) + .build() + + commands + .spawn() + .insertPrefab([ + ...createBasicMesh3D(dockMesh, rightMaterial, dockSpacing, 0, 0), + new RightDock() + ]) + .build() + + commands + .spawn() + .insertPrefab([ + ...createBasicMesh3D(childMesh, childMaterial), + new ShowcaseChild() + ]) + .build() +} + +/** + * @param {import('@wimaengine/ecs').World} world + */ +function update(world) { + const keyboard = world.getResource(Keyboard) + const materials = world.getResource(BasicMaterialAssets) + const clock = world.getResource(VirtualClock) + const delta = clock.getDelta() + const leftDock = new Query(world, [EntityHandle, LeftDock, Orientation3D, BasicMaterialInstance]).single() + const rightDock = new Query(world, [EntityHandle, RightDock, Orientation3D, BasicMaterialInstance]).single() + const child = new Query(world, [EntityHandle, ShowcaseChild, Orientation3D, BasicMaterialInstance]).single() + const activeDock = new Query(world, [EntityHandle, ActiveDock]).single() + let activeDockEntity = activeDock?.[0] + + if ( + keyboard.justPressed(KeyCode.Space) || + keyboard.justPressed(KeyCode.KeyR) + ) { + if (!leftDock || !rightDock || !child) { + return + } + + if (!activeDockEntity) { + activeDockEntity = leftDock[0] + } + + const nextDock = activeDockEntity.equals(leftDock[0]) ? rightDock[0] : leftDock[0] + + if (!nextDock.equals(activeDockEntity)) { + world.remove(activeDockEntity, [ActiveDock]) + world.insert(nextDock, [new ActiveDock()]) + activeDockEntity = nextDock + } + + new EntityCommands(world).reparent(child[0], nextDock) + } + + if (leftDock) { + leftDock[2].multiply(Quaternion.fromEuler(0, 0, delta * dockSpinSpeed)) + } + + if (rightDock) { + rightDock[2].multiply(Quaternion.fromEuler(0, 0, -delta * dockSpinSpeed)) + } + + if (child) { + child[2].multiply(Quaternion.fromEuler(0, 0, -delta * childSpinSpeed)) + } + + const left = leftDock ? materials.get(leftDock[3].handle) : null + const right = rightDock ? materials.get(rightDock[3].handle) : null + const childPaint = child ? materials.get(child[3].handle) : null + const isLeftActive = Boolean( + activeDockEntity && + leftDock && + activeDockEntity.equals(leftDock[0]) + ) + + if (left) { + left.color.copy(isLeftActive ? activeLeftColor : idleDockColor) + } + + if (right) { + right.color.copy(isLeftActive ? idleDockColor : activeRightColor) + } + + if (childPaint) { + childPaint.color.copy(childColor) + } +} diff --git a/packages/commands/package.json b/packages/commands/package.json index 9f10e8bb..94ece298 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -32,7 +32,12 @@ "dependencies": { "@wimaengine/command": "0.3.0", "@wimaengine/ecs": "0.3.0", + "@wimaengine/hierarchy": "0.3.0", "@wimaengine/logger": "0.3.0", + "@wimaengine/math": "0.3.0", + "@wimaengine/reflect": "0.3.0", + "@wimaengine/relationship": "0.3.0", + "@wimaengine/transform": "0.3.0", "@wimaengine/type": "0.3.0" }, "types": "./dist/index.d.ts" diff --git a/packages/commands/src/commands/clone.js b/packages/commands/src/commands/clone.js new file mode 100644 index 00000000..42c3a7c9 --- /dev/null +++ b/packages/commands/src/commands/clone.js @@ -0,0 +1,148 @@ +/** @import { World } from '@wimaengine/ecs' */ +import { Command } from '@wimaengine/command' +import { EntityHandle } from '@wimaengine/ecs' +import { Children, Parent } from '@wimaengine/hierarchy' +import { assert, warn } from '@wimaengine/logger' +import { TypeRegistry } from '@wimaengine/reflect' +import { RelationshipQuery } from '@wimaengine/relationship' +import { typeid } from '@wimaengine/type' +import { SpawnCommand } from './spawn' + +const entityTypeId = typeid(EntityHandle) +const parentTypeId = typeid(Parent) +const childrenTypeId = typeid(Children) + +/** + * @param {World} world + * @param {EntityHandle} entity + * @param {EntityHandle[]} ordered + */ +function collectHierarchy(world, entity, ordered) { + ordered.push(entity) + + const query = new RelationshipQuery(world, Children, Parent) + + query.treedfs(entity, ([descendant]) => { + ordered.push(/** @type {EntityHandle} */ (descendant)) + }) +} + +export class CloneCommand extends Command { + + /** + * @readonly + * @type {SpawnCommand[]} + */ + commands + + /** + * @readonly + * @type {EntityHandle} + */ + entity + + /** + * @param {SpawnCommand[]} commands + */ + constructor(commands) { + super() + assert(commands.length > 0, 'A clone command must contain at least one entity.') + this.commands = commands + this.entity = commands[0].entity + } + + /** + * @param {World} world + * @param {EntityHandle} entity + * @returns {CloneCommand} + */ + static create(world, entity) { + const cell = world.getEntity(entity) + + assert(cell.exists(), `The entity ${entity.id()} cannot be cloned because it does not exist.`) + + const registry = world.getResource(TypeRegistry) + + /** @type {EntityHandle[]} */ + const ordered = [] + + /** @type {SpawnCommand[]} */ + const cloneCommands = [] + + /** @type {Map} */ + const entityMap = new Map() + + collectHierarchy(world, entity, ordered) + + try { + for (let i = 0; i < ordered.length; i++) { + const cloneEntity = world.spawn([]) + + cloneCommands.push(new SpawnCommand(cloneEntity)) + entityMap.set(ordered[i].id(), cloneEntity.id()) + } + + for (let i = 0; i < ordered.length; i++) { + const source = ordered[i] + const sourceCell = world.getEntity(source) + + /** @type {object[]} */ + const clonedComponents = [] + const typeIds = sourceCell.components() + + for (let j = 0; j < typeIds.length; j++) { + const typeId = typeIds[j] + + if (typeId === entityTypeId || typeId === childrenTypeId) { + continue + } + + const entry = registry.getByTypeId(typeId) + + if (!entry) { + warn(`The type \`${typeId}\` is not registered in the type registry`) + continue + } + + const component = /** @type {object | undefined} */ (sourceCell.getTypeId(typeId)) + + if (!component) { + continue + } + + const clonedComponent = /** @type {object | undefined} */ (entry.call('clone', [component])) + + if (!clonedComponent) { + warn( + `The type \`${typeId}\` has not been cloned as there is no \`clone\` method registered in the \`TypeRegistry\`` + ) + continue + } + + if (typeId !== parentTypeId || !source.equals(entity)) { + entry.getMethod('map')?.method?.call(clonedComponent, entityMap) + } + + clonedComponents.push(clonedComponent) + } + + cloneCommands[i].insertPrefab(clonedComponents) + } + } catch { + for (let i = 0; i < cloneCommands.length; i++) { + world.despawn(cloneCommands[i].entity) + } + } + + return new CloneCommand(cloneCommands) + } + + /** + * @param {World} world + */ + execute(world) { + for (let i = 0; i < this.commands.length; i++) { + this.commands[i].execute(world) + } + } +} diff --git a/packages/commands/src/commands/index.js b/packages/commands/src/commands/index.js index 192d1f85..5807b0d2 100644 --- a/packages/commands/src/commands/index.js +++ b/packages/commands/src/commands/index.js @@ -1,5 +1,8 @@ export * from './addresource' +export * from './clone' export * from './despawn' +export * from './remove' +export * from './reparent' export * from './removeresource' export * from './setresourcealias' export * from './spawn' diff --git a/packages/commands/src/commands/remove.js b/packages/commands/src/commands/remove.js new file mode 100644 index 00000000..922d1e41 --- /dev/null +++ b/packages/commands/src/commands/remove.js @@ -0,0 +1,38 @@ +/** @import { EntityHandle, World } from '@wimaengine/ecs' */ +/** @import { TupleConstructor } from '@wimaengine/type' */ +import { Command } from '@wimaengine/command' + +/** + * @template {unknown[]} T + */ +export class RemoveCommand extends Command { + + /** + * @readonly + * @type {EntityHandle} + */ + entity + + /** + * @readonly + * @type {TupleConstructor} + */ + components + + /** + * @param {EntityHandle} entity + * @param {TupleConstructor} components + */ + constructor(entity, components) { + super() + this.entity = entity + this.components = components + } + + /** + * @param {World} world + */ + execute(world) { + world.remove(this.entity, this.components) + } +} diff --git a/packages/commands/src/commands/reparent.js b/packages/commands/src/commands/reparent.js new file mode 100644 index 00000000..bf38372b --- /dev/null +++ b/packages/commands/src/commands/reparent.js @@ -0,0 +1,153 @@ +/** @import { EntityCell, EntityHandle, World } from '@wimaengine/ecs' */ +import { Command } from '@wimaengine/command' +import { Parent } from '@wimaengine/hierarchy' +import { throws } from '@wimaengine/logger' +import { Affine2, Affine3 } from '@wimaengine/math' +import { + GlobalTransform2D, + GlobalTransform3D, + Orientation2D, + Orientation3D, + Position2D, + Position3D, + Scale2D, + Scale3D +} from '@wimaengine/transform' + +/** + * @param {World} world + * @param {EntityHandle} candidate + * @param {EntityHandle} entity + * @returns {boolean} + */ +function isDescendant(world, candidate, entity) { + let current = candidate + + while (current) { + if (current.equals(entity)) { + return true + } + + current = world.get(current, Parent)?.entity + } + + return false +} + +/** + * @param {EntityCell} entity + * @param {EntityCell | undefined} newParent + */ +function reparentTransforms(entity, newParent) { + const worldTransform2D = entity.get(GlobalTransform2D) + const position2D = entity.get(Position2D) + const orientation2D = entity.get(Orientation2D) + const scale2D = entity.get(Scale2D) + + if (worldTransform2D && position2D && orientation2D && scale2D) { + const parentTransform2D = newParent?.get(GlobalTransform2D) + const localTransform2D = parentTransform2D ? + Affine2.multiply( + Affine2.invert(parentTransform2D, new Affine2()), + worldTransform2D, + new Affine2() + ) : + worldTransform2D + const [nextPosition2D, nextOrientation2D, nextScale2D] = localTransform2D.decompose() + + position2D.x = nextPosition2D.x + position2D.y = nextPosition2D.y + + orientation2D.cos = nextOrientation2D.cos + orientation2D.sin = nextOrientation2D.sin + + scale2D.x = nextScale2D.x + scale2D.y = nextScale2D.y + } + + const worldTransform3D = entity.get(GlobalTransform3D) + const position3D = entity.get(Position3D) + const orientation3D = entity.get(Orientation3D) + const scale3D = entity.get(Scale3D) + + if (worldTransform3D && position3D && orientation3D && scale3D) { + const parentTransform3D = newParent?.get(GlobalTransform3D) + const localTransform3D = parentTransform3D ? + Affine3.multiply( + Affine3.invert(parentTransform3D), + worldTransform3D, + new Affine3() + ) : + worldTransform3D + const [nextPosition3D, nextOrientation3D, nextScale3D] = localTransform3D.decompose() + + position3D.x = nextPosition3D.x + position3D.y = nextPosition3D.y + position3D.z = nextPosition3D.z + + orientation3D.x = nextOrientation3D.x + orientation3D.y = nextOrientation3D.y + orientation3D.z = nextOrientation3D.z + orientation3D.w = nextOrientation3D.w + + scale3D.x = nextScale3D.x + scale3D.y = nextScale3D.y + scale3D.z = nextScale3D.z + } +} + +export class ReparentCommand extends Command { + + /** + * @readonly + * @type {EntityHandle} + */ + entity + + /** + * @readonly + * @type {EntityHandle | undefined} + */ + parent + + /** + * @param {EntityHandle} entity + * @param {EntityHandle | undefined} [parent=undefined] + */ + constructor(entity, parent = undefined) { + super() + this.entity = entity + this.parent = parent + } + + /** + * @param {World} world + */ + execute(world) { + const { entity, parent } = this + const cell = world.getEntity(entity) + const parentCell = parent ? world.getEntity(parent) : undefined + const parentComponent = cell.get(Parent) + + if (parent && isDescendant(world, parent, entity)) { + throws(`The entity ${entity.id()} cannot be reparented to itself or one of its descendants.`) + } + + if ( + (parent === undefined && !parentComponent) || + (parent !== undefined && parentComponent?.entity.equals(parent)) + ) { + return + } + + reparentTransforms(cell, parentCell) + + if (parentComponent) { + world.remove(entity, [Parent]) + } + + if (parent !== undefined) { + world.insert(entity, [new Parent(parent)]) + } + } +} diff --git a/packages/commands/src/core/entity.js b/packages/commands/src/core/entity.js index c01b5a75..19467840 100644 --- a/packages/commands/src/core/entity.js +++ b/packages/commands/src/core/entity.js @@ -1,7 +1,8 @@ /** @import { EntityHandle, World } from '@wimaengine/ecs' */ +/** @import { TupleConstructor } from '@wimaengine/type' */ import { CommandQueue } from '@wimaengine/command' import { assert } from '@wimaengine/logger' -import { SpawnCommand, DespawnCommand } from '../commands' +import { CloneCommand, SpawnCommand, DespawnCommand, RemoveCommand, ReparentCommand } from '../commands' const entityerror = 'Spawn an entity using `Entity.spawn()` before using ' @@ -109,10 +110,45 @@ export class EntityCommands { return this } + /** + * Clones an entity and its descendants. + * + * @param {EntityHandle} entity + * @returns {EntityHandle} + */ + clone(entity) { + const command = CloneCommand.create(this.world, entity) + + this.queue.add(command) + + return command.entity + } + + /** + * Removes components from a given entity. + * + * @template {unknown[]} T + * @param {EntityHandle} entity + * @param {TupleConstructor} components + */ + remove(entity, components) { + this.queue.add(new RemoveCommand(entity, components)) + } + /** * @param {EntityHandle} entity */ despawn(entity) { this.queue.add(new DespawnCommand(entity)) } + + /** + * Reparents an entity while preserving its world transform. + * + * @param {EntityHandle} entity + * @param {EntityHandle | undefined} [parent=undefined] + */ + reparent(entity, parent = undefined) { + this.queue.add(new ReparentCommand(entity, parent)) + } }