lecodes-cli 0.18.1 → 0.19.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.
@@ -7,12 +7,17 @@ import { appEventsOn, appEventsOff } from "./appEvents"
7
7
 
8
8
  type ResizeCallback = (width: number, height: number) => void
9
9
 
10
+ type HdrChangeCallback = (headroom: number) => void
11
+
10
12
  type DeviceEventMap = {
11
13
  resize: ResizeCallback
12
14
  /** Connectivity came back (best-effort, navigator.onLine semantics). */
13
15
  online: () => void
14
16
  /** Connectivity was lost. */
15
17
  offline: () => void
18
+ /** The headroom the 3D engine renders to moved (`device.hdr.headroom`): the display ramped up to
19
+ * its peak after launch, brightness changed, the window went to another screen. */
20
+ hdrchange: HdrChangeCallback
16
21
  }
17
22
 
18
23
  const resizeListeners: ResizeCallback[] = []
@@ -22,6 +27,25 @@ const onResize = (width: number, height: number): void => {
22
27
  for (const cb of resizeListeners.slice()) cb(width, height)
23
28
  }
24
29
 
30
+ // --- device.hdr state ---
31
+ // The 3D bridge is a free global that a 2D-only bundle never defines — reach it defensively. Hosts
32
+ // without the methods are SDR by definition; the look setters are remembered so the getters read
33
+ // back what the app asked for.
34
+ const gl = (): typeof _creator | undefined => (typeof _creator !== "undefined" ? _creator : undefined)
35
+ let hdrStrengthLocal = 0.35
36
+ let hdrPaperWhiteLocal = 1
37
+ // `hdrchange` is a poll: the engine quantises the headroom to half-stops, so a change is rare and
38
+ // a 4 Hz timer (only while someone listens) is cheaper than a per-frame bridge call.
39
+ const hdrListeners: HdrChangeCallback[] = []
40
+ let hdrPollTimer: ReturnType<typeof setInterval> | undefined
41
+ let hdrLastHeadroom = 1
42
+ const pollHdr = (): void => {
43
+ const h = device.hdr.headroom
44
+ if (h === hdrLastHeadroom) return
45
+ hdrLastHeadroom = h
46
+ for (const cb of hdrListeners.slice()) cb(h)
47
+ }
48
+
25
49
  /** Semantic haptic styles for `device.vibrate`. Impact styles (`light`/`medium`/`heavy`/`soft`/
26
50
  * `rigid`) are a physical "tap" of varying weight; notification styles (`success`/`warning`/`error`)
27
51
  * cue an outcome; `selection` is a light tick for a value change. Chosen to map 1:1 onto iOS
@@ -66,7 +90,10 @@ const orientedAttitude = (s: ArrayLike<number>): Quat => {
66
90
  }
67
91
 
68
92
  export const device = {
69
- get platform(): "web" | "android" | "ios" | string {
93
+ /** Which INPUT MODEL the app is running under — the thing to branch on when a build needs
94
+ * mouse-look instead of on-screen sticks. It is not the OS name: a Mac reports `"desktop"`,
95
+ * like Windows and Linux do (the OS/version detail is host-side, not here). */
96
+ get platform(): "web" | "android" | "ios" | "desktop" | string {
70
97
  return _creatorUtils.platform
71
98
  },
72
99
  get language(): string {
@@ -162,6 +189,63 @@ export const device = {
162
189
  return new Vec3(g.x, -g.y, g.z)
163
190
  },
164
191
  },
192
+ /** The display's extended dynamic range — HDR. On a screen with headroom above SDR white (Apple's
193
+ * XDR panels; the macOS host today) the 3D surface is float and the engine renders past 1.0:
194
+ * 1.0 is the white of the UI and of a diffuse white surface, and the sun, emissives and speculars
195
+ * climb up to `headroom` times that. Everywhere else it is SDR: `available` false, `headroom` 1,
196
+ * the look setters remembered but invisible.
197
+ *
198
+ * Branch content on `available`, not on `headroom` — the live value ramps up from 1 over the
199
+ * first seconds after launch and follows the brightness keys. Particle `emissive` is the thing
200
+ * to raise: it is in post-exposure units (1 = white on screen) and everything above that clips on
201
+ * SDR, so an HDR display is the only place a fireball peaking at 8 reads as one. */
202
+ hdr: {
203
+ /** Whether the surface can show anything above SDR white at all (`maxHeadroom > 1`). */
204
+ get available(): boolean {
205
+ return device.hdr.maxHeadroom > 1
206
+ },
207
+ /** The peak the display can reach, as a multiple of SDR white (a 1600-nit XDR panel reports up
208
+ * to 16). Constant for the surface; 1 on SDR. */
209
+ get maxHeadroom(): number {
210
+ const v = gl()?.getDisplayMaxHeadroom?.() ?? 1
211
+ return v > 1 ? v : 1
212
+ },
213
+ /** The headroom the engine renders to right now — the screen's current peak as a multiple of SDR
214
+ * white, quantised to half-stops. Moves with brightness and the screen under the window
215
+ * (`hdrchange` event); never below 1. */
216
+ get headroom(): number {
217
+ const v = gl()?.getDisplayHeadroom?.() ?? 1
218
+ return v > 1 ? v : 1
219
+ },
220
+ /** How much of the picture reaches for the headroom, 0..1 (default 0.35). 0 touches only what
221
+ * SDR clipped — faithful to the SDR grade, but a mostly-mid-tone frame then looks flat next to
222
+ * the one bright spot; 1 lifts nearly everything above black toward the peak. A sunlit exterior
223
+ * takes 1, a dim interior wants less. A host may pin it (`LECODES_HDR_STRENGTH`). */
224
+ get strength(): number {
225
+ return gl()?.getHdrStrength?.() ?? hdrStrengthLocal
226
+ },
227
+ set strength(v: number) {
228
+ const s = Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.35
229
+ hdrStrengthLocal = s
230
+ gl()?.setHdrStrength?.(s)
231
+ },
232
+ /** Where white lands, as a multiple of SDR white, 1..8 (default 1) — the "HDR brightness" of a
233
+ * console game's calibration screen, where paper white sits at ~200 nits against SDR's ~100.
234
+ * It scales the WHOLE picture: at 1 a white wall is as bright as the UI's white, at 2 it is
235
+ * twice that, and highlights still climb above it (the engine keeps `headroom / paperWhite` for
236
+ * them, clamped at the peak). `strength` decides how much of the picture reaches for the
237
+ * peak; this decides where the picture starts. 1.5–2 is the console norm; a game that wants to
238
+ * read as "HDR on" rather than "SDR with a brighter sun" wants this over `strength`. A host may
239
+ * pin it (`LECODES_HDR_PAPER_WHITE`). */
240
+ get paperWhite(): number {
241
+ return gl()?.getHdrPaperWhite?.() ?? hdrPaperWhiteLocal
242
+ },
243
+ set paperWhite(v: number) {
244
+ const p = Number.isFinite(v) ? Math.min(8, Math.max(1, v)) : 1
245
+ hdrPaperWhiteLocal = p
246
+ gl()?.setHdrPaperWhite?.(p)
247
+ },
248
+ },
165
249
  /** Current connectivity — best-effort navigator.onLine semantics: `false` only when the platform
166
250
  * is sure there's no network. `true` on hosts that don't track it. Change events: `"online"` /
167
251
  * `"offline"`. */
@@ -173,6 +257,14 @@ export const device = {
173
257
  appEventsOn(channel, callback as () => void)
174
258
  return
175
259
  }
260
+ if (channel === "hdrchange") {
261
+ hdrListeners.push(callback as HdrChangeCallback)
262
+ if (hdrPollTimer === undefined) {
263
+ hdrLastHeadroom = device.hdr.headroom
264
+ hdrPollTimer = setInterval(pollHdr, 250)
265
+ }
266
+ return
267
+ }
176
268
  if (channel !== "resize") return
177
269
  resizeListeners.push(callback as ResizeCallback)
178
270
  if (!resizeRegistered) {
@@ -185,6 +277,15 @@ export const device = {
185
277
  appEventsOff(channel, callback as () => void)
186
278
  return
187
279
  }
280
+ if (channel === "hdrchange") {
281
+ const i = hdrListeners.indexOf(callback as HdrChangeCallback)
282
+ if (i >= 0) hdrListeners.splice(i, 1)
283
+ if (hdrListeners.length === 0 && hdrPollTimer !== undefined) {
284
+ clearInterval(hdrPollTimer)
285
+ hdrPollTimer = undefined
286
+ }
287
+ return
288
+ }
188
289
  if (channel !== "resize") return
189
290
  const i = resizeListeners.indexOf(callback as ResizeCallback)
190
291
  if (i >= 0) resizeListeners.splice(i, 1)