Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
Rect,
type Renderable,
Sprite,
SquishEffect,
Tween,
Vector2d,
} from "melonjs";
import { LAND_TOLERANCE, oneWayPlatforms } from "../createGame";
Expand All @@ -37,11 +39,22 @@ const WALK_FORCE = 0.012;
// higher jump because matter's gravity decelerates more slowly than the
// legacy fixed-step integrator on the way up.
const JUMP_VEL = 18;
// landing faster than this is worth a squash
const LANDING_SPEED = 3;
// the most a landing squashes the player, at full falling speed
const MAX_SQUASH = 0.25;

export class PlayerEntity extends Sprite {
dying: boolean;
jumping: boolean;
multipleJump: number;
squish: SquishEffect | undefined;
// the height the player is stretched (above 1) or squashed (below 1) to
stretch = { scaleY: 1 };
stretchTween: Tween | undefined;
// last frame's state, to tell a landing from standing
wasGrounded = true;
lastVelY = 0;
// scratch vectors reused every frame. `body.applyForce(x, y)` and
// `body.setVelocity(x, y)` take primitives so no scratch is needed
// for those; only `body.getVelocity(out)` and `adapter.setPosition(v)`
Expand Down Expand Up @@ -200,6 +213,44 @@ export class PlayerEntity extends Sprite {
this.adapter = app.world.adapter;
// set the viewport to follow this renderable on both axis, and enable damping
app.viewport.follow(this, app.viewport.AXIS.BOTH, 0.1);
// the bulge that keeps a stretched or squashed player's volume is a
// shader effect, so on the GPU renderers only
if (this.squish === undefined && app.renderer.shaderLanguage !== null) {
this.squish = new SquishEffect(app.renderer);
this.addPostEffect(this.squish);
}
}

/**
* stretch (above 1) or squash (below 1) the player to `scaleY` of its
* height, then ease back to rest with a single small overshoot
*/
stretchTo(scaleY: number) {
this.stretchTween?.stop();
this.stretch.scaleY = scaleY;
this.stretchTween = new Tween(this.stretch)
.to({ scaleY: 1 }, { duration: 300, easing: Tween.Easing.Back.Out })
.onComplete(() => {
this.stretchTween = undefined;
})
.start();
}

/**
* scale the player's height about its feet, and bulge it the other way
* so it keeps its volume: pinched in when stretched, bulging out when
* squashed. Called from update(): the adapter rewrites the transform
* when it syncs the body, which happens before.
*/
applyStretch(scaleY: number) {
const feet = this.height * (1 - this.anchorPoint.y);
this.currentTransform
.identity()
.translate(0, feet)
.scale(1, scaleY)
.translate(0, -feet);
this.updateBounds();
this.squish?.setBulge(1 - scaleY);
}

/**
Expand Down Expand Up @@ -256,14 +307,18 @@ export class PlayerEntity extends Sprite {
// (one true per press). Each press fires one full-strength impulse;
// no hold-to-extend mechanic so behaviour matches the legacy SAT
// platformer 1:1.
let jumped = false;
if (input.isKeyPressed("jump")) {
if (this.multipleJump <= 2) {
jumped = true;
this.setCurrentAnimation("jump");
this.jumping = true;
body.setVelocity(vel.x, -JUMP_VEL);
this.multipleJump++;
audio.stop("jump");
audio.play("jump", false);
// stretch on take-off
this.stretchTo(1.25);
}
} else if (grounded) {
this.multipleJump = 1;
Expand All @@ -287,6 +342,20 @@ export class PlayerEntity extends Sprite {
this.setCurrentAnimation("stand");
}

// squash on landing, harder the faster we came down; a jump on the
// frame we land wins over the squash
if (
!jumped &&
grounded &&
!this.wasGrounded &&
this.lastVelY > LANDING_SPEED
) {
this.stretchTo(1 - MAX_SQUASH * Math.min(this.lastVelY / MAX_VEL_Y, 1));
}
this.wasGrounded = grounded;
this.lastVelY = vel.y;
this.applyStretch(this.stretch.scaleY);

// check if we fell into a hole
if (!this.inViewport && this.getBounds().top > game.renderer.height) {
const app = this.parentApp;
Expand All @@ -300,8 +369,14 @@ export class PlayerEntity extends Sprite {
return true;
}

// check if we moved (an "idle" animation would definitely be cleaner)
if (vel.x !== 0 || vel.y !== 0 || this.isFlickering()) {
// check if we moved (an "idle" animation would definitely be cleaner),
// or are still easing back from a stretch
if (
vel.x !== 0 ||
vel.y !== 0 ||
this.isFlickering() ||
this.stretchTween !== undefined
) {
super.update(dt);
return true;
}
Expand Down
77 changes: 75 additions & 2 deletions packages/examples/src/examples/platformer/entities/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,25 @@ import {
level,
Rect,
Sprite,
SquishEffect,
Tween,
timer,
} from "melonjs";
import { gameState } from "../gameState";

// falling faster than this, then stopping, is a landing worth a squash
const LANDING_SPEED = 3;
// the most a landing squashes the player, at full falling speed
const MAX_SQUASH = 0.25;

export class PlayerEntity extends Sprite {
dying: boolean;
multipleJump: number;
squish: SquishEffect | undefined;
// the height the player is stretched (above 1) or squashed (below 1) to
stretch = { scaleY: 1 };
stretchTween: Tween | undefined;
lastVelY: number;

constructor(x, y, settings) {
// create the sprite using atlas animation frames
Expand Down Expand Up @@ -65,6 +77,8 @@ export class PlayerEntity extends Sprite {

this.multipleJump = 1;

this.lastVelY = 0;

// enable keyboard
input.bindKey(input.KEY.LEFT, "left");
input.bindKey(input.KEY.RIGHT, "right");
Expand Down Expand Up @@ -157,6 +171,43 @@ export class PlayerEntity extends Sprite {
const app = this.parentApp;
// set the viewport to follow this renderable on both axis, and enable damping
app.viewport.follow(this, app.viewport.AXIS.BOTH, 0.1);
// the bulge that keeps a stretched or squashed player's volume is a
// shader effect, so on the GPU renderers only
if (this.squish === undefined && app.renderer.shaderLanguage !== null) {
this.squish = new SquishEffect(app.renderer);
this.addPostEffect(this.squish);
}
}

/**
* stretch (above 1) or squash (below 1) the player to `scaleY` of its
* height, then ease back to rest with a single small overshoot
*/
stretchTo(scaleY: number) {
this.stretchTween?.stop();
this.stretch.scaleY = scaleY;
this.stretchTween = new Tween(this.stretch)
.to({ scaleY: 1 }, { duration: 300, easing: Tween.Easing.Back.Out })
.onComplete(() => {
this.stretchTween = undefined;
})
.start();
}

/**
* scale the player's height about its feet, and bulge it the other way
* so it keeps its volume: pinched in when stretched, bulging out when
* squashed. Called from update(), after the physics step.
*/
applyStretch(scaleY: number) {
const feet = this.height * (1 - this.anchorPoint.y);
this.currentTransform
.identity()
.translate(0, feet)
.scale(1, scaleY)
.translate(0, -feet);
this.updateBounds();
this.squish?.setBulge(1 - scaleY);
}

/**
Expand All @@ -177,14 +228,18 @@ export class PlayerEntity extends Sprite {
this.flipX(false);
}

let jumped = false;
if (input.isKeyPressed("jump")) {
this.setCurrentAnimation("jump");
this.body.jumping = true;
if (this.multipleJump <= 2) {
jumped = true;
// easy "math" for double jump
this.body.force.y = -this.body.maxVel.y * this.multipleJump++;
audio.stop("jump");
audio.play("jump", false);
// stretch on take-off
this.stretchTo(1.25);
}
} else {
if (!this.body.falling && !this.body.jumping) {
Expand All @@ -200,6 +255,18 @@ export class PlayerEntity extends Sprite {
this.setCurrentAnimation("stand");
}

// squash on landing, harder the faster we came down. A landing stops the
// fall; a double jump turns it upward, which is not a landing, and a
// jump on the frame we land wins over the squash
const velY = this.body.vel.y;
if (!jumped && this.lastVelY > LANDING_SPEED && Math.abs(velY) < 1) {
this.stretchTo(
1 - MAX_SQUASH * Math.min(this.lastVelY / this.body.maxVel.y, 1),
);
}
this.lastVelY = velY;
this.applyStretch(this.stretch.scaleY);

// check if we fell into a hole
if (!this.inViewport && this.getBounds().top > game.renderer.height) {
const app = this.parentApp;
Expand All @@ -213,8 +280,14 @@ export class PlayerEntity extends Sprite {
return true;
}

// check if we moved (an "idle" animation would definitely be cleaner)
if (this.body.vel.x !== 0 || this.body.vel.y !== 0 || this.isFlickering()) {
// check if we moved (an "idle" animation would definitely be cleaner),
// or are still wobbling from a squash
if (
this.body.vel.x !== 0 ||
this.body.vel.y !== 0 ||
this.isFlickering() ||
this.stretchTween !== undefined
) {
super.update(dt);
return true;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

**Highlights:** a game no longer stutters the first time an effect, a blend mode or a new kind of object appears. Shaders, pipelines and render targets are now prepared when the object is added, or during `loader.preload()`, rather than in the frame that first draws it, where a single first draw was measured at up to 140ms. Dropping a batch of meshes no longer stalls the frame either.

### Added
- Effects: `SquishEffect` bulges a sprite's middle out, or pinches it in, while its top and bottom rows stay where they are. Paired with a change of height it gives squash and stretch that keeps the sprite's volume, and it draws past the sprite's own frame, so the art needs no transparent margin

### Fixed
- WebGL: releasing retained geometry no longer stalls the frame, a stutter whenever a scene dropped a batch of meshes. Teardown asked a blocking `is*` query about every buffer and vertex array before deleting it, a round trip per object, and now only asks once the context has been lost, the only case where the answer can be no
- WebGL: a `ShaderEffect` hosted on a `Mesh` is compiled during `loader.preload()`, on every call including a later one that brings a level's own effects, rather than on the frame it first draws. Preloading the shader built its standalone program, but a mesh host splices the body into the mesh shader, which is a different program again, so a game that declared every effect in its manifest still stuttered the first time each one appeared
Expand All @@ -16,6 +19,7 @@
- Sprite: a video the browser refuses to start, as it does before the user has interacted with the page, no longer logs an uncaught error on every frame. The refusal is handled once, and playback starts on the next click, touch or key press
- WebGL: ETC1 compressed textures no longer draw black. They were uploaded with a call ETC1 does not accept, which left the texture empty; they now upload as ETC2, which reads the same data, or level by level when the browser only supports ETC1
- WebGPU: the smallest mip levels of a compressed texture (the 2×2 and 1×1 levels of a 4×4-block format) are no longer rejected and left empty. Each upload now covers whole blocks, as WebGPU requires
- Tween: a tween stopped while the game updates (replaced by a new one, say) no longer takes one more step and fires its `onComplete` in that same frame

## [20.9.0] (melonJS 2) - _2026-10-18_

Expand Down
41 changes: 39 additions & 2 deletions packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,15 @@ reused across renderables.

## Built-in effects

Twenty-one presets, every one constructed as `new XEffect(renderer, options)` and
Twenty-two presets, every one constructed as `new XEffect(renderer, options)` and
every one shipping both a GLSL and a WGSL body, so they all run on either GPU
backend: `VignetteEffect`, `ScanlineEffect` (optional CRT curvature),
`BloomEffect`, `GlowEffect`, `BlurEffect`, `PixelateEffect`,
`ChromaticAberrationEffect`, `DropShadowEffect`, `FlashEffect`,
`OutlineEffect`, `ColorMatrixEffect`, `DesaturateEffect`, `DissolveEffect`,
`TintPulseEffect`, `WaveEffect`, `InvertEffect`, `SepiaEffect`,
`HologramEffect`, `ShineEffect`, `RadialGradientEffect`, `ToneMappingEffect`.
`HologramEffect`, `ShineEffect`, `RadialGradientEffect`, `SquishEffect`,
`ToneMappingEffect`.

`RadialGradientEffect` is a procedural soft spot: solid at the centre of its
host quad, fading linearly to transparent at the edge, and elliptical for free
Expand All @@ -127,6 +128,40 @@ falloff assumes the quad samples a FULL texture rect, so pair it with a
standalone image or `Renderer.getWhitePixel()` rather than a sprite that draws
a sub-region of an atlas, where the centre would land off-quad.

`SquishEffect` bulges a sprite's middle out (`setBulge(0.3)`) or pinches it in
(`setBulge(-0.3)`), its top and bottom rows staying put. Bulging out widens the
sprite past its own frame, and the effect draws on a wider quad to make room,
so the art needs no transparent margin. On its own it is a jelly wobble; paired
with a change of height it is **squash and stretch that keeps the volume**:
scale the sprite to `scaleY` of its height about its feet and set the bulge to
`1 - scaleY`, so a stretched sprite pinches in and a squashed one bulges out.
Ease `scaleY` back to 1 with a short `Tween.Easing.Back.Out`; an elastic ease
keeps it trembling long after the landing.

```js
const squish = new SquishEffect(app.renderer);
player.addPostEffect(squish);
// every frame, from the player's update()
const feet = player.height * (1 - player.anchorPoint.y);
player.currentTransform
.identity()
.translate(0, feet)
.scale(1, scaleY)
.translate(0, -feet);
squish.setBulge(1 - scaleY);
```

Apply that from the sprite's own `update()`, not from a tween's `onUpdate`. A
physics adapter that mirrors a body's angle (Matter does) rewrites
`currentTransform` on every step, before the renderables update, so a scale set
anywhere else is gone by the time the sprite is drawn. Let the tween animate a
plain `{ scaleY }` object and read it in `update()`.

It is made for a sprite carrying it as its only effect: chained with another,
or on the camera, the frame it measures against is the whole capture, so it
squishes the whole frame. The bulge is outside the sprite's bounds, so culling
and pointer events ignore it.

**`BloomEffect` and `GlowEffect` are not the same thing**, and reaching for the
wrong one is the usual disappointment here. `GlowEffect` draws a halo OUTSIDE a
sprite's silhouette: its body returns the colour untouched wherever alpha is
Expand Down Expand Up @@ -616,6 +651,8 @@ same question after construction.
| shadow/smear offset flips on some draws | vertical UV offset not multiplied by `uUVYDir` |
| frame rate collapses with many blended sprites | an *advanced* mode (overlay, darken, …) — each draw is a capture plus a composite |
| animated shader never moves | `setTime` fed milliseconds, or the shader declares no `uTime` |
| squash and stretch only bulges, the height never changes, under Matter | the adapter rewrites `currentTransform` every step; set the scale from the sprite's `update()` |
| `SquishEffect` squishes the whole screen instead of the sprite | it is chained with another effect, or on the camera; give the sprite it alone |
| syntax error in a file whose shader you just edited, pointing at a line of GLSL | a backtick in the shader body ended the template literal — most often one wrapped around an identifier in a comment |

## Related skills
Expand Down
2 changes: 2 additions & 0 deletions packages/melonjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import ScanlineEffect from "./video/effects/scanline.js";
import SepiaEffect from "./video/effects/sepia.js";
import ShaderEffect from "./video/effects/shadereffect.js";
import ShineEffect from "./video/effects/shine.js";
import SquishEffect from "./video/effects/squish.js";
import TintPulseEffect from "./video/effects/tintPulse.js";
import ToneMappingEffect from "./video/effects/tonemap.js";
import VignetteEffect from "./video/effects/vignette.js";
Expand Down Expand Up @@ -278,6 +279,7 @@ export {
ShineEffect,
Sprite,
Sprite3d,
SquishEffect,
Stage,
save,
state,
Expand Down
6 changes: 6 additions & 0 deletions packages/melonjs/src/tweens/tween.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ export default class Tween {
* @internal
*/
_onTick(timestamp: number) {
// Stopped during this tick, by a listener called before this one (the
// game loop is one): the emitter walks a snapshot of its listeners, so
// unsubscribing does not take a tween out of the tick in progress.
if (!this._isRunning) {
return;
}
if (!this._isPaused || this.updateWhenPaused) {
// compute delta from the raw RAF timestamp
const dt = timestamp - this._lastTick;
Expand Down
Loading
Loading