diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index ad085770..b0e8bf73 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -227,7 +227,11 @@ export interface NodeContext { position: { x: number; y: number; z: number }, parent: EntityHandle | null, ): EntityHandle | null; - destroy(target: EntityHandle): void; + /** + * Removes `target` with everything below it. With `keepChildren` only the object goes: its + * children move up to its parent where they stand, and are answered, nearest level only. + */ + destroy(target: EntityHandle, keepChildren: boolean): EntityHandle[]; stop(): void; /** Ends the run and starts it again from the saved scene (everything spawned is lost). */ restart(): void; diff --git a/src/core/graph/nodes/action/Destroy.ts b/src/core/graph/nodes/action/Destroy.ts index 45d35abb..cf2472fd 100644 --- a/src/core/graph/nodes/action/Destroy.ts +++ b/src/core/graph/nodes/action/Destroy.ts @@ -1,18 +1,21 @@ import type { NodeDef } from '../../NodeRegistry'; -import { asEntity } from '../../values'; -import { exec, obj } from '../shared'; +import { asBoolean, asEntity } from '../../values'; +import { bool, exec, list, obj, withDefault } from '../shared'; export const Destroy: NodeDef = { type: 'Destroy', label: 'Destroy', category: 'action', - description: 'Removes an object from the running game.', - inputs: [exec(), obj('object')], - outputs: [exec()], + description: + 'Removes an object from the running game, with everything below it. With "keep children" only the ' + + 'object goes: its children move up to its parent and stay where they are in the world, and come out ' + + 'as "children" – the nearest level only. Its behaviors and its rigidbody go with it either way.', + inputs: [exec(), obj('object'), withDefault(bool('keepChildren', 'keep children'), false)], + outputs: [exec(), list('children')], params: [], run: (ctx, inputs) => { const target = asEntity(inputs.object); - if (target) ctx.destroy(target); - return { next: 'exec' }; + const children = target ? ctx.destroy(target, asBoolean(inputs.keepChildren)) : []; + return { next: 'exec', outputs: { children } }; }, }; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 798c56fe..ac2de9e3 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -11,6 +11,9 @@ import type { Cell } from '../core/model/tilemap'; import { descendantsBelow } from '../core/model/walk'; import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project'; import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; +import { apply, IDENTITY } from '../core/model/matrix'; +import { hasTransformProps } from '../core/properties/PropertySchema'; +import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform'; import { footprintContains } from '../core/properties/bounds'; import { colliderFrame, colliderShape, shapeContains } from '../core/properties/collider'; import { resolveSceneObjectProps } from '../core/properties/resolve'; @@ -94,6 +97,8 @@ export class World { private elapsed = 0; /** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */ private structureChanged = false; + /** Children a Destroy moved up to their grandparent; written into the scene by `syncToModel`. */ + private readonly movedUp = new Set(); private tilesChanged = false; private constructor( @@ -292,6 +297,14 @@ export class World { let values = false; const spawned = this.structureChanged; this.structureChanged = false; + // Moved up by a Destroy that kept them. Only a stored object can say so: a container's content + // has its place in the template, which is why the editor run cannot destroy it for good either. + for (const entity of this.movedUp) { + const object = scene.objects.find((o) => o.id === entity.objectId); + if (object && this.entities.includes(entity)) object.parentId = entity.parent?.objectId ?? ''; + } + const reparented = this.movedUp.size > 0; + this.movedUp.clear(); const alive = new Set(this.entities.map((e) => e.objectId)); const before = scene.objects.length; for (let i = scene.objects.length - 1; i >= 0; i--) { @@ -322,7 +335,7 @@ export class World { for (const key of drifted) ref.setOverride(key, withoutObjects(entity.props[key])); values = true; } - return { structure: spawned || scene.objects.length !== before, values }; + return { structure: spawned || reparented || scene.objects.length !== before, values }; } /** Registers a system; it starts immediately and is ticked in registration order. */ @@ -454,4 +467,36 @@ export class World { const index = this.entities.indexOf(entity); if (index >= 0) this.entities.splice(index, 1); } + + /** + * Removes only the entity: its children move up to its parent (top level when it had none) and + * stay where they are in the world. Its behaviors and its rigidbody are part of it rather than + * things it carries, so they go with it – moved up they would drive the parent instead. + * Answers the children that moved up. + */ + removeKeepingChildren(entity: Entity): Entity[] { + if (!this.entities.includes(entity)) return []; + const own = localMatrixOf(entity, transformOf(entity.props)); + const kept: Entity[] = []; + for (const child of this.childrenOf(entity)) { + if (child.type === 'behavior' || child.type === 'rigidbody') continue; + const at = apply(own, child); + child.x = at.x; + child.y = at.y; + child.z += entity.z; + const before = transformOf(child.props); + const carried = carriedTransform(before, own, IDENTITY); + if (hasTransformProps(child.type) && carried !== before) { + child.props.rotation = carried.rotation; + child.props.scale = carried.scale; + child.props.mirrorX = carried.mirrorX; + child.props.mirrorY = carried.mirrorY; + } + child.parent = entity.parent; + this.movedUp.add(child); + kept.push(child); + } + this.remove(entity); + return kept; + } } diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 960dd7db..b97a149f 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -747,9 +747,12 @@ export class BehaviorSystem implements System { if (assetId === '' || file) this.audio.stop(file); }, spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null), - destroy: (target) => { + destroy: (target, keepChildren) => { const found = resolve(target); - if (found) world.remove(found); + if (!found) return []; + if (keepChildren) return world.removeKeepingChildren(found); + world.remove(found); + return []; }, loadScene: (sceneId) => { // An id naming no scene is dropped here rather than half way through the swap, where the diff --git a/tests/core/destroy-keep-children.test.ts b/tests/core/destroy-keep-children.test.ts new file mode 100644 index 00000000..029215c9 --- /dev/null +++ b/tests/core/destroy-keep-children.test.ts @@ -0,0 +1,59 @@ +/** + * Destroy with "keep children": only the object goes, its children move up to its parent and come + * out of the node as a list – the nearest level only. Its behaviors go with it. + */ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld, type SceneFixture } from '../helpers'; + +function sprite(f: SceneFixture, name: string): string { + const command = new CreateAssetCommand('sprite', name); + f.stack.execute(command); + return command.asset.id; +} + +/** Shelf › Crate › (Lid › Nail, Label, Breaker); the breaker destroys its owner on start and logs the list. */ +function scene(keepChildren: boolean) { + const f = makeSpriteScene('Crate'); + const shelf = place(f, sprite(f, 'Shelf'), { x: 0, y: 0 }); + const crate = place(f, f.spriteId, { x: 40, y: 0 }, shelf); + const lid = place(f, sprite(f, 'Lid'), { x: 40, y: 10 }, crate); + const nail = place(f, sprite(f, 'Nail'), { x: 40, y: 20 }, lid); + place(f, sprite(f, 'Label'), { x: 40, y: -10 }, crate); + const behavior = makeBehavior(f, 'Breaker'); + const edit = graphEditor(f, behavior); + const start = edit.addNode('OnStart'); + const owner = edit.addNode('Owner'); + const destroy = edit.addNode('Destroy', { keepChildren }); + const log = edit.addNode('Log'); + edit.wire([ + [start, 'exec', destroy, 'exec'], + [owner, 'object', destroy, 'object'], + [destroy, 'exec', log, 'exec'], + [destroy, 'children', log, 'message'], + ]); + const breaker = place(f, behavior.id, { x: 40, y: 0 }, crate); + const run = startWorld(f, { behaviors: true }); + run.frame(); + return { world: run.world, shelf, crate, lid, nail, breaker }; +} + +describe('Destroy with keep children', () => { + it('removes only the object and answers its children, now below its parent', () => { + const { world, shelf, crate, lid, nail, breaker } = scene(true); + expect(world.findByObjectId(crate)).toBeUndefined(); + expect(world.findByObjectId(breaker)).toBeUndefined(); + expect(world.findByObjectId(lid)?.parent).toBe(world.findByObjectId(shelf)); + expect(world.findByObjectId(nail)?.parent).toBe(world.findByObjectId(lid)); + // Both children of the crate, the nail stays below the lid. + expect(world.log).toEqual(['Lid, Label']); + }); + + it('still takes everything below along without it, and answers no children', () => { + const { world, shelf, lid, nail } = scene(false); + expect(world.findByObjectId(lid)).toBeUndefined(); + expect(world.findByObjectId(nail)).toBeUndefined(); + expect(world.findByObjectId(shelf)).toBeDefined(); + expect(world.log).toEqual(['']); + }); +}); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 5abab46d..e3704403 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -1642,11 +1642,19 @@ describe('action nodes write through to the object they are given', () => { expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 }); }); - it('Destroy hands the object to the context', () => { + it('Destroy hands the object to the context, children and all unless told to keep them', () => { const removed: string[] = []; - const ctx = fakeContext({ destroy: (target) => removed.push(target.name) }); - runDef('Destroy', ctx, { object: fakeEntity('Coin') }, {}); - expect(removed).toEqual(['Coin']); + const moved = [fakeEntity('Lid'), fakeEntity('Nail')]; + const ctx = fakeContext({ + destroy: (target, keepChildren) => { + removed.push(`${target.name} ${keepChildren}`); + return keepChildren ? moved : []; + }, + }); + expect(runDef('Destroy', ctx, { object: fakeEntity('Coin') }, {}).outputs).toEqual({ children: [] }); + const kept = runDef('Destroy', ctx, { object: fakeEntity('Crate'), keepChildren: true }, {}); + expect(kept.outputs).toEqual({ children: moved }); + expect(removed).toEqual(['Coin false', 'Crate true']); }); it('RestartGame asks the context for a restart', () => { diff --git a/tests/core/world-sync.test.ts b/tests/core/world-sync.test.ts index 445f1645..635bbef5 100644 --- a/tests/core/world-sync.test.ts +++ b/tests/core/world-sync.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { ClearOverrideCommand, RemoveSceneObjectsCommand, SetOverrideCommand, SetParentCommand, } from '../../src/core/commands/sceneObjectCommands'; +import { worldMatrixOf } from '../../src/core/graph'; import { findScene } from '../../src/core/model/Project'; import { World } from '../../src/runtime/World'; import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; @@ -116,6 +117,62 @@ describe('world ↔ model synchronisation (editing while playing)', () => { expect(world.spawn(sceneId, { x: 0, y: 0, z: 0 }, null)).toBeNull(); }); + it('removeKeepingChildren moves the children up where they stand, behaviors and rigidbody gone', () => { + const f = makeSpriteScene(); + const { stack, sceneId, spriteId, object } = f; + const behavior = new CreateAssetCommand('behavior', 'Brain'); + const rigidbody = new CreateAssetCommand('rigidbody', 'Body'); + stack.execute(behavior); + stack.execute(rigidbody); + const top = place(f, spriteId, { x: 100, y: 0 }); + const crate = place(f, spriteId, { x: 110, y: 0 }, top); + stack.execute(new SetOverrideCommand(sceneId, crate, 'rotation', 90)); + stack.execute(new SetOverrideCommand(sceneId, crate, 'scale', 2)); + const lid = place(f, spriteId, { x: 110, y: 10 }, crate); + const nail = place(f, spriteId, { x: 110, y: 20 }, lid); + const brain = place(f, behavior.asset.id, { x: 110, y: 0 }, crate); + const body = place(f, rigidbody.asset.id, { x: 110, y: 0 }, crate); + const { world } = startWorld(f); + const entity = (id: string) => world.findByObjectId(id)!; + const worldOf = (id: string) => worldMatrixOf(entity(id)); + const lidBefore = worldOf(lid); + const nailBefore = worldOf(nail); + const gone = entity(crate); + + expect(world.removeKeepingChildren(gone)).toEqual([entity(lid)]); + expect(world.findByObjectId(crate)).toBeUndefined(); + expect(world.findByObjectId(brain)).toBeUndefined(); + expect(world.findByObjectId(body)).toBeUndefined(); + // One level up, turned and sized so nothing moves on screen – the grandchild rides along. + expect(entity(lid).parent).toBe(entity(top)); + expect(entity(nail).parent).toBe(entity(lid)); + for (const [id, before] of [ + [lid, lidBefore], + [nail, nailBefore], + ] as const) + for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const) + expect(worldOf(id)[key]).toBeCloseTo(before[key], 9); + expect(entity(lid).props.rotation).toBeCloseTo(90, 9); + expect(entity(lid).props.scale).toBeCloseTo(2, 9); + + // The scene learns the new parent, so reading it back keeps the lid where it is. + expect(world.syncToModel().structure).toBe(true); + expect(object(lid).parentId).toBe(top); + world.syncFromModel(); + expect(entity(lid).parent).toBe(entity(top)); + for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const) + expect(worldOf(lid)[key]).toBeCloseTo(lidBefore[key], 9); + + // At the top level the children become top level; nothing left to answer for a gone object. + expect(world.removeKeepingChildren(entity(top))).toEqual([entity(lid)]); + expect(entity(lid).parent).toBeNull(); + for (const key of ['a', 'b', 'c', 'd', 'tx', 'ty'] as const) + expect(worldOf(lid)[key]).toBeCloseTo(lidBefore[key], 9); + world.syncToModel(); + expect(object(lid).parentId).toBe(''); + expect(world.removeKeepingChildren(gone)).toEqual([]); + }); + it('a play-mode copy leaves the original model untouched', () => { const { project, sceneId, a, object, sceneOf } = setup(); const copy = roundTrip(project); diff --git a/tests/helpers.ts b/tests/helpers.ts index 176e51cd..708ec295 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -253,7 +253,7 @@ export function fakeContext(overrides: Partial = {}): FakeContext { stopSound: (assetId) => ctx.silenced.push(assetId), loadScene: (sceneId) => ctx.loaded.push(sceneId), spawn: () => null, - destroy: () => {}, + destroy: () => [], stop: () => { ctx.stopped = true; },