Skip to content

Commit 9e49831

Browse files
authored
Merge pull request #1421 from melonjs/feat/freeze-hitstop
feat: add state.freeze() for hit-stop / hit-pause effects
2 parents c949931 + 5140a05 commit 9e49831

7 files changed

Lines changed: 324 additions & 2 deletions

File tree

packages/examples/src/examples/whac-a-mole/ExampleWhacAMole.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Application, audio, loader, save, state } from "melonjs";
22
import { createExampleComponent } from "../utils";
33
import { data } from "./data";
4+
import { setupViewportEffects } from "./effects";
45
import { PlayScreen } from "./play";
56
import { resources } from "./resources";
67

@@ -11,6 +12,9 @@ const createGame = () => {
1112
scale: "auto",
1213
});
1314

15+
// vignette (always on) + dormant chromatic aberration (burst on hit)
16+
setupViewportEffects(_app.viewport, _app.renderer);
17+
1418
// initialize the "sound engine"
1519
audio.init("mp3,ogg");
1620

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import {
2+
type Camera2d,
3+
ChromaticAberrationEffect,
4+
DropShadowEffect,
5+
pool,
6+
type Renderer,
7+
type Sprite,
8+
Tween,
9+
VignetteEffect,
10+
type WebGLRenderer,
11+
} from "melonjs";
12+
13+
/**
14+
* Attach an always-on vignette to the viewport.
15+
* Effects degrade gracefully on Canvas (ShaderEffect logs a warning and
16+
* leaves `enabled = false`; all method calls become no-ops).
17+
*/
18+
export function setupViewportEffects(
19+
viewport: Camera2d,
20+
renderer: Renderer,
21+
): void {
22+
viewport.addPostEffect(new VignetteEffect(renderer as WebGLRenderer));
23+
}
24+
25+
/**
26+
* Attach a dormant chromatic aberration effect to a sprite.
27+
*/
28+
export function attachChromaticAberration(
29+
sprite: Sprite,
30+
renderer: Renderer,
31+
): ChromaticAberrationEffect {
32+
const fx = new ChromaticAberrationEffect(renderer as WebGLRenderer, {
33+
offset: 0,
34+
textureSize: [sprite.width, sprite.height],
35+
});
36+
sprite.addPostEffect(fx);
37+
return fx;
38+
}
39+
40+
/**
41+
* Attach a static drop shadow to a sprite, giving it weight on the scene.
42+
*/
43+
export function attachDropShadow(sprite: Sprite, renderer: Renderer): void {
44+
sprite.addPostEffect(
45+
new DropShadowEffect(renderer as WebGLRenderer, {
46+
offsetX: 2.0,
47+
offsetY: 3.0,
48+
color: [0.0, 0.0, 0.0],
49+
opacity: 0.2,
50+
textureSize: [sprite.width, sprite.height],
51+
}),
52+
);
53+
}
54+
55+
/**
56+
* Trigger a brief chromatic-aberration burst on the given effect.
57+
* Uses a Tween with `updateWhenPaused = true` so the decay animates
58+
* *through* a freeze.
59+
* @param fx - the effect to drive
60+
* @param peak - peak offset in texels
61+
* @param durationMs - total duration of the decay
62+
*/
63+
export function triggerChromaticBurst(
64+
fx: ChromaticAberrationEffect,
65+
peak = 8,
66+
durationMs = 250,
67+
): void {
68+
fx.setOffset(peak);
69+
const driver = { offset: peak };
70+
const tween = pool.pull("me.Tween", driver) as Tween;
71+
tween.updateWhenPaused = true;
72+
tween
73+
.to({ offset: 0 }, { duration: durationMs })
74+
.easing(Tween.Easing.Cubic.Out)
75+
.onUpdate(() => fx.setOffset(driver.offset))
76+
.start();
77+
}

packages/examples/src/examples/whac-a-mole/mole.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
1-
import { audio, input, pool, Sprite, save, Tween } from "melonjs";
1+
import {
2+
audio,
3+
type ChromaticAberrationEffect,
4+
game,
5+
input,
6+
pool,
7+
Sprite,
8+
save,
9+
Tween,
10+
} from "melonjs";
211
import { data } from "./data";
12+
import {
13+
attachChromaticAberration,
14+
attachDropShadow,
15+
triggerChromaticBurst,
16+
} from "./effects";
317

418
/**
519
* a mole entity
@@ -12,11 +26,19 @@ export class MoleEntity extends Sprite {
1226
initialPos: number;
1327
displayTween: Tween;
1428
hideTween: Tween;
29+
chromaticEffect: ChromaticAberrationEffect;
1530

1631
constructor(x: number, y: number) {
1732
// call the constructor
1833
super(x, y, { image: "mole", framewidth: 178, frameheight: 140 });
1934

35+
// per-mole shader effects:
36+
// - drop shadow gives the mole weight against the hole
37+
// - chromatic aberration bursts on hit
38+
// shadow is added first so chromatic aberration applies on top
39+
attachDropShadow(this, game.renderer);
40+
this.chromaticEffect = attachChromaticAberration(this, game.renderer);
41+
2042
// idle animation
2143
this.addAnimation("idle", [0]);
2244
// laugh animation
@@ -60,6 +82,13 @@ export class MoleEntity extends Sprite {
6082
// play ow FX
6183
audio.play("ow");
6284

85+
// juice trifecta: shake + per-mole chromatic burst + hit-stop
86+
// (start motion/burst first, then freeze — the chromatic decays in
87+
// real-time *during* the freeze, making the impact feel punchy)
88+
game.viewport.shake(8, 400);
89+
triggerChromaticBurst(this.chromaticEffect, 8, 250);
90+
game.freeze(150);
91+
6392
// add some points
6493
data.score += 100;
6594

packages/melonjs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## [19.2.0] (melonJS 2) - _unreleased_
44

55
### Added
6+
- State: `state.freeze(duration, music?)` — freeze the current stage for a fixed duration in milliseconds, then automatically resume. Returns a `Promise<void>` that resolves on unfreeze. Reentrant calls extend the freeze to whichever end-time is later (they do not stack). Useful for hit-stop / hit-pause effects on impact.
7+
- Application: `app.pause(music?)`, `app.resume(music?)`, `app.freeze(duration, music?)` — convenience proxy methods on the Application instance for the corresponding `state.*` methods.
68
- Text: `visibleCharacters` and `visibleRatio` properties on Text and BitmapText for progressive text reveal and typewriter effects. Animate `visibleRatio` with Tween for character-by-character text display.
79
- Tween: `repeatDelay(ms)` method and `repeatDelay` option in `to()` — adds a delay before each repeat cycle.
810
- Camera: FBO-based post-processing pipeline — assign a `ShaderEffect` to any camera's `shader` property to apply full-screen post-effects (vignette, scanlines, desaturation, etc.). Works with multiple cameras independently (e.g. main camera + minimap with different effects). Renderer manages FBO lifecycle via `beginPostEffect()`/`endPostEffect()`/`blitEffect()` methods.

packages/melonjs/src/application/application.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,45 @@ export default class Application {
552552
this.isDirty = true;
553553
}
554554

555+
/**
556+
* Pause the current stage. Convenience proxy for {@link state.pause}.
557+
* @param [music=false] - also pause the current music track
558+
* @example
559+
* app.pause(); // pause game updates, keep music playing
560+
* app.pause(true); // pause game updates and music
561+
*/
562+
pause(music: boolean = false): void {
563+
state.pause(music);
564+
}
565+
566+
/**
567+
* Resume the current stage. Convenience proxy for {@link state.resume}.
568+
* @param [music=false] - also resume the current music track
569+
*/
570+
resume(music: boolean = false): void {
571+
state.resume(music);
572+
}
573+
574+
/**
575+
* Freeze the current stage for a fixed duration, then automatically resume.
576+
* Useful for hit-stop / hit-pause effects on impact. Reentrant calls extend
577+
* the freeze to whichever end-time is later (they do not stack).
578+
* Convenience proxy for {@link state.freeze}.
579+
* @param duration - duration of the freeze in milliseconds
580+
* @param [music=false] - also pause the current music track during the freeze
581+
* @returns a Promise that resolves once the freeze ends
582+
* @example
583+
* // simple hit-stop on impact
584+
* app.freeze(80);
585+
*
586+
* // chain VFX after the freeze
587+
* await app.freeze(120);
588+
* spawnImpactParticles();
589+
*/
590+
freeze(duration: number, music: boolean = false): Promise<void> {
591+
return state.freeze(duration, music);
592+
}
593+
555594
/** @ignore */
556595
_tick(time: number): void {
557596
this.update(time);

packages/melonjs/src/state/state.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ let _extraArgs: unknown[] | null = null;
6262
// store the elapsed time during pause/stop period
6363
let _pauseTime: number = 0;
6464

65+
// freeze() bookkeeping
66+
let _freezeTimer: ReturnType<typeof setTimeout> | null = null;
67+
let _freezeEndsAt: number = 0;
68+
let _freezeMusic: boolean = false;
69+
let _freezeResolvers: Array<() => void> = [];
70+
6571
/**
6672
* @ignore
6773
*/
@@ -93,6 +99,21 @@ function _pauseRunLoop(): void {
9399
_isPaused = true;
94100
}
95101

102+
/**
103+
* End an active freeze: resume the run loop and resolve waiters.
104+
* @ignore
105+
*/
106+
function _endFreeze(): void {
107+
_freezeTimer = null;
108+
_freezeEndsAt = 0;
109+
state.resume(_freezeMusic);
110+
const resolvers = _freezeResolvers;
111+
_freezeResolvers = [];
112+
for (const resolve of resolvers) {
113+
resolve();
114+
}
115+
}
116+
96117
/**
97118
* this is only called when using requestAnimFrame stuff
98119
* @param time - current timestamp in milliseconds
@@ -328,6 +349,53 @@ const state = {
328349
}
329350
},
330351

352+
/**
353+
* Freeze the current stage for a fixed duration, then automatically resume.
354+
* Useful for hit-stop / hit-pause effects on impact.
355+
*
356+
* If `freeze()` is called again while a freeze is already active, the freeze
357+
* is *extended* to whichever end-time is later (calls do not stack). The
358+
* `music` flag from the initial call is preserved for the eventual resume.
359+
* @param duration - duration of the freeze in milliseconds
360+
* @param [music=false] - also pause the current music track during the freeze
361+
* @returns a Promise that resolves once the freeze ends
362+
* @example
363+
* // simple hit-stop on impact
364+
* state.freeze(80);
365+
*
366+
* // chain VFX after the freeze
367+
* await state.freeze(120);
368+
* spawnImpactParticles();
369+
*/
370+
freeze(duration: number, music: boolean = false): Promise<void> {
371+
// guard against NaN, Infinity, and negative durations — silently no-op
372+
// (mirrors how state.pause/resume quietly ignore invalid states)
373+
if (!Number.isFinite(duration) || duration < 0) {
374+
return Promise.resolve();
375+
}
376+
const now = globalThis.performance.now();
377+
const newEndsAt = now + duration;
378+
379+
if (_freezeTimer !== null) {
380+
// already frozen: only extend if the new end-time is later
381+
if (newEndsAt > _freezeEndsAt) {
382+
clearTimeout(_freezeTimer);
383+
_freezeEndsAt = newEndsAt;
384+
_freezeTimer = setTimeout(_endFreeze, newEndsAt - now);
385+
}
386+
} else {
387+
// start a new freeze
388+
_freezeMusic = music;
389+
_freezeEndsAt = newEndsAt;
390+
this.pause(music);
391+
_freezeTimer = setTimeout(_endFreeze, duration);
392+
}
393+
394+
return new Promise<void>((resolve) => {
395+
_freezeResolvers.push(resolve);
396+
});
397+
},
398+
331399
/**
332400
* return the running state of the state manager
333401
* @returns true if a "process is running"

0 commit comments

Comments
 (0)