melonjs 20.12.0 → 20.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +66 -22
  2. package/build/application/application.d.ts +6 -1
  3. package/build/application/application.d.ts.map +1 -1
  4. package/build/application/header.d.ts.map +1 -1
  5. package/build/application/resize.d.ts.map +1 -1
  6. package/build/application/settings.d.ts +13 -8
  7. package/build/application/settings.d.ts.map +1 -1
  8. package/build/audio/audio.d.ts +8 -4
  9. package/build/audio/audio.d.ts.map +1 -1
  10. package/build/audio/backend/engine.d.ts.map +1 -1
  11. package/build/audio/playback.d.ts +2 -2
  12. package/build/audio/playback.d.ts.map +1 -1
  13. package/build/audio/spatial.d.ts.map +1 -1
  14. package/build/audio/types.d.ts +15 -5
  15. package/build/audio/types.d.ts.map +1 -1
  16. package/build/camera/camera2d.d.ts +12 -9
  17. package/build/camera/camera2d.d.ts.map +1 -1
  18. package/build/camera/camera3d.d.ts +11 -8
  19. package/build/camera/camera3d.d.ts.map +1 -1
  20. package/build/camera/effects/fade_effect.d.ts.map +1 -1
  21. package/build/geometries/ellipse.d.ts +3 -0
  22. package/build/geometries/ellipse.d.ts.map +1 -1
  23. package/build/geometries/line.d.ts.map +1 -1
  24. package/build/geometries/path2d.d.ts +9 -8
  25. package/build/geometries/path2d.d.ts.map +1 -1
  26. package/build/geometries/polygon.d.ts +2 -1
  27. package/build/geometries/polygon.d.ts.map +1 -1
  28. package/build/index.d.ts +14 -3
  29. package/build/index.d.ts.map +1 -1
  30. package/build/index.js +50215 -45051
  31. package/build/index.js.map +4 -4
  32. package/build/input/gamepad.d.ts.map +1 -1
  33. package/build/input/keyboard.d.ts.map +1 -1
  34. package/build/input/pointerevent.d.ts.map +1 -1
  35. package/build/level/gltf/GLTFModel.d.ts.map +1 -1
  36. package/build/level/level.d.ts.map +1 -1
  37. package/build/level/tiled/TMXLayer.d.ts +50 -15
  38. package/build/level/tiled/TMXLayer.d.ts.map +1 -1
  39. package/build/level/tiled/TMXTileMap.d.ts +14 -4
  40. package/build/level/tiled/TMXTileMap.d.ts.map +1 -1
  41. package/build/level/tiled/TMXTileset.d.ts.map +1 -1
  42. package/build/level/tiled/TMXUtils.d.ts.map +1 -1
  43. package/build/level/tiled/renderer/TMXHexagonalRenderer.d.ts.map +1 -1
  44. package/build/level/tiled/renderer/TMXIsometricRenderer.d.ts.map +1 -1
  45. package/build/level/tiled/renderer/TMXObliqueRenderer.d.ts +1 -0
  46. package/build/level/tiled/renderer/TMXObliqueRenderer.d.ts.map +1 -1
  47. package/build/level/tiled/renderer/TMXOrthogonalRenderer.d.ts.map +1 -1
  48. package/build/level/tiled/renderer/TMXRenderer.d.ts +7 -3
  49. package/build/level/tiled/renderer/TMXRenderer.d.ts.map +1 -1
  50. package/build/level/tiled/renderer/TMXStaggeredRenderer.d.ts.map +1 -1
  51. package/build/lighting/light3d.d.ts +55 -0
  52. package/build/lighting/light3d.d.ts.map +1 -1
  53. package/build/loader/cache.d.ts +146 -97
  54. package/build/loader/cache.d.ts.map +1 -1
  55. package/build/loader/internal-types.d.ts +69 -0
  56. package/build/loader/internal-types.d.ts.map +1 -0
  57. package/build/loader/loader.d.ts +589 -253
  58. package/build/loader/loader.d.ts.map +1 -1
  59. package/build/loader/parsers/audio.d.ts.map +1 -1
  60. package/build/loader/parsers/fontface.d.ts.map +1 -1
  61. package/build/loader/parsers/gltf.d.ts.map +1 -1
  62. package/build/loader/parsers/tmx.d.ts.map +1 -1
  63. package/build/math/color.d.ts +4 -0
  64. package/build/math/color.d.ts.map +1 -1
  65. package/build/math/math.d.ts +6 -1
  66. package/build/math/math.d.ts.map +1 -1
  67. package/build/math/noise.d.ts +16 -16
  68. package/build/math/noise.d.ts.map +1 -1
  69. package/build/math/observableVector2d.d.ts +6 -6
  70. package/build/math/observableVector2d.d.ts.map +1 -1
  71. package/build/math/observableVector3d.d.ts +8 -8
  72. package/build/math/observableVector3d.d.ts.map +1 -1
  73. package/build/particles/emitter.d.ts +15 -3
  74. package/build/particles/emitter.d.ts.map +1 -1
  75. package/build/particles/particle.d.ts +6 -0
  76. package/build/particles/particle.d.ts.map +1 -1
  77. package/build/particles/settings.d.ts +22 -5
  78. package/build/particles/settings.d.ts.map +1 -1
  79. package/build/physics/adapter.d.ts +64 -25
  80. package/build/physics/adapter.d.ts.map +1 -1
  81. package/build/physics/builtin/body.d.ts +23 -4
  82. package/build/physics/builtin/body.d.ts.map +1 -1
  83. package/build/physics/builtin/builtin-adapter.d.ts +25 -3
  84. package/build/physics/builtin/builtin-adapter.d.ts.map +1 -1
  85. package/build/physics/builtin/detector.d.ts.map +1 -1
  86. package/build/physics/builtin/raycast.d.ts.map +1 -1
  87. package/build/physics/world.d.ts +40 -10
  88. package/build/physics/world.d.ts.map +1 -1
  89. package/build/renderable/container.d.ts +41 -6
  90. package/build/renderable/container.d.ts.map +1 -1
  91. package/build/renderable/draggable.d.ts.map +1 -1
  92. package/build/renderable/frameAnimation.d.ts.map +1 -1
  93. package/build/renderable/instanced_mesh.d.ts +16 -14
  94. package/build/renderable/instanced_mesh.d.ts.map +1 -1
  95. package/build/renderable/mesh.d.ts +62 -5
  96. package/build/renderable/mesh.d.ts.map +1 -1
  97. package/build/renderable/renderable.d.ts.map +1 -1
  98. package/build/renderable/sprite.d.ts +4 -2
  99. package/build/renderable/sprite.d.ts.map +1 -1
  100. package/build/renderable/text/bitmaptext.d.ts +12 -3
  101. package/build/renderable/text/bitmaptext.d.ts.map +1 -1
  102. package/build/renderable/text/text.d.ts +147 -4
  103. package/build/renderable/text/text.d.ts.map +1 -1
  104. package/build/renderable/text/textmetrics.d.ts +10 -2
  105. package/build/renderable/text/textmetrics.d.ts.map +1 -1
  106. package/build/renderable/text/textstyle.d.ts.map +1 -1
  107. package/build/renderable/trigger.d.ts +5 -0
  108. package/build/renderable/trigger.d.ts.map +1 -1
  109. package/build/renderable/ui/panelpaint.d.ts +5 -1
  110. package/build/renderable/ui/panelpaint.d.ts.map +1 -1
  111. package/build/renderable/ui/progressbar.d.ts +267 -95
  112. package/build/renderable/ui/progressbar.d.ts.map +1 -1
  113. package/build/renderable/ui/uibaseelement.d.ts +443 -50
  114. package/build/renderable/ui/uibaseelement.d.ts.map +1 -1
  115. package/build/renderable/ui/uicheckbox.d.ts +383 -0
  116. package/build/renderable/ui/uicheckbox.d.ts.map +1 -0
  117. package/build/renderable/ui/uifocus.d.ts +6 -0
  118. package/build/renderable/ui/uifocus.d.ts.map +1 -0
  119. package/build/renderable/ui/uilabel.d.ts +31 -0
  120. package/build/renderable/ui/uilabel.d.ts.map +1 -0
  121. package/build/renderable/ui/uipanel.d.ts +589 -116
  122. package/build/renderable/ui/uipanel.d.ts.map +1 -1
  123. package/build/renderable/ui/uiskin.d.ts +25 -0
  124. package/build/renderable/ui/uiskin.d.ts.map +1 -0
  125. package/build/renderable/ui/uislider.d.ts +518 -0
  126. package/build/renderable/ui/uislider.d.ts.map +1 -0
  127. package/build/renderable/ui/uispriteelement.d.ts +161 -85
  128. package/build/renderable/ui/uispriteelement.d.ts.map +1 -1
  129. package/build/renderable/ui/uitextbutton.d.ts +369 -63
  130. package/build/renderable/ui/uitextbutton.d.ts.map +1 -1
  131. package/build/state/stage.d.ts.map +1 -1
  132. package/build/system/device.d.ts +11 -2
  133. package/build/system/device.d.ts.map +1 -1
  134. package/build/system/fullscreen.d.ts.map +1 -1
  135. package/build/system/pool.d.ts.map +1 -1
  136. package/build/system/save.d.ts +2 -1
  137. package/build/system/save.d.ts.map +1 -1
  138. package/build/tweens/tween.d.ts +5 -2
  139. package/build/tweens/tween.d.ts.map +1 -1
  140. package/build/utils/fetchdata.d.ts.map +1 -1
  141. package/build/utils/utils.d.ts.map +1 -1
  142. package/build/video/canvas/canvas_renderer.d.ts +16 -19
  143. package/build/video/canvas/canvas_renderer.d.ts.map +1 -1
  144. package/build/video/canvas_factory.d.ts.map +1 -1
  145. package/build/video/effects/shadereffect.d.ts +127 -0
  146. package/build/video/effects/shadereffect.d.ts.map +1 -1
  147. package/build/video/renderer.d.ts +20 -4
  148. package/build/video/renderer.d.ts.map +1 -1
  149. package/build/video/renderstate.d.ts +26 -3
  150. package/build/video/renderstate.d.ts.map +1 -1
  151. package/build/video/utils/tessellation.d.ts +11 -0
  152. package/build/video/utils/tessellation.d.ts.map +1 -1
  153. package/build/video/webgl/batchers/lit_mesh_batcher.d.ts.map +1 -1
  154. package/build/video/webgl/webgl_renderer.d.ts +3 -6
  155. package/build/video/webgl/webgl_renderer.d.ts.map +1 -1
  156. package/build/video/webgpu/batchers/lit_mesh_batcher.d.ts.map +1 -1
  157. package/build/video/webgpu/batchers/primitive_batcher.d.ts.map +1 -1
  158. package/build/video/webgpu/webgpu_renderer.d.ts +3 -6
  159. package/build/video/webgpu/webgpu_renderer.d.ts.map +1 -1
  160. package/package.json +1 -1
  161. package/skills/melonjs/SKILL.md +41 -17
  162. package/skills/melonjs-20-migration/SKILL.md +50 -7
  163. package/skills/melonjs-3d/SKILL.md +138 -38
  164. package/skills/melonjs-3d-assets/SKILL.md +83 -33
  165. package/skills/melonjs-audio/SKILL.md +62 -19
  166. package/skills/melonjs-camera-and-drawing/SKILL.md +135 -8
  167. package/skills/melonjs-deployment/SKILL.md +92 -14
  168. package/skills/melonjs-display-and-scaling/SKILL.md +8 -3
  169. package/skills/melonjs-effects-and-shaders/SKILL.md +47 -15
  170. package/skills/melonjs-events/SKILL.md +6 -3
  171. package/skills/melonjs-getting-started/SKILL.md +6 -2
  172. package/skills/melonjs-input/SKILL.md +102 -16
  173. package/skills/melonjs-lighting/SKILL.md +13 -1
  174. package/skills/melonjs-loading-assets/SKILL.md +255 -39
  175. package/skills/melonjs-math/SKILL.md +26 -8
  176. package/skills/melonjs-particles-and-trails/SKILL.md +77 -9
  177. package/skills/melonjs-performance/SKILL.md +18 -8
  178. package/skills/melonjs-physics/SKILL.md +435 -128
  179. package/skills/melonjs-plugins/SKILL.md +50 -11
  180. package/skills/melonjs-renderables/SKILL.md +31 -7
  181. package/skills/melonjs-renderer-backends/SKILL.md +38 -17
  182. package/skills/melonjs-scenes-and-state/SKILL.md +45 -5
  183. package/skills/melonjs-sprites-and-animation/SKILL.md +37 -2
  184. package/skills/melonjs-tilemaps/SKILL.md +149 -17
  185. package/skills/melonjs-ui-and-text/SKILL.md +674 -42
@@ -1,17 +1,32 @@
1
1
  ---
2
2
  name: melonjs-ui-and-text
3
- description: "Use this skill for HUDs, buttons, menus, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, ProgressBar, UIPanel, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, UIBaseElement, UISpriteElement, UITextButton, ProgressBar, progress bar, health bar, gauge, Draggable, DropTarget, menu, dialogue, overlapping panels, moveToTop, onOver, onClick, bindKey, keyboard shortcut, button keyboard, Tween, easing, animate a label, wavy text, per-glyph effect, glyphEffect, shake text, rainbow text, Text, BitmapText, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, NineSliceSprite, score display, floating., fullscreen, full screen, requestFullscreen, exitFullscreen, isFullscreen, fullscreen button, fullscreen option"
3
+ description: "Use this skill for HUDs, buttons, menus, options screens, dialogue panels, progress bars and on-screen text in melonJS. Covers UIBaseElement/UISpriteElement/UITextButton, UICheckbox, UISlider, ProgressBar, UIPanel (skins, layout, scrolling), keyboard and gamepad focus, Draggable and DropTarget, the floating screen-space container pattern, which of two overlapping panels gets the pointer, Text and BitmapText, web font loading, and NineSliceSprite panels. Triggers on: UI, HUD, button, checkbox, radio button, slider, volume slider, options menu, settings screen, scrolling list, UI skin, progress bar, health bar, menu, dialogue, moveToTop, onOver, onClick, bindKey, keyboard shortcut, Tween, animate a label, wavy text, glyphEffect, rainbow text, typewriter, visibleRatio, drag and drop, isDraggable, focus, fillStyle, fillGradient, gradient text, tint, font, fontface, wordWrapWidth, score display, fullscreen, requestFullscreen, fullscreen button."
4
4
  license: MIT
5
5
  ---
6
6
 
7
7
  # UI, HUD and text
8
8
 
9
- ## Outlined text: the stroke eats into the glyph
9
+ ## Outlined text: behind the glyphs, or over them
10
10
 
11
- `Text` draws `fillText` and then `strokeText`, so the outline lands **on top of
12
- the fill** and is centred on the glyph edge — half of it inward. On a small or
13
- chunky face a `lineWidth` of 3 leaves the letters solid black. Keep it to 1,
14
- and raise the font size rather than the stroke.
11
+ An outline is centred on the glyph's edge, so half of it lies inside the
12
+ glyph. `Text` paints it **behind** the glyphs by default (`paintOrder:
13
+ "stroke"`): the letters stay whole and the outline shows only around them, so
14
+ a thick outline works on any face. What shows is half of `lineWidth`: ask for
15
+ twice the width you want to see.
16
+
17
+ `paintOrder: "fill"` paints the glyphs first and the outline over them, as a
18
+ plain canvas does with `fillText` then `strokeText`, and as melonJS did before
19
+ 20.13. Its inner half then covers the letters: on a small or thin face a
20
+ `lineWidth` of 3 already leaves them solid outline colour.
21
+
22
+ ```js
23
+ const style = {
24
+ font: "sans-serif", size: 32,
25
+ fillStyle: "#ffd23f", strokeStyle: "#1b1b1b", lineWidth: 6,
26
+ };
27
+ new Text(x, y, { ...style, text: "BEHIND" }); // the default
28
+ new Text(x, y, { ...style, paintOrder: "fill", text: "OVER" }); // the old look
29
+ ```
15
30
 
16
31
  The stroke used to cost you the top row of pixels as well, and a display face
17
32
  whose glyphs overshoot the nominal ascent did the same on its own: the render
@@ -29,6 +44,36 @@ If you have a label carrying a leading `\n` to buy headroom, that workaround is
29
44
  now dead weight — drop it, and take the line height back off the position you
30
45
  shifted it by.
31
46
 
47
+ The outline's corners are set by `lineJoin`, the same property a canvas
48
+ context has, and `miterLimit`:
49
+
50
+ | setting | corners | use it for |
51
+ |---|---|---|
52
+ | `lineJoin: "round"` (default) | rounded: the outline is the same width all around | almost everything: HUDs, buttons, titles |
53
+ | `lineJoin: "bevel"` | cut flat | a harder edge with no spikes |
54
+ | `lineJoin: "miter"` | pointed, as a plain canvas strokes text | a sharp, angular look |
55
+ | `miterLimit: n` (default 10) | with `"miter"` only: a corner pointing further than `n` half line widths is cut flat instead | taming the spikes `"miter"` grows on "A", "V", "M" |
56
+
57
+ At the canvas default of 10, a thick `"miter"` outline grows spikes several
58
+ times its width from sharp glyphs; 2 to 4 keeps the corners pointed and the
59
+ spikes short:
60
+
61
+ ```js
62
+ const outline = {
63
+ font: "sans-serif", size: 48,
64
+ fillStyle: "#ffffff", strokeStyle: "#000000", lineWidth: 6,
65
+ };
66
+ new Text(x, y, { ...outline, text: "ROUND" }); // the default
67
+ new Text(x, y, { ...outline, lineJoin: "bevel", text: "BEVEL" });
68
+ new Text(x, y, { ...outline, lineJoin: "miter", text: "MITER" }); // long spikes
69
+ new Text(x, y, { ...outline, lineJoin: "miter", miterLimit: 3, text: "MITER 3" });
70
+ ```
71
+
72
+ Whichever you pick, the bake leaves room for it on all four sides, so no corner
73
+ is cut and the label's position and bounds do not move; pointed corners just
74
+ cost a bigger texture. Changed on a live label, they show from the next
75
+ `setText()`.
76
+
32
77
  ## Gradient text
33
78
 
34
79
  `fillStyle` takes a `Gradient` as well as a colour — the same object
@@ -127,10 +172,58 @@ including one it is not currently holding:
127
172
  const widest = hudScore.measureText("00:00").width;
128
173
  ```
129
174
 
175
+ Given a string, it returns a new `TextMetrics` and leaves the label as it was;
176
+ with no argument it returns the label's own metrics. Before 20.13 measuring
177
+ another string overwrote the label's own metrics, so a centred or
178
+ right-aligned label was drawn shifted until its next `setText()`.
179
+
130
180
  Advance widths scale linearly with the point size for one family, so measuring
131
181
  on an existing label and scaling the result is exact. For labels you genuinely
132
182
  spawn in bulk, `getPool("text")` recycles the bake.
133
183
 
184
+ ### Multi-line text: the baseline places the whole block
185
+
186
+ A `\n` in the string (or an array of strings) starts a new line, on `Text` and
187
+ `BitmapText` alike. `textBaseline` then places the block as one piece against
188
+ `pos.y`: its top for `"top"` / `"hanging"`, its middle for `"middle"`, its
189
+ bottom for `"alphabetic"`, `"ideographic"` and `"bottom"`. So a two-line
190
+ `"bottom"` label in a screen corner sits wholly above `pos.y`, and a centred
191
+ `"middle"` label centres every line, not the first. `getBounds()` is that same
192
+ box, which is what culling and the pointer use.
193
+
194
+ `wordWrapWidth` only ADDS line breaks: each `\n`-separated line is wrapped on
195
+ its own, so explicit breaks and blank lines survive. A line breaks between
196
+ words, never inside one, so a single word wider than `wordWrapWidth` gets a
197
+ line of its own and overflows it:
198
+
199
+ ```js
200
+ const help = new Text(8, 8, {
201
+ font: "sans-serif", size: 16, wordWrapWidth: 200,
202
+ text: "CONTROLS\nArrows move, Space jumps, Esc pauses the game.",
203
+ });
204
+ // "CONTROLS" on its own line, then the sentence wrapped under it
205
+ ```
206
+
207
+ ### Typewriter reveal: `visibleRatio`
208
+
209
+ `visibleCharacters` (`-1`, the default, shows them all) and `visibleRatio`
210
+ (0 to 1) show a label a character at a time, on `Text` and `BitmapText` alike.
211
+ Tween the ratio: it takes the same time whatever the length of the string.
212
+
213
+ ```js
214
+ line.setText("The door is locked. The keeper has the key.");
215
+ line.visibleRatio = 0;
216
+ new Tween(line).to({ visibleRatio: 1 }, { duration: 1500 }).start();
217
+ ```
218
+
219
+ Line breaks are not counted, and the wrapping and the bounds are those of the
220
+ whole string, so a word does not jump to the next line as it appears. The two
221
+ classes differ in two ways. A `BitmapText` keeps each glyph where the full line
222
+ puts it, while a centred or right-aligned `Text` aligns only the part shown so
223
+ far, so it slides sideways as it types: left-align a `Text` you reveal. And a
224
+ `Text` repaints and re-uploads its bake at every step, where a `BitmapText`
225
+ just draws fewer glyphs.
226
+
134
227
  ### `fillStyle` means two different things
135
228
 
136
229
  The name is shared and the behaviour is not, which is the trap when moving a
@@ -142,7 +235,7 @@ label from one class to the other:
142
235
  | white | white glyphs | the ABSENCE of a tint |
143
236
  | gradient | yes, via `fillGradient` | no: a tint is one colour over a page image |
144
237
  | recolour it later | `label.fillStyle.parseCSS("#ff4040")` — the field is a `Color` with no setter, and ASSIGNING a string replaces it, after which the glyphs stop filling entirely and nothing is logged | `label.fillStyle = "#ff4040"` — a real setter that parses the string |
145
- | stroke | `strokeStyle` + `lineWidth` | none |
238
+ | stroke | `strokeStyle` + `lineWidth`, behind or over the glyphs with `paintOrder`, corners with `lineJoin` / `miterLimit` | none |
146
239
  | `size` | pixels | a RATIO of the authored size |
147
240
 
148
241
  ### Per-glyph wave, shake and colour: `glyphEffect`
@@ -218,10 +311,13 @@ an amplitude around an eighth of it, a wavelength around ten times it, and a
218
311
  little under 5 radians per second. For a shake, re-target each glyph roughly
219
312
  20 to 40 times a second.
220
313
 
221
- `ctx.time` starts when the effect is **assigned**, so attach it at the moment
222
- you want to be t=0 — on the frame the hit lands, not when the label was built.
223
- A label reused across lives carries the old clock, so a decay attached once
224
- will already be spent the second time.
314
+ `ctx.time` runs only while an effect is set and nothing resets it, so it is 0
315
+ only when a label gets its **first** effect. A label that already had one, or
316
+ is reused across lives, carries the old clock, and a decay measured from
317
+ `ctx.time` itself is already spent there. Note `ctx.time` on the effect's first
318
+ call and measure from that, as the shake below does, and attach it at the
319
+ moment you want to be t=0: on the frame the hit lands, not when the label was
320
+ built.
225
321
 
226
322
  Both, whole:
227
323
 
@@ -249,22 +345,28 @@ const angle = (i, tick) => {
249
345
  const h = Math.sin(i * 127.1 + tick * 311.7) * 43758.5453;
250
346
  return (h - Math.floor(h)) * Math.PI * 2;
251
347
  };
252
- const impact = (amplitude, durationMs, rate) => (out, ctx) => {
253
- const left = 1 - ctx.time / durationMs;
254
- if (left <= 0) {
255
- return; // settled; `out` is already zeroed
256
- }
257
- const amp = amplitude * left * left;
258
- const tick = (ctx.time / 1000) * rate;
259
- const held = Math.floor(tick);
260
- const k = tick - held;
261
- const a = angle(ctx.index, held);
262
- const b = angle(ctx.index, held + 1);
263
- out.offsetX = Math.round((Math.sin(a) + (Math.sin(b) - Math.sin(a)) * k) * amp);
264
- out.offsetY = Math.round((Math.cos(a) + (Math.cos(b) - Math.cos(a)) * k) * amp);
348
+ const impact = (amplitude, durationMs, rate) => {
349
+ let start; // ctx.time on the first call
350
+ return (out, ctx) => {
351
+ start ??= ctx.time; // the label's clock never resets
352
+ const t = ctx.time - start;
353
+ const left = 1 - t / durationMs;
354
+ if (left <= 0) {
355
+ return; // settled; `out` is already zeroed
356
+ }
357
+ const amp = amplitude * left * left;
358
+ const tick = (t / 1000) * rate;
359
+ const held = Math.floor(tick);
360
+ const k = tick - held;
361
+ const a = angle(ctx.index, held);
362
+ const b = angle(ctx.index, held + 1);
363
+ out.offsetX = Math.round((Math.sin(a) + (Math.sin(b) - Math.sin(a)) * k) * amp);
364
+ out.offsetY = Math.round((Math.cos(a) + (Math.cos(b) - Math.cos(a)) * k) * amp);
365
+ };
265
366
  };
266
367
 
267
- // assigned at the moment of the hit, which is what makes ctx.time start here
368
+ // a new impact at the moment of each hit: it measures from its own first call,
369
+ // so it shakes again on a banner that has shown one before
268
370
  banner.setText("CAPSIZED!");
269
371
  banner.glyphEffect = impact(3, 650, 24);
270
372
  ```
@@ -290,6 +392,68 @@ There is no `UIContainer` class. Use `Container` for a bare grouping,
290
392
  `UIBaseElement` when you want the pointer handling and nothing drawn — it is a
291
393
  `Container` that already sets `floating = true` and `isKinematic = false`.
292
394
 
395
+ ### Which widget for what
396
+
397
+ | you want | use | notes |
398
+ |---|---|---|
399
+ | a button with a label | `UITextButton` | drawn, or skinned from an atlas; `onClick` setting |
400
+ | an image-only button or icon | `UISpriteElement` | a `UIBaseElement` holding a `Sprite` (`sprite`); override `onClick` |
401
+ | an on/off option, or one of several | `UICheckbox` | a radio button with `group` |
402
+ | a value in a range: volume, sensitivity | `UISlider` | horizontal or vertical |
403
+ | health, shield, cooldown, loading | `ProgressBar` | not clickable; `value` redraws |
404
+ | a frame, dialog or menu card | `UIPanel` | skin, title, layout, scrolling, drag, shadow |
405
+ | your own clickable widget | extend `UIBaseElement` | pointer handlers, `enabled`, focus, bounce |
406
+ | a group with nothing drawn | `Container` | set `floating` for screen space |
407
+
408
+ ### A whole options menu
409
+
410
+ Everything below in one place: a skinned panel that lays its widgets out
411
+ as a column, a checkbox, a slider, two buttons, a drop shadow, a little
412
+ bounce, and keyboard / gamepad play. Each piece has its own section after
413
+ this.
414
+
415
+ ```js
416
+ import {
417
+ UICheckbox, UIPanel, UISlider, UITextButton, audio, state,
418
+ } from "melonjs";
419
+
420
+ // one style object for every widget's text
421
+ const FONT = { font: "kenpixel", fontSize: 14, labelFillStyle: "black" };
422
+
423
+ const menu = new UIPanel(40, 40, {
424
+ width: 360, height: 100, // autoSize grows the height to fit
425
+ skin: { atlas, region: "grey_panel", insetx: 10, insety: 10 },
426
+ title: "OPTIONS", titleFont: "kenpixel", titleFontSize: 20,
427
+ layout: "column", padding: 16, gap: 12, align: "stretch", autoSize: true,
428
+ shadowColor: "#00000059", shadowOffsetY: 6, shadowBlur: 10, radius: 6,
429
+ });
430
+
431
+ const music = menu.addChild(new UICheckbox(0, 0, {
432
+ ...FONT,
433
+ labelOn: "Music ON", labelOff: "Music OFF",
434
+ checked: settings.music,
435
+ onChange: (on) => { settings.music = on; }, // user input only
436
+ }));
437
+
438
+ menu.addChild(new UISlider(0, 0, {
439
+ ...FONT,
440
+ label: "Volume", showValue: true,
441
+ valueFormat: (v, ratio) => `${Math.round(ratio * 100)}%`,
442
+ value: audio.getVolume(),
443
+ onChange: (v) => audio.setVolume(v), // while dragging
444
+ onChangeEnd: () => saveSettings(), // once, on release
445
+ }));
446
+
447
+ menu.addChild(new UITextButton(0, 0, {
448
+ ...FONT, text: "Back", height: 40,
449
+ hoverScale: 1.04, pressScale: 0.96, // the bounce
450
+ onClick: () => state.change(state.MENU), // a completed click
451
+ }));
452
+
453
+ app.world.addChild(menu, 10);
454
+ music.focus(); // arrows / D-pad and Enter / A now drive the menu
455
+ ```
456
+
293
457
  Three things matter here:
294
458
 
295
459
  - **`floating = true`** opts the container out of camera transforms, so it stays
@@ -321,11 +485,19 @@ const hp = new ProgressBar(20, 20, {
321
485
  hud.addChild(hp);
322
486
 
323
487
  hp.value -= 15; // clamps; `ratio` reads back 0..1
488
+ hp.max = 50; // re-clamps the value into the new range
324
489
  ```
325
490
 
326
491
  `direction` takes `"left-to-right"` (the default), `"right-to-left"`,
327
492
  `"top-to-bottom"` or `"bottom-to-top"`, each growing from its own edge.
328
493
 
494
+ `showLabel: true` writes text centred over the bar, a percentage by default:
495
+ `` labelFormat: (value, ratio) => `${Math.round(value)} HP` `` changes what it says,
496
+ and `font` (a CSS family), `fontSize` and `labelFillStyle` how it looks.
497
+ `onChange(value, ratio, bar)` is called on every change of the value, from code
498
+ too, unlike a checkbox's or a slider's: a bar has no user input to tell apart.
499
+ Setting the value it already has calls nothing.
500
+
329
501
  **The colour follows the value by mutating the `Color` you passed.** This is
330
502
  the one non-obvious part, and it is deliberate: every game wants a different
331
503
  rule, and most want the colour somewhere else too, next to a shader uniform or
@@ -441,6 +613,118 @@ One consequence worth knowing: `getBounds()` on a bobbing panel answers
441
613
  differently each frame, so do not cache it or compare two children's bounds
442
614
  while a bob is running.
443
615
 
616
+ ### Panel art, title, dragging: `skin`, `title`, `draggable`
617
+
618
+ ```js
619
+ const options = new UIPanel(20, 20, {
620
+ width: 380, height: 460,
621
+ // one atlas region, stretched as a nine-slice to the panel's size
622
+ skin: { atlas, region: "grey_panel", insetx: 10, insety: 10 },
623
+ title: "OPTIONS", titleFont: "kenpixel", titleFontSize: 20, titleFillStyle: "black",
624
+ draggable: true, // comes to the front when grabbed
625
+ });
626
+ ```
627
+
628
+ **A drop shadow**, here or on a `ProgressBar`, takes a colour whose alpha is
629
+ how dark it gets: `shadowColor: "#00000059"`, with `shadowOffsetX` (0),
630
+ `shadowOffsetY` (4) and `shadowBlur` (8 pixels of soft edge, `0` for sharp).
631
+ There is none until `shadowColor` is set.
632
+
633
+ ```js
634
+ const card = new UIPanel(100, 80, {
635
+ width: 300, height: 180, fillColor: "#f0f0f0", radius: 8,
636
+ shadowColor: "#00000059", // about 35% black
637
+ shadowOffsetY: 6, // drops it below the card
638
+ shadowBlur: 10, // pixels of soft edge
639
+ });
640
+ const hp = new ProgressBar(20, 20, {
641
+ width: 200, height: 16, radius: 8,
642
+ shadowColor: "#00000080", shadowOffsetY: 2, shadowBlur: 4,
643
+ });
644
+ ```
645
+ It follows `radius`, which a skinned panel can set to match its art's
646
+ corners, and it is drawn outside the bounds, so it never takes the pointer.
647
+ Under a see-through fill or track it shows through; give that a solid colour.
648
+
649
+ The title sits at the top and the content starts below it. `insetx` /
650
+ `insety` are the unscaled corners of the art, and also where the frame ends:
651
+ layout `padding` is measured from there.
652
+
653
+ ### Laying a panel out: `layout`
654
+
655
+ Leave `layout` at `"none"` and children stay where you put them. Otherwise
656
+ the panel places them, below the title and inside the frame:
657
+
658
+ ```js
659
+ const menu = new UIPanel(x, y, {
660
+ width: 360, height: 100,
661
+ layout: "column", // "row", "grid" (with `columns`), or "none"
662
+ padding: 16, // from the frame to the content
663
+ gap: 12, // between children
664
+ align: "stretch", // "start", "center", "end", or "stretch"
665
+ autoSize: true, // grow the panel to fit
666
+ });
667
+ menu.addChild(new UITextButton(0, 0, { text: "Play", onClick: play }));
668
+ menu.addChild(new UITextButton(0, 0, { text: "Options", onClick: openOptions }));
669
+ ```
670
+
671
+ Children are laid out in the order they were added. It runs when a child is
672
+ added or removed and when the panel is resized, **not every frame**: a child
673
+ that changes size afterwards, a label whose text grew, needs
674
+ `menu.relayout()`. `"stretch"` resizes buttons and panels, which lay
675
+ themselves out again at a new size; anything else keeps its size.
676
+
677
+ A row inside a column is a second panel that draws nothing and lets clicks
678
+ through: `new UIPanel(0, 0, { width, height, layout: "row", blocking: false })`.
679
+
680
+ ### Scrolling lists: `scroll`
681
+
682
+ ```js
683
+ const levels = new UIPanel(420, 435, {
684
+ width: 340, height: 150,
685
+ layout: "grid", columns: 5, gap: 8, padding: 10,
686
+ scroll: "vertical", // "horizontal", "both", or "none"
687
+ onScroll: (x, y, panel) => { saved.levelScroll = y; },
688
+ });
689
+ const buttons = [];
690
+ for (let i = 1; i <= 30; i++) {
691
+ buttons[i] = levels.addChild(new UITextButton(0, 0, {
692
+ text: String(i), width: 56, height: 40,
693
+ enabled: i <= unlocked,
694
+ onClick: () => startLevel(i),
695
+ }));
696
+ }
697
+ levels.scrollTo(0, saved.levelScroll); // restore where it was
698
+ levels.scrollToChild(buttons[unlocked]); // or show the latest
699
+ ```
700
+
701
+ The content is cropped to the area inside the frame and below the title, and
702
+ scrolls with the wheel over the panel and by dragging, with momentum when
703
+ flung. A scrollbar shows while it does not fit. `scrollTo(x, y)`,
704
+ `scrollToChild(child)`, `scrollX` / `scrollY`, `maxScrollX` / `maxScrollY` and
705
+ an `onScroll(x, y, panel)` setting are there to drive it from code. Scrolling
706
+ from code stops a fling that is still coasting. `clearChildren()` empties the
707
+ list and scrolls back to the start, ready to be filled again, and `autoSize`
708
+ is ignored on a scrolling panel.
709
+
710
+ What to know:
711
+
712
+ - **Scrolling moves the children.** Their `pos` is where they are drawn, so
713
+ it changes as the list scrolls. A child added to a `"none"` layout while
714
+ scrolled is placed in content coordinates and moved into view.
715
+ - **A drag that starts on a button scrolls the list and cancels the press.**
716
+ The button gets `onCancel()` instead of `onRelease()`, and its `onClick`
717
+ SETTING is not called. The `onClick()` METHOD already ran on the press, so
718
+ a button in a list does its work in the setting: see the next section.
719
+ - **A slider in a scrolling panel is dragged, not the panel.** A widget of
720
+ your own that is dragged too, a knob or a colour picker, sets
721
+ `keepsDrag = true` to keep its drags.
722
+ - Children scrolled out of view do not get pointer events. A scrolling panel
723
+ inside another scrolls alone, and is cropped to both: a clip inside a clip
724
+ is the overlap of the two, on Canvas, WebGL and WebGPU alike.
725
+ - A draggable panel is moved by its title or its frame. A press on a widget
726
+ on it is the widget's.
727
+
444
728
  ### A pause overlay: `updateWhenPaused` cascades
445
729
 
446
730
  `state.pause()` stops every world child stepping while rendering carries on, so
@@ -460,16 +744,18 @@ it, silently. A panel whose labels never appear while paused is this.
460
744
 
461
745
  ### Buttons: extend the handlers, do not bind listeners
462
746
 
463
- `UISpriteElement` is a `Sprite` that already registers itself for pointer
464
- events, so a button is made by overriding methods rather than by wiring
465
- `registerPointerEvent`:
747
+ Every UI element already registers itself for pointer events, so a button is
748
+ made by overriding methods rather than by wiring `registerPointerEvent`. For
749
+ an image-only button that is `UISpriteElement`: a `UIBaseElement` holding a
750
+ `Sprite`, placed like a sprite (`(x, y)` is where its anchor goes, the centre
751
+ by default):
466
752
 
467
753
  | handler | fires | returns |
468
754
  |---|---|---|
469
755
  | `onClick(event)` | pressed | `false` to stop the event propagating |
470
- | `onRelease(event)` | pressed and released | `false` to stop propagating |
756
+ | `onRelease(event)` | the press ends (up, left while held, or cancelled) | `false` to stop propagating |
471
757
  | `onOver(event)` | pointer enters | `false` to stop propagating |
472
- | `onOut(event)` | pointer leaves | — |
758
+ | `onOut(event)` | pointer leaves, or the element is removed while hovered (no event then) | — |
473
759
  | `onHold()` | pressed and held | — |
474
760
 
475
761
  ```js
@@ -482,16 +768,174 @@ class MuteButton extends UISpriteElement {
482
768
  onOut() { this.setOpacity(0.5); }
483
769
  onClick() {
484
770
  audio.muteAll();
485
- return false; // consumed — do not fall through to the world
771
+ this.setRegion(atlas.getRegion("speaker_off.png")); // or this.sprite.setRegion
772
+ return false; // consumed (the default)
486
773
  }
487
774
  }
488
775
  ```
489
776
 
777
+ Undo in `onOut` whatever `onOver` set up (a tooltip, a highlight, a cursor):
778
+ it also runs when the element leaves the world while the pointer is on it,
779
+ removed on its own, with the panel holding it, or with the scene, so nothing
780
+ is left behind. Keep effects that should only follow the pointer (a sound, a
781
+ tween) out of it, or check `event`, which is `undefined` for a removal.
782
+
783
+ The image is `button.sprite`: animations, tint and regions live there
784
+ (`setRegion`, `addAnimation`, `setCurrentAnimation` and `isCurrentAnimation`
785
+ are also on the element as shortcuts). As a `UIBaseElement` it has
786
+ `enabled` (drawn at half opacity when off), `cancelPress()` / `onCancel()`,
787
+ the bounce, and focus once `focusable` is set. `pos` is the element's top
788
+ left corner, and a second sprite of your own, such as a joystick's knob, is
789
+ a child added with `addChild`, not drawn by hand in `draw()`.
790
+
791
+ **A press stops at a UI element by default.** `onClick` and `onRelease`
792
+ return `false` unless you override them, on every `UIBaseElement` (buttons,
793
+ checkboxes, sliders, sprite buttons, your own widgets): a click on
794
+ a HUD button does not also click the world behind it. Return `true` from
795
+ them to let the press through as well. Hover is different: `onOver` lets it
796
+ through unless it returns `false`. A non-blocking `UIPanel` lets a press on
797
+ its background through, as a frame that only decorates should.
798
+
490
799
  The pointer still has to reach it: a renderable with `isKinematic = true` — the
491
800
  default on a plain `Renderable` — is skipped by the broadphase and receives
492
- nothing. `UISpriteElement` and `UIBaseElement` clear it for you; anything else
801
+ nothing. Every `UIBaseElement` clears it for you; anything else
493
802
  you make clickable has to clear it itself.
494
803
 
804
+ ### A button that does one thing: `UITextButton`
805
+
806
+ No subclass needed for a single action:
807
+
808
+ ```js
809
+ const play = new UITextButton(100, 200, {
810
+ text: "Play", // a CSS font by default ("sans-serif")
811
+ fontSize: 24, // pixels, with a CSS font or a bitmap font
812
+ onClick: () => state.change(state.PLAY),
813
+ });
814
+ ```
815
+
816
+ **`onClick` is called on a COMPLETED click**: pressed and released over the
817
+ button, or its `bindKey` pressed and released. A press dragged off the button,
818
+ cancelled, or turned into a scroll does not call it, and neither does a
819
+ disabled button. The `onClick()` method is different: it runs on the press,
820
+ as before, for subclasses.
821
+
822
+ `font` is a loaded bitmap font's name, drawn with `BitmapText` as before, or
823
+ any other name, drawn as a CSS font with `Text`. The label is
824
+ `button.label` (`bitmapText`, its old name, is deprecated since 20.13.0).
825
+
826
+ With no skin it is a rounded rectangle in `hoverOffColor`, `hoverOnColor`
827
+ under the pointer and `pressedColor` while held (`hoverOnColor` when left
828
+ out), outlined in `borderColor`.
829
+
830
+ The button takes the same names as the other widgets: `width` and `height`
831
+ for its size, `fontSize` (pixels) and `labelFillStyle` for its text,
832
+ `borderColor` for its outline. The older `borderWidth` / `borderHeight`
833
+ (its SIZE, not an outline thickness), `size` (a scale with a bitmap font)
834
+ and `fillStyle` still work. One style object can then go to every widget:
835
+
836
+ ```js
837
+ const FONT = { font: "kenpixel", fontSize: 14, labelFillStyle: "black" };
838
+ new UITextButton(0, 0, { ...FONT, text: "OK", width: 160 });
839
+ new UICheckbox(0, 0, { ...FONT, label: "Music" });
840
+ new UISlider(0, 0, { ...FONT, label: "Volume" });
841
+ ```
842
+
843
+ **Skinned** from an atlas, drawn as a nine-slice so one image makes a button
844
+ of any width:
845
+
846
+ ```js
847
+ new UITextButton(0, 0, {
848
+ font: "kenpixel", fontSize: 12, text: "Accept", width: 160,
849
+ skin: { atlas, normal: "green_button04", pressed: "green_button05" },
850
+ });
851
+ ```
852
+
853
+ Pressed art is usually shorter than the normal art. The button draws it
854
+ lowered by the difference and moves the label down with it, so it looks
855
+ pushed in. `hover` and `disabled` regions are optional. `enabled`, shared by
856
+ every UI element, turns a widget off: `enabled = false`
857
+ draws the `disabled` region, or the button at half opacity, and ignores the
858
+ pointer and the key.
859
+
860
+ ### A little bounce: `hoverScale` and `pressScale`
861
+
862
+ Every `UIBaseElement`, buttons, checkboxes, sliders, panels and your own,
863
+ can grow a little under the pointer or with the focus, and shrink while held
864
+ (`ProgressBar` is not a `UIBaseElement`, and does not):
865
+
866
+ ```js
867
+ new UITextButton(0, 0, { text: "Play", hoverScale: 1.05, pressScale: 0.92 });
868
+ levelButton.hoverScale = 1.12; // or set it later
869
+ ```
870
+
871
+ Both default to 1, which is still. The scale eases there and back by itself.
872
+ Only the drawing scales, about the centre: the hit area and the layout stay
873
+ put, so nothing jitters and the widgets around it do not move.
874
+
875
+ ### Keyboard and gamepad navigation: `focus()`
876
+
877
+ For a game played without a mouse or a touch screen, give a widget the focus:
878
+
879
+ ```js
880
+ playButton.focus(); // where keyboard or gamepad play starts
881
+ ```
882
+
883
+ From then on the arrow keys or a pad's D-pad move the focus to the nearest
884
+ focusable widget that way. Enter, Space or the pad's A press the focused
885
+ widget as a click does: a button calls its `onClick` setting, a checkbox
886
+ toggles. Left and right step a slider (up and down on a vertical one). A ring
887
+ (`UIBaseElement.focusRingColor`) shows the focus, and hides while the mouse
888
+ is used. A focused widget in a scrolling panel is scrolled into view.
889
+
890
+ There is nothing to turn on. Until a widget has the focus the arrow keys and
891
+ the pad are left to the game, and `widget.blur()`, or the focused widget
892
+ leaving the scene, gives them back. `UIBaseElement.focusOwner` is the widget
893
+ with the focus. The pad is read directly, so the game's own pad bindings
894
+ are untouched.
895
+
896
+ Buttons, checkboxes and sliders are `focusable`. A widget of your own sets
897
+ `focusable = true`, and overrides `onNavigate(direction)` (return `true` for
898
+ what it handles) and `focusRelease()` for what Enter does:
899
+
900
+ ```js
901
+ // a left / right picker: "< Normal >"; Enter confirms the choice
902
+ class Picker extends UIBaseElement {
903
+ constructor(x, y, options, onPick) {
904
+ super(x, y, 200, 32);
905
+ this.focusable = true;
906
+ this.options = options;
907
+ this.onPick = onPick;
908
+ this.index = 0;
909
+ // draw() left out: render this.options[this.index]
910
+ }
911
+ onNavigate(direction) {
912
+ if (direction === "left" || direction === "right") {
913
+ const step = direction === "left" ? -1 : 1;
914
+ this.index = (this.index + step + this.options.length) % this.options.length;
915
+ this.isDirty = true;
916
+ return true; // handled: the focus stays here
917
+ }
918
+ return false; // up and down move the focus on
919
+ }
920
+ focusRelease() {
921
+ super.focusRelease(); // the widget comes back up, as on a click
922
+ this.onPick(this.options[this.index]);
923
+ }
924
+ }
925
+ ```
926
+
927
+ Moving the focus from code is `other.focus()`; `UIBaseElement.focusOwner?.blur()`
928
+ hands the keys back to the game, for example when gameplay resumes.
929
+
930
+ Three things to know. Enter and Space go to the focused widget, so another
931
+ button whose `bindKey` is Enter or Space waits while any other widget has the
932
+ focus, ring shown or hidden: one key press never clicks both. `blur()` the
933
+ focused widget to give Enter back to the shortcut (a disabled or hidden
934
+ focused widget does not keep it). Enter or A held while the window loses
935
+ focus is called off (`onCancel()`, no click), as a held `bindKey` is. And
936
+ accept calls a subclass's `onClick()` METHOD with no pointer event, as
937
+ `bindKey` does: guard `event` there, or act in the `onClick` setting.
938
+
495
939
  ### Keyboard shortcuts on a button: `bindKey`
496
940
 
497
941
  A `UITextButton` can be activated from the keyboard as well as the pointer:
@@ -524,15 +968,131 @@ Three more rules, each with a reason you can feel in a game:
524
968
  - **The first input to press owns the button.** A pointer cannot release a key
525
969
  press and a key cannot release a pointer press, so a button cannot be left
526
970
  half pressed by using both at once.
527
- - **Losing focus cancels a held key WITHOUT calling `onRelease()`.** If you
528
- use the pair as press and depress, firing while held, also stop on
529
- `event.BLUR` or the gun is still firing when the player alt-tabs back.
530
- - **`isClickable` gates the press, not the release.** Disabling a button while
531
- its key is down still releases it, so it cannot stick.
971
+ - **A held key that is called off goes to `onCancel()`, not `onRelease()`.**
972
+ That happens when the window loses focus, the button is removed, or it is
973
+ disabled (`enabled = false`) while the key is down. If you use the pair as
974
+ press and depress, firing while held, stop in `onCancel()` too, or the gun
975
+ is still firing when the player alt-tabs back.
976
+ - **`isClickable` alone gates the press, not the release.** Clearing it while
977
+ the key is down still lets the key release the button, so it cannot stick.
532
978
 
533
979
  Shortcuts live only while the button is activated, and never start dragging or
534
980
  tap-and-hold: those stay pointer gestures.
535
981
 
982
+ ### Checkboxes and radio buttons: `UICheckbox`
983
+
984
+ ```js
985
+ const music = new UICheckbox(40, 60, {
986
+ labelOn: "Music ON", labelOff: "Music OFF", // or one `label`
987
+ checked: settings.music,
988
+ onChange: (on) => { settings.music = on; on ? audio.playTrack("theme") : audio.stopTrack(); },
989
+ });
990
+ ```
991
+
992
+ The label is part of the widget: clicking it toggles the box, and removing
993
+ the checkbox takes the label with it. Drawn with primitives by default, or
994
+ `skin: { atlas, on, off, hoverOn?, hoverOff?, disabledOn?, disabledOff? }`.
995
+ `boxSize`, `boxColor`, `boxHoverColor` and `checkColor` style the drawn box,
996
+ and `labelOn` / `labelOff` can be changed later: the widget refits.
997
+
998
+ **Setting `checked` from code does NOT call `onChange`**, nor does
999
+ `toggle()`. Only the user does. Restoring saved settings into the widgets
1000
+ therefore does not re-run the game logic attached to them, so apply what
1001
+ they stand for yourself after restoring:
1002
+
1003
+ ```js
1004
+ music.checked = saved.music; // the look only
1005
+ applyMusic(saved.music); // the effect, once
1006
+ ```
1007
+
1008
+ **Radio buttons** are checkboxes sharing a `group`: exactly one is checked,
1009
+ clicking the checked one leaves it checked (so does `toggle()`; set
1010
+ `checked = false` to clear a group on purpose), and they are drawn round. Only
1011
+ the one the user picked gets `onChange`, as in a web page; read the group with
1012
+ `UICheckbox.checkedIn("difficulty")`. A checkbox is in its group from when it is
1013
+ made until it is destroyed, so the group holds one choice, and `checkedIn`
1014
+ answers, before the panel is ever added to the world.
1015
+
1016
+ ```js
1017
+ for (const level of ["Easy", "Normal", "Hard"]) {
1018
+ panel.addChild(new UICheckbox(0, 0, {
1019
+ label: level,
1020
+ group: "difficulty",
1021
+ checked: level === saved.difficulty,
1022
+ onChange: (on) => { if (on) { saved.difficulty = level; } },
1023
+ }));
1024
+ }
1025
+ // later: the checked UICheckbox of the group, or undefined
1026
+ const picked = UICheckbox.checkedIn("difficulty");
1027
+ ```
1028
+
1029
+ **Skinned** from an atlas (the region names are your art's; these are the
1030
+ UI example's):
1031
+
1032
+ ```js
1033
+ new UICheckbox(0, 0, {
1034
+ label: "Sound FX",
1035
+ skin: {
1036
+ atlas,
1037
+ on: "green_boxCheckmark",
1038
+ off: "grey_boxCheckmark",
1039
+ // optional: hoverOn, hoverOff, disabledOn, disabledOff
1040
+ },
1041
+ });
1042
+ ```
1043
+
1044
+ ### Sliders: `UISlider`
1045
+
1046
+ ```js
1047
+ const volume = new UISlider(40, 120, {
1048
+ label: "Music", showValue: true,
1049
+ valueFormat: (v, ratio) => Math.round(ratio * 100) + "%",
1050
+ min: 0, max: 1, step: 0.01, value: audio.getVolume(),
1051
+ length: 300, // orientation: "vertical" too
1052
+ onChange: (v) => audio.setVolume(v), // every step of the drag
1053
+ onChangeEnd: () => saveSettings(), // once, on release
1054
+ });
1055
+ ```
1056
+
1057
+ Drag the thumb, or click the track to jump there. The drag follows the
1058
+ pointer after it leaves the track, until it is released. As on any widget, the
1059
+ press (`onClick`) ends in exactly one of `onRelease` (let go, before
1060
+ `onChangeEnd`) or `onCancel` (called off by `cancelPress()`, `enabled = false`
1061
+ or removal, with no `onChangeEnd`). The mouse wheel over
1062
+ the slider moves it one `step`. `value` snaps to `step` and clamps to the
1063
+ range, and setting it does not call `onChange` or `onChangeEnd`: the same rule
1064
+ as the checkbox, and neither does changing `min`, `max` or `step`, which
1065
+ re-clamp it. `ratio` reads where it is from 0 to 1, and `valueFormat` gets
1066
+ both. `labelText` changes the caption later. The slider refits to its texts
1067
+ (the widest value it can show, at either end of the range), and on a
1068
+ horizontal one the value moves below the track when it would run into the
1069
+ caption; a `UIPanel` holding it needs `relayout()` if its size changes. `thumbColor`, `thumbHoverColor`, `thumbBorderColor` and
1070
+ `thumbBorderWidth` style the drawn thumb. `skin: { atlas, track, fill?, thumb, thumbHover? }` draws it
1071
+ from art: the track and fill as nine-slices along its length, the thumb at its
1072
+ own size.
1073
+
1074
+ Put work that should not run 60 times a second, such as saving or playing a
1075
+ test sound, in `onChangeEnd`, which runs once on release and after each wheel
1076
+ or key step.
1077
+
1078
+ ```js
1079
+ // whole numbers: a step of 1 shows "7", not "7.00"
1080
+ new UISlider(0, 0, { label: "Difficulty", showValue: true, min: 1, max: 10, step: 1, value: 5 });
1081
+
1082
+ // vertical, 160 pixels tall: up raises it
1083
+ new UISlider(0, 0, { orientation: "vertical", length: 160, value: 0.5 });
1084
+
1085
+ // skinned: the track and fill stretch, the thumb keeps its size
1086
+ new UISlider(0, 0, {
1087
+ length: 240,
1088
+ // optional: fill (the part before the thumb), thumbHover
1089
+ skin: { atlas, track: "grey_sliderHorizontal", thumb: "blue_sliderDown" },
1090
+ });
1091
+ ```
1092
+
1093
+ With no `valueFormat`, the value text shows as many decimals as `step`
1094
+ has, and follows a later change of `step`.
1095
+
536
1096
  ### A fullscreen option
537
1097
 
538
1098
  Three methods on the `Application`, not on `device` (they moved there in
@@ -592,6 +1152,47 @@ Fullscreen does not resize a fixed-resolution game: with
592
1152
  unchanged and only the canvas scales, so a HUD built from
593
1153
  `renderer.width`/`height` needs no repositioning.
594
1154
 
1155
+ ### Dragging: `isDraggable`, or `Draggable` and `DropTarget`
1156
+
1157
+ Any UI element follows the pointer while held once `isDraggable` is set:
1158
+ `onClick` is the pick-up, `onMove(event)` runs after each move with `pos`
1159
+ already following, and `onRelease` is the drop. It works by touch as by
1160
+ mouse, on a floating element as on one in the world. (`UIPanel` has its own
1161
+ `draggable` setting, above.)
1162
+
1163
+ ```js
1164
+ class Token extends UISpriteElement {
1165
+ constructor(x, y) {
1166
+ super(x, y, { image: atlas, region: "token" });
1167
+ this.isDraggable = true;
1168
+ }
1169
+ onRelease() {
1170
+ this.pos.x = Math.round(this.pos.x / 32) * 32; // snap to the grid
1171
+ this.pos.y = Math.round(this.pos.y / 32) * 32;
1172
+ return false;
1173
+ }
1174
+ }
1175
+ ```
1176
+
1177
+ When something else has to notice the drop, `Draggable` and `DropTarget` are
1178
+ the pair. Both are plain `Renderable`s, in the world unless you make them
1179
+ `floating`, that draw nothing until you give them a `draw()`. A `Draggable`
1180
+ follows the pointer while held (`dragStart(e)` / `dragEnd(e)` to override,
1181
+ calling `super`), and when it is let go every `DropTarget` its bounds overlap
1182
+ gets `drop(draggable)`; after `setCheckMethod(target.CHECKMETHOD_CONTAINS)`
1183
+ only one it lies wholly inside does.
1184
+
1185
+ ```js
1186
+ class Slot extends DropTarget {
1187
+ drop(card) {
1188
+ card.pos.set(this.pos.x, this.pos.y, card.pos.z); // settle it in
1189
+ }
1190
+ }
1191
+ ```
1192
+
1193
+ The drop is checked on the draggable's own pointer-up (`event.DRAGEND` comes
1194
+ from it), so the release has to land on the draggable itself.
1195
+
595
1196
  ### `z` is container-local
596
1197
 
597
1198
  `addChild(child, z)` writes a depth that means something **only among that
@@ -619,8 +1220,9 @@ keeps the four corners at their own size, and stretches only the edges and the
619
1220
  middle, so the border stays crisp at any size. Do not hand-roll it from nine
620
1221
  sprites.
621
1222
 
622
- From a texture atlas, the third argument of `createSpriteFromName` asks for the
623
- 9-slice version. This is how the UI example's draggable panel draws itself:
1223
+ `UIPanel`'s `skin` and `UITextButton`'s `skin` already do this for you. By
1224
+ hand, from a texture atlas, the third argument of `createSpriteFromName` asks
1225
+ for the 9-slice version:
624
1226
 
625
1227
  ```js
626
1228
  class Panel extends UIBaseElement {
@@ -665,14 +1267,16 @@ sprite.
665
1267
  ### An opaque panel has to say so
666
1268
 
667
1269
  The hit test asks the topmost renderable first and then keeps walking down, so
668
- consuming is what stops a widget underneath answering too. A panel that can
669
- overlap another one therefore wants both:
1270
+ consuming is what stops a widget underneath answering too. A press is consumed
1271
+ by default on a UI element, but hover is not, so a panel of your own that can
1272
+ overlap another one also stops hover (`UIPanel` does all of this with
1273
+ `blocking`):
670
1274
 
671
1275
  ```js
672
1276
  class Panel extends UIBaseElement {
673
1277
  onClick() {
674
1278
  this.ancestor.moveToTop(this); // and bring it to the front
675
- return false; // consumed
1279
+ return false; // consumed (the default)
676
1280
  }
677
1281
  onOver() {
678
1282
  return false; // the frame the pointer crosses in
@@ -721,6 +1325,34 @@ of the level instead, with the scenery drawing over it. Both shipped idioms are
721
1325
  the same rule: afterBurner's HUD sits at `-150`, and the glTF, Billboard, Night
722
1326
  City and Instanced Forest examples park a floating sky at `-10000` or `100000`.
723
1327
 
1328
+ ## When the UI misbehaves
1329
+
1330
+ | symptom | cause | fix |
1331
+ |---|---|---|
1332
+ | loading saved settings plays the music, saves again or fires game logic | the game called its own change handler while restoring | set `checkbox.checked` / `slider.value`, which never call `onChange`, then apply the setting once yourself |
1333
+ | `onChange` never fires when settings are restored | by design: only user input calls it | apply the restored value yourself |
1334
+ | a button in a scrolling list fires when the user only meant to scroll | the work is in the `onClick()` METHOD, which runs on the press | use the `onClick` SETTING, called on a completed click; a scroll cancels the press |
1335
+ | clicking a HUD button also clicks the world behind it (before 20.13) | a `UIBaseElement` let presses through by default | update: presses now stop at UI elements; a handler that returns `true` still lets them through |
1336
+ | a custom widget no longer lets clicks reach what is behind it (since 20.13) | presses stop at UI elements by default | return `true` from its `onClick` / `onRelease` |
1337
+ | a button scrolled out of a hand-made list still gets clicks | its container does not clip, so the pointer still finds it where it moved to | use `UIPanel`'s `scroll`, or set `clipping` on the container: since 20.13 the pointer skips children outside a clipping container |
1338
+ | a layout ignores a label that changed size | layout runs on add, remove and resize only | call `panel.relayout()` |
1339
+ | the arrow keys or the pad do nothing in a menu | no widget has the focus: nothing listens until one does | call `firstWidget.focus()` when the menu opens |
1340
+ | the arrow keys stop moving the player after a menu | a widget still has the focus | `UIBaseElement.focusOwner?.blur()`, or remove the menu: a focused widget leaving the scene gives the keys back |
1341
+ | a `bindKey: ENTER` button does not fire while another widget is focused | Enter and Space go to the focused widget, ring shown or not, so one press never clicks both | focus that button, `blur()` the focused one, or bind another key |
1342
+ | a tooltip or highlight shown in `onOver` stays after its widget is removed (before 20.14) | the pointer's leave never reaches a removed widget | update: a widget removed while hovered now gets `onOut()`, with no event |
1343
+ | an `onOut` sound or tween plays when a menu closes or the scene changes (since 20.14) | a widget removed while hovered gets `onOut()` | keep pointer-only effects out of `onOut`, or skip them when its `event` is `undefined` |
1344
+ | a widget removed while held gets `onCancel()` and no `onRelease()` | the pointer-up never reaches a removed widget, so the press is called off (and `hover` cleared), leaving it at rest when added back | stop what `onClick()` started in `onCancel()` as well as in `onRelease()` |
1345
+ | a `ProgressBar` fill runs past its track after `max` is lowered (before 20.13) | `min` and `max` did not re-clamp the value | update: they re-clamp it now (and call `onChange` if it moved); `ratio` stays within 0..1 |
1346
+ | a skinned panel's shadow has square corners | the shadow follows `radius`, and a skin does not set it | set `radius` to the art's corner size |
1347
+ | a wrapped label lost its `\n` line breaks, or starts with a blank line (before 20.13) | word wrap joined the lines with spaces, and closed an empty line before an over-wide first word | update: each line is now wrapped on its own and an over-wide word starts the first line |
1348
+ | the second line of a multi-line `"bottom"` or `"middle"` `Text` is drawn below where you put it, or outside its bounds (before 20.13) | `Text` placed only the first line by its baseline | update: the whole block is placed by the baseline, as `BitmapText` does |
1349
+ | `setText("1,000")` leaves a label showing `["1", "000"]` on two lines, or the other way round (before 20.13) | the change test compared `toString()`, and an array's joins with commas | update |
1350
+ | a centred or right-aligned label jumps sideways, or is clipped, after `label.measureText("…")` (before 20.13) | measuring another string overwrote the label's own metrics until its next `setText()` | update, or measure on a label that is not displayed |
1351
+ | `new UITextButton` with a font name draws in a plain font | the name is not a loaded bitmap font, so it is drawn as a CSS font | load the bitmap font (image and `.fnt`) under that name, or load the web font |
1352
+ | `UI skin: no region named "…" in the atlas` thrown where a widget is made | every region a `skin` names is looked up when the widget is built | fix the name, or re-export the atlas with that image |
1353
+ | a typewriter reveal on a centred `Text` slides sideways as it types | a `Text` aligns only the part shown so far | left-align it, or reveal a `BitmapText`, which keeps each glyph in its final place |
1354
+ | a `glyphEffect` decay or intro plays the first time and never again | `ctx.time` runs while any effect is set and never resets, so a later effect starts late | note `ctx.time` on the effect's first call and measure from that |
1355
+
724
1356
  ## Related skills
725
1357
 
726
1358
  - `melonjs-scenes-and-state` — `Tween` and the easing families, for animating any of this