diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 1cebb5b7c2..aa7472a25a 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -5,12 +5,19 @@ ### Added - Effects: colors take an alpha. Every color option and `setColor` takes `[r, g, b]` or `[r, g, b, a]`, the alpha being the color's opacity (`[r, g, b]` stays opaque), `RadialGradientEffect` honours the alpha of its `Color`, and `DropShadowEffect` gains the `setColor` the others have - Effects: `SwayEffect` leans a sprite or a layer from side to side, as if in a breeze, its top swaying most and everything from its anchor row down staying put. It is a `WaveEffect`, which now takes an `anchor` for the row its wave fades out at, and it wraps its own time so it never stutters on a phone's medium-precision floats -- Skills: the effects skill covers colors with an alpha and `SwayEffect`, with an example of each +- Rendering: `Renderable#sortOffset` lines a renderable up as if it stood that many pixels lower (or higher) when its container sorts on `"y"` or `"x"`, without moving it: a torch drawn over the wall it hangs on, dust sorted with the door it rises from, or art of different heights sorted by its base +- Lighting: `Light2d#softness` (0 to 1) fades the edge of a light's hole in the stage's ambient darkness, from a hard rim (0, the default, as before) to a darkness that thins straight from the center (1), along the same falloff as the glow. Hard and soft lights mix in one scene, overlapping lights multiply, and it works on every renderer +- Particles: `tintOverLife` gives the tints a particle takes over its life, evenly spaced and blended from the first at birth to the last at the end, such as a flame turning from white hot to deep red. It sets the color only (the fade still sets the opacity), and the blend is taken in 32 steps, which keeps the Canvas renderer to 32 tinted copies of the texture +- Animation: `setCurrentAnimation(name, { onFrame })` calls back with each frame index as playback reaches it, from frame 0 when the animation starts, so a footstep or a hit can land on its frame. It applies to the frame animations of a `Sprite` or a `Sprite3d` +- Audio: `playTrack(name, { volume, sprite })` plays one region of a clip as the track and loops on it. A region of a loop's exact length keeps an MP3 loop gapless in Safari, which plays the padding an MP3 encoder adds to the end of the file - Tiled: a tile object with no class of its own is built by the class registered under its tile's class, as Tiled shows it, so a class set once on a tile in the tileset applies to every object placed from it. The object's own class or registered name still comes first, and a tile classed like a built-in (`Sprite`, `Trigger`, `shape`...) stays a plain tile - Tiled: `TMXObject#resolvedProperty()` and `resolvedProperties()` return an object's custom properties together with those it inherits from its tile, the object's own winning, as the methods of the same names in Tiled's scripting API do. Only the object's own properties are set on it by name, as before - Tiled: `TMXTileset#getTileClass()` returns the class set on a tile in Tiled's tileset editor - Typings: `TMXObject`, the settings a class registered with `registerTiledObjectClass` (or a factory registered with `registerTiledObjectFactory`) receives, is exported as a type, and both functions are typed with it. A class or factory typed with its own settings still compiles +### Changed +- Rendering: a container sorted on `"x"` or `"y"` keeps its children in order as they move. With `autoSort` on, the default, it checks the order at the end of every update and re-sorts when anything is out of place, so a walking character no longer needs a `sort()` call to pass in front of and behind things. A frame where nothing moved costs one pass over the children and causes no redraw; turn `autoSort` off to keep the order you set. A `"z"` sort is unchanged + ### Fixed - Effects: a color given four values no longer breaks the effect. WebGL rejected it, and on WebGPU the fourth value overwrote the next setting, such as an outline's width; a color of any other length now throws a `TypeError` saying so - Effects: a colored drop shadow below full opacity is no longer too bright, nor are the soft edges of an outline. Both added their color at full strength whatever its opacity @@ -28,9 +35,13 @@ - Physics: the builtin `raycast` measures a body from the frame its renderable draws in, as collisions have since 20.7. On a body whose anchor is not its top left corner, such as a `Sprite`'s default centre, a ray hit half the body's size away from where it collides, or missed it - Camera: a zoomed camera follows its target, stays within its bounds and centres on `focusOn` in world units. Zoomed out, it kept the target off centre and scrolled past its bounds; zoomed in, it stopped short of them - Camera: `localToWorld` and `worldToLocal` take the camera's zoom and its place on the screen (`screenX`, `screenY`) into account. A pointer on a zoomed camera, or on one drawn in a corner of the screen, hit the world somewhere else than it pointed -- Skills: the lighting skill no longer says a sprite without a normal map is lit flatly. Only a normal-mapped sprite is shaded by a `Light2d`; any other sprite just sits under its glow and its cutout -- Skills: the drawing skill never said that on a GPU backend a `CanvasRenderTarget` keeps its texture for as long as the renderer lives. It now says to create one once and reuse it, and to call `invalidate()` after painting it again -- Skills: the tilemaps skill now explains that `sortOn = "y"` sorts objects and never tiles, so scenery a character walks in front of and behind belongs on the character's own object layer as tile objects. It also warns that Tiled objects sort by the top of their image, and that a character with the default collision mask collides with the default body of every class-less tile object +- Effects: a glow, outline, blur, drop shadow, chromatic aberration, wave, sway, pixelate or bloom on a sprite cut from an atlas or a tileset keeps to the sprite's own frame. Along the frame's edges it picked up the neighbouring frames, and drew a glow or a line from pixels that are not part of the sprite +- Camera: a zoomed camera draws its floating renderables, such as a HUD or a `ColorLayer`, and a `ColorLayer` fills all of its view. It skipped them all, as a minimap does, on every renderer +- Lighting: on the Canvas renderer, a light's hole in the ambient darkness is cut where the light is under a scrolled camera. The mask kept the darkness inside the light and cleared it around it instead +- Lighting: on the Canvas renderer, the ambient darkness of a zoomed camera, or of one drawn at an offset such as a minimap, has its holes where the lights are. They were cut as if the camera were neither zoomed nor offset +- Lighting: the ambient darkness covers the first column and row of a camera at a fractional position, such as one following its target with damping. Unless `subPixel` was set, they were left lit +- Lighting: the ambient darkness covers the whole view of a zoomed-out camera. It covered as much of the world as the camera is wide, leaving the rest of the view lit +- Audio: a sprite region declared looping (`[start, duration, true]`) loops when played with `audio.play(name, { sprite })`. It played only once unless `loop: true` was passed as well - Text: a label whose text is empty or only spaces no longer blanks the whole frame on WebGPU. Its canvas was sized 0 pixels wide, which WebGPU refuses to upload - Typings: `TextureAtlas#getAnimationSettings()` is typed as the settings it returns, so they pass to a `Sprite` constructor from TypeScript without a cast. It returned `object`, and `Sprite3d` typed the named animations an aseprite atlas carries as an array diff --git a/packages/melonjs/skills/melonjs-audio/SKILL.md b/packages/melonjs/skills/melonjs-audio/SKILL.md index 448aa7bb99..52f7e9ab39 100644 --- a/packages/melonjs/skills/melonjs-audio/SKILL.md +++ b/packages/melonjs/skills/melonjs-audio/SKILL.md @@ -100,7 +100,27 @@ await loader.preload([{ audio.play("sfx", { sprite: "jump" }); ``` -The optional third element marks a region as looping. `play()`'s second argument +### A loop with no gap + +An MP3 encoder pads the end of its file. Chrome and Firefox trim the padding when +they decode it, Safari keeps it, so a looping MP3 has a short silence at every +loop there (27 ms on a 12 s clip at 22 kHz, measured). Declare a region of the +loop's exact length and loop on that: the voice never reaches the padding. + +```js +await loader.preload([{ + name: "ambience", type: "audio", src: "data/sfx/", + sprite: { loop: [0, 12000] }, +}]); +audio.playTrack("ambience", { sprite: "loop", volume: 0.6 }); +// or, as a plain clip: audio.play("ambience", { sprite: "loop", loop: true }) +``` + +Ogg Vorbis carries no such padding. + +The optional third element marks a region as looping: it loops unless `play()` is +told `loop: false` (before 20.11 it only looped when `loop: true` was passed as +well). `play()`'s second argument takes either the original `loop` boolean or a `PlayOptions` object (`sprite`, `loop`, `onend`, `volume`, plus the spatial `follow`, `at` and `stopWithTarget`) — both forms work. Object fields win over the positional @@ -467,6 +487,7 @@ either way — blank screen, empty console. | a looping sound outlives the entity that owns it | `follow` without `stopWithTarget: true` | | a voice panned with `stereo()` loses its 3D placement, or the reverse | one panner node per voice and last writer wins — pick one per voice and stay with it | | a reverb / compressor on the master gain does not affect the music | the music is `stream: true`, which bypasses the master gain — preload it buffered | +| a looping MP3 drops out for a moment at every loop, in Safari only | Safari keeps the encoder's padding at the end of the file: loop a sprite region of the clip's exact length (see "A loop with no gap") | | sound effects keep playing after the window loses focus | `pauseOnBlur` and `stopOnBlur` are both off, so the engine is deliberately leaving the game running in the background | ## Related skills diff --git a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md index f192599ad9..b14497a934 100644 --- a/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md +++ b/packages/melonjs/skills/melonjs-effects-and-shaders/SKILL.md @@ -130,6 +130,15 @@ covers. `[r, g, b]` is opaque, as it always was. Any other length throws. sprite.addPostEffect(new OutlineEffect(app.renderer, { color: [1, 1, 1, 0.5] })); ``` +**On a sprite cut from an atlas or a tileset, the effects that read around a +pixel keep to the sprite's own frame.** `GlowEffect`, `OutlineEffect`, +`BlurEffect`, `DropShadowEffect`, `ChromaticAberrationEffect`, `WaveEffect`, +`SwayEffect`, `PixelateEffect` and `BloomEffect` read the neighbouring frames +as transparent, as if the sprite were drawn on its own, so they never show +along its edges and the atlas needs no padding for them. Past the texture's own +edge (a standalone image, or the outside of an atlas) nothing changes. Their `textureSize` is still the size of the whole atlas image: it is +what turns a width in texels into a distance in the texture. + `SwayEffect` leans a sprite or a layer from side to side, as if in a breeze: the top sways most and everything from its `anchor` row down stays put, so a tree's trunk or a reed's root stays planted. It suits anything planted at its bottom; diff --git a/packages/melonjs/skills/melonjs-lighting/SKILL.md b/packages/melonjs/skills/melonjs-lighting/SKILL.md index 9350231705..76b8cad591 100644 --- a/packages/melonjs/skills/melonjs-lighting/SKILL.md +++ b/packages/melonjs/skills/melonjs-lighting/SKILL.md @@ -41,6 +41,29 @@ normal-mapped sprites. returns immediately while its alpha is 0 — so with no ambient colour set there is nothing to cut through and the lights just look like soft additive blobs. +## Soft edges: `softness` + +A light's hole in the darkness is hard-edged by default: full light inside its +radius, full darkness outside. `light.softness` (0 to 1) fades it instead: + +```js +const torch = new Light2d(x, y, 220, 220, "#ffb46a", 0.4); +torch.softness = 0.8; // lit core of 20% of the radius, then fading out +``` + +- `1` thins the darkness straight from the centre to the edge, the same falloff + as the glow; in between, a fully lit core of `1 - softness` of the radius. +- Hard and soft lights mix in one scene. Where lights overlap, what is left of the + darkness is what each leaves, multiplied, so two half-lights make three + quarters light. +- The hole is the light's size. For an opening wider than the glow (a torch whose + light reaches further than its flame's halo), add a second, bigger light with + `illuminationOnly = true`: it cuts the darkness and draws nothing. +- Up to 32 lights in view are cut by one soft pass; lights outside the view do not + count. A scene with no soft light draws exactly as before. +- To keep a camera out of the darkness (a minimap showing the level lit), + override `drawLighting` on the stage and skip that camera. + ## Multiple lights Add as many as you need — the glow quads and the ambient cutouts accumulate with @@ -141,8 +164,9 @@ Less than you would expect. `Light2d` is renderer-agnostic: it just calls `renderer.drawLight(this)`, and every backend implements it — the GPU backends through a shared procedural radial-falloff shader, the Canvas renderer by rasterising a cached `Gradient` into one shared render target and compositing it -with `drawImage`. The ambient cutout pass is plain `setMask` + `fillRect`. So the -lit-spot look works everywhere, Canvas included. +with `drawImage`. The hard ambient cutout pass is plain `setMask` + `fillRect`; +the soft one is one shader pass on the GPU backends and an offscreen canvas on +Canvas. So the lit-spot look, soft or hard, works everywhere, Canvas included. What Canvas cannot do is **per-pixel normal-map lighting**: `renderer.setLightUniforms()` is a no-op there, and the first frame that passes a @@ -172,6 +196,9 @@ See the `melonjs-3d` skill. | symptom | cause | |---|---| | lights added but the scene never darkens | no ambient colour set — `ambientLight` alpha is 0, so the pass returns early | +| the lit area has a hard rim | `softness` is 0 (the default): set it on the light | +| the darkness opens no wider than the glow | the hole is the light's size: add a bigger `illuminationOnly` light for the opening | +| a minimap or second camera is dark too | every camera draws the stage's darkness: override `drawLighting` and skip that camera | | sprite not shaded by a light, only glowed over | no `normalMap` on the sprite, or the Canvas backend is active | | normal-mapped sprite is pure black where unlit | `Stage.ambientLightingColor` left at black | | the 33rd light shades nothing | `MAX_LIGHTS = 32` for the normal-map pipeline | diff --git a/packages/melonjs/skills/melonjs-math/SKILL.md b/packages/melonjs/skills/melonjs-math/SKILL.md index 407c7f12e8..c610889f63 100644 --- a/packages/melonjs/skills/melonjs-math/SKILL.md +++ b/packages/melonjs/skills/melonjs-math/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-math -description: "Use this skill for vector and matrix maths in melonJS — Vector2d, Vector3d, their observable variants, Matrix2d, Matrix3d, and the math utility functions. Covers which methods mutate and which allocate, the vector pool and when clone() is the wrong tool, interpolation with lerp, slerp, damp and moveTowards, and the argument defaults that silently drop a component. Triggers on: Vector2d, Vector3d, ObservableVector2d, ObservableVector3d, Matrix2d, Matrix3d, vector, vector maths, dot, cross, normalize, length, distance, angle, lerp, slerp, damp, moveTowards, project, clamp, degToRad, radToDeg, clone, vector pool, vector3dPool, setMuted, setCallback, my vector is wrong, z is zero, scale ignores z., moveTowards ignores z, projectN, currentTransform, setTransform, matrix, transform matrix, column-major, val, Float32Array, basis matrix, orientation matrix." +description: "Use this skill for vector and matrix maths in melonJS — Vector2d, Vector3d, their observable variants, Matrix2d, Matrix3d, and the math utility functions. Covers which methods mutate and which allocate, the vector pool and when clone() is the wrong tool, interpolation with lerp, slerp, damp and moveTowards, and the argument defaults that silently drop a component. Triggers on: Vector2d, Vector3d, ObservableVector2d, ObservableVector3d, Matrix2d, Matrix3d, vector, vector maths, dot, cross, normalize, length, distance, angle, lerp, slerp, damp, moveTowards, project, clamp, degToRad, radToDeg, clone, vector pool, vector3dPool, setMuted, setCallback, my vector is wrong, z is zero, scale ignores z., moveTowards ignores z, projectN, currentTransform, setTransform, matrix, transform matrix, column-major, val, Float32Array, basis matrix, orientation matrix, Noise, getNoise1d, flicker, wobble." license: MIT --- @@ -291,6 +291,25 @@ math.pow(n); // n squared math.DEG_TO_RAD math.RAD_TO_DEG math.TAU math.ETA math.EPSILON ``` +### Values that wobble over time + +`Noise`, the generator behind `NoiseTexture2d`, also samples directly. For a +value that should drift without repeating (a torch's flicker, a sway, a camera +wander), sample it with time: + +```js +import { Noise } from "melonjs"; + +const noise = new Noise({ seed: 7, frequency: 1.5, octaves: 3 }); +// around 1; give each object its own offset so they do not move together +const flicker = 1 + 0.2 * noise.getNoise1d(seconds + offset); +``` + +`getNoise1d` returns `[-1, 1]` (with fractal octaves most values fall within +about ±0.6), `frequency` is in changes per unit of the input, and the same seed +gives the same field every run. A sum of sines repeats where people notice it, +and `Math.random()` every frame jitters instead of drifting. + ## Symptoms | symptom | cause | @@ -305,6 +324,7 @@ math.DEG_TO_RAD math.RAD_TO_DEG math.TAU math.ETA math.EPSILON | you built your own 16-number array | `Renderable.currentTransform` is already a `Matrix3d`; write its `val` | | a value read back from a matrix differs | `val` is `Float32Array`; it holds float32 | | easing differs at 120fps | per-frame `lerp`; use `damp` | +| a flicker or sway looks mechanical, or visibly repeats | a sum of sines or a per-frame `Math.random()`: sample `Noise#getNoise1d(time)` | ## Related skills diff --git a/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md b/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md index a67a0fa530..970697e2b0 100644 --- a/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md +++ b/packages/melonjs/skills/melonjs-particles-and-trails/SKILL.md @@ -1,6 +1,6 @@ --- name: melonjs-particles-and-trails -description: "Use this skill for particle effects and motion trails in melonJS — ParticleEmitter configuration, burst versus stream emission, the referenceSpace frame of reference introduced in 20.2, 3D particle motion through elevation and minSpread/maxSpread in 20.8, and the Trail renderable. Covers the emitter that silently does nothing until you call an emit method. Triggers on: Trail3d, ribbon, exhaust trail, speed streak, ski track, tyre mark, ribbonNormal, ParticleEmitter, particles, burstParticles, streamParticles, referenceSpace, ParticleEmitterSettings, elevation, minSpread, maxSpread, spawn area, Trail, explosion, smoke, sparks, debris, ring, exhaust, emitter." +description: "Use this skill for particle effects and motion trails in melonJS — ParticleEmitter configuration, burst versus stream emission, the referenceSpace frame of reference introduced in 20.2, 3D particle motion through elevation and minSpread/maxSpread in 20.8, and the Trail renderable. Covers the emitter that silently does nothing until you call an emit method. Triggers on: Trail3d, ribbon, exhaust trail, speed streak, ski track, tyre mark, ribbonNormal, ParticleEmitter, particles, burstParticles, streamParticles, referenceSpace, ParticleEmitterSettings, elevation, minSpread, maxSpread, spawn area, Trail, explosion, smoke, sparks, debris, ring, exhaust, emitter, tintOverLife, colour over life, color over life, flame, fire." license: MIT --- @@ -60,7 +60,8 @@ pair it with `framewidth`/`frameheight` or it does nothing. Note the variety is in SHAPE, not colour. `tint` belongs to the emitter, so every particle from one emitter is tinted alike however many frames it picks -between. +between. For a colour that changes over each particle's life, use +`tintOverLife` (below) rather than drawing one frame per colour. Frame dimensions must be positive integers that fit inside the image, and an explicit `frames` sequence must be nonempty and contain valid integer indices. @@ -69,6 +70,35 @@ Invalid configurations throw `RangeError` before changing an emitter during only future particles, so ones already alive finish with their original image, frame size and sequence. +## Colour over life: `tintOverLife` + +`tintOverLife` is the list of tints a particle takes over its life: the first at +birth, the last at the end, evenly spaced and blended in between. Give as many +CSS colours as you like (repeat one to hold it longer); it replaces `tint`. + +```js +// a flame: white hot, then orange, then deep red as it rises and dies +const flame = new ParticleEmitter(x, y, { + image: softDot, // a white texture takes the tints as they are + tintOverLife: ["#fff3b0", "#ffc455", "#ff8a26", "#e45414", "#a0280c"], + textureAdditive: true, +}); +``` + +- **Colour only.** Opacity is still the built-in fade over the particle's life; + the alpha of a colour in the list is ignored. On an additive emitter, a darker + colour is a dimmer particle, so fold a "fainter" step into the colour itself. +- **32 steps.** However many colours, the blend is taken in 32 steps, the same on + every renderer: a particle changes colour at most 32 times. That matters on + Canvas, which keeps a tinted copy of the texture for every colour it is asked + to draw: 32 at most per texture. +- **Author the texture white.** `tint` multiplies, so a coloured texture muddies + every step. +- It works alongside sprite-sheet `frames` (one picks the image, the other the + colour), and a list that is empty, not an array, or holds a non-string throws a + `TypeError` from the constructor or `reset()`, which then leaves the emitter as + it was. The list is copied: change it through `reset()`. + ## Where particles are born `width` and `height` are the spawn AREA, and both default to `1` — so by @@ -298,6 +328,9 @@ answer when you want discrete puffs rather than a continuous ribbon. | the cloud follows a moving emitter | default `referenceSpace: "local"` — use `"world"` for trails | | particles do not travel far enough | settings tuned pre-20.2, when drawn travel was doubled | | one-shot emitters accumulate | missing `autoDestroyOnComplete: true` | +| every particle of an emitter is the same colour all its life | `tint` is one value for the emitter: use `tintOverLife` for a colour that changes as it ages | +| a `tintOverLife` colour's transparency has no effect | the list sets the colour only; opacity is the particle's fade | +| `tintOverLife` colours look muddy | the texture is coloured; author it white so the tints come through as given | | frame rate drops with many effects | an emitter per event instead of one re-aimed emitter | | a 3D burst is invisible under a `Camera3d` | before 20.8 a particle was culled at twice its depth; update | | the whole burst launches from one pixel | `width`/`height` are the spawn area and default to `1` | diff --git a/packages/melonjs/skills/melonjs-renderables/SKILL.md b/packages/melonjs/skills/melonjs-renderables/SKILL.md index bf8ccd005a..686f4868a8 100644 --- a/packages/melonjs/skills/melonjs-renderables/SKILL.md +++ b/packages/melonjs/skills/melonjs-renderables/SKILL.md @@ -96,9 +96,44 @@ world.addChild(sprite, 10); // ✓ the only reliable form 2. **Setting `depth` *before* `addChild` is overwritten.** With `autoDepth` on (the default), `addChild` without an explicit `z` assigns `child.pos.z = children.length` after the push, discarding whatever you set. -3. **Setting `depth` *after* `addChild` does not reorder.** Containers sort when - a child is added (`autoSort`, deferred to the next tick), not when `pos.z` - changes. If you must change it later, call `container.sort()` yourself. +3. **Setting `depth` *after* `addChild` does not reorder.** A `"z"` sort runs + when a child is added (`autoSort`, deferred to the next tick), not when + `pos.z` changes. If you must change it later, call `container.sort()` + yourself, or use `moveToTop` / `moveUp` / `moveDown` / `moveToBottom`, which + reorder the children themselves. (An `"x"` / `"y"` sort is different: see + below.) + +### Sorting on `"y"` (or `"x"`), and `sortOffset` + +A container with `sortOn = "y"` draws its children from the smallest `pos.y` to +the largest, so whatever is lower on screen covers what is above it (top-down and +isometric games). `"x"` does the same along x. A different `pos.z` still decides +first, so keep the children that should interleave at one z. + +- **Moving a child re-sorts by itself.** With `autoSort` on (the default), the + container checks its order at the end of every update, after physics and the + children's own updates, and re-sorts when anything is out of place: a walking + character needs no `sort()` call. A frame where nothing moved costs one pass + over the children and does not cause a redraw. Turn `autoSort` off to keep + the children in the order you put them. +- **Ties keep insertion order, and the child added LAST is drawn BEHIND.** A + torch placed at its wall's exact position disappears behind the wall. + +`sortOffset` changes the ORDER only, never where anything is: the child is lined +up as if it stood that many pixels lower (positive) or higher (negative), and is +drawn exactly where it was. Use it when `pos` is not the point to sort by: + +```js +torch.sortOffset = 1; // win the tie with the wall it hangs on +dust.sortOffset = door.pos.y - dust.pos.y; // an emitter's pos is the top of its + // spawn area: line it up with the door +prop.sortOffset = prop.height; // sort by the bottom of the image +``` + +A container that keeps itself sorted picks a change up at its next update (with +`autoSort` off, call its `sort()`). `"z"` and `"depth"` sorting ignore it. It must be a finite number (a string +or `NaN` throws, rather than scrambling every sibling), and a pooled renderable +starts again at `0`. The exception: under `Camera3d` the world's `sortOn` is `"depth"`, and the camera calls `container.sortNow(true)` every frame. Note that mode does **not** diff --git a/packages/melonjs/skills/melonjs-sprites-and-animation/SKILL.md b/packages/melonjs/skills/melonjs-sprites-and-animation/SKILL.md index 2c0529d6f8..e734a53bf4 100644 --- a/packages/melonjs/skills/melonjs-sprites-and-animation/SKILL.md +++ b/packages/melonjs/skills/melonjs-sprites-and-animation/SKILL.md @@ -59,6 +59,10 @@ hero.setCurrentAnimation("walk"); hero.setCurrentAnimation("die", "idle"); // chain to idle when it ends hero.setCurrentAnimation("die", { loop: false }); // play once, hold the last frame hero.setCurrentAnimation("die", { loop: false, onComplete: fn }); // …and call fn when it does +hero.setCurrentAnimation("use", { next: "idle", onComplete: fn }); // call fn, then chain to idle +hero.setCurrentAnimation("run", { onFrame: (frame) => { // each frame playback reaches + if (frame === 3 || frame === 8) footstep(); +} }); if (hero.isCurrentAnimation("walk")) { /* … */ } ``` @@ -71,13 +75,25 @@ accepted — passing region names throws. The second argument to `setCurrentAnimation` is polymorphic. A **string** chains to that animation when this one ends. An **options object** takes -`{ loop, next, speed, onComplete }`, and only an explicit `loop: false` stops the -looping. A **bare function** is the legacy form: it fires at *every* cycle end, +`{ loop, next, speed, onComplete, onFrame }`, and only an explicit `loop: false` +stops the looping. A **bare function** is the legacy form: it fires at *every* cycle end, and only holds the last frame if it returns `false`. Omit the argument entirely and the animation loops forever. +`onFrame(frame)` is called with frame 0 when the animation starts, then with each +frame playback reaches, so a footstep or a hit lands on its frame without polling +`getCurrentAnimationFrame()`. A long tick reports every frame it goes past. It is +not called for a frame set with `setAnimationFrame`, nor for the frame 0 a +play-once animation would wrap to. Keep the callback in a field when you call +`setCurrentAnimation` every frame: re-selecting keeps the first one anyway. + +With both `next` and `onComplete`, the callback runs first and the chained +animation starts with none of its own, so a one-off action (a pickup, an +attack) needs no manual switch back to idle. + Re-selecting the animation that is already current is a no-op, so calling -`setCurrentAnimation` every frame is safe. An unknown id throws. +`setCurrentAnimation` every frame is safe, and an `isCurrentAnimation` check +around it adds nothing. An unknown id throws. ## Texture atlases diff --git a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md index eed9e7451c..ab4e361d0c 100644 --- a/packages/melonjs/skills/melonjs-tilemaps/SKILL.md +++ b/packages/melonjs/skills/melonjs-tilemaps/SKILL.md @@ -232,6 +232,10 @@ Isometric maps need y-sorting so objects overlap correctly: app.world.sortOn = "y"; // after level.load ``` +The world then keeps its objects in order as they move (`autoSort`, on by +default): a character walking around passes in front of and behind the scenery +with no `sort()` call. + `sortOn` only accepts `"x"`, `"y"`, `"z"` (the default) or `"depth"` and throws on anything else. `Vector2d`, `Vector3d`, their observable variants and `Polygon` all carry `.toIso()` / `.to2d()` for converting between screen and map space. @@ -269,9 +273,27 @@ The `y` it sorts on is `pos.y`, and Tiled objects default to a `(0, 0)` anchor, so that is the **top** of each image. Sorting by the top only matches sorting by the feet when every image is the same height and stands on the bottom of its box, the usual layout of an isometric pack. A shorter image sorts in front of -things it stands behind, so pad the art to one height. A character you create -in code needs the same convention: `anchorPoint` `(0, 0)` and a frame as tall -as the scenery's. +things it stands behind. Either pad the art to one height, or line every object +up by the bottom of its image with `sortOffset`, which changes the order and +never the position: + +```js +level.load("map1"); +app.world.sortOn = "y"; +for (const child of app.world.getChildren()) { + child.sortOffset = child.height; // sort by the bottom of each image +} +``` + +A character you create in code follows the same convention as the scenery: by +the top of a frame as tall as the scenery's (`anchorPoint` `(0, 0)`), or by its +feet when everything sorts by the bottom. + +Two equal positions keep insertion order, and the one added **last** is drawn +**behind**: a decoration placed at its wall's exact position disappears. Give +it `sortOffset = 1`. A `Container` or a particle emitter is sorted by its top +left corner (an emitter's by the top of its spawn area); give it the offset to +the point it belongs with, such as `door.pos.y - dust.pos.y`. ## Image layers @@ -328,7 +350,9 @@ unanimated layer into the offscreen-bake path instead. | collision shapes are not solid | the object layer's name does not contain `"collision"`, or the objects are named | | isometric objects overlap wrongly | `world.sortOn` not set to `"y"` | | a character is always in front of (or behind) every wall | the walls are on a tile layer, or on another object layer than the character: each layer has its own `z`, compared before `y` | -| a small prop draws over a character standing in front of it | images of different heights: the y-sort uses `pos.y`, the top of a Tiled object's image | +| a small prop draws over a character standing in front of it | images of different heights: the y-sort uses `pos.y`, the top of a Tiled object's image. Pad the art, or set `sortOffset = height` on every object to sort by the bottom | +| a decoration placed at the same position as a wall disappears behind it | a tie keeps insertion order and draws the later one behind: `sortOffset = 1` | +| particles (or a container) at the foot of something draw over a character standing in front of it | they sort by the top of their spawn area (or the container's top left): set `sortOffset` to line them up with what they belong to | | a character is stopped short of walls and props placed as tile objects | it collides with their default bodies (static, `ENEMY_OBJECT`, the whole image box); mask it to `collision.types.WORLD_SHAPE` when the collision layer holds the real shapes | | a property set on a tile is undefined on the object's `settings` | tile properties are not copied onto `settings`: read them with `settings.resolvedProperty(name)` | | objects placed from a classed tile come out as plain sprites | that class is not registered, it is a built-in name, or the object has a class or a registered name of its own, which come first | diff --git a/packages/melonjs/src/audio/audio.ts b/packages/melonjs/src/audio/audio.ts index cd3c6115c7..58e1ba13e3 100644 --- a/packages/melonjs/src/audio/audio.ts +++ b/packages/melonjs/src/audio/audio.ts @@ -30,6 +30,7 @@ import { setGlobalMuted, setGlobalVolume, } from "./state.ts"; +import type { TrackOptions } from "./types.ts"; export { duration, @@ -73,6 +74,7 @@ export type { PannerAttributes, SoundAsset, ToneOptions, + TrackOptions, } from "./types.ts"; /** @@ -154,18 +156,34 @@ export function disable(): void { * * Use {@link play} for sound effects: they are meant to overlap, and nothing * here applies to them. + * The track can be one region of the clip, from its `sprite` map. An MP3 + * encoder pads the end of its file and Safari plays the padding, so a looping + * MP3 drops out for a moment at each loop there: a region of the loop's exact + * length keeps it gapless. * @param sound_name - Audio track name (case-sensitive). - * @param volume - Playback volume, `0.0..1.0`. Defaults to the current - * global volume. + * @param volumeOrOptions - Playback volume, `0.0..1.0` (defaults to the + * current global volume), or a {@link TrackOptions} object with `volume` + * and the `sprite` region to loop on. * @returns The sound instance ID. * @throws {Error} when no clip of that name is loaded, raised before anything * is stopped, so a mistyped name cannot silence the music already playing. * @example * me.audio.playTrack("menu_theme"); * me.audio.playTrack("battle_theme"); // the menu theme stops + * @example + * // loop on a region of exactly 12 s, declared when the clip is loaded: + * // { name: "ambience", type: "audio", src: "sfx/", sprite: { loop: [0, 12000] } } + * me.audio.playTrack("ambience", { sprite: "loop", volume: 0.6 }); * @category Audio */ -export function playTrack(sound_name: string, volume?: number): number { +export function playTrack( + sound_name: string, + volumeOrOptions?: number | TrackOptions, +): number { + const options: TrackOptions = + typeof volumeOrOptions === "object" + ? volumeOrOptions + : { volume: volumeOrOptions }; const previousName = audioState.currentTrackId; const previousVoice = audioState.currentTrackVoiceId; @@ -179,7 +197,11 @@ export function playTrack(sound_name: string, volume?: number): number { // audio context is still locked, and a queued stop with no id resolves // its ids when it finally runs — late enough to catch the replacement // and leave the game silent. - const id = play(sound_name, true, null, volume); + const id = play(sound_name, { + loop: true, + sprite: options.sprite, + volume: options.volume, + }); audioState.currentTrackId = sound_name; audioState.currentTrackVoiceId = id; diff --git a/packages/melonjs/src/audio/playback.ts b/packages/melonjs/src/audio/playback.ts index c45fa45752..8fa1310ca5 100644 --- a/packages/melonjs/src/audio/playback.ts +++ b/packages/melonjs/src/audio/playback.ts @@ -176,7 +176,6 @@ export function play( typeof loopOrOptions === "object" && loopOrOptions !== null ? loopOrOptions : { loop: loopOrOptions }; - const loop = options.loop === true; const endCallback = options.onend ?? onend; const level = options.volume ?? volume; @@ -192,6 +191,13 @@ export function play( : `melonJS: audio clip "${sound_name}" could not be played`, ); } + // Left out, `loop` follows the clip: its own default, or the loop flag + // of the region played (the third element of its sprite entry). The + // positional form always says, so only the options form can leave it out + const loop = + options.loop ?? + (sound.loop() || + sound._sprite[options.sprite ?? "__default"]?.[2] === true); sound.loop(loop, id); sound.volume( typeof level === "number" ? clamp(level, 0.0, 1.0) : getGlobalVolume(), diff --git a/packages/melonjs/src/audio/types.ts b/packages/melonjs/src/audio/types.ts index 04bb74f386..918d7d2c0f 100644 --- a/packages/melonjs/src/audio/types.ts +++ b/packages/melonjs/src/audio/types.ts @@ -298,7 +298,10 @@ export interface PlayOptions { * whole clip. */ sprite?: string | undefined; - /** Loop playback when the region ends. Defaults to `false`. */ + /** + * Loop playback when the region ends. Defaults to the region's own loop + * flag (the third element of its `sprite` entry), else `false`. + */ loop?: boolean | undefined; /** Called when this instance finishes playing. */ onend?: (() => void) | undefined; @@ -361,3 +364,19 @@ export interface SoundEvents { /** Audio was unlocked by a user gesture. */ unlock?: (() => void) | undefined; } + +/** + * Options accepted by {@link audio.playTrack | `audio.playTrack`} in place of its `volume` argument. + * @category Audio + */ +export interface TrackOptions { + /** Playback volume, `0.0..1.0`. Defaults to the current global volume. */ + volume?: number | undefined; + /** + * Name of a region declared in the clip's `sprite` map: the track loops on + * that region instead of the whole clip. A region of a loop's exact length + * keeps it gapless where a decoder pads the end of the clip (Safari, on + * MP3). + */ + sprite?: string | undefined; +} diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts index 8f75fe837d..cdc3b7172a 100644 --- a/packages/melonjs/src/camera/camera2d.ts +++ b/packages/melonjs/src/camera/camera2d.ts @@ -461,6 +461,26 @@ export default class Camera2d extends Renderable { return this.screenX === 0 && this.screenY === 0 && this.zoom === 1; } + /** + * Whether this camera draws the floating renderables that are not + * `visibleInAllCameras` (a HUD, a `ColorLayer`). A camera with default + * settings does, and so does the stage's main camera when it is zoomed: + * zoom does not make it a minimap. Any other camera (a minimap, a + * split-screen view) leaves them to the main camera. + * @ignore + * @internal + */ + get drawsFloating(): boolean { + if (this.isDefault) { + return true; + } + return ( + this.screenX === 0 && + this.screenY === 0 && + state.current()?.cameras.get("default") === this + ); + } + /** * The world-space bounds currently visible through this camera, * taking into account position and zoom level. @@ -1259,7 +1279,17 @@ export default class Camera2d extends Renderable { // non-default cameras (minimap/splitscreen) line up correctly — // `camera.pos + camera.offset` alone misses `containerOffsetX/Y`. if (stage) { + // the projection the world was drawn through, bound again: the + // Canvas renderer keeps it in the context transform, which the + // world's restore has undone by now, so the darkness of a zoomed + // or offset camera was cut where the lights would be unzoomed. + // Within its own save, so nothing drawn after inherits it + renderer.save(); + renderer.setProjection( + isNonDefault ? this.worldProjection : this.projectionMatrix, + ); stage.drawLighting(renderer, this, translateX, translateY); + renderer.restore(); } // post-effect: unbind FBO and blit to screen through shader effect diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index 87e55667cc..b671cdd035 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -126,6 +126,7 @@ export type { PannerAttributes, PlayOptions, SoundEvents, + TrackOptions, } from "./audio/types.ts"; // export all public constants export * from "./const.ts"; diff --git a/packages/melonjs/src/lighting/light2d.ts b/packages/melonjs/src/lighting/light2d.ts index 13cc947536..6b8d5b8032 100644 --- a/packages/melonjs/src/lighting/light2d.ts +++ b/packages/melonjs/src/lighting/light2d.ts @@ -150,6 +150,13 @@ export default class Light2d extends Renderable { */ lightHeight: number; + /** + * backing value of {@link Light2d#softness} + * @ignore + * @internal + */ + private _softness = 0; + /** * Create a 2D point light. * @@ -252,6 +259,51 @@ export default class Light2d extends Renderable { this.updateBounds(); } + /** + * How soft the edge of this light's hole in the stage's ambient darkness + * ({@link Stage#ambientLight}) is, from `0` to `1`. The light only cuts + * that darkness; its glow, and the shading of normal-mapped sprites, are + * unaffected. + * + * - `0` (the default): a hard edge. Inside the light's radius the darkness + * is gone, outside it is whole. + * - `1`: the darkness thins out from the edge all the way to the center, + * along the same straight falloff as the glow. + * - In between: a fully lit core of `1 - softness` of the radius, then the + * darkness comes back over the rest of it. + * + * Hard and soft lights mix in one scene, and where lights overlap, the + * darkness left is what each of them leaves, multiplied. The hole has the + * light's size: for an opening wider than a glow, add a second, bigger + * light with {@link Light2d#illuminationOnly} set, which cuts the darkness + * and draws nothing. Up to 32 soft-path lights in view at once are cut. + * @default 0 + * @throws {TypeError} if set to anything but a number from 0 to 1 + * @example + * // a torch whose light fades into the dark, rather than a hole with a hard rim + * const torch = new Light2d(x, y, 200, 200, "#ffb46a", 0.4); + * torch.softness = 0.8; + * app.world.addChild(torch); + * // the darkness it cuts (on your Stage) + * this.ambientLight.parseCSS("rgba(4, 3, 10, 0.95)"); + */ + get softness(): number { + return this._softness; + } + set softness(value: number) { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0 || + value > 1 + ) { + throw new TypeError( + `Light2d: softness must be a number from 0 to 1, not ${String(value)}`, + ); + } + this._softness = value; + } + /** * Set new radii for this light. * diff --git a/packages/melonjs/src/particles/emitter.ts b/packages/melonjs/src/particles/emitter.ts index 3eccba18ae..9870082c8f 100644 --- a/packages/melonjs/src/particles/emitter.ts +++ b/packages/melonjs/src/particles/emitter.ts @@ -1,3 +1,4 @@ +import { Color } from "./../math/color.ts"; import { randomFloat } from "./../math/math.ts"; import { Matrix3d } from "../math/matrix3d.ts"; import { Vector2d } from "../math/vector2d.ts"; @@ -78,6 +79,55 @@ function clampMinToMax( * @ignore * @internal */ +/** + * How many steps a {@link ParticleEmitterSettings.tintOverLife} blend is taken + * in, whatever the number of colors. + * @ignore + * @internal + */ +const TINT_STEPS = 32; + +/** + * The tint of each step of a particle's life, blended from the + * `tintOverLife` colors (undefined without them). Checked before the + * settings change, like the sheet settings. + * @ignore + * @internal + */ +function tintOverLifeSteps( + settings: ParticleEmitterSettings, +): readonly Color[] | undefined { + const colors = settings.tintOverLife; + if (colors === undefined) { + return undefined; + } + if (!Array.isArray(colors) || colors.length === 0) { + throw new TypeError( + "ParticleEmitter: tintOverLife must be a non-empty array of CSS colors", + ); + } + const stops = colors.map((css: unknown) => { + if (typeof css !== "string") { + throw new TypeError( + `ParticleEmitter: tintOverLife colors must be CSS strings, not ${String(css)}`, + ); + } + return new Color().parseCSS(css); + }); + const steps: Color[] = []; + for (let i = 0; i < TINT_STEPS; i++) { + // the first step is the first color, the last one the last color + const at = (i / (TINT_STEPS - 1)) * (stops.length - 1); + const from = Math.floor(at); + const to = Math.min(from + 1, stops.length - 1); + const step = stops[from].clone().lerp(stops[to], at - from); + // the color only: opacity is the particle's fade + step.alpha = 1; + steps.push(step); + } + return steps; +} + function particleFrames( settings: ParticleEmitterSettings, ): ReadonlyArray | undefined { @@ -185,6 +235,14 @@ export default class ParticleEmitter extends Container { */ _particleFrames: ReadonlyArray | undefined; + /** + * The tint of each step of a particle's life (`tintOverLife`), shared by + * the particles emitted after reset. + * @ignore + * @internal + */ + _tintSteps: readonly Color[] | undefined; + /** * @ignore * @internal @@ -391,8 +449,10 @@ export default class ParticleEmitter extends Container { // fallback texture. Live particles retain the previous frame snapshot. const nextSettings = { ...defaultEmitterSettings, ...settings }; const frames = particleFrames(nextSettings); + const tintSteps = tintOverLifeSteps(nextSettings); Object.assign(this.settings, nextSettings); this._particleFrames = frames; + this._tintSteps = tintSteps; // Clamp range-style settings: if `min > max`, lower `min` to `max`. // Catches the common footgun where a user overrides only one half of diff --git a/packages/melonjs/src/particles/particle.ts b/packages/melonjs/src/particles/particle.ts index cb2a0832db..2a0e4c554c 100644 --- a/packages/melonjs/src/particles/particle.ts +++ b/packages/melonjs/src/particles/particle.ts @@ -1,3 +1,4 @@ +import type { Color } from "../math/color.ts"; import { randomFloat } from "../math/math.ts"; import type { Matrix3d } from "../math/matrix3d.ts"; import { Vector2d, vector2dPool } from "../math/vector2d.ts"; @@ -77,6 +78,13 @@ export default class Particle extends Renderable { alive: boolean; /** @ignore */ private _frames: ReadonlyArray | undefined; + + /** + * the emitter's tint for each step of the particle's life, if any + * @ignore + * @internal + */ + private _tintSteps: readonly Color[] | undefined; /** @ignore */ private _frameIndex: number; /** @ignore */ @@ -201,7 +209,10 @@ export default class Particle extends Renderable { // consume the matrix directly instead. this.autoTransform = false; - if (typeof emitter.settings.tint === "string") { + this._tintSteps = emitter._tintSteps; + if (this._tintSteps !== undefined) { + this.tint.copy(this._tintSteps[0]); + } else if (typeof emitter.settings.tint === "string") { this.tint.parseCSS(emitter.settings.tint); } @@ -417,6 +428,13 @@ export default class Particle extends Renderable { // Set the particle opacity as Age Ratio this.alpha = ageRatio; + // the tint over life, stepped like the frames above, and on the same + // grounds the index stays within the steps + const tintSteps = this._tintSteps; + if (tintSteps !== undefined) { + this.tint.copy(tintSteps[Math.floor((1 - ageRatio) * tintSteps.length)]); + } + // Adjust the particle velocity this.vel.x += this.wind * skew; this.vel.y += this.gravity * skew; diff --git a/packages/melonjs/src/particles/settings.ts b/packages/melonjs/src/particles/settings.ts index 2f65dfa1e1..183d47e86b 100644 --- a/packages/melonjs/src/particles/settings.ts +++ b/packages/melonjs/src/particles/settings.ts @@ -79,9 +79,35 @@ export interface ParticleEmitterSettings { /** * Tint to be applied to particles. * @default "#fff" + * @see {@link tintOverLife} for a tint that changes over each particle's life */ tint: string; + /** + * The tints a particle takes over its life: the first at birth, the last + * at the end, evenly spaced and blended in between, such as a flame that + * starts white hot and dies deep red. Give as many CSS colors as you like + * (one is a plain tint), and repeat a color to hold it longer. When set, + * it replaces {@link tint}. + *

+ * It changes the color only: a particle still fades out over its life, + * and the alpha of a color given here is ignored. However many colors + * there are, the blend is taken in 32 steps, the same on every renderer, + * so a particle changes color at most 32 times; on the Canvas renderer, + * which keeps a tinted copy of the texture per color, that bounds the + * copies to 32. + *

+ * Set it through the constructor or {@link ParticleEmitter#reset}. + * @default undefined + * @throws {TypeError} from the emitter, if not a non-empty array of strings + * @example + * new ParticleEmitter(x, y, { + * image: spark, + * tintOverLife: ["#fff3b0", "#ffb04a", "#e45414", "#501408"], + * }); + */ + tintOverLife?: readonly string[] | undefined; + /** * Total number of particles in the emitter. * @default 50 @@ -398,6 +424,7 @@ const defaultParticleEmitterSettings: ParticleEmitterSettings = { randomFrame: false, textureSize: 8, tint: "#fff", + tintOverLife: undefined, totalParticles: 50, angle: Math.PI / 2, angleVariation: 0, diff --git a/packages/melonjs/src/renderable/animation.ts b/packages/melonjs/src/renderable/animation.ts index 6aeb0b46c4..da1ffd652b 100644 --- a/packages/melonjs/src/renderable/animation.ts +++ b/packages/melonjs/src/renderable/animation.ts @@ -7,6 +7,13 @@ export interface AnimationOptions { /** called when the animation completes a cycle (each loop, or once if `loop: false`). */ onComplete?: (() => unknown) | undefined; + /** + * called with the index of each frame as playback reaches it, from frame 0 + * when the animation starts, and again at the start of each cycle. Frame + * animations only (a {@link Sprite}, a {@link Sprite3d}): a {@link Mesh} + * keyframe animation has no frames, and never calls it. + */ + onFrame?: ((frame: number) => unknown) | undefined; /** name of an animation to switch to when this one finishes. */ next?: string | undefined; /** loop forever (default) or play once and hold the last frame. */ @@ -39,6 +46,8 @@ export type AnimationOptionsInput = | { /** called when the animation completes a cycle */ onComplete?: () => unknown; + /** called with the index of each frame as playback reaches it (frame animations only) */ + onFrame?: (frame: number) => unknown; /** name of an animation to switch to when this one finishes */ next?: string; /** loop forever (default) or play once and hold the last frame */ @@ -53,7 +62,7 @@ export type AnimationOptionsInput = * - `undefined` → loop forever * - a `string` → the name of the next animation to chain to * - a `function` → legacy completion callback (return `false` to hold the last frame) - * - an options object → `{ onComplete, next, loop, speed }` + * - an options object → `{ onComplete, onFrame, next, loop, speed }` * @param arg - the second argument passed to `setCurrentAnimation` * @returns the normalized options * @category Animation @@ -74,6 +83,7 @@ export function parseAnimationOptions( if (arg !== null && typeof arg === "object") { return { onComplete: arg.onComplete, + onFrame: arg.onFrame, next: arg.next, // only an explicit `false` disables looping loop: arg.loop !== false, diff --git a/packages/melonjs/src/renderable/colorlayer.js b/packages/melonjs/src/renderable/colorlayer.js index e016ecf6a6..bc662350f9 100644 --- a/packages/melonjs/src/renderable/colorlayer.js +++ b/packages/melonjs/src/renderable/colorlayer.js @@ -54,8 +54,12 @@ export default class ColorLayer extends Renderable { * @param {Camera2d} [viewport] - the viewport to (re)draw */ draw(renderer, viewport) { + // a zoomed camera measures itself in the world it sees while the + // world is drawn, and the layer fills the screen: scale back by the + // zoom, as ImageLayer does, or zoomed in it fills part of the view + const zoom = viewport.zoom; renderer.save(); - renderer.clipRect(0, 0, viewport.width, viewport.height); + renderer.clipRect(0, 0, viewport.width * zoom, viewport.height * zoom); renderer.clearColor(this.color); renderer.restore(); } diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js index f551f3d0b2..f22b72327a 100644 --- a/packages/melonjs/src/renderable/container.js +++ b/packages/melonjs/src/renderable/container.js @@ -284,7 +284,23 @@ export default class Container extends Renderable { this._comparator = this._sortZ; /** - * Specify if the children list should be automatically sorted when adding a new child + * Whether the container keeps its children sorted by itself. + * + * - When a child is added, the container sorts (on the next tick). + * - On a `"x"` or `"y"` sort, it also keeps its children in order every + * frame, as they move: at the end of each update, after physics and + * the children's own updates, it checks the order and re-sorts when + * anything is out of place. A character walking around a y-sorted + * scene passes in front of and behind things with no `sort()` call. + * A frame where nothing moved costs one pass over the children, and + * nothing is redrawn because of it. + * - On a `"z"` sort, changing a child's `depth` after adding it does + * NOT re-sort: call {@link Container#sort}, or use + * {@link Container#moveToTop} and the other move methods, which + * reorder the children themselves. `"depth"` is re-sorted every frame + * by its {@link Camera3d}. + * + * Turn it off to keep the children in the order you put them. * @type {boolean} * @default true */ @@ -382,7 +398,11 @@ export default class Container extends Renderable { * * - `"x"` / `"y"` — 2D scroll-order, descending on both axes * (higher pos.z first; tied z, higher pos.x or pos.y first). - * Suited to side-scrollers and top-down games. + * Suited to side-scrollers and top-down games. Each child is compared + * at `pos.x` / `pos.y` plus its {@link Renderable#sortOffset}, which + * changes the order and never the position. With + * {@link Container#autoSort} on (the default), the children are kept in + * that order every frame as they move. * - `"z"` — descending pos.z (higher z draws on top). Default for * `Camera2d` — matches the painter's-algorithm 2D layering model * where pos.z is "layer index". @@ -1303,7 +1323,9 @@ export default class Container extends Renderable { * @internal */ _sortX(a, b) { - return b.pos.z - a.pos.z || b.pos.x - a.pos.x; + return ( + b.pos.z - a.pos.z || b.pos.x + b.sortOffset - (a.pos.x + a.sortOffset) + ); } /** @@ -1312,7 +1334,9 @@ export default class Container extends Renderable { * @internal */ _sortY(a, b) { - return b.pos.z - a.pos.z || b.pos.y - a.pos.y; + return ( + b.pos.z - a.pos.z || b.pos.y + b.sortOffset - (a.pos.y + a.sortOffset) + ); } /** @@ -1456,10 +1480,38 @@ export default class Container extends Renderable { } } + // an "x" / "y" sort follows its children every frame, now that they + // have all moved, by physics or by their own update (see `autoSort`) + if ( + this.autoSort === true && + (this.sortOn === "x" || this.sortOn === "y") + ) { + this.keepSorted(); + } + // call the parent method return super.update(dt); } + /** + * Re-sort the children if any is out of order: one pass when none is, + * which leaves the container clean, so a still scene is not redrawn + * because of it. + * @ignore + * @internal + */ + keepSorted() { + const children = this.getChildren(); + const compare = this._comparator; + for (let i = 1; i < children.length; i++) { + if (compare(children[i - 1], children[i]) > 0) { + children.sort(compare); + this.isDirty = true; + return; + } + } + } + /** * A container additionally offsets its children by its own position — the * `translate()` at the top of {@link Container#draw} — which is what makes @@ -1520,14 +1572,19 @@ export default class Container extends Renderable { // viewport is documented optional — legacy subclass overrides call // `super.draw(renderer)` without one; treat that as the default camera const isNonDefaultCamera = viewport ? !viewport.isDefault : false; + // a minimap or a split-screen view leaves the UI-only floating + // elements to the main camera, which draws them zoomed or not (a + // viewport that is not a camera answers with `isDefault`) + const skipsFloating = viewport + ? !(viewport.drawsFloating ?? viewport.isDefault) + : false; const children = this.getChildren(); for (let i = children.length, obj; i--, (obj = children[i]); ) { const isFloating = obj.floating === true; if (obj.inViewport || isFloating) { - // skip UI-only floating elements on non-default cameras - if (isFloating && isNonDefaultCamera && !obj.visibleInAllCameras) { + if (isFloating && skipsFloating && !obj.visibleInAllCameras) { continue; } diff --git a/packages/melonjs/src/renderable/frameAnimation.js b/packages/melonjs/src/renderable/frameAnimation.js index 6d06818c15..9c99de674a 100644 --- a/packages/melonjs/src/renderable/frameAnimation.js +++ b/packages/melonjs/src/renderable/frameAnimation.js @@ -71,6 +71,13 @@ export default class FrameAnimation { */ this.resetAnim = undefined; + /** + * called with each frame index playback reaches (the `onFrame` option) + * @ignore + * @internal + */ + this.onFrame = undefined; + /** current frame info @type {object} */ this.current = { // the current animation name @@ -259,12 +266,17 @@ export default class FrameAnimation { } else { this.resetAnim = undefined; } + this.onFrame = opts.onFrame; // `setAnimationFrame(0)` applies frame 0 → `_applyFrame`, which is // where the host marks itself dirty (the engine never touches it). this.setAnimationFrame(0); if (!preserve_dt) { this.dt = 0; } + // the first frame is reached too + if (typeof this.onFrame === "function") { + this.onFrame(0); + } } } else { throw new Error("animation id '" + name + "' not defined"); @@ -426,6 +438,9 @@ export default class FrameAnimation { while (this.dt >= duration) { changed = true; this.dt -= duration; + // what was playing, to tell whether a callback below replaced it + const playing = this.current.name; + const onFrame = this.onFrame; const nextFrame = this.current.length > 1 ? this.current.idx + 1 : this.current.idx; @@ -460,6 +475,18 @@ export default class FrameAnimation { } } } + // the frame playback reached, unless a callback above switched to + // another animation, which reported its own first frame + if ( + typeof onFrame === "function" && + this.current.name === playing && + this.onFrame === onFrame + ) { + onFrame(this.current.idx); + if (!this._isCurrentAnimationLive()) { + break; + } + } // Get next frame duration duration = this.getAnimationFrameObjectByIndex(this.current.idx).delay; diff --git a/packages/melonjs/src/renderable/renderable.js b/packages/melonjs/src/renderable/renderable.js index 5091029283..88ca93c7cf 100644 --- a/packages/melonjs/src/renderable/renderable.js +++ b/packages/melonjs/src/renderable/renderable.js @@ -235,6 +235,14 @@ export default class Renderable extends Rect { */ this.onVisibilityChange = undefined; + /** + * backing value of {@link Renderable#sortOffset} + * @type {number} + * @ignore + * @internal + */ + this._sortOffset = 0; + /** * Whether the renderable object will always update, even when outside of the viewport
* @type {boolean} @@ -271,7 +279,8 @@ export default class Renderable extends Rect { /** * If true, this floating renderable will be rendered by all cameras * (e.g. background image layers). If false (default), floating elements - * are only rendered by the default camera (e.g. UI/HUD elements). + * are only rendered by the default camera (e.g. UI/HUD elements), + * zoomed or not. * Only applies to floating renderables in multi-camera setups. * @type {boolean} * @default false @@ -602,6 +611,59 @@ export default class Renderable extends Rect { return this; } + /** + * An offset, in pixels, added to this renderable's position when its + * parent container sorts its children on `"y"` (or `"x"`), and used for + * nothing else. It **never moves the renderable** and never changes where + * or how it is drawn: it only decides whether it is drawn before or after + * its siblings, and so which one is on top, and which one a pointer hits. + *

+ * A container sorted on `"y"` draws its children from the smallest + * `pos.y` to the largest, the last one on top. A child with a + * `sortOffset` is lined up as if it stood that many pixels lower + * (positive) or higher (negative), while staying where it is. Use it when + * `pos` is not the point a renderable should be sorted by: the top of a + * Tiled tile object's image, the top left corner of a container, or the + * top of a particle emitter's spawn area. + *

+ * - A container that keeps itself sorted (`autoSort`, the default) picks a + * change up at its next update; with `autoSort` off, call its `sort()`. + * - Containers sorted on `"z"` or `"depth"` ignore it, and on `"x"` and + * `"y"` a different `pos.z` still decides first. + * - Two children that come out equal keep their order, which draws the one + * added last BEHIND the other: an offset of `1` settles such a tie on + * purpose. + * - A renderable taken from a pool starts again at `0`. + * @type {number} + * @default 0 + * @throws {TypeError} if set to anything but a finite number + * @example + * // a torch hung on a wall at the wall's own position: line it up + * // 1 px lower, so that it is drawn over the wall + * torch.sortOffset = 1; + * @example + * // a particle emitter's pos is the top of its spawn area. Dust rising at + * // the foot of a door sits far below the door's pos, and would be drawn + * // over everything between the two: line it up with the door instead + * dust.sortOffset = door.pos.y - dust.pos.y; + */ + get sortOffset() { + return this._sortOffset; + } + set sortOffset(value) { + // a NaN or a string would quietly scramble the order of every sibling + // (`pos.y + "5"` is "1005"), so refuse it where it is set + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new TypeError( + `sortOffset must be a finite number, not ${String(value)}`, + ); + } + if (value === this._sortOffset) { + return; + } + this._sortOffset = value; + } + /** * the depth of this renderable on the z axis * @type {number} @@ -1618,6 +1680,7 @@ export function resetRenderableState(renderable) { renderable.applyAnchorTransform = true; renderable.postEffectNeedsCapture = false; renderable.anchorPoint.set(0.5, 0.5); + renderable.sortOffset = 0; renderable.currentTransform.identity(); renderable._flip.x = false; renderable._flip.y = false; diff --git a/packages/melonjs/src/renderable/sprite.js b/packages/melonjs/src/renderable/sprite.js index 1a0ca21e32..baf49f6e5c 100644 --- a/packages/melonjs/src/renderable/sprite.js +++ b/packages/melonjs/src/renderable/sprite.js @@ -717,17 +717,28 @@ export default class Sprite extends Renderable { * set the current animation, and reset the frame to zero. Selecting the * animation that is already current is a no-op unless it had completed. * @param {string} name - animation id - * @param {string|Function|object} [resetAnim] - animation id to switch to when complete, a callback, or an options object (`{ loop, next, speed, onComplete }`) + * @param {string|Function|object} [resetAnim] - animation id to switch to when complete, a callback, or an options object (`{ loop, next, speed, onComplete, onFrame }`) * @param {boolean} [preserve_dt=false] - if false will reset the elapsed time counter since last frame * @returns {Sprite} Reference to this object for method chaining * @example * // set "walk" animation * this.setCurrentAnimation("walk"); * - * // set "walk" animation if it is not the current animation - * if (!this.isCurrentAnimation("walk")) { - * this.setCurrentAnimation("walk"); - * } + * // calling it every frame is fine: selecting the animation that is + * // already playing does nothing, so it is never restarted + * this.setCurrentAnimation(moving ? "walk" : "stand"); + * + * // a footstep on the frames where a foot touches the ground + * this.setCurrentAnimation("walk", { + * onFrame: (frame) => { + * if (frame === 3 || frame === 8) { + * audio.play("step"); + * } + * }, + * }); + * + * // pick something up, then stand still + * this.setCurrentAnimation("pickup", { next: "stand", onComplete: () => this.take() }); * * // set "eat" animation, and switch to "walk" when complete * this.setCurrentAnimation("eat", "walk"); @@ -739,11 +750,12 @@ export default class Sprite extends Renderable { * }); * * // set "attack" animation, and pause for a short duration - * this.setCurrentAnimation("die", () => { + * this.setCurrentAnimation("attack", () => { * this.animationpause = true; * * // back to "standing" animation after 1 second - * setTimeout(function () { + * setTimeout(() => { + * this.animationpause = false; * this.setCurrentAnimation("standing"); * }, 1000); * diff --git a/packages/melonjs/src/renderable/sprite3d.js b/packages/melonjs/src/renderable/sprite3d.js index e89d80f3d5..151cec5232 100644 --- a/packages/melonjs/src/renderable/sprite3d.js +++ b/packages/melonjs/src/renderable/sprite3d.js @@ -709,7 +709,7 @@ export default class Sprite3d extends Mesh { /** * Select the active animation, identical to {@link Sprite#setCurrentAnimation}. * @param {string} name - animation id - * @param {string|Function|object} [resetAnim] - loop / chain / completion behavior + * @param {string|Function|object} [resetAnim] - loop / chain / completion / per-frame (`onFrame`) behavior * @param {boolean} [preserve_dt=false] * @returns {Sprite3d} Reference to this object for method chaining */ diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts index ef476b1a5e..72a6d543bc 100644 --- a/packages/melonjs/src/state/stage.ts +++ b/packages/melonjs/src/state/stage.ts @@ -95,6 +95,9 @@ export default class Stage { * an ambient light that will be added to the stage rendering. Its alpha * is what gates the lighting pass: at alpha 0 (the default) `drawLighting` * returns early and the lights still draw as additive glows. + * + * Every active {@link Light2d} cuts a hole in it, hard-edged unless the + * light has a {@link Light2d#softness}. * @default rgba(0,0,0,0) * @see Light2d * @see {@link Stage#ambientLightingColor} for the other, easily confused knob @@ -362,9 +365,13 @@ export default class Stage { * Renderables); this pass only paints the dark fill that the lights cut * holes through. * + * A light with a {@link Light2d#softness} takes the whole pass to the + * renderer's soft path, which fades each hole along the light's falloff. + * * Subclasses can override this method to implement custom lighting (e.g. - * per-pixel normal-mapped lighting via a custom shader). Called once per - * camera per frame. + * per-pixel normal-mapped lighting via a custom shader), or to skip a + * camera (a minimap showing the level lit). Called once per camera per + * frame. * @param renderer - the active renderer * @param camera - the camera currently rendering this stage * @param translateX - the same world-to-screen X translate that @@ -398,15 +405,46 @@ export default class Stage { if (tx !== 0 || ty !== 0) { r.translate(-tx, -ty); } + // the darkness covers the camera's view in the (now world-space) + // renderer: from the camera origin, as wide as the world it shows + // (`width / zoom`: zoomed out, a camera-sized fill left part of the + // view lit), and a unit more on every side. Unless `subPixel` is set, + // the renderer drops the fraction of that translate, which moves the + // view's edge by up to a unit: a camera at a fractional position (any + // smoothed follow) left its first column and row lit. The camera + // clips what reaches past its view. A whole number of pixels, so a + // renderer drawing the darkness at screen resolution keeps it on the + // screen's pixel grid + const margin = Math.ceil(camera.zoom) / camera.zoom; + const x = tx - margin; + const y = ty - margin; + const width = camera.width / camera.zoom + margin * 2; + const height = camera.height / camera.zoom + margin * 2; + // any light with a soft edge: the renderer draws the darkness with + // every light's falloff + for (const light of this._activeLights) { + if (light.softness > 0) { + r.drawAmbientLight( + this.ambientLight, + this._activeLights, + x, + y, + width, + height, + width * camera.zoom, + height * camera.zoom, + ); + r.restore(); + return; + } + } // punch out each active light's visible area so the ambient fill // doesn't darken what the light is illuminating this._activeLights.forEach((light) => { r.setMask(light.getVisibleArea(), true); }); r.setColor(this.ambientLight); - // fillRect must cover the camera's view in the (now world-space) - // renderer — offset by the camera origin so it matches the viewport. - r.fillRect(tx, ty, camera.width, camera.height); + r.fillRect(x, y, width, height); r.clearMask(); r.restore(); } diff --git a/packages/melonjs/src/video/canvas/canvas_renderer.js b/packages/melonjs/src/video/canvas/canvas_renderer.js index fd5f7ba9c9..6fbbb849b9 100644 --- a/packages/melonjs/src/video/canvas/canvas_renderer.js +++ b/packages/melonjs/src/video/canvas/canvas_renderer.js @@ -7,6 +7,7 @@ import { off, on, } from "../../system/event.ts"; +import { createCanvas } from "./../canvas_factory.js"; import { Gradient } from "./../gradient.js"; import Renderer from "./../renderer.js"; import TextureCache from "./../texture/cache.js"; @@ -515,6 +516,84 @@ export default class CanvasRenderer extends Renderer { ); } + /** + * Draw the ambient darkness with a hole per light: filled into an + * offscreen canvas at the view's resolution, each light erased from it + * along its falloff (`destination-out` leaves `1 - cut` of what was + * there, so overlapping lights multiply), then drawn over the view. + * @ignore + * @internal + */ + drawAmbientLight( + color, + lights, + x, + y, + width, + height, + pixelWidth, + pixelHeight, + ) { + const pw = Math.max(1, Math.ceil(pixelWidth)); + const ph = Math.max(1, Math.ceil(pixelHeight)); + // one canvas, kept and resized, so a frame allocates nothing + if (this._ambientCanvas === undefined) { + this._ambientCanvas = createCanvas(pw, ph, true); + } else if ( + this._ambientCanvas.width !== pw || + this._ambientCanvas.height !== ph + ) { + this._ambientCanvas.width = pw; + this._ambientCanvas.height = ph; + } + const canvas = this._ambientCanvas; + const context = canvas.getContext("2d"); + const sx = pw / width; + const sy = ph / height; + + context.setTransform(1, 0, 0, 1, 0, 0); + context.globalCompositeOperation = "copy"; + context.fillStyle = color.toRGBA(); + context.fillRect(0, 0, pw, ph); + context.globalCompositeOperation = "destination-out"; + for (const light of lights) { + const area = light.getVisibleArea(); + const cx = (area.pos.x - x) * sx; + const cy = (area.pos.y - y) * sy; + const rx = area.radiusV.x * sx; + const ry = area.radiusV.y * sy; + if ( + !(rx > 0) || + !(ry > 0) || + cx + rx < 0 || + cx - rx > pw || + cy + ry < 0 || + cy - ry > ph + ) { + continue; + } + // a circle of radius `rx`, squashed to the light's ellipse: fully + // erased out to the core, then straight back to nothing at the edge + context.setTransform(1, 0, 0, ry / rx, cx, cy); + const fill = context.createRadialGradient(0, 0, 0, 0, 0, rx); + fill.addColorStop(0, "rgba(0, 0, 0, 1)"); + fill.addColorStop(1 - light.softness, "rgba(0, 0, 0, 1)"); + fill.addColorStop(1, "rgba(0, 0, 0, 0)"); + context.fillStyle = fill; + context.fillRect(-rx, -rx, rx * 2, rx * 2); + } + context.setTransform(1, 0, 0, 1, 0, 0); + context.globalCompositeOperation = "source-over"; + + // over the view, straight onto the context: no tint, no blend mode + const target = this.getContext(); + target.save(); + target.globalAlpha = 1; + target.globalCompositeOperation = "source-over"; + target.drawImage(canvas, 0, 0, pw, ph, x, y, width, height); + target.restore(); + } + /** * Draw a pattern within the given rectangle. * @param {CanvasPattern} pattern - Pattern object @@ -1576,7 +1655,14 @@ export default class CanvasRenderer extends Renderer { // shape adds another hole to the same evenodd path) instead of // duplicate full-canvas rects cancelling under evenodd parity. if (this._maskInvertOuterAdded !== true) { + // the whole canvas, in canvas pixels whatever the transform: a + // path takes the transform current at each command, and a + // scrolled or zoomed view put the outer edge somewhere in the + // world, so the mask kept what it should have cut + const transform = context.getTransform(); + context.setTransform(1, 0, 0, 1, 0, 0); context.rect(0, 0, this.getCanvas().width, this.getCanvas().height); + context.setTransform(transform); this._maskInvertOuterAdded = true; } context.clip("evenodd"); diff --git a/packages/melonjs/src/video/effects/ambientlight.js b/packages/melonjs/src/video/effects/ambientlight.js new file mode 100644 index 0000000000..11747e1b9b --- /dev/null +++ b/packages/melonjs/src/video/effects/ambientlight.js @@ -0,0 +1,161 @@ +import ShaderEffect from "./shadereffect.js"; + +/** + * additional import for TypeScript + * @import Renderer from "../renderer.js"; + * @import Light2d from "../../lighting/light2d.ts"; + */ + +/** + * How many lights one ambient pass cuts: the ones in view, in the order the + * stage holds them. + * @ignore + * @internal + */ +export const MAX_AMBIENT_LIGHTS = 32; + +/** + * The stage's ambient darkness with a hole for each light, drawn as one quad + * over the camera's view (the GPU side of the soft-cutout path; the Canvas + * renderer gets the same result by erasing from an offscreen canvas). + * + * At each pixel, every light lets through `1 - cut`, where `cut` is 1 inside + * the light's core and falls straight to 0 at its radius (a step at the radius + * for a light with no softness). What is left of the darkness is the ambient + * alpha times what every light lets through, and the output is premultiplied, + * to be drawn with the normal blend. + * + * The quad's color, the per-vertex tint, carries the ambient color and alpha. + * `uv` spans the quad, top left (0, 0) to bottom right (1, 1), drawn the way + * `drawLight` draws, so `uv * uView.zw` is the pixel's offset into the view. + * @ignore + * @internal + */ +export default class AmbientLightEffect extends ShaderEffect { + /** + * @param {Renderer} renderer - the current renderer instance + */ + constructor(renderer) { + super(renderer, { + glsl: ` + uniform vec4 uLights[${MAX_AMBIENT_LIGHTS}]; + uniform vec4 uParams[${MAX_AMBIENT_LIGHTS}]; + uniform vec4 uView; + uniform float uCount; + vec4 apply(vec4 color, vec2 uv) { + vec2 p = uv * uView.zw; + float through = 1.0; + for (int i = 0; i < ${MAX_AMBIENT_LIGHTS}; i++) { + if (float(i) >= uCount) { + break; + } + vec4 light = uLights[i]; + float d = length((p - light.xy) / light.zw); + float soft = uParams[i].x; + float cut = soft > 0.0 + ? clamp((1.0 - d) / soft, 0.0, 1.0) + : step(d, 1.0); + through *= 1.0 - cut; + } + return color * through; + } + `, + wgsl: ` + struct AmbientLightUniforms { + uLights : array, + uParams : array, + uView : vec4f, + uCount : f32, + }; + @group(3) @binding(0) var fx : AmbientLightUniforms; + + fn apply(color : vec4f, uv : vec2f) -> vec4f { + let p = uv * fx.uView.zw; + var through = 1.0; + for (var i = 0; i < ${MAX_AMBIENT_LIGHTS}; i++) { + if (f32(i) >= fx.uCount) { + break; + } + let light = fx.uLights[i]; + let d = length((p - light.xy) / light.zw); + let soft = fx.uParams[i].x; + var cut = select(0.0, 1.0, d <= 1.0); + if (soft > 0.0) { + cut = clamp((1.0 - d) / soft, 0.0, 1.0); + } + through = through * (1.0 - cut); + } + return color * through; + } + `, + }); + + /** + * light centers and radii, relative to the view's top left + * @ignore + * @internal + */ + this._lights = new Float32Array(MAX_AMBIENT_LIGHTS * 4); + /** + * per light: softness, then unused + * @ignore + * @internal + */ + this._params = new Float32Array(MAX_AMBIENT_LIGHTS * 4); + /** + * @ignore + * @internal + */ + this._view = new Float32Array(4); + } + + /** + * Load the lights that reach into a view, and the view itself. + * @param {Iterable} lights - the stage's active lights + * @param {number} x - left of the view, in world units + * @param {number} y - top of the view, in world units + * @param {number} width - width of the view, in world units + * @param {number} height - height of the view, in world units + * @ignore + * @internal + */ + setLights(lights, x, y, width, height) { + let count = 0; + for (const light of lights) { + if (count === MAX_AMBIENT_LIGHTS) { + break; + } + const area = light.getVisibleArea(); + const cx = area.pos.x - x; + const cy = area.pos.y - y; + const rx = area.radiusV.x; + const ry = area.radiusV.y; + // nothing to cut, or entirely outside the view + if ( + !(rx > 0) || + !(ry > 0) || + cx + rx < 0 || + cx - rx > width || + cy + ry < 0 || + cy - ry > height + ) { + continue; + } + const i = count * 4; + this._lights[i] = cx; + this._lights[i + 1] = cy; + this._lights[i + 2] = rx; + this._lights[i + 3] = ry; + this._params[i] = light.softness; + count++; + } + this._view[0] = x; + this._view[1] = y; + this._view[2] = width; + this._view[3] = height; + this.setUniform("uLights", this._lights); + this.setUniform("uParams", this._params); + this.setUniform("uView", this._view); + this.setUniform("uCount", count); + } +} diff --git a/packages/melonjs/src/video/effects/bloom.js b/packages/melonjs/src/video/effects/bloom.js index 45316edcf6..1a3ad68bab 100644 --- a/packages/melonjs/src/video/effects/bloom.js +++ b/packages/melonjs/src/video/effects/bloom.js @@ -1,3 +1,4 @@ +import { FRAME_GLSL, FRAME_WGSL, FRAME_WGSL_MEMBERS } from "./frame.js"; import ShaderEffect from "./shadereffect.js"; /** @@ -81,7 +82,7 @@ uniform float uThreshold; uniform float uIntensity; uniform float uRadius; uniform vec2 uTextureSize; - +${FRAME_GLSL} vec3 overBright(vec4 c) { float l = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); // soft quadratic knee over the eighth of a unit above the threshold @@ -93,7 +94,7 @@ vec4 apply(vec4 color, vec2 uv) { vec2 texel = uRadius / uTextureSize; vec3 sum = overBright(color) * 0.16; ${tapList("vec2", (at) => { - return `texture2D(uSampler, ${at})`; + return `frameTexel(${at})`; })} return vec4(color.rgb + sum * uIntensity, color.a); } @@ -104,10 +105,10 @@ struct BloomUniforms { uThreshold : f32, uIntensity : f32, uRadius : f32, - uTextureSize : vec2f, + uTextureSize : vec2f,${FRAME_WGSL_MEMBERS} }; @group(3) @binding(0) var fx : BloomUniforms; - +${FRAME_WGSL} fn overBright(c : vec4f) -> vec3f { let l = dot(c.rgb, vec3f(0.2126, 0.7152, 0.0722)); let knee = clamp((l - fx.uThreshold) / 0.125, 0.0, 1.0); @@ -118,7 +119,7 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { let texel = fx.uRadius / fx.uTextureSize; var sum = overBright(color) * 0.16; ${tapList("vec2f", (at) => { - return `textureSample(uTexture, uSampler, ${at})`; + return `frameTexel(${at})`; })} return vec4f(color.rgb + sum * fx.uIntensity, color.a); } diff --git a/packages/melonjs/src/video/effects/blur.js b/packages/melonjs/src/video/effects/blur.js index fcec236694..ea35c5cb01 100644 --- a/packages/melonjs/src/video/effects/blur.js +++ b/packages/melonjs/src/video/effects/blur.js @@ -1,3 +1,4 @@ +import { FRAME_GLSL, FRAME_WGSL, FRAME_WGSL_MEMBERS } from "./frame.js"; import ShaderEffect from "./shadereffect.js"; /** @@ -12,23 +13,23 @@ import ShaderEffect from "./shadereffect.js"; const wgslFragment = ` struct BlurUniforms { uBlurStrength : f32, - uTextureSize : vec2f, + uTextureSize : vec2f,${FRAME_WGSL_MEMBERS} }; @group(3) @binding(0) var fx : BlurUniforms; - +${FRAME_WGSL} fn apply(color : vec4f, uv : vec2f) -> vec4f { let texel = fx.uBlurStrength / fx.uTextureSize; var sum = vec4f(0.0); // 9-tap box blur - sum += textureSample(uTexture, uSampler, uv + vec2f(-texel.x, -texel.y)); - sum += textureSample(uTexture, uSampler, uv + vec2f(0.0, -texel.y)); - sum += textureSample(uTexture, uSampler, uv + vec2f(texel.x, -texel.y)); - sum += textureSample(uTexture, uSampler, uv + vec2f(-texel.x, 0.0)); - sum += textureSample(uTexture, uSampler, uv); - sum += textureSample(uTexture, uSampler, uv + vec2f(texel.x, 0.0)); - sum += textureSample(uTexture, uSampler, uv + vec2f(-texel.x, texel.y)); - sum += textureSample(uTexture, uSampler, uv + vec2f(0.0, texel.y)); - sum += textureSample(uTexture, uSampler, uv + vec2f(texel.x, texel.y)); + sum += frameTexel(uv + vec2f(-texel.x, -texel.y)); + sum += frameTexel(uv + vec2f(0.0, -texel.y)); + sum += frameTexel(uv + vec2f(texel.x, -texel.y)); + sum += frameTexel(uv + vec2f(-texel.x, 0.0)); + sum += frameTexel(uv); + sum += frameTexel(uv + vec2f(texel.x, 0.0)); + sum += frameTexel(uv + vec2f(-texel.x, texel.y)); + sum += frameTexel(uv + vec2f(0.0, texel.y)); + sum += frameTexel(uv + vec2f(texel.x, texel.y)); return (sum / 9.0) * vColor; } `; @@ -55,19 +56,20 @@ export default class BlurEffect extends ShaderEffect { glsl: ` uniform float uBlurStrength; uniform vec2 uTextureSize; + ${FRAME_GLSL} vec4 apply(vec4 color, vec2 uv) { vec2 texel = uBlurStrength / uTextureSize; vec4 sum = vec4(0.0); // 9-tap box blur - sum += texture2D(uSampler, uv + vec2(-texel.x, -texel.y)); - sum += texture2D(uSampler, uv + vec2( 0.0, -texel.y)); - sum += texture2D(uSampler, uv + vec2( texel.x, -texel.y)); - sum += texture2D(uSampler, uv + vec2(-texel.x, 0.0)); - sum += texture2D(uSampler, uv); - sum += texture2D(uSampler, uv + vec2( texel.x, 0.0)); - sum += texture2D(uSampler, uv + vec2(-texel.x, texel.y)); - sum += texture2D(uSampler, uv + vec2( 0.0, texel.y)); - sum += texture2D(uSampler, uv + vec2( texel.x, texel.y)); + sum += frameTexel(uv + vec2(-texel.x, -texel.y)); + sum += frameTexel(uv + vec2( 0.0, -texel.y)); + sum += frameTexel(uv + vec2( texel.x, -texel.y)); + sum += frameTexel(uv + vec2(-texel.x, 0.0)); + sum += frameTexel(uv); + sum += frameTexel(uv + vec2( texel.x, 0.0)); + sum += frameTexel(uv + vec2(-texel.x, texel.y)); + sum += frameTexel(uv + vec2( 0.0, texel.y)); + sum += frameTexel(uv + vec2( texel.x, texel.y)); return (sum / 9.0) * vColor; } `, diff --git a/packages/melonjs/src/video/effects/chromaticAberration.js b/packages/melonjs/src/video/effects/chromaticAberration.js index 23a01be88a..3bef33f75c 100644 --- a/packages/melonjs/src/video/effects/chromaticAberration.js +++ b/packages/melonjs/src/video/effects/chromaticAberration.js @@ -1,3 +1,4 @@ +import { FRAME_GLSL, FRAME_WGSL, FRAME_WGSL_MEMBERS } from "./frame.js"; import ShaderEffect from "./shadereffect.js"; /** @@ -12,15 +13,15 @@ import ShaderEffect from "./shadereffect.js"; const wgslFragment = ` struct AberrationUniforms { uOffset : f32, - uTextureSize : vec2f, + uTextureSize : vec2f,${FRAME_WGSL_MEMBERS} }; @group(3) @binding(0) var fx : AberrationUniforms; - +${FRAME_WGSL} fn apply(color : vec4f, uv : vec2f) -> vec4f { let texel = fx.uOffset / fx.uTextureSize; - let r = textureSample(uTexture, uSampler, uv + vec2f(texel.x, 0.0)).r; + let r = frameTexel(uv + vec2f(texel.x, 0.0)).r; let g = textureSample(uTexture, uSampler, uv).g; - let b = textureSample(uTexture, uSampler, uv - vec2f(texel.x, 0.0)).b; + let b = frameTexel(uv - vec2f(texel.x, 0.0)).b; let a = textureSample(uTexture, uSampler, uv).a; return vec4f(r, g, b, a) * vColor; } @@ -51,11 +52,12 @@ export default class ChromaticAberrationEffect extends ShaderEffect { glsl: ` uniform float uOffset; uniform vec2 uTextureSize; + ${FRAME_GLSL} vec4 apply(vec4 color, vec2 uv) { vec2 texel = uOffset / uTextureSize; - float r = texture2D(uSampler, uv + vec2(texel.x, 0.0)).r; + float r = frameTexel(uv + vec2(texel.x, 0.0)).r; float g = texture2D(uSampler, uv).g; - float b = texture2D(uSampler, uv - vec2(texel.x, 0.0)).b; + float b = frameTexel(uv - vec2(texel.x, 0.0)).b; float a = texture2D(uSampler, uv).a; return vec4(r, g, b, a) * vColor; } diff --git a/packages/melonjs/src/video/effects/dropShadow.js b/packages/melonjs/src/video/effects/dropShadow.js index 3cf15f1df7..f5ab1b6548 100644 --- a/packages/melonjs/src/video/effects/dropShadow.js +++ b/packages/melonjs/src/video/effects/dropShadow.js @@ -1,3 +1,4 @@ +import { FRAME_GLSL, FRAME_WGSL, FRAME_WGSL_MEMBERS } from "./frame.js"; import { rgba } from "./rgba.js"; import ShaderEffect from "./shadereffect.js"; @@ -16,10 +17,10 @@ struct ShadowUniforms { uShadowColor : vec4f, uShadowOpacity : f32, uTextureSize : vec2f, - uUVYDir : f32, + uUVYDir : f32,${FRAME_WGSL_MEMBERS} }; @group(3) @binding(0) var fx : ShadowUniforms; - +${FRAME_WGSL} fn apply(color : vec4f, uv : vec2f) -> vec4f { if (color.a > 0.0) { return color; @@ -32,7 +33,7 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { // vertical orientation (always +1 under WebGPU; the GL pooled path // sets -1 for its bottom-up captures) offset.y = offset.y * fx.uUVYDir; - let shadowAlpha = textureSampleLevel(uTexture, uSampler, uv - offset, 0.0).a; + let shadowAlpha = frameTexelLevel(uv - offset).a; if (shadowAlpha > 0.0) { // the color's alpha scales the opacity; premultiplied, as everything the batcher draws is let alpha = shadowAlpha * fx.uShadowOpacity * fx.uShadowColor.a; @@ -81,6 +82,7 @@ export default class DropShadowEffect extends ShaderEffect { uniform float uShadowOpacity; uniform vec2 uTextureSize; uniform float uUVYDir; + ${FRAME_GLSL} vec4 apply(vec4 color, vec2 uv) { if (color.a > 0.0) { return color; @@ -91,7 +93,7 @@ export default class DropShadowEffect extends ShaderEffect { // the pooled path's bottom-up capture FBOs) vec2 offset = uShadowOffset / uTextureSize; offset.y *= uUVYDir; - float shadowAlpha = texture2D(uSampler, uv - offset).a; + float shadowAlpha = frameTexel(uv - offset).a; if (shadowAlpha > 0.0) { // the color's alpha scales the opacity; premultiplied, as everything the batcher draws is float alpha = shadowAlpha * uShadowOpacity * uShadowColor.a; diff --git a/packages/melonjs/src/video/effects/frame.js b/packages/melonjs/src/video/effects/frame.js new file mode 100644 index 0000000000..bed3f360fb --- /dev/null +++ b/packages/melonjs/src/video/effects/frame.js @@ -0,0 +1,63 @@ +/** + * Keeping an effect's samples inside the sprite's own frame. + * + * A sprite drawn from an atlas or a tileset shows one frame of a bigger + * texture, and the texture goes on past that frame into the neighbouring + * ones. An effect that samples away from the fragment it shades (a glow, an + * outline, a blur, a shadow, a distortion) reads those neighbours along the + * frame's edge, and draws them. The quad batchers hand the effect the frame + * it draws, as `ME_frame` (its UV rect: left, top, right, bottom) and + * `ME_frame_half` (half a texel of its texture, in UV), through + * `ShaderEffect#_setFrameRect`. `frameTexel()` reads a texel of that frame: + * transparent outside it, and kept half a texel inside it, so filtering + * never reaches over the edge. Only where another frame could be: a side of + * the frame that is the texture's own edge is left unbounded, and so is a + * pass over a whole texture (a render target blit, a mesh). There, sampling + * is what it always was. + * @ignore + */ + +/** + * the uniforms and `frameTexel()`, for a GLSL effect body + * @ignore + */ +export const FRAME_GLSL = ` +uniform vec4 ME_frame; +uniform vec2 ME_frame_half; +vec4 frameTexel(vec2 p) { + vec2 inside = step(ME_frame.xy, p) * step(p, ME_frame.zw); + vec2 q = clamp(p, ME_frame.xy + ME_frame_half, ME_frame.zw - ME_frame_half); + return texture2D(uSampler, q) * (inside.x * inside.y); +} +`; + +/** + * the members to add to a WGSL effect's uniform struct, which `frameTexel()` + * reads through the effect's `fx` binding + * @ignore + */ +export const FRAME_WGSL_MEMBERS = ` + ME_frame : vec4f, + ME_frame_half : vec2f,`; + +/** + * `frameTexel()` for a WGSL effect body, sampling with implicit derivatives + * (uniform control flow only), and `frameTexelLevel()`, sampling level 0, + * which works anywhere + * @ignore + */ +export const FRAME_WGSL = ` +fn frameUV(p : vec2f) -> vec2f { + return clamp(p, fx.ME_frame.xy + fx.ME_frame_half, fx.ME_frame.zw - fx.ME_frame_half); +} +fn frameInside(p : vec2f) -> f32 { + let inside = step(fx.ME_frame.xy, p) * step(p, fx.ME_frame.zw); + return inside.x * inside.y; +} +fn frameTexel(p : vec2f) -> vec4f { + return textureSample(uTexture, uSampler, frameUV(p)) * frameInside(p); +} +fn frameTexelLevel(p : vec2f) -> vec4f { + return textureSampleLevel(uTexture, uSampler, frameUV(p), 0.0) * frameInside(p); +} +`; diff --git a/packages/melonjs/src/video/effects/glow.js b/packages/melonjs/src/video/effects/glow.js index cd5d070807..5f0826e95a 100644 --- a/packages/melonjs/src/video/effects/glow.js +++ b/packages/melonjs/src/video/effects/glow.js @@ -1,3 +1,4 @@ +import { FRAME_GLSL, FRAME_WGSL, FRAME_WGSL_MEMBERS } from "./frame.js"; import { rgba } from "./rgba.js"; import ShaderEffect from "./shadereffect.js"; @@ -15,10 +16,10 @@ struct GlowUniforms { uGlowColor : vec4f, uGlowWidth : f32, uGlowIntensity : f32, - uTextureSize : vec2f, + uTextureSize : vec2f,${FRAME_WGSL_MEMBERS} }; @group(3) @binding(0) var fx : GlowUniforms; - +${FRAME_WGSL} fn apply(color : vec4f, uv : vec2f) -> vec4f { if (color.a > 0.0) { return color; @@ -28,7 +29,7 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { let texel = fx.uGlowWidth / fx.uTextureSize; for (var angle = 0.0; angle < 6.28; angle += 0.785) { let offset = vec2f(cos(angle), sin(angle)) * texel; - a += textureSampleLevel(uTexture, uSampler, uv + offset, 0.0).a; + a += frameTexelLevel(uv + offset).a; } a = a / 8.0 * fx.uGlowIntensity; if (a > 0.0) { @@ -72,6 +73,7 @@ export default class GlowEffect extends ShaderEffect { uniform float uGlowWidth; uniform float uGlowIntensity; uniform vec2 uTextureSize; + ${FRAME_GLSL} vec4 apply(vec4 color, vec2 uv) { if (color.a > 0.0) { return color; @@ -81,7 +83,7 @@ export default class GlowEffect extends ShaderEffect { vec2 texel = uGlowWidth / uTextureSize; for (float angle = 0.0; angle < 6.28; angle += 0.785) { vec2 offset = vec2(cos(angle), sin(angle)) * texel; - a += texture2D(uSampler, uv + offset).a; + a += frameTexel(uv + offset).a; } a = a / 8.0 * uGlowIntensity; if (a > 0.0) { diff --git a/packages/melonjs/src/video/effects/outline.js b/packages/melonjs/src/video/effects/outline.js index aaf13ad3a2..a600e26d9d 100644 --- a/packages/melonjs/src/video/effects/outline.js +++ b/packages/melonjs/src/video/effects/outline.js @@ -1,3 +1,4 @@ +import { FRAME_GLSL, FRAME_WGSL, FRAME_WGSL_MEMBERS } from "./frame.js"; import { rgba } from "./rgba.js"; import ShaderEffect from "./shadereffect.js"; @@ -14,10 +15,10 @@ const wgslFragment = ` struct OutlineUniforms { uOutlineColor : vec4f, uOutlineWidth : f32, - uTextureSize : vec2f, + uTextureSize : vec2f,${FRAME_WGSL_MEMBERS} }; @group(3) @binding(0) var fx : OutlineUniforms; - +${FRAME_WGSL} fn apply(color : vec4f, uv : vec2f) -> vec4f { if (color.a > 0.0) { return color; @@ -25,14 +26,14 @@ fn apply(color : vec4f, uv : vec2f) -> vec4f { // sample neighbors to detect edges (level-0: non-uniform flow) let texel = fx.uOutlineWidth / fx.uTextureSize; var a = 0.0; - a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(-texel.x, 0.0), 0.0).a); - a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(texel.x, 0.0), 0.0).a); - a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(0.0, -texel.y), 0.0).a); - a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(0.0, texel.y), 0.0).a); - a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(-texel.x, -texel.y), 0.0).a); - a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(texel.x, -texel.y), 0.0).a); - a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(-texel.x, texel.y), 0.0).a); - a = max(a, textureSampleLevel(uTexture, uSampler, uv + vec2f(texel.x, texel.y), 0.0).a); + a = max(a, frameTexelLevel(uv + vec2f(-texel.x, 0.0)).a); + a = max(a, frameTexelLevel(uv + vec2f(texel.x, 0.0)).a); + a = max(a, frameTexelLevel(uv + vec2f(0.0, -texel.y)).a); + a = max(a, frameTexelLevel(uv + vec2f(0.0, texel.y)).a); + a = max(a, frameTexelLevel(uv + vec2f(-texel.x, -texel.y)).a); + a = max(a, frameTexelLevel(uv + vec2f(texel.x, -texel.y)).a); + a = max(a, frameTexelLevel(uv + vec2f(-texel.x, texel.y)).a); + a = max(a, frameTexelLevel(uv + vec2f(texel.x, texel.y)).a); if (a > 0.0) { // the color's alpha is the outline's opacity; premultiplied, as everything the batcher draws is let alpha = a * fx.uOutlineColor.a; @@ -76,6 +77,7 @@ export default class OutlineEffect extends ShaderEffect { uniform vec4 uOutlineColor; uniform float uOutlineWidth; uniform vec2 uTextureSize; + ${FRAME_GLSL} vec4 apply(vec4 color, vec2 uv) { if (color.a > 0.0) { return color; @@ -83,14 +85,14 @@ export default class OutlineEffect extends ShaderEffect { // sample neighbors to detect edges vec2 texel = uOutlineWidth / uTextureSize; float a = 0.0; - a = max(a, texture2D(uSampler, uv + vec2(-texel.x, 0.0)).a); - a = max(a, texture2D(uSampler, uv + vec2( texel.x, 0.0)).a); - a = max(a, texture2D(uSampler, uv + vec2(0.0, -texel.y)).a); - a = max(a, texture2D(uSampler, uv + vec2(0.0, texel.y)).a); - a = max(a, texture2D(uSampler, uv + vec2(-texel.x, -texel.y)).a); - a = max(a, texture2D(uSampler, uv + vec2( texel.x, -texel.y)).a); - a = max(a, texture2D(uSampler, uv + vec2(-texel.x, texel.y)).a); - a = max(a, texture2D(uSampler, uv + vec2( texel.x, texel.y)).a); + a = max(a, frameTexel(uv + vec2(-texel.x, 0.0)).a); + a = max(a, frameTexel(uv + vec2( texel.x, 0.0)).a); + a = max(a, frameTexel(uv + vec2(0.0, -texel.y)).a); + a = max(a, frameTexel(uv + vec2(0.0, texel.y)).a); + a = max(a, frameTexel(uv + vec2(-texel.x, -texel.y)).a); + a = max(a, frameTexel(uv + vec2( texel.x, -texel.y)).a); + a = max(a, frameTexel(uv + vec2(-texel.x, texel.y)).a); + a = max(a, frameTexel(uv + vec2( texel.x, texel.y)).a); if (a > 0.0) { // the color's alpha is the outline's opacity; premultiplied, as everything the batcher draws is float alpha = a * uOutlineColor.a; diff --git a/packages/melonjs/src/video/effects/pixelate.js b/packages/melonjs/src/video/effects/pixelate.js index cfab39c7cd..4e0a803784 100644 --- a/packages/melonjs/src/video/effects/pixelate.js +++ b/packages/melonjs/src/video/effects/pixelate.js @@ -1,3 +1,4 @@ +import { FRAME_GLSL, FRAME_WGSL, FRAME_WGSL_MEMBERS } from "./frame.js"; import ShaderEffect from "./shadereffect.js"; /** @@ -12,14 +13,14 @@ import ShaderEffect from "./shadereffect.js"; const wgslFragment = ` struct PixelateUniforms { uPixelSize : f32, - uTextureSize : vec2f, + uTextureSize : vec2f,${FRAME_WGSL_MEMBERS} }; @group(3) @binding(0) var fx : PixelateUniforms; - +${FRAME_WGSL} fn apply(color : vec4f, uv : vec2f) -> vec4f { let texel = fx.uPixelSize / fx.uTextureSize; let snapped = texel * floor(uv / texel) + texel * 0.5; - return textureSample(uTexture, uSampler, snapped) * vColor; + return frameTexel(snapped) * vColor; } `; @@ -47,10 +48,11 @@ export default class PixelateEffect extends ShaderEffect { glsl: ` uniform float uPixelSize; uniform vec2 uTextureSize; + ${FRAME_GLSL} vec4 apply(vec4 color, vec2 uv) { vec2 texel = uPixelSize / uTextureSize; vec2 snapped = texel * floor(uv / texel) + texel * 0.5; - return texture2D(uSampler, snapped) * vColor; + return frameTexel(snapped) * vColor; } `, wgsl: wgslFragment, diff --git a/packages/melonjs/src/video/effects/shadereffect.js b/packages/melonjs/src/video/effects/shadereffect.js index 3d9e0aa336..bd2394aa6d 100644 --- a/packages/melonjs/src/video/effects/shadereffect.js +++ b/packages/melonjs/src/video/effects/shadereffect.js @@ -16,6 +16,13 @@ import WGSLEffectRealization from "./wgsl_realization.js"; * @import WebGPURenderer from "../webgpu/webgpu_renderer.js"; * @import Renderer from "../renderer.js"; */ + +/** + * `ME_frame` when there is no frame to keep to: far past any UV + * @ignore + */ +const UNBOUNDED_FRAME = [-1e6, -1e6, 1e6, 1e6]; + /** * A simplified shader class for applying custom fragment effects to renderables. * Only requires a fragment `apply()` function — the vertex shader, uniforms, and @@ -373,6 +380,29 @@ export default class ShaderEffect { this.enabled = true; } + // the frame a body declaring `ME_frame` keeps its samples inside (see + // frame.js): unbounded until a quad batcher hands it one. Recorded + // as well, for a mesh hosting the effect, which replays the record + // and has no frame to keep to + const hasFrame = + typeof this._shader !== "undefined" + ? typeof this._shader.uniforms?.ME_frame !== "undefined" + : this.wgslRealization.hasUniform("ME_frame"); + if (hasFrame) { + /** + * @ignore + * @internal + */ + this._frameRect = new Float32Array(UNBOUNDED_FRAME); + /** + * @ignore + * @internal + */ + this._frameHalf = new Float32Array(2); + this.setUniform("ME_frame", new Float32Array(UNBOUNDED_FRAME)); + this.setUniform("ME_frame_half", new Float32Array(2)); + } + // flip enabled across context/device loss so beginPostEffect skips us on(ONCONTEXT_LOST, this._onContextLost, this); on(ONCONTEXT_RESTORED, this._onContextRestored, this); @@ -567,6 +597,101 @@ export default class ShaderEffect { } } + /** + * Hand a body declaring `ME_frame` the frame of the quad about to draw, + * so it keeps its samples inside it (see frame.js): on an atlas, the + * texture goes on past the frame into the neighbouring ones. A side of + * the frame that is the texture's own edge has no frame past it, and is + * left unbounded: there, sampling stays what it always was (the + * texture's wrap mode), so a standalone image, a pattern, or a frame on + * the outside of its atlas draws as it did. Called by the quad batchers + * per quad, each of which an effect draws on its own (a quad through an + * effect is drawn before the next one is queued); a no-op for any other + * body. + * @param {number} u0 - the frame's left UV coordinate + * @param {number} v0 - the frame's top UV coordinate + * @param {number} u1 - the frame's right UV coordinate + * @param {number} v1 - the frame's bottom UV coordinate + * @param {number} sourceWidth - the texture's width, in pixels + * @param {number} sourceHeight - the texture's height, in pixels + * @ignore + * @internal + */ + _setFrameRect(u0, v0, u1, v1, sourceWidth, sourceHeight) { + const width = Math.max(sourceWidth, 1); + const height = Math.max(sourceHeight, 1); + const left = Math.min(u0, u1); + const top = Math.min(v0, v1); + const right = Math.max(u0, u1); + const bottom = Math.max(v0, v1); + // within a hundredth of a texel of the texture's edge: on it (a UV + // is a division, and rarely lands on 0 or 1 exactly) + const edge = 0.01; + this._writeFrameRect( + left * width <= edge ? UNBOUNDED_FRAME[0] : left, + top * height <= edge ? UNBOUNDED_FRAME[1] : top, + right * width >= width - edge ? UNBOUNDED_FRAME[2] : right, + bottom * height >= height - edge ? UNBOUNDED_FRAME[3] : bottom, + 0.5 / width, + 0.5 / height, + ); + } + + /** + * Leave the samples of a body declaring `ME_frame` unbounded, for a pass + * over a whole texture (a render target blit): there, sampling is what + * it always was, clamped by the texture's own wrap mode. + * @ignore + * @internal + */ + _clearFrameRect() { + this._writeFrameRect( + UNBOUNDED_FRAME[0], + UNBOUNDED_FRAME[1], + UNBOUNDED_FRAME[2], + UNBOUNDED_FRAME[3], + 0, + 0, + ); + } + + /** + * @ignore + * @internal + */ + _writeFrameRect(left, top, right, bottom, halfX, halfY) { + const rect = this._frameRect; + const half = this._frameHalf; + if (rect === undefined || this.destroyed === true) { + return; + } + if ( + rect[0] === left && + rect[1] === top && + rect[2] === right && + rect[3] === bottom && + half[0] === halfX && + half[1] === halfY + ) { + return; + } + rect[0] = left; + rect[1] = top; + rect[2] = right; + rect[3] = bottom; + half[0] = halfX; + half[1] = halfY; + // straight to the program, past the record a mesh replays (as the + // `noise_uv` rect does) + if (typeof this.wgslRealization !== "undefined") { + this.wgslRealization.setUniform("ME_frame", rect); + this.wgslRealization.setUniform("ME_frame_half", half); + } else if (typeof this._shader !== "undefined") { + this._shader.setUniform("ME_frame", rect); + this._shader.setUniform("ME_frame_half", half); + } + } + /** * Feed the `noise_uv` builtin's frame rect for the object about to draw: * the source texture dimensions, the destination (object) dimensions, and diff --git a/packages/melonjs/src/video/effects/wave.js b/packages/melonjs/src/video/effects/wave.js index b7ca4f85b4..5fdfc46f42 100644 --- a/packages/melonjs/src/video/effects/wave.js +++ b/packages/melonjs/src/video/effects/wave.js @@ -1,3 +1,4 @@ +import { FRAME_GLSL, FRAME_WGSL, FRAME_WGSL_MEMBERS } from "./frame.js"; import ShaderEffect from "./shadereffect.js"; /** @@ -15,16 +16,16 @@ struct WaveUniforms { uFrequency : f32, uSpeed : f32, uTime : f32, - uAnchor : f32, + uAnchor : f32,${FRAME_WGSL_MEMBERS} }; @group(3) @binding(0) var fx : WaveUniforms; - +${FRAME_WGSL} fn apply(color : vec4f, uv : vec2f) -> vec4f { // with an anchor, rows fade from full wave at the top to none at the anchor let bend = select(1.0, clamp((fx.uAnchor - uv.y) / fx.uAnchor, 0.0, 1.0), fx.uAnchor > 0.0); let wave = sin(uv.y * fx.uFrequency + fx.uTime * fx.uSpeed) * fx.uAmplitude * bend; let distorted = vec2f(uv.x + wave, uv.y); - return textureSample(uTexture, uSampler, distorted) * vColor; + return frameTexel(distorted) * vColor; } `; @@ -67,12 +68,13 @@ export default class WaveEffect extends ShaderEffect { uniform float uSpeed; uniform float uTime; uniform float uAnchor; + ${FRAME_GLSL} vec4 apply(vec4 color, vec2 uv) { // with an anchor, rows fade from full wave at the top to none at the anchor float bend = uAnchor > 0.0 ? clamp((uAnchor - uv.y) / uAnchor, 0.0, 1.0) : 1.0; float wave = sin(uv.y * uFrequency + uTime * uSpeed) * uAmplitude * bend; vec2 distorted = vec2(uv.x + wave, uv.y); - return texture2D(uSampler, distorted) * vColor; + return frameTexel(distorted) * vColor; } `, wgsl: wgslFragment, diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 82551d8b6d..dbfe8836ac 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -1349,6 +1349,38 @@ export default class Renderer { // base no-op; concrete renderers override } + /** + * Draw the stage's ambient darkness over a view, with a hole for each + * light that follows its {@link Light2d#softness}: the soft-cutout path of + * {@link Stage#drawLighting}, taken when any light has a softness. Called + * with the renderer translated to world space. The base implementation is + * a no-op; concrete renderers override. + * @param {Color} color - the ambient color, its alpha the darkness + * @param {Iterable} lights - the stage's active lights + * @param {number} x - left of the view, in world units + * @param {number} y - top of the view, in world units + * @param {number} width - width of the view, in world units + * @param {number} height - height of the view, in world units + * @param {number} pixelWidth - width of the view on the screen, in pixels + * @param {number} pixelHeight - height of the view on the screen, in pixels + * @ignore + * @internal + */ + /* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ + drawAmbientLight( + color, + lights, + x, + y, + width, + height, + pixelWidth, + pixelHeight, + ) { + // base no-op; concrete renderers override + } + /* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */ + /** * Draw a TMX tile layer. Default behavior: * - if `layer.canvasRenderer` is set (preRender bake), blit the cached diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index 5b1956f840..2dbb24e887 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -589,17 +589,28 @@ export default class LitQuadBatcher extends QuadBatcher { this.currentShader.setUniform("uSampler", unit); this.currentSamplerUnit = unit; } - // feed the effect's `noise_uv` builtin — see QuadBatcher.addQuad + // feed the effect's `noise_uv` builtin and its frame — see + // QuadBatcher.addQuad if (typeof this.currentShader._setNoiseUVRect === "function") { const source = texture.getTexture(); + const sourceWidth = source.width || source.videoWidth || 1; + const sourceHeight = source.height || source.videoHeight || 1; this.currentShader._setNoiseUVRect( - source.width || source.videoWidth || 1, - source.height || source.videoHeight || 1, + sourceWidth, + sourceHeight, w, h, Math.min(u0, u1), Math.min(v0, v1), ); + this.currentShader._setFrameRect?.( + u0, + v0, + u1, + v1, + sourceWidth, + sourceHeight, + ); } // direct atlas sampling: uv.y grows downward — see // QuadBatcher.addQuad @@ -727,6 +738,8 @@ export default class LitQuadBatcher extends QuadBatcher { // `noise_uv` builtin: a blit is a full-frame quad — identity rect shader._setNoiseUVRect?.(width, height, width, height, 0, 0); + // and the whole texture is the frame: sampling stays unbounded + shader._clearFrameRect?.(); // bottom-up capture FBO: uv.y grows upward inside apply() — see // QuadBatcher.blitTexture diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js index d623e1fc26..708c602f4e 100644 --- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js @@ -217,6 +217,8 @@ export default class QuadBatcher extends MaterialBatcher { // `noise_uv` builtin: a blit is a full-frame quad — identity rect shader._setNoiseUVRect?.(width, height, width, height, 0, 0); + // and the whole texture is the frame: sampling stays unbounded + shader._clearFrameRect?.(); // the blit samples a bottom-up capture FBO (the Y-flipped UVs // below): uv.y grows UPWARD inside apply(), so directional bodies @@ -315,14 +317,26 @@ export default class QuadBatcher extends MaterialBatcher { // quad's frame rect — min() normalizes flipped (swapped) UVs if (typeof this.currentShader._setNoiseUVRect === "function") { const source = texture.getTexture(); + const sourceWidth = source.width || source.videoWidth || 1; + const sourceHeight = source.height || source.videoHeight || 1; this.currentShader._setNoiseUVRect( - source.width || source.videoWidth || 1, - source.height || source.videoHeight || 1, + sourceWidth, + sourceHeight, w, h, Math.min(u0, u1), Math.min(v0, v1), ); + // and keep its samples inside that frame: on an atlas, the + // texture goes on into the neighbouring frames + this.currentShader._setFrameRect?.( + u0, + v0, + u1, + v1, + sourceWidth, + sourceHeight, + ); } // direct atlas sampling: uv.y grows downward (directional bodies // compensate on the pooled path, where captures are bottom-up) diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 4413e146e7..91ec84e4ab 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -19,6 +19,7 @@ import { isSupportedBlendMode, normalizeBlendMode, } from "../blendmodes.js"; +import AmbientLightEffect from "../effects/ambientlight.js"; import BlendEffect from "../effects/blendEffect.js"; import RadialGradientEffect from "../effects/radialGradient.js"; import { Gradient } from "../gradient.js"; @@ -1305,6 +1306,16 @@ export default class WebGLRenderer extends Renderer { */ this._lightShader = undefined; } + // the same for the ambient-light effect (re-created on the next + // soft-cutout pass) + if (this._ambientShader !== undefined) { + this._ambientShader.destroy?.(); + /** + * @ignore + * @internal + */ + this._ambientShader = undefined; + } if (this._lightAtlas !== undefined) { // `TextureCache` is keyed by the source image, not by the // `TextureAtlas` instance. Dropping the wrong key would leak @@ -1686,6 +1697,39 @@ export default class WebGLRenderer extends Renderer { // `setBatcher`), and that's what unlocks the cross-light batch. } + /** + * Draw the ambient darkness with a hole per light, as one quad over the + * view through the ambient-light effect (see `AmbientLightEffect`). + * @ignore + * @internal + */ + drawAmbientLight(color, lights, x, y, width, height) { + if (this._ambientShader === undefined) { + this._ambientShader = new AmbientLightEffect(this); + } + this._ambientShader.setLights(lights, x, y, width, height); + this.save(); + this.setGlobalAlpha(1.0); + this.setBlendMode("normal"); + const batcher = this.setBatcher("quad", this._ambientShader); + batcher.addQuad( + this._getLightAtlas(), + x, + y, + width, + height, + 0, + 0, + 1, + 1, + // the ambient color and its alpha, through the vertex tint + color.toUint32(color.alpha), + ); + // drawn now, under these lights: the next camera loads its own + this.flush(); + this.restore(); + } + /** * Lazy-init a shared 1×1 white `TextureAtlas` used as the source * texture for `drawLight`'s procedural shader. The shader ignores diff --git a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js index 6df2fd8e80..d02faeb3d5 100644 --- a/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/quad_batcher.js @@ -201,14 +201,19 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // feed the effect's `noise_uv` builtin with this quad's frame // rect — min() normalizes flipped (swapped) UVs const source = texture.getTexture(); + const sourceWidth = source.width || source.videoWidth || 1; + const sourceHeight = source.height || source.videoHeight || 1; effect._setNoiseUVRect?.( - source.width || source.videoWidth || 1, - source.height || source.videoHeight || 1, + sourceWidth, + sourceHeight, w, h, Math.min(u0, u1), Math.min(v0, v1), ); + // and keep its samples inside that frame: on an atlas, the + // texture goes on into the neighbouring frames + effect._setFrameRect?.(u0, v0, u1, v1, sourceWidth, sourceHeight); // the effect's vertex stage, AFTER the frame rect above, which // must stay the sprite's own @@ -443,6 +448,8 @@ export default class WebGPUQuadBatcher extends WebGPUBatcher { // identity noise_uv rect for the fullscreen quad (GL parity), BEFORE // the uniform snapshot below effect?._setNoiseUVRect?.(w, h, w, h, 0, 0); + // and the whole texture is the frame: sampling stays unbounded + effect?._clearFrameRect?.(); const binding = effect ? prepareEffectBinding(renderer, effect) : null; const pass = renderer.ensurePass(); diff --git a/packages/melonjs/src/video/webgpu/webgpu_renderer.js b/packages/melonjs/src/video/webgpu/webgpu_renderer.js index 82658ec5c6..3523b85f00 100644 --- a/packages/melonjs/src/video/webgpu/webgpu_renderer.js +++ b/packages/melonjs/src/video/webgpu/webgpu_renderer.js @@ -18,6 +18,7 @@ import { normalizeBlendMode, reportedBlendMode, } from "../blendmodes.js"; +import AmbientLightEffect from "../effects/ambientlight.js"; import BlendEffect from "../effects/blendEffect.js"; import { meshHostingBlocker } from "../effects/mesh_splice.js"; import RadialGradientEffect from "../effects/radialGradient.js"; @@ -3230,6 +3231,42 @@ export default class WebGPURenderer extends Renderer { this.customShader = previousShader; } + /** + * Draw the ambient darkness with a hole per light, as one quad over the + * view through the ambient-light effect (see `AmbientLightEffect`). + * @ignore + * @internal + */ + drawAmbientLight(color, lights, x, y, width, height) { + if (this._ambientShader === undefined) { + this._ambientShader = new AmbientLightEffect(this); + } + this._ambientShader.setLights(lights, x, y, width, height); + this.save(); + this.setGlobalAlpha(1.0); + this.setBlendMode("normal"); + const previousShader = this.customShader; + this.customShader = this._ambientShader; + const batcher = this.setBatcher("quad"); + batcher.addQuad( + this.getLightAtlas(), + x, + y, + width, + height, + 0, + 0, + 1, + 1, + // the ambient color and its alpha, through the vertex tint + color.toUint32(color.alpha), + ); + // drawn now, under these lights: the next camera loads its own + this.flush(); + this.customShader = previousShader; + this.restore(); + } + /** * Lazy-init a shared 1x1 flat-normal TextureAtlas — RGB (128, 128, 255), * which decodes to the tangent-space normal (0, 0, 1). @@ -4534,6 +4571,13 @@ export default class WebGPURenderer extends Renderer { * @internal */ this._toneMapEffect = undefined; + // the ambient-light effect, created by the first soft-cutout pass + this._ambientShader?.destroy?.(); + /** + * @ignore + * @internal + */ + this._ambientShader = undefined; // advanced-blend resources, all lazily created: a game that never used // one of the six modes has nothing here to release diff --git a/packages/melonjs/tests/animation.spec.js b/packages/melonjs/tests/animation.spec.js index 563ac735dc..a37c9f1d0d 100644 --- a/packages/melonjs/tests/animation.spec.js +++ b/packages/melonjs/tests/animation.spec.js @@ -47,6 +47,14 @@ describe("parseAnimationOptions", () => { expect(opts.legacyFn).toBeUndefined(); }); + it("an options object carries onFrame; the other forms have none", () => { + const onFrame = () => {}; + expect(parseAnimationOptions({ onFrame }).onFrame).toBe(onFrame); + expect(parseAnimationOptions("idle").onFrame).toBeUndefined(); + expect(parseAnimationOptions(() => {}).onFrame).toBeUndefined(); + expect(parseAnimationOptions().onFrame).toBeUndefined(); + }); + it("options loop:false disables looping (only an explicit false)", () => { expect(parseAnimationOptions({ loop: false }).loop).toBe(false); // any other falsy-ish value is NOT treated as false diff --git a/packages/melonjs/tests/audio-backend.spec.js b/packages/melonjs/tests/audio-backend.spec.js index b6208af405..9cd5b807cd 100644 --- a/packages/melonjs/tests/audio-backend.spec.js +++ b/packages/melonjs/tests/audio-backend.spec.js @@ -370,6 +370,87 @@ describe("audio backend", () => { audio.unload("sp-dur"); }); + // a clip with a region declared looping (the third element) and one not + const loadLooping = (name) => { + audio.init("wav"); + return new Promise((resolve, reject) => { + audio.load( + { + name, + src: makeSilentWavDataUrl(0.05), + sprite: { loopy: [0, 20, true], once: [20, 20] }, + }, + resolve, + () => { + reject(new Error(`load failed for ${name}`)); + }, + ); + }); + }; + + it("a region declared looping loops when played with no loop option", async () => { + await loadLooping("sp-loopy"); + const id = audio.play("sp-loopy", { sprite: "loopy" }); + expect(state.tracks["sp-loopy"].loop(id)).toBe(true); + audio.stop("sp-loopy"); + audio.unload("sp-loopy"); + }); + + it("a region not declared looping still plays once by default", async () => { + await loadLooping("sp-once"); + const id = audio.play("sp-once", { sprite: "once" }); + expect(state.tracks["sp-once"].loop(id)).toBe(false); + audio.stop("sp-once"); + audio.unload("sp-once"); + }); + + it("loop: true loops a region that is not declared looping", async () => { + await loadLooping("sp-force"); + const id = audio.play("sp-force", { sprite: "once", loop: true }); + expect(state.tracks["sp-force"].loop(id)).toBe(true); + audio.stop("sp-force"); + audio.unload("sp-force"); + }); + + it("the positional form still decides on its own", async () => { + // play(name) has always meant once, play(name, true) a loop + await loadClip("sp-pos"); + const sound = state.tracks["sp-pos"]; + expect(sound.loop(audio.play("sp-pos"))).toBe(false); + expect(sound.loop(audio.play("sp-pos", true))).toBe(true); + audio.stop("sp-pos"); + audio.unload("sp-pos"); + }); + + it("playTrack loops on the region it is given, at its volume", async () => { + await loadLooping("sp-track"); + const sound = state.tracks["sp-track"]; + // the volume of a voice is queued while the context is locked, so + // what can be pinned is the call that sets it + const volume = vi.spyOn(sound, "volume"); + const id = audio.playTrack("sp-track", { sprite: "once", volume: 0.4 }); + expect(sound._soundById(id)._sprite).toBe("once"); + expect(sound.loop(id)).toBe(true); + expect(volume).toHaveBeenCalledWith(0.4, id); + expect(audio.getCurrentTrack()).toBe("sp-track"); + audio.stopTrack(); + volume.mockRestore(); + audio.unload("sp-track"); + }); + + it("playTrack still takes a plain volume, and loops the whole clip", async () => { + await loadClip("sp-track2"); + const sound = state.tracks["sp-track2"]; + const volume = vi.spyOn(sound, "volume"); + const id = audio.playTrack("sp-track2", 0.3); + expect(sound._soundById(id)._sprite).toBe("__default"); + expect(sound.loop(id)).toBe(true); + expect(volume).toHaveBeenCalledWith(0.3, id); + audio.stopTrack(); + volume.mockRestore(); + audio.unload("sp-track2"); + }); + it("playing() reports instance state", async () => { // Headless has no user gesture, so the AudioContext stays suspended and // an instance never leaves `_playLock` — playback genuinely does not diff --git a/packages/melonjs/tests/bloom-effect.spec.js b/packages/melonjs/tests/bloom-effect.spec.js index a59eebbf1b..b1ad1c1d84 100644 --- a/packages/melonjs/tests/bloom-effect.spec.js +++ b/packages/melonjs/tests/bloom-effect.spec.js @@ -49,7 +49,8 @@ describe("BloomEffect — the two language bodies", () => { const body = effect.wgslRealization.body; expect(body).toContain("fn overBright("); // one read per tap, plus none for the centre, which reuses `color` - const taps = body.match(/textureSample\(uTexture, uSampler, uv \+/g) ?? []; + // (through `frameTexel`, which keeps it inside the sprite's frame) + const taps = body.match(/frameTexel\(uv \+/g) ?? []; expect(taps).toHaveLength(TAP_COUNT); // the centre is the incoming colour, NOT a thirteenth sample: taking // it would read the same texel the quad already handed over @@ -145,7 +146,7 @@ describe("BloomEffect — on a real driver", () => { const effect = new BloomEffect(renderer); const src = effect._shader._sourceFragment; expect(src).toContain("overBright"); - const taps = src.match(/texture2D\(uSampler, uv \+/g) ?? []; + const taps = src.match(/frameTexel\(uv \+/g) ?? []; expect(taps).toHaveLength(TAP_COUNT); // and the same kernel as the WGSL body, checked here rather than // assumed: both are generated from one table, so the only way they diff --git a/packages/melonjs/tests/camera-zoom-floating.spec.js b/packages/melonjs/tests/camera-zoom-floating.spec.js new file mode 100644 index 0000000000..3b81b4e3d7 --- /dev/null +++ b/packages/melonjs/tests/camera-zoom-floating.spec.js @@ -0,0 +1,218 @@ +/** + * Floating renderables (a HUD, a `ColorLayer`) under a zoomed main camera. + * + * Floating elements that are not `visibleInAllCameras` are drawn by the + * stage's main camera only, a minimap or a split-screen view leaving them + * to it. Zooming the main camera made it look like one of those: every + * floating element, the background color and the HUD included, was gone. + * + * Read in pixels, on Canvas and WebGL. What has to hold: + * - a zoomed main camera, in or out, draws its `ColorLayer` over the whole + * view, and a floating element where it is on the screen, unzoomed + * - the world under it is still zoomed + * - a minimap still leaves the HUD to the main camera, zoomed out at the + * screen's origin included (zoom is all that tells it apart there), and + * still draws what is `visibleInAllCameras` + */ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Camera2d, + ColorLayer, + Renderable, + state, + video, +} from "../src/index.js"; + +/** a filled square, top left at (x, y) */ +class Square extends Renderable { + constructor(x, y, size, color) { + super(x, y, size, size); + this.anchorPoint.set(0, 0); + this.color = color; + } + draw(renderer) { + renderer.setColor(this.color); + renderer.fillRect(this.pos.x, this.pos.y, this.width, this.height); + } +} + +/** the same, in screen space */ +const hud = (x, y, size, color) => { + const square = new Square(x, y, size, color); + square.floating = true; + return square; +}; + +const RED = "255,0,0"; +const GREEN = "0,255,0"; +const BLUE = "0,0,255"; +const BLACK = "0,0,0"; + +for (const backend of [video.CANVAS, video.WEBGL]) { + describe(`floating renderables under a zoomed camera (${backend})`, () => { + let app, renderer, stage, camera; + + beforeAll(async () => { + boot(); + app = new Application(100, 100, { + parent: "screen", + renderer: backend, + scale: "1.0", + antiAlias: false, + failIfMajorPerformanceCaveat: false, + }); + await app.init(); + renderer = app.renderer; + stage = state.current(); + camera = stage.cameras.get("default"); + }); + afterEach(() => { + stage.cameras.delete("minimap"); + app.world.reset(); + camera.zoom = 1; + camera.setBounds(0, 0, 100, 100); + camera.moveTo(0, 0); + }); + afterAll(() => { + return app?.destroy(); + }); + + /** draw these, each at the depth of its place in the list */ + const scene = (...renderables) => { + renderables.forEach((renderable, depth) => { + app.world.addChild(renderable, depth); + }); + app.world.sortNow(); + app.world.update(0); + app.repaint(); + app.draw(); + }; + + /** the color of the pixel at (x, y), as "r,g,b" */ + const at = (x, y) => { + if (backend === video.CANVAS) { + const data = renderer.getContext().getImageData(x, y, 1, 1).data; + return Array.from(data.slice(0, 3)).join(); + } + const bytes = new Uint8Array(4); + renderer.gl.readPixels( + x, + 99 - y, + 1, + 1, + renderer.gl.RGBA, + renderer.gl.UNSIGNED_BYTE, + bytes, + ); + return Array.from(bytes.slice(0, 3)).join(); + }; + + const zoomTo = (zoom) => { + camera.zoom = zoom; + camera.setBounds(0, 0, 1000, 1000); + camera.moveTo(0, 0); + }; + + /** a second camera, `size` pixels across at (screenX, screenY) */ + const minimap = (screenX, screenY, size, zoom) => { + const view = new Camera2d(0, 0, size, size); + view.name = "minimap"; + view.autoResize = false; + view.screenX = screenX; + view.screenY = screenY; + view.zoom = zoom; + view.setBounds(0, 0, 1000, 1000); + stage.cameras.set("minimap", view); + return view; + }; + + // ---- the main camera, zoomed ------------------------------------------ + + for (const zoom of [0.5, 2]) { + it(`fills the whole view with a ColorLayer at zoom ${zoom}`, () => { + zoomTo(zoom); + scene(new ColorLayer("background", "#ff0000")); + expect(at(1, 1)).toBe(RED); + expect(at(50, 50)).toBe(RED); + expect(at(98, 98)).toBe(RED); + }); + + it(`draws a floating element where it is on the screen at zoom ${zoom}`, () => { + zoomTo(zoom); + scene( + new ColorLayer("background", "#ff0000"), + hud(10, 10, 20, "#00ff00"), + ); + // 20 pixels across at (10, 10), not scaled by the zoom + expect(at(11, 11)).toBe(GREEN); + expect(at(28, 28)).toBe(GREEN); + expect(at(8, 8)).toBe(RED); + expect(at(32, 32)).toBe(RED); + }); + } + + it("still zooms the world under them", () => { + zoomTo(2); + scene( + new ColorLayer("background", "#ff0000"), + new Square(10, 10, 10, "#0000ff"), + hud(70, 70, 10, "#00ff00"), + ); + // 10 world units at (10, 10), twice the size on the screen + expect(at(21, 21)).toBe(BLUE); + expect(at(38, 38)).toBe(BLUE); + expect(at(18, 18)).toBe(RED); + expect(at(42, 42)).toBe(RED); + expect(at(75, 75)).toBe(GREEN); + }); + + // ---- a minimap ------------------------------------------------------------ + + /** a floating square that notes the name of each camera drawing it */ + const recorded = () => { + const square = hud(10, 10, 20, "#00ff00"); + const drawn = []; + const draw = square.draw.bind(square); + square.draw = (r, viewport) => { + drawn.push(viewport.name); + draw(r, viewport); + }; + return { square, drawn }; + }; + + it("still leaves the HUD to the main camera on a zoomed-out minimap at the screen's origin", () => { + // placed where the main camera is: its zoom is all that tells it apart + minimap(0, 0, 40, 0.25); + const { square, drawn } = recorded(); + scene(square); + expect(drawn).toEqual(["default"]); + }); + + it("still leaves the HUD to the main camera on a minimap in a corner", () => { + minimap(60, 0, 40, 0.25); + const { square, drawn } = recorded(); + scene(square); + expect(drawn).toEqual(["default"]); + }); + + it("draws what is visibleInAllCameras on a minimap too", () => { + minimap(60, 0, 40, 0.25); + const { square, drawn } = recorded(); + square.visibleInAllCameras = true; + scene(square); + expect(drawn.sort()).toEqual(["default", "minimap"]); + }); + + it("leaves the HUD to the main camera when both are zoomed", () => { + zoomTo(2); + minimap(60, 0, 40, 0.25); + const { square, drawn } = recorded(); + scene(new ColorLayer("background", "#000000"), square); + expect(drawn).toEqual(["default"]); + expect(at(15, 15)).toBe(GREEN); + expect(at(50, 50)).toBe(BLACK); + }); + }); +} diff --git a/packages/melonjs/tests/container-autosort.spec.js b/packages/melonjs/tests/container-autosort.spec.js new file mode 100644 index 0000000000..5652f5c158 --- /dev/null +++ b/packages/melonjs/tests/container-autosort.spec.js @@ -0,0 +1,189 @@ +/** + * `Container#autoSort` on an `"x"` or `"y"` sort: the container keeps its + * children in order every frame, as they move. + * + * What has to hold: + * - a child that moved is put back in order by the container's next update, + * after physics and the children's own updates, and not before + * - however it moved, change notification or not (`pos.setMuted`) + * - a frame where nothing moved sorts nothing, and leaves the container + * clean, so a still scene is not redrawn because of it + * - one sort per update, however many children moved; drawing never sorts + * - a `"z"` sort is unchanged: a new `depth` still waits for `sort()` + * - with `autoSort` off, the order is left alone + * + * Order is read as the children array, topmost first (`Container.draw` + * walks it backwards, so index 0 is drawn last). + */ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { + Application, + boot, + Container, + Renderable, + video, +} from "../src/index.js"; + +describe("Container#autoSort keeps an x / y sort in order", () => { + let app; + + beforeAll(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + }); + + const box = (name, x, y) => { + const r = new Renderable(x, y, 10, 10); + r.anchorPoint.set(0, 0); + r.name = name; + return r; + }; + + /** a container sorted on `sortOn`, holding `children` at depth 0, sorted */ + const sorted = (sortOn, ...children) => { + const c = new Container(0, 0, 1000, 1000); + c.sortOn = sortOn; + for (const child of children) { + c.addChild(child, 0); + } + c.sortNow(); + // drop the sort `addChild` queued for the next tick, so that nothing + // sorts behind a test's back + clearTimeout(c.pendingSort); + c.pendingSort = null; + c.isDirty = false; + return c; + }; + + const topFirst = (c) => { + return c.getChildren().map((child) => { + return child.name; + }); + }; + + it("puts a child that moved back in order at the next update, not before", () => { + const walker = box("walker", 0, 10); + const c = sorted("y", walker, box("wall", 0, 30)); + expect(topFirst(c)).toEqual(["wall", "walker"]); + walker.pos.y = 50; // walks in front of the wall + expect(topFirst(c)).toEqual(["wall", "walker"]); + c.update(16); + expect(topFirst(c)).toEqual(["walker", "wall"]); + walker.pos.y = 10; // and back behind it + c.update(16); + expect(topFirst(c)).toEqual(["wall", "walker"]); + }); + + it("does the same along x on an x sort", () => { + const walker = box("walker", 10, 0); + const c = sorted("x", walker, box("post", 30, 0)); + walker.pos.x = 50; + c.update(16); + expect(topFirst(c)).toEqual(["walker", "post"]); + }); + + it("catches a move made without a change notification", () => { + // nothing is listening for moves: the order is checked every frame + const walker = box("walker", 0, 10); + const c = sorted("y", walker, box("wall", 0, 30)); + walker.pos.setMuted(0, 50, 0); + c.update(16); + expect(topFirst(c)).toEqual(["walker", "wall"]); + }); + + it("orders a child that moves in its own update within the same frame", () => { + const walker = box("walker", 0, 10); + walker.alwaysUpdate = true; + walker.update = () => { + walker.pos.y = 50; + return true; + }; + const c = sorted("y", walker, box("wall", 0, 30)); + c.update(16); + expect(topFirst(c)).toEqual(["walker", "wall"]); + }); + + it("sorts nothing, and leaves the container clean, when nothing moved", () => { + const c = sorted("y", box("a", 0, 10), box("b", 0, 30), box("c", 0, 20)); + const children = c.getChildren(); + // a still scene: nothing left to redraw from before + for (const child of children) { + child.isDirty = false; + } + const sort = vi.spyOn(children, "sort"); + c.update(16); + c.update(16); + expect(sort).not.toHaveBeenCalled(); + expect(Boolean(c.isDirty)).toBe(false); + sort.mockRestore(); + }); + + it("sorts once per update however many children moved, and marks it for redraw", () => { + const kids = [box("a", 0, 10), box("b", 0, 20), box("c", 0, 30)]; + const c = sorted("y", ...kids); + const sort = vi.spyOn(c.getChildren(), "sort"); + kids[0].pos.y = 90; + kids[1].pos.y = 80; + kids[2].pos.y = 5; + c.update(16); + expect(sort).toHaveBeenCalledTimes(1); + expect(topFirst(c)).toEqual(["a", "b", "c"]); + expect(c.isDirty).toBe(true); + sort.mockRestore(); + }); + + it("never sorts when drawing, however many cameras draw it", () => { + const walker = box("walker", 0, 10); + const c = sorted("y", walker, box("wall", 0, 30)); + const sort = vi.spyOn(c.getChildren(), "sort"); + walker.pos.y = 50; + c.draw(app.renderer, app.viewport); + c.draw(app.renderer, app.viewport); + expect(sort).not.toHaveBeenCalled(); + sort.mockRestore(); + }); + + it("leaves a z sort as it was: a new depth still waits for sort()", () => { + const a = box("a", 0, 0); + const b = box("b", 0, 0); + const c = new Container(0, 0, 1000, 1000); + c.sortOn = "z"; + c.addChild(a, 1); + c.addChild(b, 2); + c.sortNow(); + a.depth = 5; + c.update(16); + expect(topFirst(c)).toEqual(["b", "a"]); + c.sortNow(); + expect(topFirst(c)).toEqual(["a", "b"]); + }); + + it("leaves the order alone with autoSort off", () => { + const walker = box("walker", 0, 10); + const c = sorted("y", walker, box("wall", 0, 30)); + c.autoSort = false; + walker.pos.y = 50; + c.update(16); + expect(topFirst(c)).toEqual(["wall", "walker"]); + }); + + it("keeps a nested y-sorted container in order when its parent updates it", () => { + const walker = box("walker", 0, 10); + const inner = sorted("y", walker, box("wall", 0, 30)); + inner.alwaysUpdate = true; + const outer = new Container(0, 0, 1000, 1000); + outer.addChild(inner, 0); + walker.pos.y = 50; + outer.update(16); + expect(topFirst(inner)).toEqual(["walker", "wall"]); + }); +}); diff --git a/packages/melonjs/tests/effect-frame-bounds.spec.js b/packages/melonjs/tests/effect-frame-bounds.spec.js new file mode 100644 index 0000000000..932d5f96ee --- /dev/null +++ b/packages/melonjs/tests/effect-frame-bounds.spec.js @@ -0,0 +1,516 @@ +/** + * Effects that sample away from the fragment they shade keep to the sprite's + * own frame. + * + * A sprite drawn from an atlas or a tileset shows one frame of a bigger + * texture, which goes on past the frame into the neighbouring ones. An effect + * reading a few texels away read those neighbours along the frame's edge, and + * drew them: a glow, an outline or a shadow from a frame that is not there, + * a blur or a wave dragging the next frame in. + * + * Read in pixels on WebGL. The atlas holds three frames side by side: an + * opaque blue one, the sprite's frame (transparent, with a red square in the + * middle), and an opaque white one. The sprite's frame starts at texel 30, so + * pixelate's 8-texel blocks straddle its left edge. What has to hold: + * - each of the eight effects leaves the sprite's frame edges as clear as + * the background, whatever lies next to the frame in the atlas + * - and still does its job on the sprite's own pixels + * - sprites sharing one effect each keep to their own frame + * - a pass over a whole texture (a camera effect) still samples to the + * texture's edge, as it always did + */ +import "./helpers/webgpu-globals.js"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + BloomEffect, + BlurEffect, + boot, + ChromaticAberrationEffect, + DropShadowEffect, + GlowEffect, + OutlineEffect, + PixelateEffect, + Renderable, + ShaderEffect, + state, + video, + WaveEffect, +} from "../src/index.js"; +import Renderer from "../src/video/renderer.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, +} from "./helpers/webgl-context.js"; + +// the atlas: [0, 30) blue, [30, 62) the sprite's frame, [62, 94) white +const ATLAS_W = 94; +const ATLAS_H = 32; +const FRAME_X = 30; +const FRAME_W = 32; +const TEXTURE_SIZE = [ATLAS_W, ATLAS_H]; +// the sprite's frame is drawn 1:1 with its top left here +const AT = 16; +// its first and last columns on the screen, half way down +const LEFT = [AT, AT + 16]; +const RIGHT = [AT + FRAME_W - 1, AT + 16]; +// the column just left of the red square (frame texels 12 to 19) +const BESIDE_SQUARE = [AT + 11, AT + 16]; + +const BLACK = [0, 0, 0]; + +describe("effects keep to the sprite's frame (WebGL)", () => { + const SIZE = 64; + let renderer; + let atlas; + + beforeAll(async () => { + await boot(); + try { + renderer = await getWebGLRenderer(SIZE, SIZE); + } catch { + // WebGL genuinely unavailable: the tests skip below + } + if (renderer) { + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + } + atlas = Renderer.createCanvas(ATLAS_W, ATLAS_H); + const ctx = atlas.getContext("2d"); + ctx.fillStyle = "#0000ff"; + ctx.fillRect(0, 0, FRAME_X, ATLAS_H); + ctx.fillStyle = "#ff0000"; + ctx.fillRect(FRAME_X + 12, 12, 8, 8); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(FRAME_X + FRAME_W, 0, ATLAS_W - FRAME_X - FRAME_W, ATLAS_H); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + const pixel = (x, y) => { + const gl = renderer.gl; + const out = new Uint8Array(4); + gl.readPixels(x, SIZE - 1 - y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, out); + return [out[0], out[1], out[2]]; + }; + + /** draw these frames of the atlas through `effect`, over black */ + const draw = (effect, quads = [[FRAME_X, AT, AT]]) => { + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.customShader = effect; + for (const [sx, dx, dy] of quads) { + renderer.drawImage( + atlas, + sx, + 0, + FRAME_W, + ATLAS_H, + dx, + dy, + FRAME_W, + ATLAS_H, + ); + } + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + }; + + const bright = (rgb) => { + return Math.max(...rgb); + }; + + // one effect per row: what it is, and where it used to read a neighbour + const EFFECTS = [ + { + name: "OutlineEffect", + make: () => { + return new OutlineEffect(renderer, { + color: [1, 1, 1], + textureSize: TEXTURE_SIZE, + }); + }, + edge: LEFT, + own: BESIDE_SQUARE, + }, + { + name: "GlowEffect", + make: () => { + return new GlowEffect(renderer, { + color: [1, 1, 1], + width: 2, + intensity: 2, + textureSize: TEXTURE_SIZE, + }); + }, + edge: LEFT, + own: BESIDE_SQUARE, + }, + { + name: "BlurEffect", + make: () => { + return new BlurEffect(renderer, { + strength: 2, + textureSize: TEXTURE_SIZE, + }); + }, + edge: RIGHT, + own: BESIDE_SQUARE, + }, + { + name: "ChromaticAberrationEffect", + make: () => { + return new ChromaticAberrationEffect(renderer, { + offset: 3, + textureSize: TEXTURE_SIZE, + }); + }, + // blue comes from three texels to the left + edge: LEFT, + // and red from three texels to the right: the square's red, seen + // three texels before it starts + own: [AT + 10, AT + 16], + }, + { + name: "DropShadowEffect", + make: () => { + return new DropShadowEffect(renderer, { + color: [1, 1, 1], + opacity: 1, + textureSize: TEXTURE_SIZE, + }); + }, + // the shadow of whatever is two texels up and left + edge: LEFT, + // the square's shadow, two texels down and right of it + own: [AT + 21, AT + 21], + }, + { + name: "WaveEffect", + make: () => { + // no frequency and a quarter period in: every row shifts by + // the full amplitude, three texels to the right + const effect = new WaveEffect(renderer, { + amplitude: 3 / ATLAS_W, + frequency: 0, + speed: 1, + }); + effect.setTime(Math.PI / 2); + return effect; + }, + edge: RIGHT, + // the square, shifted three texels left + own: [AT + 9, AT + 16], + }, + { + name: "PixelateEffect", + make: () => { + return new PixelateEffect(renderer, { + size: 8, + textureSize: TEXTURE_SIZE, + }); + }, + // the frame's first two columns share an 8-texel block centred + // in the blue frame + edge: LEFT, + // a block centred inside the square (atlas texel 44, row 12) + own: [AT + 14, AT + 12], + }, + { + name: "BloomEffect", + make: () => { + return new BloomEffect(renderer, { + threshold: 0.5, + intensity: 2, + radius: 2, + textureSize: TEXTURE_SIZE, + }); + }, + // the white frame is over the threshold + edge: RIGHT, + own: [AT + 16, AT + 16], + }, + ]; + + for (const { name, make, edge, own } of EFFECTS) { + it(`${name} leaves the frame's edge clear of the next frame`, (ctx) => { + requireWebGL(ctx); + const effect = make(); + draw(effect); + effect.destroy(); + expect(pixel(...edge), `${name} at ${edge}`).toEqual(BLACK); + }); + + it(`${name} still works on the sprite's own pixels`, (ctx) => { + requireWebGL(ctx); + const effect = make(); + draw(effect); + effect.destroy(); + expect(bright(pixel(...own)), `${name} at ${own}`).toBeGreaterThan(40); + }); + } + + it("reads past the frame as transparent, as a sprite drawn on its own", (ctx) => { + requireWebGL(ctx); + // the white frame, blurred: at its first column three of the nine + // taps fall past its edge, and read nothing, rather than repeating + // the edge (which a sprite blurred alone, offscreen, never does) + const effect = new BlurEffect(renderer, { + strength: 1, + textureSize: TEXTURE_SIZE, + }); + draw(effect, [[FRAME_X + FRAME_W, AT, AT]]); + effect.destroy(); + const [r] = pixel(...LEFT); + expect(Math.abs(r - 170), `${r}`).toBeLessThanOrEqual(6); + }); + + it("keeps sprites sharing one effect each to their own frame", (ctx) => { + requireWebGL(ctx); + // the sprite's frame, then the blue one, through the same glow: the + // second must not take the first one's frame from it + const effect = EFFECTS[1].make(); + draw(effect, [ + [FRAME_X, AT, AT], + [0, 0, 0], + ]); + effect.destroy(); + expect(bright(pixel(...BESIDE_SQUARE))).toBeGreaterThan(40); + }); + + // ---- past the texture's own edge, nothing changes ---------------------- + + /** every pixel of the canvas */ + const snapshot = () => { + const gl = renderer.gl; + const out = new Uint8Array(SIZE * SIZE * 4); + gl.readPixels(0, 0, SIZE, SIZE, gl.RGBA, gl.UNSIGNED_BYTE, out); + return out; + }; + + /** run `fn` with the effects sampling as they did before frames were kept to */ + const unbounded = (fn) => { + const proto = ShaderEffect.prototype; + const setFrameRect = proto._setFrameRect; + proto._setFrameRect = function () { + this._clearFrameRect(); + }; + try { + return fn(); + } finally { + proto._setFrameRect = setFrameRect; + } + }; + + /** where two snapshots differ, as "x,y" */ + const differences = (a, b) => { + const out = []; + for (let i = 0; i < a.length; i += 4) { + if ( + a[i] !== b[i] || + a[i + 1] !== b[i + 1] || + a[i + 2] !== b[i + 2] || + a[i + 3] !== b[i + 3] + ) { + const p = i / 4; + out.push(`${p % SIZE},${SIZE - 1 - Math.floor(p / SIZE)}`); + } + } + return out; + }; + + for (const { name, make } of EFFECTS) { + it(`${name} draws a standalone image as it always did`, (ctx) => { + requireWebGL(ctx); + // opaque to its very edge, in four colors: past it, a sample + // reads what the texture's wrap mode gives, as it always has + const image = Renderer.createCanvas(16, 16); + const ictx = image.getContext("2d"); + for (const [color, x, y] of [ + ["#ff0000", 0, 0], + ["#00ff00", 8, 0], + ["#0000ff", 0, 8], + ["#ffffff", 8, 8], + ]) { + ictx.fillStyle = color; + ictx.fillRect(x, y, 8, 8); + } + const frame = () => { + const effect = make(); + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.customShader = effect; + renderer.drawImage(image, 0, 0, 16, 16, AT, AT, 16, 16); + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + effect.destroy(); + return snapshot(); + }; + const now = frame(); + const before = unbounded(frame); + expect(differences(now, before)).toEqual([]); + }); + } + + it("draws the outer side of a frame on the atlas's edge as it always did", (ctx) => { + requireWebGL(ctx); + // the white frame ends where the atlas does: past its right edge + // there is no other frame, and the blur clamps there as before + const frame = () => { + const effect = new BlurEffect(renderer, { + strength: 2, + textureSize: TEXTURE_SIZE, + }); + draw(effect, [[FRAME_X + FRAME_W, AT, AT]]); + effect.destroy(); + return pixel(...RIGHT); + }; + const now = frame(); + expect(now).toEqual(unbounded(frame)); + expect(now).toEqual([255, 255, 255]); + }); +}); + +describe("effects keep to the sprite's frame under linear filtering (WebGL)", () => { + const SIZE = 64; + let app; + let atlas; + + beforeAll(async () => { + boot(); + app = new Application(SIZE, SIZE, { + parent: "screen", + renderer: video.WEBGL, + scale: "1.0", + // linear texture filtering + antiAlias: true, + failIfMajorPerformanceCaveat: false, + }); + await app.init(); + const renderer = app.renderer; + renderer.projectionMatrix.ortho(0, SIZE, SIZE, 0, -1000, 1000); + renderer.currentBatcher.setProjection(renderer.projectionMatrix); + atlas = Renderer.createCanvas(ATLAS_W, ATLAS_H); + const ctx = atlas.getContext("2d"); + ctx.fillStyle = "#0000ff"; + ctx.fillRect(0, 0, FRAME_X, ATLAS_H); + }); + afterAll(() => { + return app?.destroy(); + }); + + it("never filters the next frame in at the frame's edge", () => { + const renderer = app.renderer; + // every row shifted a quarter of a texel left: the frame's first + // column samples a quarter texel inside its edge, where filtering + // would blend a quarter of the blue texel next to it in + const effect = new WaveEffect(renderer, { + amplitude: -0.25 / ATLAS_W, + frequency: 0, + speed: 1, + }); + effect.setTime(Math.PI / 2); + renderer.clearColor("#000000ff"); + renderer.save(); + renderer.customShader = effect; + renderer.drawImage( + atlas, + FRAME_X, + 0, + FRAME_W, + ATLAS_H, + AT, + AT, + FRAME_W, + ATLAS_H, + ); + renderer.flush(); + renderer.customShader = undefined; + renderer.restore(); + effect.destroy(); + const gl = renderer.gl; + const out = new Uint8Array(4); + gl.readPixels( + AT, + SIZE - 1 - (AT + 16), + 1, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + out, + ); + expect(Array.from(out.slice(0, 3))).toEqual(BLACK); + }); +}); + +describe("a camera effect still samples to the screen's edge (WebGL)", () => { + let app; + + /** a white rectangle over the whole view */ + class White extends Renderable { + constructor() { + super(0, 0, 200, 200); + this.anchorPoint.set(0, 0); + } + draw(renderer) { + renderer.setColor("#ffffff"); + renderer.fillRect(0, 0, this.width, this.height); + } + } + + beforeAll(async () => { + boot(); + app = new Application(100, 100, { + parent: "screen", + renderer: video.WEBGL, + scale: "1.0", + antiAlias: false, + failIfMajorPerformanceCaveat: false, + }); + await app.init(); + }); + afterEach(() => { + app.world.reset(); + }); + afterAll(() => { + return app?.destroy(); + }); + + it("does not fade a blurred white screen at its edges", () => { + const camera = state.current().cameras.get("default"); + const blur = new BlurEffect(app.renderer, { + strength: 3, + textureSize: [100, 100], + }); + camera.addPostEffect(blur); + try { + app.world.addChild(new White(), 0); + app.world.update(0); + app.repaint(); + app.draw(); + const gl = app.renderer.gl; + const out = new Uint8Array(4); + for (const [x, y] of [ + [0, 0], + [99, 99], + [0, 99], + [50, 0], + ]) { + gl.readPixels(x, 99 - y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, out); + expect(out[0], `at ${x}, ${y}`).toBeGreaterThan(250); + } + } finally { + camera.removePostEffect(blur); + } + }); +}); diff --git a/packages/melonjs/tests/frameAnimation.spec.js b/packages/melonjs/tests/frameAnimation.spec.js index b91742665a..34ea5bcd77 100644 --- a/packages/melonjs/tests/frameAnimation.spec.js +++ b/packages/melonjs/tests/frameAnimation.spec.js @@ -254,6 +254,101 @@ for (const HOST of HOSTS) { expect(spy).toHaveBeenCalledTimes(1); }); + // ── onFrame ────────────────────────────────────────────────────────── + + // the frames an onFrame callback was given + const frames = () => { + const seen = []; + return { + seen, + onFrame: (frame) => { + seen.push(frame); + }, + }; + }; + + it("onFrame reports frame 0 at the start, then each frame playback reaches", () => { + const s = make(); + const f = frames(); + s.setCurrentAnimation("walk", { onFrame: f.onFrame }); + expect(f.seen).toEqual([0]); + s.update(100); + expect(f.seen).toEqual([0, 1]); + s.update(200); + expect(f.seen).toEqual([0, 1, 2, 3]); + s.update(100); // a new cycle starts + expect(f.seen).toEqual([0, 1, 2, 3, 0]); + }); + + it("onFrame reports every frame a long tick goes past", () => { + const s = make(); + const f = frames(); + s.setCurrentAnimation("walk", { onFrame: f.onFrame }); + s.update(300); // three frames in one tick: none is skipped + expect(f.seen).toEqual([0, 1, 2, 3]); + }); + + it("onFrame reports no frame 0 when a play-once animation holds its last", () => { + const s = make(); + const f = frames(); + s.setCurrentAnimation("ab", { loop: false, onFrame: f.onFrame }); + s.update(100); + s.update(1000); + expect(f.seen).toEqual([0, 1]); + expect(s.getCurrentAnimationFrame()).toBe(1); + }); + + it("onFrame stops when the animation chains to the next one", () => { + const s = make(); + const f = frames(); + s.setCurrentAnimation("ab", { next: "walk", onFrame: f.onFrame }); + s.update(200); // the cycle ends: chained to walk + s.update(100); // walk advances, with no callback of its own + expect(s.isCurrentAnimation("walk")).toBe(true); + expect(f.seen).toEqual([0, 1]); + }); + + it("switching to another animation drops the onFrame of the last one", () => { + const s = make(); + const f = frames(); + s.setCurrentAnimation("walk", { onFrame: f.onFrame }); + s.setCurrentAnimation("ab"); + s.update(100); + expect(f.seen).toEqual([0]); + }); + + it("re-selecting the current animation keeps its onFrame", () => { + const s = make(); + const f = frames(); + s.setCurrentAnimation("walk", { onFrame: f.onFrame }); + s.setCurrentAnimation("walk"); // a no-op, as every frame of a game loop + s.update(100); + expect(f.seen).toEqual([0, 1]); + }); + + it("onFrame is not called for a frame set by hand", () => { + const s = make(); + const f = frames(); + s.setCurrentAnimation("walk", { onFrame: f.onFrame }); + s.setAnimationFrame(2); + expect(f.seen).toEqual([0]); + }); + + it("onComplete runs before the frame 0 of the next cycle is reported", () => { + const s = make(); + const order = []; + s.setCurrentAnimation("ab", { + onFrame: (frame) => { + order.push(`frame ${frame}`); + }, + onComplete: () => { + order.push("complete"); + }, + }); + s.update(200); + expect(order).toEqual(["frame 0", "frame 1", "complete", "frame 0"]); + }); + // ── legacy resetAnim forms (back-compat) ───────────────────────────── it("legacy: a bare function returning false holds the last frame", () => { diff --git a/packages/melonjs/tests/light-softness.spec.js b/packages/melonjs/tests/light-softness.spec.js new file mode 100644 index 0000000000..3c8838109e --- /dev/null +++ b/packages/melonjs/tests/light-softness.spec.js @@ -0,0 +1,346 @@ +/** + * `Light2d#softness`: a soft edge for a light's hole in the stage's ambient + * darkness. + * + * Read in pixels, over a white layer under a black ambient of full alpha, with + * `illuminationOnly` lights (they cut the darkness and draw no glow): a pixel's + * brightness is then 255 times what the lights cut there. + * + * What has to hold, on Canvas and WebGL: + * - with no soft light, the hard path runs exactly as before + * - the falloff: lit out to the core, then straight back to dark at the edge + * - hard and soft lights in one scene, overlapping lights multiplying + * - an off-centre light lands where it is (no flipped axis), an elliptical + * one along both radii, under a scrolled and a zoomed camera too + * - the whole view is darkened, zoomed out included + * - lights outside the view do not count against the 32 a pass cuts + * - nothing grows frame after frame + * - a bad softness is refused + */ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { + Application, + boot, + Camera2d, + Light2d, + Renderable, + state, + video, +} from "../src/index.js"; + +/** a white rectangle over the whole test world, as the scene */ +class White extends Renderable { + constructor() { + super(0, 0, 2000, 2000); + this.anchorPoint.set(0, 0); + } + draw(renderer) { + renderer.setColor("#ffffff"); + renderer.fillRect(0, 0, this.width, this.height); + } +} + +for (const backend of [video.CANVAS, video.WEBGL]) { + describe(`Light2d softness (${backend})`, () => { + let app, renderer, stage, camera; + + beforeAll(async () => { + boot(); + app = new Application(100, 100, { + parent: "screen", + renderer: backend, + scale: "1.0", + antiAlias: false, + failIfMajorPerformanceCaveat: false, + }); + await app.init(); + renderer = app.renderer; + stage = state.current(); + // the stage's own camera: `app.viewport` is only set by a reset + camera = stage.cameras.get("default"); + }); + afterEach(() => { + vi.restoreAllMocks(); + stage.ambientLight.setColor(0, 0, 0, 0); + app.world.reset(); + camera.zoom = 1; + camera.setBounds(0, 0, 100, 100); + camera.moveTo(0, 0); + }); + afterAll(() => { + return app?.destroy(); + }); + + /** a white scene, black darkness over it, and these lights */ + const scene = (...lights) => { + app.world.addChild(new White(), 0); + stage.ambientLight.setColor(0, 0, 0, 1); + for (const light of lights) { + app.world.addChild(light, 1); + } + app.world.update(0); + app.repaint(); + app.draw(); + }; + + /** a light that cuts the darkness and draws no glow */ + const cutter = (x, y, rx, ry, softness) => { + const light = new Light2d(x, y, rx, ry, "#ffffff", 1); + light.illuminationOnly = true; + light.softness = softness; + return light; + }; + + /** brightness of the pixel at (x, y), 0..255 */ + const at = (x, y) => { + if (backend === video.CANVAS) { + return renderer.getContext().getImageData(x, y, 1, 1).data[0]; + } + const bytes = new Uint8Array(4); + renderer.gl.readPixels( + x, + 99 - y, + 1, + 1, + renderer.gl.RGBA, + renderer.gl.UNSIGNED_BYTE, + bytes, + ); + return bytes[0]; + }; + + /** + * the brightness a light of `radius` (and `radiusY`) and `softness`, centered + * at (cx, cy) on screen, gives the pixel at (x, y): its cut, read at + * the pixel's center + */ + const falloff = (x, y, cx, cy, radius, softness, radiusY = radius) => { + const d = Math.hypot((x + 0.5 - cx) / radius, (y + 0.5 - cy) / radiusY); + const cut = + softness > 0 + ? Math.min(1, Math.max(0, (1 - d) / softness)) + : d <= 1 + ? 1 + : 0; + return Math.round(255 * cut); + }; + + // within a couple of levels: gradients are rasterized per pixel + const near = (value, expected, tolerance = 4) => { + expect( + Math.abs(value - expected), + `${value} vs ${expected}`, + ).toBeLessThanOrEqual(tolerance); + }; + + // ---- the hard path ---------------------------------------------------- + + it("keeps the hard path when no light is soft", () => { + const soft = vi.spyOn(renderer, "drawAmbientLight"); + scene(cutter(50, 50, 30, 30, 0)); + expect(soft).not.toHaveBeenCalled(); + expect(at(50, 50)).toBe(255); // inside: no darkness + expect(at(50, 25)).toBe(255); + expect(at(5, 5)).toBe(0); // outside: all of it + }); + + // ---- the falloff -------------------------------------------------------- + + it("thins the darkness straight from the center with a softness of 1", () => { + const soft = vi.spyOn(renderer, "drawAmbientLight"); + scene(cutter(50, 50, 40, 40, 1)); + expect(soft).toHaveBeenCalled(); + near(at(50, 50), falloff(50, 50, 50, 50, 40, 1)); + near(at(70, 50), falloff(70, 50, 50, 50, 40, 1)); // about half cut + near(at(80, 50), falloff(80, 50, 50, 50, 40, 1)); // about a quarter + expect(at(95, 50)).toBe(0); // past the radius + }); + + it("keeps a lit core of 1 - softness, then fades to the edge", () => { + scene(cutter(50, 50, 40, 40, 0.5)); + near(at(50, 50), 255); + near(at(66, 50), 255); // 0.4 of the radius: still the core + near(at(80, 50), falloff(80, 50, 50, 50, 40, 0.5)); // about half way back + expect(at(95, 50)).toBe(0); + }); + + it("mixes a hard and a soft light in one scene", () => { + scene(cutter(25, 50, 20, 20, 0), cutter(75, 50, 20, 20, 1)); + // the hard one is crisp + expect(at(25, 50)).toBe(255); + expect(at(40, 50)).toBe(255); + expect(at(25, 75)).toBe(0); + // the soft one fades + near(at(85, 50), falloff(85, 50, 75, 50, 20, 1)); + }); + + it("multiplies what overlapping lights leave of the darkness", () => { + // at (50, 50) each light is at half its radius and cuts half: + // a quarter of the darkness is left + scene(cutter(30, 50, 40, 40, 1), cutter(70, 50, 40, 40, 1)); + near(at(50, 50), 191); + }); + + // ---- where the hole lands ----------------------------------------------- + + it("lands an off-centre light where it is, not mirrored", () => { + scene(cutter(20, 75, 15, 15, 0.5)); + near(at(20, 75), 255); + expect(at(20, 25)).toBe(0); // its mirror on the vertical axis + expect(at(80, 75)).toBe(0); // and on the horizontal one + }); + + it("follows an elliptical light along both of its radii", () => { + scene(cutter(50, 50, 40, 20, 1)); + near(at(70, 50), falloff(70, 50, 50, 50, 40, 1, 20)); // half the horizontal radius + near(at(50, 60), falloff(50, 60, 50, 50, 40, 1, 20)); // half the vertical radius + near(at(50, 70), 0); // its vertical radius + }); + + it("follows the light under a scrolled camera", () => { + camera.setBounds(0, 0, 1000, 1000); + camera.moveTo(200, 100); + scene(cutter(250, 150, 30, 30, 1)); // at (50, 50) on screen + near(at(50, 50), falloff(50, 50, 50, 50, 30, 1)); + near(at(65, 50), falloff(65, 50, 50, 50, 30, 1)); + expect(at(5, 5)).toBe(0); + }); + + it("darkens the whole view of a zoomed-out camera, the hole where the light is", () => { + camera.zoom = 0.5; // 200 x 200 of the world on 100 x 100 pixels + camera.setBounds(0, 0, 1000, 1000); + scene(cutter(100, 100, 40, 40, 0.5)); // at (50, 50) on screen, 20 px across + near(at(50, 50), 255); + near(at(65, 50), falloff(65, 50, 50, 50, 20, 0.5)); // 0.75 of the radius, on screen + expect(at(75, 50)).toBe(0); + // the far corner of the view is dark too + expect(at(97, 97)).toBe(0); + }); + + it("cuts a hard hole where the light is under a scrolled camera", () => { + // the hard path, whose inverted mask on Canvas took its outer edge + // from the world, not the screen + camera.setBounds(0, 0, 1000, 1000); + camera.moveTo(200, 100); + scene(cutter(250, 150, 30, 30, 0)); // at (50, 50) on screen + expect(at(50, 50)).toBe(255); + expect(at(70, 50)).toBe(255); + expect(at(5, 5)).toBe(0); + expect(at(95, 95)).toBe(0); + }); + + it("cuts a hard hole where the light is under a zoomed camera too", () => { + // the hard path, which a zoomed camera on Canvas used to cut where + // the light would be unzoomed + camera.zoom = 0.5; + camera.setBounds(0, 0, 1000, 1000); + scene(cutter(100, 100, 40, 40, 0)); + expect(at(50, 50)).toBe(255); + expect(at(65, 50)).toBe(255); + expect(at(95, 95)).toBe(0); + }); + + // the renderer drops the fraction of the world translate (no + // `subPixel`), which moved the darkness up to a world unit in from + // the view's top left edge, leaving them lit: any smoothed follow does + // it. Zoomed in, that unit is two pixels + for (const softness of [0, 1]) { + for (const zoom of [1, 2]) { + it(`darkens the edges of a camera at a fractional position (softness ${softness}, zoom ${zoom})`, () => { + camera.zoom = zoom; + camera.setBounds(0, 0, 4000, 4000); + scene(cutter(1025, 1040, 10, 10, softness)); // clear of the edges read + for (const fraction of [0.25, 0.5, 0.75, 0.9]) { + camera.pos.set(1000 + fraction, 1000 + fraction, camera.pos.z); + app.repaint(); + app.draw(); + const edges = [at(0, 30), at(30, 0), at(99, 30), at(30, 99)]; + expect(edges, `at ${fraction}`).toEqual([0, 0, 0, 0]); + } + }); + } + } + + it("cuts each camera's darkness for its own view, a second camera included", () => { + // a 40 x 40 camera in the top right corner, looking far away + const second = new Camera2d(0, 0, 40, 40); + second.name = "second"; + second.autoResize = false; + second.screenX = 60; + second.screenY = 0; + second.setBounds(0, 0, 2000, 2000); + second.moveTo(480, 480); + stage.cameras.set("second", second); + try { + scene( + cutter(30, 70, 25, 25, 0.5), // seen by the main view + cutter(500, 500, 15, 15, 0.5), // seen by the second, in its middle + ); + // the main view's hole, where its light is + expect(at(30, 70)).toBe(255); + expect(at(30, 20)).toBe(0); + // the second camera's hole, in the middle of its corner + expect(at(80, 20)).toBe(255); + expect(at(62, 37)).toBe(0); + } finally { + stage.cameras.delete("second"); + } + }); + + // ---- many lights ----------------------------------------------------------- + + it("does not count lights outside the view against the 32 a pass cuts", () => { + const lights = []; + for (let i = 0; i < 40; i++) { + lights.push(cutter(2000 + i * 50, 2000, 20, 20, 1)); // far off + } + lights.push(cutter(50, 50, 20, 20, 0.5)); // the 41st, in view + scene(...lights); + near(at(50, 50), 255); + }); + + // ---- frame after frame ------------------------------------------------------- + + it("reuses what it draws with, frame after frame", () => { + scene(cutter(50, 50, 30, 30, 1)); + const reused = + backend === video.CANVAS + ? renderer._ambientCanvas + : renderer._ambientShader; + expect(reused).toBeDefined(); + for (let i = 0; i < 10; i++) { + app.repaint(); + app.draw(); + } + expect( + backend === video.CANVAS + ? renderer._ambientCanvas + : renderer._ambientShader, + ).toBe(reused); + }); + + // ---- bad values --------------------------------------------------------------- + + it("refuses a softness outside 0..1, leaving it as it was", () => { + const light = new Light2d(0, 0, 10); + light.softness = 0.25; + for (const bad of [-0.1, 1.1, NaN, Infinity, "0.5", null, undefined]) { + expect(() => { + light.softness = bad; + }, String(bad)).toThrow(TypeError); + expect(light.softness).toBe(0.25); + } + light.softness = 0; + light.softness = 1; + expect(light.softness).toBe(1); + }); + }); +} diff --git a/packages/melonjs/tests/particle-tint-over-life.spec.js b/packages/melonjs/tests/particle-tint-over-life.spec.js new file mode 100644 index 0000000000..5540514d39 --- /dev/null +++ b/packages/melonjs/tests/particle-tint-over-life.spec.js @@ -0,0 +1,299 @@ +/** + * `ParticleEmitterSettings.tintOverLife`: the tints a particle takes over its + * life, evenly spaced and blended, in 32 steps whatever the number of colors. + * + * What has to hold, on Canvas and WebGL: + * - the first color at birth, the last at the end, a blend in between + * - one color is a plain tint, and the setting replaces `tint` + * - the color only: opacity is still the fade, and a color's alpha is ignored + * - at most 32 colors over a life, however many are given, which bounds the + * Canvas renderer's tinted copies of the texture to 32 + * - drawn pixels take the tint + * - it works alongside sprite-sheet frames + * - a pooled particle reused by another emitter takes that emitter's tint + * - the caller's array is snapshot; `reset()` changes it for new particles + * - bad values throw, and leave the previous settings in place + */ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { Application, boot, ParticleEmitter, video } from "../src/index.js"; + +for (const backend of [video.CANVAS, video.WEBGL]) { + describe(`particle tintOverLife (${backend})`, () => { + let app, renderer, white; + + beforeAll(async () => { + boot(); + app = new Application(64, 64, { + parent: "screen", + renderer: backend, + scale: "1.0", + antiAlias: false, + failIfMajorPerformanceCaveat: false, + transparent: true, + }); + await app.init(); + renderer = app.renderer; + white = document.createElement("canvas"); + white.width = 8; + white.height = 8; + const ctx = white.getContext("2d"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, 8, 8); + }); + afterEach(() => { + vi.restoreAllMocks(); + app.world.reset(); + app.world.broadphase.clear(); + }); + afterAll(() => { + return app?.destroy(); + }); + + /** an emitter with one particle of a 1000 ms life, at rest */ + function make(settings = {}) { + const emitter = new ParticleEmitter(0, 0, { + image: white, + width: 0, + height: 0, + speed: 0, + speedVariation: 0, + minLife: 1000, + maxLife: 1000, + minEndScale: 1, + maxEndScale: 1, + onlyInViewport: false, + ...settings, + }); + app.world.addChild(emitter); + emitter.burstParticles(1); + return emitter; + } + + const rgb = (particle) => { + return Array.from(particle.tint.toArray().slice(0, 3), (v) => { + return Math.round(v * 255); + }); + }; + + // ---- the blend ------------------------------------------------------ + + it("is the first color at birth", () => { + const p = make({ tintOverLife: ["#ff0000", "#0000ff"] }).children[0]; + expect(rgb(p)).toEqual([255, 0, 0]); + p.update(0); + expect(rgb(p)).toEqual([255, 0, 0]); + }); + + it("ends on the last color", () => { + const p = make({ tintOverLife: ["#ff0000", "#0000ff"] }).children[0]; + p.update(999); + expect(rgb(p)).toEqual([0, 0, 255]); + }); + + it("blends evenly spaced colors in between", () => { + const p = make({ + tintOverLife: ["#ff0000", "#00ff00", "#0000ff"], + }).children[0]; + // half way: the middle color, give or take one of the 32 steps + p.update(500); + const [r, g, b] = rgb(p); + expect(g).toBeGreaterThan(230); + expect(r + b).toBeLessThan(25); + // and a quarter of the way: half red, half green + const q = make({ + tintOverLife: ["#ff0000", "#00ff00", "#0000ff"], + }).children[0]; + q.update(250); + const [qr, qg, qb] = rgb(q); + expect(Math.abs(qr - qg)).toBeLessThan(40); + expect(qb).toBe(0); + }); + + it("treats one color as a plain tint, and replaces tint", () => { + const p = make({ tint: "#00ff00", tintOverLife: ["#ff0000"] }) + .children[0]; + expect(rgb(p)).toEqual([255, 0, 0]); + p.update(700); + expect(rgb(p)).toEqual([255, 0, 0]); + }); + + it("leaves the opacity to the fade, ignoring a color's alpha", () => { + const p = make({ + tintOverLife: ["rgba(255, 0, 0, 0.2)", "rgba(0, 0, 255, 0.2)"], + }).children[0]; + p.update(500); + expect(p.alpha).toBeCloseTo(0.5, 5); + expect(p.tint.alpha).toBe(1); + }); + + it("changes color at most 32 times over a life, however many colors", () => { + // a hundred distinct colors, stepped through one millisecond at a time + const colors = Array.from({ length: 100 }, (_, i) => { + return `rgb(${(i * 37) % 256}, ${(i * 91) % 256}, ${(i * 53) % 256})`; + }); + const p = make({ tintOverLife: colors }).children[0]; + const seen = new Set([rgb(p).join()]); + for (let t = 0; t < 999; t++) { + p.update(1); + seen.add(rgb(p).join()); + } + expect(seen.size).toBeLessThanOrEqual(32); + expect(seen.size).toBeGreaterThan(16); + }); + + // ---- drawing -------------------------------------------------------- + + it("draws in the tint", () => { + const em = make({ tintOverLife: ["#ff0000", "#0000ff"] }); + em.children[0].pos.set(16, 16); + em.children[0].update(0); + renderer.backgroundColor.setFloat(0, 0, 0, 0); + renderer.clearRect(0, 0, 64, 64); + app.world.update(0); + app.repaint(); + app.draw(); + const [r, g, b, a] = + backend === video.CANVAS + ? Array.from(renderer.getContext().getImageData(16, 16, 1, 1).data) + : (() => { + const bytes = new Uint8Array(4); + renderer.gl.readPixels( + 16, + 63 - 16, + 1, + 1, + renderer.gl.RGBA, + renderer.gl.UNSIGNED_BYTE, + bytes, + ); + return Array.from(bytes); + })(); + expect(a).toBeGreaterThan(200); + expect(r).toBeGreaterThan(200); + expect(g + b).toBeLessThan(40); + }); + + if (backend === video.CANVAS) { + it("keeps the Canvas renderer's tinted copies of the texture to 32", () => { + // many particles at every age, through a hundred colors, drawn + // frame after frame: without the steps, each new color would + // leave another tinted copy of the texture in the cache for good + const colors = Array.from({ length: 100 }, (_, i) => { + return `rgb(${(i * 37) % 256}, ${(i * 91) % 256}, ${(i * 53) % 256})`; + }); + // a texture of its own: the cache outlives each test + const texture = document.createElement("canvas"); + texture.width = 8; + texture.height = 8; + texture.getContext("2d").fillRect(0, 0, 8, 8); + const em = make({ + image: texture, + tintOverLife: colors, + totalParticles: 60, + frequency: 5, + minLife: 300, + maxLife: 900, + }); + em.streamParticles(); + for (let frame = 0; frame < 120; frame++) { + app.world.update(16); + app.repaint(); + app.draw(); + } + const copies = renderer.cache.tinted.get(texture); + expect(copies).toBeDefined(); + expect(copies.size).toBeLessThanOrEqual(32); + }); + } + + // ---- with the rest of the emitter ------------------------------------ + + it("works alongside sprite-sheet frames", () => { + const sheet = document.createElement("canvas"); + sheet.width = 16; + sheet.height = 8; + const p = make({ + image: sheet, + framewidth: 8, + frameheight: 8, + tintOverLife: ["#ff0000", "#0000ff"], + }).children[0]; + p.update(999); + const draw = vi.fn(); + p.draw({ drawImage: draw }); + // the last frame, in the last color + expect(draw.mock.calls[0].slice(1, 3)).toEqual([8, 0]); + expect(rgb(p)).toEqual([0, 0, 255]); + }); + + it("gives a pooled particle reused by another emitter that emitter's tint", () => { + const first = make({ tintOverLife: ["#ff0000", "#0000ff"] }); + const p = first.children[0]; + p.update(999); // blue, the end of the first emitter's blend + // what the pool runs on a particle it hands out again + const second = new ParticleEmitter(0, 0, { + image: white, + tint: "#00ff00", + }); + p.onResetEvent(second, false); + expect(rgb(p)).toEqual([0, 255, 0]); + p.update(500); + expect(rgb(p)).toEqual([0, 255, 0]); + // and back on an emitter with a blend + p.onResetEvent(first, false); + expect(rgb(p)).toEqual([255, 0, 0]); + }); + + it("snapshots the colors: changing the array afterwards does nothing", () => { + const colors = ["#ff0000", "#0000ff"]; + const em = make({ tintOverLife: colors }); + colors[0] = "#00ff00"; + em.burstParticles(1); + expect(rgb(em.children[1])).toEqual([255, 0, 0]); + }); + + it("takes new colors from reset(), for the particles emitted after", () => { + const em = make({ tintOverLife: ["#ff0000", "#0000ff"] }); + em.reset({ ...em.settings, tintOverLife: ["#00ff00"] }); + em.burstParticles(1); + expect(rgb(em.children[em.children.length - 1])).toEqual([0, 255, 0]); + }); + + // ---- bad values ------------------------------------------------------- + + it("throws on anything but a non-empty array of CSS colors, saying so", () => { + // what is wrong, rather than a crash deeper in + for (const bad of [[], "red", {}]) { + expect(() => { + return new ParticleEmitter(0, 0, { tintOverLife: bad }); + }, JSON.stringify(bad)).toThrow(/non-empty array of CSS colors/); + } + for (const bad of [[5], [null], ["#ff0000", 7]]) { + expect(() => { + return new ParticleEmitter(0, 0, { tintOverLife: bad }); + }, JSON.stringify(bad)).toThrow(/must be CSS strings/); + } + expect(() => { + return new ParticleEmitter(0, 0, { tintOverLife: ["not a color"] }); + }).toThrow(); + }); + + it("leaves the previous settings in place when reset() throws", () => { + const em = make({ tintOverLife: ["#ff0000", "#0000ff"] }); + expect(() => { + em.reset({ ...em.settings, tintOverLife: [] }); + }).toThrow(TypeError); + expect(em.settings.tintOverLife).toEqual(["#ff0000", "#0000ff"]); + em.burstParticles(1); + expect(rgb(em.children[em.children.length - 1])).toEqual([255, 0, 0]); + }); + }); +} diff --git a/packages/melonjs/tests/sort-offset.spec.js b/packages/melonjs/tests/sort-offset.spec.js new file mode 100644 index 0000000000..6540423333 --- /dev/null +++ b/packages/melonjs/tests/sort-offset.spec.js @@ -0,0 +1,347 @@ +/** + * `Renderable#sortOffset`: an offset added to a child's position when its + * parent container sorts on `"y"` (or `"x"`), and used for nothing else. + * + * What has to hold: + * - left at 0, everything sorts exactly as it always did + * - it changes the order, never the position nor the bounds + * - what is drawn, and what a pointer hits, follow that order + * - `"z"` sorting ignores it, and on `"x"`/`"y"` a different `pos.z` still + * decides first + * - a tie keeps insertion order (the child added last is drawn BEHIND), and + * an offset settles a tie on purpose + * - a change is picked up by the parent's next update when it keeps itself + * sorted (`autoSort`), and not with `autoSort` off nor on a `"z"` sort + * - a bad value is refused where it is set, leaving the order as it was + * - a renderable taken from a pool starts again at 0 + * + * Order is read after `sortNow()`: the children array is then topmost first, + * since `Container.draw` walks it backwards (index 0 is drawn last). + */ +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + Application, + boot, + Container, + input, + Renderable, + video, +} from "../src/index.js"; +import { resetRenderableState } from "../src/renderable/renderable.js"; + +describe("Renderable#sortOffset", () => { + let app; + + beforeAll(async () => { + boot(); + app = new Application(800, 600, { + parent: "screen", + scale: "auto", + renderer: video.CANVAS, + }); + await app.init(); + }); + + afterAll(() => { + app?.destroy(); + }); + + /** a 10x10 child at (x, y), anchored at its top left like a Tiled object */ + const box = (name, x, y, sortOffset) => { + const r = new Renderable(x, y, 10, 10); + r.anchorPoint.set(0, 0); + r.name = name; + if (sortOffset !== undefined) { + r.sortOffset = sortOffset; + } + return r; + }; + + /** a container sorted on `sortOn`, holding `children` at depth 0, sorted */ + const sorted = (sortOn, ...children) => { + const c = new Container(0, 0, 1000, 1000); + c.sortOn = sortOn; + for (const child of children) { + c.addChild(child, 0); + } + c.sortNow(); + // drop the sort `addChild` queued for the next tick, so that nothing + // sorts behind a test's back + clearTimeout(c.pendingSort); + c.pendingSort = null; + return c; + }; + + /** the names of the children, topmost first */ + const topFirst = (c) => { + return c.getChildren().map((child) => { + return child.name; + }); + }; + + // ---- the order -------------------------------------------------------- + + describe("the order", () => { + it("is 0 by default, and sorts exactly as before", () => { + const a = box("a", 0, 10); + expect(a.sortOffset).toBe(0); + const c = sorted("y", a, box("b", 0, 30), box("c", 0, 20)); + // the lowest on screen is drawn last, on top + expect(topFirst(c)).toEqual(["b", "c", "a"]); + }); + + it("lines a child up as if it stood lower, without moving it", () => { + const a = box("a", 0, 10, 25); // counts as 35 + const c = sorted("y", a, box("b", 0, 30)); + expect(topFirst(c)).toEqual(["a", "b"]); + // where it is, and where it is drawn, are untouched + expect(a.pos.y).toBe(10); + expect(a.getBounds().top).toBe(10); + }); + + it("lines it up higher with a negative offset", () => { + const c = sorted("y", box("a", 0, 50, -30), box("b", 0, 30)); + expect(topFirst(c)).toEqual(["b", "a"]); + }); + + it("takes a fractional offset", () => { + const c = sorted("y", box("a", 0, 30, 0.5), box("b", 0, 30.25)); + expect(topFirst(c)).toEqual(["a", "b"]); + }); + + it("works on the x axis of an x-sorted container", () => { + const c = sorted("x", box("a", 10, 0, 25), box("b", 30, 0)); + expect(topFirst(c)).toEqual(["a", "b"]); + // and the y offset of a child does not leak into an x sort: it is + // the same single number, applied to the axis being sorted + const d = sorted("x", box("a", 10, 500), box("b", 30, 0)); + expect(topFirst(d)).toEqual(["b", "a"]); + }); + + it("is ignored by a z-sorted container", () => { + const a = box("a", 0, 0); + const b = box("b", 0, 0); + const c = new Container(0, 0, 1000, 1000); + c.sortOn = "z"; + c.addChild(a, 1); + c.addChild(b, 2); + a.sortOffset = 1e6; + c.sortNow(); + expect(topFirst(c)).toEqual(["b", "a"]); + }); + + it("never outweighs a different pos.z on a y sort", () => { + const c = new Container(0, 0, 1000, 1000); + c.sortOn = "y"; + const high = box("high", 0, 0); + const low = box("low", 0, 100, 1e6); + c.addChild(high, 2); + c.addChild(low, 1); + c.sortNow(); + expect(topFirst(c)).toEqual(["high", "low"]); + }); + + it("works on a container child, which sorts as one piece", () => { + const group = new Container(0, 10, 50, 50); + group.name = "group"; + group.addChild(box("inside", 0, 0), 0); + group.sortOffset = 25; // counts as 35 + const c = sorted("y", group, box("b", 0, 30)); + expect(topFirst(c)).toEqual(["group", "b"]); + }); + + it("can be set before the child is added, and sorts it once added", () => { + const a = box("a", 0, 10); + a.sortOffset = 40; + const c = sorted("y", a, box("b", 0, 30)); + expect(topFirst(c)).toEqual(["a", "b"]); + }); + }); + + // ---- ties ------------------------------------------------------------- + + describe("ties", () => { + it("keep insertion order, which draws the child added last behind", () => { + // a torch hung on a wall, at the wall's own position + const c = sorted("y", box("wall", 0, 50), box("torch", 0, 50)); + expect(topFirst(c)).toEqual(["wall", "torch"]); + }); + + it("are settled on purpose by an offset", () => { + const c = sorted("y", box("wall", 0, 50), box("torch", 0, 50, 1)); + expect(topFirst(c)).toEqual(["torch", "wall"]); + }); + }); + + // ---- what the player sees and clicks ---------------------------------- + + describe("drawing and pointer hits follow it", () => { + it("draws the children in that order", () => { + const drawn = []; + const recording = (name, y, sortOffset) => { + const r = box(name, 0, y, sortOffset); + r.inViewport = true; + r.draw = () => { + drawn.push(name); + }; + return r; + }; + const c = sorted( + "y", + recording("front", 40), + recording("lifted", 10, 50), // counts as 60 + recording("back", 20), + ); + c.draw(app.renderer, app.viewport); + // first drawn is at the bottom of the pile, last on top + expect(drawn).toEqual(["back", "front", "lifted"]); + }); + + describe("a pointer hits the child drawn on top", () => { + afterEach(() => { + app.world.reset(); + app.world.sortOn = "z"; + app.world.broadphase.clear(); + }); + + // two 40x40 regions on top of each other, in a y-sorted world + const overlapping = (offsetOfSecond) => { + const make = (name) => { + const r = new Renderable(100, 100, 40, 40); + r.anchorPoint.set(0, 0); + r.name = name; + r.isKinematic = false; // hit detection skips kinematic ones + return r; + }; + const first = make("first"); + const second = make("second"); + second.sortOffset = offsetOfSecond; + app.world.sortOn = "y"; + app.world.addChild(first, 0); + app.world.addChild(second, 0); + app.world.sortNow(); + first.updateBounds(true); + second.updateBounds(true); + app.world.broadphase.clear(); + app.world.broadphase.insertContainer(app.world); + return [first, second]; + }; + + /** click the middle of both, and return who got it first */ + const click = (regions) => { + const hits = []; + for (const r of regions) { + input.registerPointerEvent("pointerdown", r, () => { + hits.push(r.name); + return false; // consumed: nothing underneath gets it + }); + } + try { + const canvas = app.renderer.getCanvas(); + const rect = canvas.getBoundingClientRect(); + canvas.dispatchEvent( + new PointerEvent("pointerdown", { + clientX: rect.left + (120 * rect.width) / canvas.width, + clientY: rect.top + (120 * rect.height) / canvas.height, + pointerId: 1, + width: 1, + height: 1, + isPrimary: true, + bubbles: true, + }), + ); + } finally { + for (const r of regions) { + input.releasePointerEvent("pointerdown", r); + } + } + return hits; + }; + + it("without an offset, the one drawn on top of the tie", () => { + expect(click(overlapping(0))).toEqual(["first"]); + }); + + it("with an offset, the one it lifts on top", () => { + expect(click(overlapping(1))).toEqual(["second"]); + }); + }); + }); + + // ---- re-sorting ------------------------------------------------------- + + describe("changing it", () => { + it("is picked up by the parent's next update", () => { + const a = box("a", 0, 10); + const c = sorted("y", a, box("b", 0, 30)); + a.sortOffset = 50; + // nothing moves until the container updates + expect(topFirst(c)).toEqual(["b", "a"]); + c.update(16); + expect(topFirst(c)).toEqual(["a", "b"]); + }); + + it("leaves a container with autoSort off to its own order", () => { + const a = box("a", 0, 10); + const c = sorted("y", a, box("b", 0, 30)); + c.autoSort = false; + a.sortOffset = 50; + c.update(16); + expect(topFirst(c)).toEqual(["b", "a"]); + // until it is told to sort + c.sortNow(); + expect(topFirst(c)).toEqual(["a", "b"]); + }); + + it("is ignored by a z-sorted container's update", () => { + const a = box("a", 0, 0); + const b = box("b", 0, 0); + const c = new Container(0, 0, 1000, 1000); + c.sortOn = "z"; + c.addChild(a, 1); + c.addChild(b, 2); + c.sortNow(); + a.sortOffset = 1e6; + c.update(16); + expect(topFirst(c)).toEqual(["b", "a"]); + }); + }); + + // ---- bad values ------------------------------------------------------- + + describe("bad values", () => { + it("are refused where they are set, leaving the value and the order alone", () => { + const a = box("a", 0, 10, 3); + const c = sorted("y", a, box("b", 0, 30)); + // a NaN or a string would quietly scramble every sibling's order: + // `pos.y + "5"` is the string "105" + for (const bad of [ + NaN, + Infinity, + -Infinity, + "5", + null, + undefined, + {}, + true, + ]) { + expect(() => { + a.sortOffset = bad; + }, String(bad)).toThrow(TypeError); + expect(a.sortOffset).toBe(3); + } + c.sortNow(); + expect(topFirst(c)).toEqual(["b", "a"]); + }); + }); + + // ---- pools ------------------------------------------------------------ + + describe("a renderable taken from a pool", () => { + it("starts again at 0", () => { + const a = box("a", 0, 10, 42); + resetRenderableState(a); + expect(a.sortOffset).toBe(0); + }); + }); +}); diff --git a/packages/melonjs/tests/tmxlayer-data.spec.js b/packages/melonjs/tests/tmxlayer-data.spec.js index ac21002a80..e1803cdbef 100644 --- a/packages/melonjs/tests/tmxlayer-data.spec.js +++ b/packages/melonjs/tests/tmxlayer-data.spec.js @@ -409,8 +409,8 @@ describe("TMXLayer.layerData (Uint16Array refactor)", () => { expect(got2).not.toBe(got1); }); - it("isometricRpg-style usage: stable identity across pointerMove calls", () => { - // regression for examples/isometricRpg/play.ts line 49: + it("tile-selector usage: stable identity across pointerMove calls", () => { + // a tile selector compares the hovered cell with the previous one: // if (tile && tile !== this.currentTile) { ... } const data = [...ALL_ZERO_4x3]; data[6] = 3;