diff --git a/.plans/mockups/stars-demo-needed-features-2026-05-26/01-spawn-scatter-stars.svg b/.plans/mockups/stars-demo-needed-features-2026-05-26/01-spawn-scatter-stars.svg new file mode 100644 index 0000000..f485278 --- /dev/null +++ b/.plans/mockups/stars-demo-needed-features-2026-05-26/01-spawn-scatter-stars.svg @@ -0,0 +1,100 @@ + + + + + + + + + + Canvas ▸ Selection: 400 entities + + + + Selection Bar + Align • Distribute • Layout… + + Layout… + + + Layout ▸ Placement + + + Scatter… + Randomize positions of existing entities inside a region + + + Jitter… + Small random offsets (keeps general structure) + + + Shuffle Order + Reorders selection (useful for groups/slots) + + + Mockup focus: authoring-time scatter (no runtime spawn required) + + + + Layout ▸ Scatter… + + + Scatter Selection + Random placement tool for many workflows (debris, pickups, decor, crowds, etc.) + + Region + + World + + Viewport + + Rect… + + + Bounds + + minX = 0 + + maxX = worldW + + minY = 0 + + maxY = worldH + + Distribution + + Uniform + Poisson / Grid+jitter + + Seed + + 12345 + + Re-roll + + + Options: keep inside bounds • avoid overlaps • scatter X only / Y only + + + Apply + + This replaces “Spawn Instances” for Stars authoring: create stars once (duplicate), then Scatter Selection to randomize placement quickly. + diff --git a/.plans/mockups/stars-demo-needed-features-2026-05-26/02-randomize-color-and-position.svg b/.plans/mockups/stars-demo-needed-features-2026-05-26/02-randomize-color-and-position.svg new file mode 100644 index 0000000..95d88a0 --- /dev/null +++ b/.plans/mockups/stars-demo-needed-features-2026-05-26/02-randomize-color-and-position.svg @@ -0,0 +1,109 @@ + + + + + + + + + Inspector ▸ Attachment ▸ Randomize Properties + + + Randomize Properties (per-instance) + No-code per-instance variation (enemies, loot, particles, decor, etc.) + + + Target + + Collection: targets + + Apply To + + Members + + Group + unique per instance + + + Random Seed + + 12345 + + Re-roll + + Deterministic + same seed → same result + + + + Rules + Each rule applies to each member using the same seed stream + + + + Rule 1 + + Set Tint Color + + Mode + + Random between colors + + Min + + #141414 + + + Max + + #FFFFFF + + + + + + + preview + + + + Rule 2 + + Set Position + + Region + + World bounds + + Keep inside + + Yes + optional: X only / Y only + + + + + + Add Rule + + + Apply + + No-code goal: randomization configured via dropdowns + min/max fields + seeds (no expressions required). + diff --git a/.plans/mockups/stars-demo-needed-features-2026-05-26/03-moveuntil-wrap-callback-reroll-x.svg b/.plans/mockups/stars-demo-needed-features-2026-05-26/03-moveuntil-wrap-callback-reroll-x.svg new file mode 100644 index 0000000..f8c4520 --- /dev/null +++ b/.plans/mockups/stars-demo-needed-features-2026-05-26/03-moveuntil-wrap-callback-reroll-x.svg @@ -0,0 +1,80 @@ + + + + + + + + + Inspector ▸ Attachment ▸ Move Until ▸ Bounds + + + + + Move Until + Velocity X + + 0 + Velocity Y + + -4 + constant-speed segment (phase loop handled elsewhere) + + + + Bounds (BoundsHit) + + Enabled + + + + Behavior + + Wrap + + + + On Wrap (new) + Run a mini-script on boundary events (wrap/bounce/exit) for many gameplay uses + + When + + Y Wrap + + X Wrap + + Any Wrap + + Scope + + Per Member + + Group + + Script + + Set Position: x = rand(bounds.minX,bounds.maxX), y = exitSide == Top ? bounds.maxY : bounds.minY + + + + Add Step… + + Apply + + General feature: boundary behaviors should support optional event hooks to author respawn, teleport, scoring, VFX, etc. without code. + diff --git a/.plans/pattern_demo_workflow.md b/.plans/pattern_demo_workflow.md index 418fbc8..fc7cf48 100644 --- a/.plans/pattern_demo_workflow.md +++ b/.plans/pattern_demo_workflow.md @@ -1,13 +1,5 @@ # Studio workflow: recreate `pattern_demo.py` and Save YAML (updated for new QoL features) -This version assumes the features in `.plans/implementation-plan-qol-text-bounds-duplicate-loops-layout-2026-05-17.md` exist: - -- Text entities (labels) -- Bounds Helper calculator -- Duplicate carries behaviors + `Duplicate…` options dialog -- Loop Templates (“Loops” tab in Add Step) -- Layout panel (Align/Distribute/Spacing) accessible from the on-canvas selection bar - ## 1. Start a new project/scene - Set Startup to New Empty Scene (or otherwise reset to an empty scene). @@ -16,8 +8,10 @@ This version assumes the features in `.plans/implementation-plan-qol-text-bounds ## 2. Import the ship sprite once, then duplicate it - Use **A20 — Import Asset into Project** to import a ship image (or use any existing sprite asset you already have). + NOTE: In the res/images directory, there is a ship_sidesA.png that I use. - Use **A21 — Drag Asset to Target**: drag it onto the canvas to create your first ship entity. -- Duplicate until you have 7 ship entities: + NOTE: You may want to use the Scale X and Scale Y feature in the Inspector to reduce the sprite size to 0.5 if it's too large. +- Duplicate the sprite until you have 7 separate sprite entities: - Fast path: **Alt+Drag** to duplicate and place. - Alternative: Entity List `⋯` → **Duplicate** (or **Duplicate…** if you need to change options). - Name the 7 ships (via **A4 — Rename Item (inline)** in Entity List): @@ -113,8 +107,8 @@ Several patterns below say “Add **Repeat** with X children”. Use the Loop Te - In **Bounds → Edit mode**, switch to **Center/Span**. - Use **Auto from selection** to fill center (and pull sprite size). - Set travel span: - - `± X Span = 60` - - `± Y Span = 40` + - `± X Span = 50` + - `± Y Span = 60` - Click **Apply** (writes the computed values into Bounds Min/Max). ### Patrol diff --git a/.plans/stars_demo_workflow.md b/.plans/stars_demo_workflow.md new file mode 100644 index 0000000..125d460 --- /dev/null +++ b/.plans/stars_demo_workflow.md @@ -0,0 +1,107 @@ +# Studio workflow: attempt to recreate `stars.py` (ArcadeActions starfield) — current blockers + +Reference: `/home/bcorfman/dev/arcadeactions/examples/stars.py` + +This document is written in the same “do-this-in-Studio” style as `.plans/pattern_demo_workflow.md`, but `stars.py` cannot currently be duplicated faithfully in Studio without missing/extra product capabilities (listed in **Why this can’t be duplicated (yet)**). + +## Target behavior (what `stars.py` does) + +- Scene size: `W=720`, `H=1280` with a solid black background. +- ~`400` tiny square “star” sprites: + - Spawned at random `x∈[0..W]`, `y∈[0..H]` (with a small vertical margin). + - Each star has a random bright-ish color. +- 5 blinking groups with different blink rates (~0.2s → ~0.4s). +- A repeating velocity “phase loop” affecting *all* stars’ vertical speed: + - 1s stopped → 2s accelerate down (ease-in) → 5s hold down speed + - 0.5s accelerate up (ease-out) → 1.5s hold up speed + - 2s ease back to stopped → repeat forever +- When a star wraps vertically, it re-enters on the opposite side *with a new random X* (to avoid vertical columns). + +## What you can build in Studio today (approximation) + +If you are willing to accept visible differences vs `stars.py`, you can approximate a “starfield” as follows. + +### 1. Start a new project/scene + +- Set Startup to New Empty Scene (or otherwise reset to an empty scene). +- Set Scene World Size to `W=720`, `H=1280`. +- Set scene background color to black. + +### 2. Create/import a star sprite asset + +Studio currently can’t create `SpriteSolidColor` programmatically the way Arcade does, so you’ll need an asset. + +- Import a tiny square image (e.g. `3x3` or `4x4`) as a sprite asset. + - Use white by default (you won’t be able to randomize per-star tint yet). + +### 3. Create “star” entities (manual placement only) + +- Drag the star sprite onto the canvas to create a `Star` entity. +- Duplicate it many times (Alt+Drag / Duplicate in Entity List). +- Roughly distribute the stars across the full world. + +Optional organization (recommended if you do this manually): + +- Put stars into 5 groups (e.g. `StarsBlink1` … `StarsBlink5`) so you can apply different blink rates. + +### 4. Add blinking (per group) + +For each blink group: + +- Select the group and attach **Blink Until**. +- Set **Seconds Until Change** to values spanning ~`0.2` to `0.4` seconds (example: `0.20`, `0.25`, `0.30`, `0.35`, `0.40`). +- Leave “Stop After” disabled (infinite blinking). + +### 5. Add movement with wrapping (per group) + +For each star group: + +- Attach **Move Until**. +- Set `Velocity X = 0`. +- Set `Velocity Y` to a constant speed (pick either downward or upward). +- Enable **Bounds** and set: + - Behavior: **Wrap** + - Bounds to the full world (optionally add a small ±Y margin). + +This yields a continuous scrolling starfield with wrap, but: + +- Stars will wrap keeping their original X (creating “columns” over time). +- Speed won’t follow the phase/tween schedule from `stars.py`. + +### 6. (Now possible) Add a smooth “velocity phase loop” using Tween Until (optional) + +Studio now has **Tween Until**, which can animate a numeric property (including `vy`) with easing. + +To make the starfield speed ramp up/down smoothly, you can run two attachments in parallel on the same target: + +- Attachment A (runs forever): **Move Until** with **Bounds → Wrap** enabled + - Set `Velocity X = 0`, `Velocity Y = 0` + - Bounds Behavior: **Wrap** + - This attachment is responsible for translating entities every update tick (it reads `vx/vy` each frame). +- Attachment B (loops forever): **Repeat** wrapping a sequence of **Tween Until** + **Wait** steps that updates `vy` + - Use **Tween Until** with: + - Property: `vy` + - From: `Current value` + - Duration: set per phase + - Easing: `easeIn` / `easeOut` / `easeInOut` / `linear` + +Note: you still won’t match `stars.py` perfectly until you can respawn with a new random X on wrap, but the easing/phase feel can now be reproduced without scripting. + +## Why this can’t be duplicated (yet) + +`stars.py` still relies on capabilities that Studio does not currently expose as authorable workflow steps: + +1. **Per-instance randomization (color + wrap respawn X)** + - `stars.py` assigns each star a random RGB color at creation time. + - On vertical wrap, it repositions the star to the opposite edge *and picks a new random X*. + - Studio’s **Bounds → Wrap** behavior wraps deterministically (no “on wrap” hook/callback to randomize X). + +Because of (1), even a manual “400 stars” setup will still diverge visually from `stars.py` (especially the lack of random X re-roll on wrap, and lack of per-star random tint). + +## Minimal product additions that would make a faithful workflow possible + +If/when these exist, a Studio workflow for `stars.py` becomes straightforward: + +- A “Scatter / Randomize Placement” authoring tool (randomize selected entities inside bounds, with seed + distribution options). +- A per-entity RNG helper usable in steps (random float/int, random color, etc.). +- A “Callback / On Bounds Wrap” hook (or “Move Until: wrap callback”) to re-seed X on vertical wrap. diff --git a/public/editor-registry.yaml b/public/editor-registry.yaml index 82ec398..6572022 100644 --- a/public/editor-registry.yaml +++ b/public/editor-registry.yaml @@ -146,6 +146,27 @@ actions: parameters: - { name: dx, type: number, default: 0 } - { name: dy, type: number, default: 0 } + - type: TweenUntil + displayName: Tween Until + category: transforms + targetKinds: [entity, group] + implemented: true + propertyTargets: + - { key: x, type: number, tweenable: true, affectsBounds: true } + - { key: y, type: number, tweenable: true, affectsBounds: true } + - { key: rotationDeg, type: number, tweenable: true, affectsBounds: true } + - { key: scaleX, type: number, tweenable: true, affectsBounds: true } + - { key: scaleY, type: number, tweenable: true, affectsBounds: true } + - { key: alpha, type: number, tweenable: true, affectsBounds: false } + - { key: vx, type: number, tweenable: true, affectsBounds: false } + - { key: vy, type: number, tweenable: true, affectsBounds: false } + parameters: + - { name: property, type: string, default: x } + - { name: from, type: string, default: current } + - { name: startValue, type: number, default: 0 } + - { name: endValue, type: number, default: 0 } + - { name: durationMs, type: number, default: 250 } + - { name: easing, type: string, default: linear } - type: MoveXUntil displayName: Move X Until category: movement diff --git a/src/compiler/compileAttachments.ts b/src/compiler/compileAttachments.ts index 2e4f399..5eef2a9 100644 --- a/src/compiler/compileAttachments.ts +++ b/src/compiler/compileAttachments.ts @@ -16,6 +16,7 @@ import { MoveYUntil } from '../runtime/actions/MoveYUntil'; import { BlinkUntil } from '../runtime/actions/BlinkUntil'; import { CallbackUntil } from '../runtime/actions/CallbackUntil'; import { CycleFramesUntil } from '../runtime/actions/CycleFramesUntil'; +import { TweenUntil } from '../runtime/actions/TweenUntil'; import { AddSelfToCollection } from '../runtime/actions/AddSelfToCollection'; import { AddToCounter } from '../runtime/actions/AddToCounter'; import { ClampCounter } from '../runtime/actions/ClampCounter'; @@ -238,6 +239,29 @@ function compileAtomicAttachment(attachment: AttachmentSpec, ctx: CompileContext const target = resolveTarget(targetRef, ctx.targets); return new MoveBy(target, { dx: Number.isFinite(dx) ? dx : 0, dy: Number.isFinite(dy) ? dy : 0 }); } + if (presetId === 'TweenUntil') { + const property = String(attachment.params?.property ?? 'x'); + const fromRaw = String(attachment.params?.from ?? 'current'); + const from = fromRaw === 'value' ? 'value' : 'current'; + const startValue = typeof attachment.params?.startValue === 'number' ? attachment.params.startValue : Number(attachment.params?.startValue); + const endValue = Number(attachment.params?.endValue ?? 0); + const durationMs = Number(attachment.params?.durationMs ?? 250); + const easingRaw = String(attachment.params?.easing ?? 'linear'); + const easing = easingRaw === 'easeIn' || easingRaw === 'easeOut' || easingRaw === 'easeInOut' ? easingRaw : 'linear'; + + const targetRef = targetOverride ?? attachment.target; + const target = resolveTarget(targetRef, ctx.targets); + const condition = instantiateInlineCondition(attachment.condition, ctx); + return new TweenUntil(target, { + property, + from, + ...(Number.isFinite(startValue) ? { startValue } : {}), + endValue: Number.isFinite(endValue) ? endValue : 0, + durationMs: Number.isFinite(durationMs) ? durationMs : 0, + easing: easing as any, + condition, + }); + } if (presetId === 'WavePattern') { const amplitude = Number(attachment.params?.amplitude ?? 30); const length = Number(attachment.params?.length ?? 80); diff --git a/src/editor/EventsPanel.tsx b/src/editor/EventsPanel.tsx index 1ee52ba..d03ab33 100644 --- a/src/editor/EventsPanel.tsx +++ b/src/editor/EventsPanel.tsx @@ -10,6 +10,7 @@ const SUPPORTED_PRESETS = new Set([ 'MoveUntil', 'MoveTo', 'MoveBy', + 'TweenUntil', 'MoveXUntil', 'MoveYUntil', 'WavePattern', diff --git a/src/editor/Inspector.tsx b/src/editor/Inspector.tsx index 6353473..6fe2243 100644 --- a/src/editor/Inspector.tsx +++ b/src/editor/Inspector.tsx @@ -2709,6 +2709,96 @@ function AttachmentInspector({ )} + {attachment.presetId === 'TweenUntil' && ( + foldouts.toggle('attachment.tweenuntil', true)} + > + + + + + {String(params.from ?? 'current') === 'value' && ( + + )} + +
+ + +
+ + +
+ )} + {attachment.presetId === 'MoveXUntil' && ( number { + if (id === 'easeIn') return (t) => t * t; + if (id === 'easeOut') return (t) => 1 - (1 - t) * (1 - t); + if (id === 'easeInOut') return (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2); + return (t) => t; +} + +function coerceFiniteNumber(value: unknown, fallback: number): number { + const num = typeof value === 'number' ? value : Number(value); + return Number.isFinite(num) ? num : fallback; +} + +export class TweenUntil extends ActionBase { + private target: RuntimeTarget; + private opts: Required> & { + startValue?: number; + easing: TweenEasingId; + }; + private elapsedMs = 0; + private startByEntityId = new Map(); + + constructor(targets: RuntimeTarget | RuntimeEntity[], options: TweenUntilOptions) { + super(); + this.target = coerceTarget(targets); + this.opts = { + property: options.property, + from: options.from, + startValue: options.startValue, + endValue: options.endValue, + durationMs: options.durationMs, + easing: options.easing ?? 'linear', + condition: options.condition, + }; + } + + start(): void { + if (this.started) return; + super.start(); + this.elapsedMs = 0; + this.startByEntityId.clear(); + this.opts.condition.reset(); + + const durationMs = Math.max(0, coerceFiniteNumber(this.opts.durationMs, 0)); + const endValue = coerceFiniteNumber(this.opts.endValue, 0); + if (durationMs === 0) { + this.forEachTargetEntity((entity) => { + (entity as any)[this.opts.property] = endValue; + }); + this.stop(); + } + } + + update(dtMs: number): void { + if (this.complete || this.cancelled) return; + const durationMs = Math.max(0, coerceFiniteNumber(this.opts.durationMs, 0)); + if (durationMs === 0) return; + + this.elapsedMs += Math.max(0, coerceFiniteNumber(dtMs, 0)); + const t = Math.min(1, this.elapsedMs / durationMs); + const eased = easingFn(this.opts.easing)(t); + + const endValue = coerceFiniteNumber(this.opts.endValue, 0); + const fromMode = this.opts.from; + const explicitStartValue = typeof this.opts.startValue === 'number' && Number.isFinite(this.opts.startValue) ? this.opts.startValue : undefined; + + this.forEachTargetEntity((entity) => { + const key = entity.id; + const start = this.startByEntityId.has(key) + ? this.startByEntityId.get(key)! + : fromMode === 'value' + ? coerceFiniteNumber(explicitStartValue, 0) + : coerceFiniteNumber((entity as any)[this.opts.property], 0); + if (!this.startByEntityId.has(key)) this.startByEntityId.set(key, start); + + const value = start + (endValue - start) * eased; + (entity as any)[this.opts.property] = value; + }); + + this.opts.condition.update(dtMs); + if (this.opts.condition.isMet(this.target) || t >= 1) { + this.stop(); + } + } + + reset(): void { + super.reset(); + this.elapsedMs = 0; + this.startByEntityId.clear(); + this.opts.condition.reset(); + } + + private forEachTargetEntity(fn: (entity: RuntimeEntity) => void): void { + if ('members' in this.target) { + for (const member of this.target.members) fn(member); + return; + } + fn(this.target); + } +} + diff --git a/tests/compiler/tweenuntil-attachment.test.ts b/tests/compiler/tweenuntil-attachment.test.ts new file mode 100644 index 0000000..7a30cd2 --- /dev/null +++ b/tests/compiler/tweenuntil-attachment.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { compileScene } from '../../src/compiler/compileScene'; +import type { SceneSpec } from '../../src/model/types'; + +function makeScene(attachment: SceneSpec['attachments'][string]): SceneSpec { + return { + id: 'scene-1', + world: { width: 800, height: 600 }, + entities: { + e1: { id: 'e1', x: 0, y: 0, width: 10, height: 10, vx: 0, vy: 0 }, + }, + groups: {}, + attachments: { att1: attachment }, + behaviors: {}, + actions: {}, + conditions: {}, + }; +} + +describe('compileAttachments', () => { + it('compiles TweenUntil and advances it via ActionManager.update()', () => { + const scene = makeScene({ + id: 'att1', + target: { type: 'entity', entityId: 'e1' }, + enabled: true, + order: 0, + presetId: 'TweenUntil', + params: { property: 'vy', from: 'current', endValue: -4, durationMs: 1000, easing: 'linear' }, + }); + + const compiled = compileScene(scene); + compiled.startAll(); + expect(compiled.actionManager.size()).toBe(1); + + compiled.actionManager.update(500); + expect(compiled.entities.e1.vy).toBeCloseTo(-2, 5); + expect(compiled.actionManager.size()).toBe(1); + + compiled.actionManager.update(500); + expect(compiled.entities.e1.vy).toBeCloseTo(-4, 5); + expect(compiled.actionManager.size()).toBe(0); + }); +}); + diff --git a/tests/e2e/view-sync.spec.ts b/tests/e2e/view-sync.spec.ts index b9f8092..5be2767 100644 --- a/tests/e2e/view-sync.spec.ts +++ b/tests/e2e/view-sync.spec.ts @@ -21,6 +21,11 @@ test('Edit and Preview preserve camera view state @critical', async ({ page }) = expect(editSnapshot.zoom).toBeGreaterThan(editBefore.zoom); const editPoint = await worldToClient(page, anchorWorld); expect(editPoint).toBeTruthy(); + const canvas = page.locator('#game-container canvas'); + await expect(canvas).toBeVisible(); + const editCanvasBox = await canvas.boundingBox(); + if (!editCanvasBox) throw new Error('Canvas bounding box unavailable'); + const editPointInCanvas = { x: editPoint.x - editCanvasBox.x, y: editPoint.y - editCanvasBox.y }; await page.evaluate(() => window.__PHASER_FORGE_TEST__?.setMode?.('play')); await expect.poll(async () => (await getState<{ mode?: string }>(page))?.mode).toBe('play'); @@ -32,13 +37,17 @@ test('Edit and Preview preserve camera view state @critical', async ({ page }) = // Validate the user-visible invariant: the same world point stays in (nearly) the same screen location. // Minor pixel drift can occur due to per-scene pixel-rounding / device scale differences, especially under load. + // Compare relative to the canvas origin so layout shifts between edit/play modes don't cause false failures. const maxPixelDelta = 5; await expect .poll(async () => { const playPoint = await worldToClient(page, anchorWorld); if (!playPoint) return Number.POSITIVE_INFINITY; - const dx = Math.abs(playPoint.x - editPoint.x); - const dy = Math.abs(playPoint.y - editPoint.y); + const playCanvasBox = await canvas.boundingBox(); + if (!playCanvasBox) return Number.POSITIVE_INFINITY; + const playPointInCanvas = { x: playPoint.x - playCanvasBox.x, y: playPoint.y - playCanvasBox.y }; + const dx = Math.abs(playPointInCanvas.x - editPointInCanvas.x); + const dy = Math.abs(playPointInCanvas.y - editPointInCanvas.y); return Math.max(dx, dy); }) .toBeLessThanOrEqual(maxPixelDelta); diff --git a/tests/editor/tweenuntil-inspector.test.tsx b/tests/editor/tweenuntil-inspector.test.tsx new file mode 100644 index 0000000..dd12190 --- /dev/null +++ b/tests/editor/tweenuntil-inspector.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { renderAttachmentInspector } from '../../src/editor/Inspector'; +import { sampleScene } from '../../src/model/sampleScene'; +import { sampleProject } from '../../src/model/sampleProject'; + +describe('Attachment inspector', () => { + it('renders TweenUntil editor controls', () => { + const markup = renderToStaticMarkup( + renderAttachmentInspector( + { + id: 'att-tween', + target: { type: 'group', groupId: 'g-enemies' }, + enabled: true, + order: 0, + presetId: 'TweenUntil', + params: { property: 'vy', from: 'current', endValue: -4, durationMs: 2000, easing: 'linear' }, + } as any, + sampleProject, + sampleScene, + { + arrange: [], + actions: [{ type: 'TweenUntil', displayName: 'Tween Until', category: 'transforms', targetKinds: ['entity', 'group'], implemented: true }], + conditions: [], + }, + () => {}, + () => {} + ) + ); + + expect(markup).toContain('Tween Until'); + expect(markup).toContain('Duration (ms)'); + expect(markup).toContain('Easing'); + }); + + it('shows Start Value when From is explicit value', () => { + const markup = renderToStaticMarkup( + renderAttachmentInspector( + { + id: 'att-tween', + target: { type: 'group', groupId: 'g-enemies' }, + enabled: true, + order: 0, + presetId: 'TweenUntil', + params: { property: 'x', from: 'value', startValue: 10, endValue: 20, durationMs: 250, easing: 'linear' }, + } as any, + sampleProject, + sampleScene, + { + arrange: [], + actions: [{ type: 'TweenUntil', displayName: 'Tween Until', category: 'transforms', targetKinds: ['entity', 'group'], implemented: true }], + conditions: [], + }, + () => {}, + () => {} + ) + ); + + expect(markup).toContain('Start Value'); + }); +}); diff --git a/tests/runtime/TweenUntil.test.ts b/tests/runtime/TweenUntil.test.ts new file mode 100644 index 0000000..357d3f7 --- /dev/null +++ b/tests/runtime/TweenUntil.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import type { RuntimeEntity } from '../../src/runtime/targets/types'; +import { Never } from '../../src/runtime/conditions/Never'; +import { TweenUntil } from '../../src/runtime/actions/TweenUntil'; + +function makeEntity(partial?: Partial): RuntimeEntity { + return { + id: 'entity', + x: 0, + y: 0, + width: 10, + height: 10, + vx: 0, + vy: 0, + ...partial, + }; +} + +describe('TweenUntil', () => { + it('tweens a numeric property from current value to target over duration', () => { + const target = makeEntity({ vy: 10 }); + const action = new TweenUntil([target], { + property: 'vy', + from: 'current', + endValue: -10, + durationMs: 1000, + easing: 'linear', + condition: new Never(), + }); + + action.start(); + action.update(500); + + expect(target.vy).toBeCloseTo(0, 5); + expect(action.isComplete()).toBe(false); + + action.update(500); + + expect(target.vy).toBeCloseTo(-10, 5); + expect(action.isComplete()).toBe(true); + }); + + it('supports durationMs=0 by snapping to endValue immediately', () => { + const target = makeEntity({ x: 5 }); + const action = new TweenUntil([target], { + property: 'x', + from: 'current', + endValue: 25, + durationMs: 0, + easing: 'linear', + condition: new Never(), + }); + + action.start(); + + expect(target.x).toBe(25); + expect(action.isComplete()).toBe(true); + }); +}); +