Skip to content

Refactor camera effects into an extensible effect list with scene transitions #1369

@obiot

Description

@obiot

Summary

Refactor the camera's hardcoded effect system (_shake, _fadeIn, _fadeOut) into an extensible effect list architecture. This enables effect accumulation (multiple effects running simultaneously), custom user effects, and scene transitions — all through a single unified mechanism.

Current Limitations

  • Camera2d has hardcoded state objects for shake and fade (_shake, _fadeIn, _fadeOut)
  • Effects are processed in a fixed order in update() and drawFX()
  • Only one shake and one fade can run at a time
  • Adding a new effect type requires modifying Camera2d internals
  • state.transition() only supports "fade" (hardcoded string check)
  • Scene transitions are coupled to viewport.fadeIn/fadeOut in the state manager
  • No way for users to create custom camera effects

Proposed Architecture

Base class

class CameraEffect {
    constructor(camera, options) {
        this.camera = camera;
        this.isComplete = false;
    }

    // called each frame — modify camera state (offset, zoom, etc.)
    update(dt) { }

    // called after scene renders — draw overlays (color rects, masks, etc.)
    draw(renderer, width, height) { }

    // called when the effect is removed
    destroy() { }
}

Camera holds an effect list

// in Camera2d
this.effects = [];

// in update(dt) — run all active effects
for (const fx of this.effects) fx.update(dt);
// auto-remove completed effects
this.effects = this.effects.filter(fx => !fx.isComplete);

// in drawFX(renderer) — draw all active effects
for (const fx of this.effects) fx.draw(renderer, this.width, this.height);

Built-in effects as subclasses

Migrate existing functionality into effect classes:

  • FadeEffect — color overlay with alpha tween (replaces _fadeIn / _fadeOut)
  • ShakeEffect — random position offset (replaces _shake)
  • FlashEffect — quick color burst (convenience, could be a short FadeEffect)
  • TransitionEffect — mask-based scene transition using a Polygon, Ellipse, or texture (see below)

Effect accumulation

Multiple effects of any type can coexist naturally:

// shake + fade at the same time
camera.addEffect(new ShakeEffect({ intensity: 10, duration: 300 }));
camera.addEffect(new FadeEffect({ color: "#000", duration: 500 }));

// two overlapping fades
camera.addEffect(new FadeEffect({ color: "#f00", duration: 200 }));
camera.addEffect(new FadeEffect({ color: "#000", duration: 500 }));

// user-defined custom effect
camera.addEffect(new MyCustomEffect());

Backward-compatible convenience methods

Existing camera methods become thin wrappers that create and add effects:

// these stay, unchanged API
camera.fadeIn(color, duration, onComplete);   // → addEffect(new FadeEffect(...))
camera.fadeOut(color, duration, onComplete);   // → addEffect(new FadeEffect(...))
camera.shake(intensity, duration, axis);       // → addEffect(new ShakeEffect(...))

Scene Transitions

TransitionEffect

A CameraEffect subclass that uses a shape or texture as a clip mask, scaled by a tweened progress value (0→1):

  • Phase 1 (hide): mask shrinks from full-screen to zero → scene is covered by transition color
  • Phase 2 (reveal): mask grows from zero to full-screen → new scene is revealed

The mask can be:

  • A Polygon or Ellipse — defined in code, no asset needed
  • An image/texture — for complex shapes (star, heart, game logo)

Built-in presets are just pre-defined shapes:

  • iris: Ellipse centered on viewport
  • diamond: 4-point Polygon
  • Any custom shape the developer provides

Rendering the mask transition

Both renderers support clipping:

  • Canvas: ctx.clip() with evenodd fill rule (outer rect path + inner shape path → fills outside the shape)
  • WebGL: stencil buffer (already used for masking in melonJS)

The shape is centered on the viewport and scaled based on progress. At progress=0 the shape covers nothing (screen filled with color). At progress=1 the shape covers the full viewport diagonal (screen fully visible).

Updated state.transition() API

// backward compatible — fade still works
state.transition("fade", "#000", 500);

// new: pass a shape for mask-based transitions
state.transition({
    shape: new Ellipse(0, 0, 1, 1),   // iris
    color: "#000",
    duration: 500
});

// polygon shape
state.transition({
    shape: new Polygon(0, 0, [
        { x: 0, y: -1 }, { x: 1, y: 0 },
        { x: 0, y: 1 }, { x: -1, y: 0 }
    ]),  // diamond
    color: "#000",
    duration: 400
});

// texture mask
state.transition({
    texture: assets.getImage("star_silhouette"),
    color: "#000",
    duration: 500
});

State manager changes

Minimal — state.change() creates a TransitionEffect (or FadeEffect for backward compat) instead of directly calling viewport.fadeIn/fadeOut. The transition lifecycle (hide → switch stage → reveal) is driven by the effect's onComplete callback, same pattern as today.

Implementation Steps

  1. Create CameraEffect base class
  2. Implement FadeEffect and ShakeEffect, migrating existing logic from Camera2d
  3. Add effects array and addEffect()/removeEffect() to Camera2d
  4. Refactor update() and drawFX() to iterate the effect list
  5. Keep fadeIn()/fadeOut()/shake() as convenience wrappers
  6. Implement TransitionEffect with polygon/texture mask support
  7. Update state.transition() to accept shape/texture configuration
  8. Ship built-in presets (iris, diamond) as pre-defined shapes

References

  • Camera2d (shake, fade, drawFX): src/camera/camera2d.ts
  • state.transition(): src/state/state.ts:423
  • state.change() transition flow: src/state/state.ts:464-478
  • Polygon / Ellipse: src/geometries/

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions