From 483c34bb5cf540037331282c5b9a8cf1390ac4cb Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 26 Sep 2026 21:45:27 +0200 Subject: [PATCH] Let Destroy keep the children and hand them back as a list Destroying a container used to take everything below it along. Now Destroy can remove just the object itself and pass its direct children on, so a behavior can keep working with what was inside. - New input keep children (bool, default off). When on, only the object goes. Its direct children move up to the object's parent, or to the top level if there is none. Grandchildren stay where they are. - New output children (object list) with the direct children that were kept. It is empty when keep children is off. - The children keep their place on screen. carriedTransform folds the destroyed object's position, rotation and scale into their own, the same as reparenting in the editor. - Behaviors and the rigidbody of the object are always destroyed with it and are not in the list. Moved up they would steer or simulate the parent. - World remembers which children it reparented, and syncToModel writes their new parent into the scene. A reload of the model keeps it. - Limit, read from the code only: in the editor run the contents of a placed container are not baked. Moved children there last for the run only. Tests: - ./check.sh: 170 files, 2478 tests, CHECK EXIT 0 - e2e 34 (the module with a Destroy node): ALL PASSED, 21 checks - world-sync: world matrix of child and grandchild unchanged under a rotated and scaled parent, parent survives syncFromModel, top level case, already destroyed object gives [] - destroy-keep-children: real graph Destroy(Owner) -> Log(children) logs Lid, Label without the grandchild Nail - 3 counter-probes, all caught: no world transform (1 test fails), no parent in the scene (1), behaviors and rigidbody moved up (2) Co-Authored-By: Claude Opus 5.5 --- src/core/graph/NodeContext.ts | 6 ++- src/core/graph/nodes/action/Destroy.ts | 17 ++++--- src/runtime/World.ts | 47 ++++++++++++++++++- src/runtime/systems/BehaviorSystem.ts | 7 ++- tests/core/destroy-keep-children.test.ts | 59 ++++++++++++++++++++++++ tests/core/node-catalog.test.ts | 16 +++++-- tests/core/world-sync.test.ts | 59 +++++++++++++++++++++++- tests/helpers.ts | 2 +- 8 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 tests/core/destroy-keep-children.test.ts 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; },