Key what an empty timeline row shows on a double-click (#412)
A double-click into a timeline row without keys set the property's
default, while the field in the row header showed the value the target
actually holds. A sprite standing on x 40 got a key with 0, so the
first key moved it. The double-click now keys the value the row shows.
- trackHeld in src/core/model/animation.ts says what a row without keys
holds. The field in the row header and SetAnimationKeyCommand both
ask it, so they cannot drift apart again.
- A relative row still gets 0, a row with keys still takes the value of
its curve at that frame, and a row whose target is gone still only
gives a notice and no key.
- README and the wiki page Development name 2047 checks.
Tests:
- Unit animation: 87 green, 1 new. A sprite on x 40 with the override
scale 2 gets keys with 40 and 2. Red with the old def.default.
- e2e 25 on melektaus-atx: ALL PASSED, 35 checks, 25i-ii new. The arm
stands on x 40 and a double-click on frame 3 keys { frame: 3,
value: 40 }.
- Gate on melektaus-atx: green, 304 files, 4703 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4fc425233
commit
c425eea004
5 files changed
+51
-6
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2046 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2047 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
targetAssetOf,
|
||||
targetDefs,
|
||||
trackDef,
|
||||
trackHeld,
|
||||
trackValueAt,
|
||||
} from '../model/animation';
|
||||
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset';
|
||||
@@ -215,7 +216,7 @@ export class SetTrackRelativeCommand extends TimelineCommand {
|
||||
/**
|
||||
* Sets the key at `frame`, replacing the value of one already there but keeping its interpolation.
|
||||
* Without a value it takes the value the row has at that frame – what a double click in the timeline
|
||||
* means – and on an empty row the property's default, which for a relative row is no offset at all.
|
||||
* means – and on an empty row what the row's header shows: the object's value, or no offset when relative.
|
||||
*/
|
||||
export class SetAnimationKeyCommand extends TimelineCommand {
|
||||
readonly label = 'Set key';
|
||||
@@ -235,7 +236,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
|
||||
const def = trackDef(project, asset, track);
|
||||
if (!def) throw new CommandError('The row aims at something that is gone');
|
||||
const current = trackValueAt(track.keys, def.kind, this.frame, false, curveLookup(project));
|
||||
const raw = this.value ?? current ?? (track.relative ? 0 : def.default);
|
||||
const raw = this.value ?? current ?? trackHeld(project, asset, track);
|
||||
const key: AnimationKey = { frame: this.frame, value: coerceValue(def, raw) };
|
||||
const interpolation = track.keys.find((k) => k.frame === this.frame)?.interpolation;
|
||||
if (interpolation) key.interpolation = interpolation;
|
||||
|
||||
@@ -625,18 +625,27 @@ export function timelineLines(project: Project, animation: AnimationAsset): Time
|
||||
for (const track of rows) {
|
||||
const def = defs.find((d) => d.key === track.property);
|
||||
const label = def ? def.label : `${track.property} (missing)`;
|
||||
const held = !aimed || !def ? 0 : track.relative ? 0 : heldBy(aimed, def);
|
||||
lines.push({ kind: 'row', track, label, def, held, depth });
|
||||
lines.push({ kind: 'row', track, label, def, held: heldBy(aimed, def, track), depth });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function heldBy(aimed: Aimed, def: PropertyDef): PropertyValue {
|
||||
function heldBy(
|
||||
aimed: Aimed | undefined,
|
||||
def: PropertyDef | undefined,
|
||||
track: AnimationTrack,
|
||||
): PropertyValue {
|
||||
if (!aimed || !def || track.relative) return 0;
|
||||
if (isPositionRow(aimed.asset.type, def.key)) return aimed.object?.position[def.key] ?? 0;
|
||||
return aimed.bag[def.key] ?? def.default;
|
||||
}
|
||||
|
||||
/** What a row holds without keys (#412): the value its object has, no offset for a relative row. */
|
||||
export function trackHeld(project: Project, animation: AnimationAsset, track: AnimationTrack): PropertyValue {
|
||||
return heldBy(aimedAt(project, animation, track.target), trackDef(project, animation, track), track);
|
||||
}
|
||||
|
||||
/** What a row's header shows at `frame` (#408): the row's value there, else what it holds without keys. */
|
||||
export function lineValueAt(
|
||||
line: TimelineLine & { kind: 'row' },
|
||||
|
||||
@@ -366,6 +366,23 @@ describe('editing the timeline', () => {
|
||||
expect(rowOf(walk, x).keys).toEqual(keys([0, 0]));
|
||||
});
|
||||
|
||||
it('starts an empty row at what its header shows, the value the object holds (#412)', () => {
|
||||
const f = makeSpriteScene();
|
||||
const a = place(f, f.spriteId, { x: 40, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, a, 'scale', 2));
|
||||
const walk = addAnimation(f, f.sceneId);
|
||||
const x = addRow(f, walk, 'x', [], a);
|
||||
const scale = addRow(f, walk, 'scale', [], a);
|
||||
const shown = timelineLines(f.project, current(f, walk)).flatMap((l) =>
|
||||
l.kind === 'row' ? [lineValueAt(l, 0)] : [],
|
||||
);
|
||||
f.stack.execute(new SetAnimationKeyCommand(walk.id, x, 0));
|
||||
f.stack.execute(new SetAnimationKeyCommand(walk.id, scale, 0));
|
||||
expect(shown).toEqual([40, 2]);
|
||||
expect(rowOf(current(f, walk), x).keys).toEqual(keys([0, 40]));
|
||||
expect(rowOf(current(f, walk), scale).keys).toEqual(keys([0, 2]));
|
||||
});
|
||||
|
||||
it('moves keys together; one landing on a key left behind replaces it, and undo brings both back', () => {
|
||||
const f = makeSpriteScene();
|
||||
const walk = addAnimation(f, f.spriteId);
|
||||
|
||||
@@ -478,6 +478,24 @@ export async function animation(t) {
|
||||
JSON.stringify({ whilePlaying, scrubbed }),
|
||||
);
|
||||
|
||||
// 25i-ii. A double click into a row without keys keys what its header showed (#412): the Arm
|
||||
// stands at x 40, not at the property's default 0.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets.find((a) => a.id === 'an-box').objects[0].position.x = 40;
|
||||
p.assets.find((a) => a.id === 'an-walk').tracks = [{ id: 'an-r4', target: 'an-arm', property: 'x', keys: [] }];
|
||||
`);
|
||||
await sleep(150);
|
||||
const shownX = (await json('__gm.animation.lines()')).find((l) => l.kind !== 'group')?.value;
|
||||
const emptyCell = await json("__gm.animation.cellAt('an-r4', 3)");
|
||||
await doubleClick(emptyCell.x, emptyCell.y);
|
||||
const keyedX = (await walk()).tracks[0].keys;
|
||||
check(
|
||||
'a double click into an empty row keys the value its header showed – 40, not the default 0',
|
||||
shownX === 40 && JSON.stringify(keyedX) === JSON.stringify([{ frame: 3, value: 40 }]),
|
||||
JSON.stringify({ shownX, keyedX }),
|
||||
);
|
||||
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id));
|
||||
|
||||
Reference in new issue
Block a user