diff --git a/README.md b/README.md index d5011ec2..3815b35e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Live: · Widget playground: o.id === split.instanceId); if (!split || !instance) return undefined; + const segments = split.path.split(PATH_SEP); let asset = containerAssetOf(project, instance); let template: SceneObject | undefined; - for (const segment of split.path.split(PATH_SEP)) { + // Each container on the way holds edits for what lies below it, keyed relative to itself. + const between: { holder: SceneObject; key: string }[] = []; + for (const [index, segment] of segments.entries()) { const next = template ? containerAssetOf(project, template) : asset; if (!next) return undefined; asset = next; template = next.objects.find((t) => t.id === segment); if (!template) return undefined; + if (index < segments.length - 1) + between.push({ holder: template, key: segments.slice(index + 1).join(PATH_SEP) }); } - return asset && template ? { instance, path: split.path, asset, template } : undefined; + if (!asset || !template) return undefined; + const leaf = template; + return { + instance, + path: split.path, + asset, + template: leaf, + get base() { + let edit: InstanceEdit | undefined; + // Outermost wins, as in `expand`: it sits closest to the instance being edited. + for (let i = between.length - 1; i >= 0; i--) + edit = mergeEdits(edit, between[i]!.holder.instanceEdits[between[i]!.key]); + return resolveChild(leaf, edit, leaf.id, leaf.parentId); + }, + }; } // --- writing ----------------------------------------------------------------------------------- @@ -267,13 +295,13 @@ function instanceRef(target: InstanceTarget, object: SceneObject): ObjectRef { target, setName: (name) => write((edit) => { - if (name === target.template.name) delete edit.name; + if (name === target.base.name) delete edit.name; else edit.name = name; }), setPosition: (position) => write((edit) => { for (const axis of AXES) { - if (position[axis] === target.template.position[axis]) delete edit.position[axis]; + if (position[axis] === target.base.position[axis]) delete edit.position[axis]; else edit.position[axis] = position[axis]; } }), @@ -284,7 +312,7 @@ function instanceRef(target: InstanceTarget, object: SceneObject): ObjectRef { }), setOverride: (key, value) => write((edit) => { - if (value === target.template.overrides[key]) delete edit.overrides[key]; + if (value === target.base.overrides[key]) delete edit.overrides[key]; else setBagValue(edit.overrides, key, value); }), }; diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts index cc4a4054..38c69ded 100644 --- a/src/core/properties/resolve.ts +++ b/src/core/properties/resolve.ts @@ -53,6 +53,6 @@ export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string * Compared as overrides, not as effective values: both sides resolve against the same asset, so * "neither overrides" means equal even when the asset value changed since. */ -export function deviatesFromTemplate(object: SceneObject, template: SceneObject, key: string): boolean { - return object.overrides[key] !== template.overrides[key]; +export function deviatesFromTemplate(object: SceneObject, base: SceneObject, key: string): boolean { + return object.overrides[key] !== base.overrides[key]; } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 7d2872c3..17775379 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -9,7 +9,7 @@ import { attachAutosave } from './autosave'; import { ContainerEditor } from './container/ContainerEditor'; import { GraphEditor } from './graph/GraphEditor'; import { Inspector } from './inspector/Inspector'; -import { AssetPanel, type PlacementTarget } from './panels/AssetPanel'; +import { AssetPanel } from './panels/AssetPanel'; import { HierarchyPanel } from './panels/HierarchyPanel'; import { Toolbar } from './panels/Toolbar'; import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession'; @@ -92,7 +92,7 @@ export class Editor implements PlayHost { this.workspace = new HStack(); this.leftColumn = new VStack({ gap: 1 }); - this.assets = new AssetPanel(state, () => this.placementTarget(), textures); + this.assets = new AssetPanel(state, textures); this.leftColumn.add(this.assets, 1); installTextureImport(app.canvas, { state, textures }); this.leftColumn.add(this.hierarchy, 1); @@ -185,16 +185,6 @@ export class Editor implements PlayHost { ui().focus.validate(); } - /** Where the Assets panel places: into the container being edited, otherwise into the scene. */ - private placementTarget(): PlacementTarget | undefined { - if (this.tab === 'container') { - const holder = findObjectHolder(this.state.project, this.containerEditor.currentAssetId); - return holder ? { holder, at: this.containerEditor.worldCenter() } : undefined; - } - const scene = this.state.activeScene; - return scene ? { holder: scene, at: this.viewport.worldCenter() } : undefined; - } - boot(): Promise { return this.projects.boot(); } diff --git a/src/editor/container/ContainerEditor.ts b/src/editor/container/ContainerEditor.ts index d3c4e76c..a5f3c8d7 100644 --- a/src/editor/container/ContainerEditor.ts +++ b/src/editor/container/ContainerEditor.ts @@ -1,6 +1,5 @@ import { Graphics } from 'pixi.js'; import type { ContainerAsset } from '../../core/model/Asset'; -import type { Vec2 } from '../../core/model/Properties'; import { findAsset, listContainers } from '../../core/model/Project'; import { activeDrag, @@ -79,11 +78,6 @@ export class ContainerEditor extends VStack { return this.canvas.dropInfo(); } - /** Middle of the view – where a double-click in the Assets panel places into this template. */ - worldCenter(): Vec2 { - return this.canvas.worldCenter(); - } - /** Where a template object is drawn, in global screen coordinates – for the check scripts. */ objectScreen(objectId: string): { x: number; y: number } | null { return this.canvas.objectScreen(objectId); diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index a87aeb61..f6e01339 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -487,12 +487,12 @@ function markObjectRows( for (const axis of POSITION_AXES) host.fields .get(axis) - ?.setModified(Boolean(target) && object.position[axis] !== target!.template.position[axis]); + ?.setModified(Boolean(target) && object.position[axis] !== target!.base.position[axis]); for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0); for (const def of schemaForAsset(asset)) { const deviates = target - ? deviatesFromTemplate(object, target.template, def.key) + ? deviatesFromTemplate(object, target.base, def.key) : deviatesFromAsset(object, asset, def.key); host.fields.get(def.key)?.setModified(deviates); } @@ -516,7 +516,7 @@ function deviations( const target = targetOf(state.project, holderId, objectId); const fromAsset = deviatesFromAsset(object, asset, key); return { - template: Boolean(target) && deviatesFromTemplate(object, target!.template, key), + template: Boolean(target) && deviatesFromTemplate(object, target!.base, key), asset: !target && fromAsset, applyToAsset: fromAsset, }; @@ -531,7 +531,7 @@ function positionMoved( ): boolean { const target = targetOf(state.project, holderId, objectId); const object = currentRef(state, holderId, objectId)?.object; - return Boolean(target && object) && object!.position[axis] !== target!.template.position[axis]; + return Boolean(target && object) && object!.position[axis] !== target!.base.position[axis]; } function currentRef( diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index e9d226f6..1f36eb2d 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -1,6 +1,5 @@ import { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands'; import { nextAssetName } from '../../core/commands/naming'; -import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; import { ASSET_TYPE_LABELS, ASSET_TYPES, @@ -8,31 +7,20 @@ import { hasColorSwatch, isPlaceableType, } from '../../core/model/AssetTypes'; -import type { Vec2 } from '../../core/model/Properties'; -import { findAsset, type ObjectHolder } from '../../core/model/Project'; +import { findAsset } from '../../core/model/Project'; import type { TextureStore } from '../../core/store/TextureStore'; import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui'; import { confirmDeleteAsset } from '../actions'; -import { type EditorState, objectSelection } from '../state'; +import type { EditorState } from '../state'; import { pickAndImport } from '../textureImport'; -/** - * Where a placed asset lands. The tab in front decides, so it appears where the user is - * looking: the Scene tab places into the active scene, the Container tab into the template. - */ -export interface PlacementTarget { - holder: ObjectHolder; - at: Vec2; -} - -/** Lists all assets of the project; creates, places and deletes them. */ +/** Lists all assets of the project; creates and deletes them. Placing is a drag into a view. */ export class AssetPanel extends Panel { private readonly list = new ListView(); private readonly scroll = new ScrollContainer(); constructor( private readonly state: EditorState, - private readonly target: () => PlacementTarget | undefined, /** Where imported images go. Without it the Texture entry has nothing to upload to. */ private readonly textures: TextureStore | null = null, ) { @@ -99,12 +87,10 @@ export class AssetPanel extends Panel { pickAndImport({ state: this.state, textures: this.textures }); } - /** Double-click / Enter: scenes become active, everything else is placed. */ + /** Double-click / Enter on a scene makes it the active one. Placing is a drag, not a click. */ private activate(id: string): void { const asset = findAsset(this.state.project, id); - if (!asset) return; - if (asset.type === 'scene') this.state.execute(new SetActiveSceneCommand(id)); - else this.place(id); + if (asset?.type === 'scene') this.state.execute(new SetActiveSceneCommand(id)); } /** Where an asset's row sits on screen, so a check can pick it up by id (test hook). */ @@ -119,17 +105,6 @@ export class AssetPanel extends Panel { startDrag({ kind: 'asset', id: assetId }, asset.name); } - private place(assetId: string): void { - const target = this.target(); - if (!target) { - this.state.setNotice('Nothing to place into – create a scene, or open a container first.'); - return; - } - const { holder, at } = target; - const command = new PlaceAssetCommand(holder.id, assetId, at); - if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId])); - } - private openContextMenu(id: string, x: number, y: number): Menu | undefined { const asset = findAsset(this.state.project, id); if (!asset) return undefined; diff --git a/src/viewport/SceneViewport.ts b/src/viewport/SceneViewport.ts index 091afa35..a6cc3d6a 100644 --- a/src/viewport/SceneViewport.ts +++ b/src/viewport/SceneViewport.ts @@ -116,11 +116,6 @@ export class SceneViewport extends WorldViewport { return p ? { assetId: p.assetId, x: p.at.x, y: p.at.y, rejection: p.rejection } : null; } - /** World coordinates at the centre of the view. */ - worldCenter(): Vec2 { - return { x: Math.round(this.view.x), y: Math.round(this.view.y) }; - } - /** * Frames everything the holder contains. The origin is always included: it is where a container * is grabbed when placed, so a template drawn far off to one side would otherwise hide it. diff --git a/tests/core/apply.test.ts b/tests/core/apply.test.ts index 389a0af4..ee628b05 100644 --- a/tests/core/apply.test.ts +++ b/tests/core/apply.test.ts @@ -107,12 +107,15 @@ describe('position apply / revert', () => { describe('override apply / revert', () => { it('applies an override to the container template and to instances that had no own value', () => { - const { stack, sceneId, a, a2, object, templateOfA } = setup(); + const { stack, sceneId, a, a2, object, templateOfA, sceneOf, instanceId } = setup(); stack.execute(new SetOverrideCommand(sceneId, a, 'width', 77)); stack.execute(new ApplyOverrideToContainerCommand(sceneId, a, 'width')); expect(templateOfA().overrides.width).toBe(77); expect(object(a2).overrides.width).toBe(77); expect(object(a).overrides.width).toBe(77); + // The template carries it now, so nothing is left to deviate – the edit has to be gone, not + // merely equal. It is dropped by comparing against a base read *after* the write. + expect(sceneOf().objects.find((o) => o.id === instanceId)!.instanceEdits).toEqual({}); stack.undo(); expect(templateOfA().overrides.width).toBeUndefined(); expect(object(a2).overrides.width).toBeUndefined(); @@ -173,3 +176,57 @@ describe('override apply / revert', () => { expect(object(a2).overrides.height).toBe(9); }); }); + +/** + * A container inside a container: the value a scene edit falls back to is the template *plus* the + * edits of every container in between – not the innermost template alone. + */ +function nested() { + const f = makeSpriteScene(); + const inner = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x: 0, y: 0 })]); + f.stack.execute(inner); + const innerAsset = () => findAsset(f.project, inner.assetId) as ContainerAsset; + const leaf = innerAsset().objects[0]!.id; + // The sprite is 100 wide anyway: an override carrying the asset's own value, the way putting a + // value back by hand in a container editor leaves one behind. + f.stack.execute(new SetOverrideCommand(inner.assetId, leaf, 'width', 100)); + + const outer = new CreateContainerCommand(f.sceneId, [inner.objectId]); + f.stack.execute(outer); + const outerAsset = () => findAsset(f.project, outer.assetId) as ContainerAsset; + const middle = outerAsset().objects[0]!.id; + // Inside the outer container, that one sprite is narrowed to 8. + f.stack.execute(new SetOverrideCommand(outer.assetId, instanceChildId(middle, leaf), 'width', 8)); + + return { ...f, leaf, deep: instanceChildId(outer.objectId, `${middle}/${leaf}`) }; +} + +describe('a base two levels up', () => { + it('keeps a scene value that equals the inner template but not the container in between', () => { + const { stack, sceneId, deep, object } = nested(); + expect(object(deep).overrides.width).toBe(8); + stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 10)); + expect(object(deep).overrides.width).toBe(10); + // 100 is what the innermost template says – but the container in between says 8, so this is + // a deviation and has to be kept. Comparing against the template alone dropped it. + stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 100)); + expect(object(deep).overrides.width).toBe(100); + stack.undo(); + expect(object(deep).overrides.width).toBe(10); + }); + + it('resets to the value the container in between gives, not to the inner template', () => { + const { stack, sceneId, deep, object } = nested(); + stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 100)); + stack.execute(new RevertOverrideToTemplateCommand(sceneId, deep, 'width')); + expect(object(deep).overrides.width).toBe(8); + }); + + it('drops an edit that matches the base, so the value follows again', () => { + const { stack, sceneId, deep, object, sceneOf } = nested(); + stack.execute(new SetOverrideCommand(sceneId, deep, 'width', 8)); + const instance = sceneOf().objects.find((o) => o.id === deep.split('~')[0])!; + expect(instance.instanceEdits).toEqual({}); + expect(object(deep).overrides.width).toBe(8); + }); +}); diff --git a/todo-notes/done/asset-doppelklick.md b/todo-notes/done/asset-doppelklick.md new file mode 100644 index 00000000..31552689 --- /dev/null +++ b/todo-notes/done/asset-doppelklick.md @@ -0,0 +1,18 @@ +Angeblich kann man Assets per Doppelklick in die Scene/Container platzieren. Das funktioniert aber nicht. +Falls es irgendwie implementiert ist gehört es sowieso raus => Das Doppelklick Feature brauchen wir nicht! + +--- + +**Umgesetzt (2026-09-09).** Raus, wie gewuenscht. + +Es *war* implementiert: `ListView` meldete einen Doppelklick (und Enter) als `onActivate`, und +`AssetPanel.activate` platzierte damit in die Mitte der sichtbaren Ansicht — der Weg, den ich in +`asset-drag-and-drop.md` noch als "bleibt bestehen" beschrieben hatte. Warum er bei dir nichts +tat, habe ich nicht weiter verfolgt: das Feature faellt ohnehin weg. + +Entfernt sind damit auch die Teile, die nur dafuer existierten: `AssetPanel.place`, das +`PlacementTarget`-Interface samt `Editor.placementTarget`, und die beiden `worldCenter()`-Methoden +in `ContainerEditor` und `SceneViewport`, die sonst tot im Baum stehengeblieben waeren. + +**Geblieben:** Doppelklick (oder Enter) auf ein **Szenen**-Asset setzt es weiterhin als aktive +Szene — das ist kein Platzieren, sondern derselbe Befehl wie im Kontextmenue. diff --git a/todo-notes/done/asset-drag-and-drop.md b/todo-notes/done/asset-drag-and-drop.md index b7308e4e..2e2024f0 100644 --- a/todo-notes/done/asset-drag-and-drop.md +++ b/todo-notes/done/asset-drag-and-drop.md @@ -5,7 +5,8 @@ zukünftig soll es per drag and drop passieren **Umgesetzt (2026-09-09).** Drag and Drop ersetzt den Menueintrag vollstaendig — auf deine Ansage hin ist "Place in scene"/"Place in container" aus dem Kontextmenue verschwunden. -Doppelklick auf die Zeile platziert weiterhin in die Mitte der sichtbaren Ansicht. +Doppelklick auf die Zeile platzierte zunaechst weiterhin in die Mitte der Ansicht — auf deine Notiz +`asset-doppelklick.md` hin ist auch das entfernt. - **Ziehen:** die Zeile im Assets-Panel wird nach 4 px Weg zum Drag; ein Ghost mit dem Asset-Namen haengt am Cursor (`src/ui/drag.ts`). Die `ListView` erkennt die Geste nur und diff --git a/todo-notes/done/overwrite-reset-nested.md b/todo-notes/done/overwrite-reset-nested.md new file mode 100644 index 00000000..31f982af --- /dev/null +++ b/todo-notes/done/overwrite-reset-nested.md @@ -0,0 +1,30 @@ +folgende fehlverhalten, beispiel jump and run projekt: +- wenn ich IM container asset "level" von einem einzigen coin sprite die breite ändere von 24 => 8 +- wenn ich dann in der level scene bin und auf das geänderte coin sprite gehe und von 8 => 10 ändere funktioniert es noch +- sobald ich aber auf den original standard wert 24 ändere (oder auf den reset button klicke) dann springt es zurück auf 8 (ist korrekt bei reset, aber wenn ich explizit die ursprünglichen 24 haben will, geht das nicht, weil dieser wert nicht mehr als overwrite gilt) + +--- + +**Umgesetzt (2026-09-09).** Reproduziert im Browser am echten Jump-and-Run-Stand: nach dem Tippen +von 24 stand `{"ov":8}` da, genau wie gemeldet. Deine Deutung war richtig — der Wert galt nicht +mehr als Override und wurde weggeworfen. + +**Ursache.** Ein Instanz-Edit wurde geloescht, sobald der Wert dem Template-Objekt gleicht +(`instanceRef.setOverride`). Bei **verschachtelten** Containern ist das Template aber nicht die +Basis: `level` haelt auf seinem `c1`-Objekt einen Edit fuer das darunterliegende `coin-body` +(die 8), und der gewinnt gegen das Template im `coin`-Asset. Traegt dieses Template zufaellig +schon die 24 — was jede Ruecknahme auf den Asset-Wert in einem Container-Editor hinterlaesst, +weil ein gespeicherter Override nie geloescht wird — dann galten 24 als "gleich dem Template", +der Edit fiel weg und der Wert landete auf der Zwischenebene: **8**. + +**Fix.** `InstanceTarget` hat jetzt `base`: das Template mit den Edits **jedes Containers +dazwischen** eingewoben. Nur das ist ein Wert, auf den etwas zurueckfallen kann. Alles, was eine +Abweichung beurteilt, benutzt es: `setOverride`/`setName`/`setPosition`, die beiden +`Revert…ToTemplate`-Kommandos und die Gold-Markierung im Inspector. Das **Schreiben** von +"Apply to container" geht weiterhin ins echte Template — dort gehoert der Wert hin. + +Bei einer einzigen Ebene sind `base` und `template` identisch, das Verhalten aendert sich dort +nicht. Reset landet weiterhin auf 8 (deine Erwartung), 24 bleibt jetzt als Override stehen. + +Drei Unit-Tests in `tests/core/apply.test.ts` halten es fest; zwei davon schlagen ohne den Fix +fehl (gegengeprueft). diff --git a/todo-notes/flaky-pixel-art-filter.md b/todo-notes/flaky-pixel-art-filter.md new file mode 100644 index 00000000..f4feee72 --- /dev/null +++ b/todo-notes/flaky-pixel-art-filter.md @@ -0,0 +1,14 @@ +Der E2E-Check `pixel-art filtering keeps the border between two quadrants hard` faellt +**sporadisch** um — heute (2026-09-09) zweimal in vier Laeufen, jeweils ohne dass eine Aenderung +Texturen beruehrt haette. + +- gruen: `["#e63c3c","#e63c3c","#3cc85a","#3cc85a","#3cc85a"]` — harte Kante, `nearest` greift +- rot: `["#c65641","#a67047","#868a4c","#66a552","#3cc85a"]` — weicher Verlauf, also `linear` + +Der Folge-Check (`switching the filter back to smooth …`) meldet im Fehlerfall fuer **beide** +Filter denselben Verlauf, ist aber trotzdem gruen — er vergleicht nicht gegen den Erwartungswert. +Das heisst: wenn der Filterwechsel haengt, faellt genau ein Check, und der zweite deckt es zu. + +Zu klaeren: laedt der Test die Textur zu frueh aus (Timing), oder wird `scaleMode` beim ersten +Setzen manchmal nicht auf die schon hochgeladene GPU-Textur durchgereicht (dann ist es ein echter +Produktfehler und kein Testproblem)? diff --git a/todo-notes/overwrite-reset-nested.md b/todo-notes/overwrite-reset-nested.md deleted file mode 100644 index 0612ae90..00000000 --- a/todo-notes/overwrite-reset-nested.md +++ /dev/null @@ -1,4 +0,0 @@ -folgende fehlverhalten, beispiel jump and run projekt: -- wenn ich IM container asset "level" von einem einzigen coin sprite die breite ändere von 24 => 8 -- wenn ich dann in der level scene bin und auf das geänderte coin sprite gehe und von 8 => 10 ändere funktioniert es noch -- sobald ich aber auf den original standard wert 24 ändere (oder auf den reset button klicke) dann springt es zurück auf 8 (ist korrekt bei reset, aber wenn ich explizit die ursprünglichen 24 haben will, geht das nicht, weil dieser wert nicht mehr als overwrite gilt) diff --git a/todo-notes/overwrite-textfarbe-auch-bei-assets.md b/todo-notes/overwrite-textfarbe-auch-bei-assets.md index 687e0c68..5ec01b89 100644 --- a/todo-notes/overwrite-textfarbe-auch-bei-assets.md +++ b/todo-notes/overwrite-textfarbe-auch-bei-assets.md @@ -1 +1 @@ -die textfarbe beim asset soll auch in diesem gold dargestellt werden, falls ein property des assets einen override hat \ No newline at end of file +die textfarbe beim asset soll auch in diesem gold dargestellt werden, falls ein property des assets einen override hat