@lute-lang/player-web 0.40.0 → 0.41.1
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.
- package/README.md +10 -4
- package/dist/audio/pixiAudio.d.ts +17 -1
- package/dist/effects/catalogue.d.ts +4 -1
- package/dist/index.js +63 -15
- package/dist/presenter/pixiPresenter.d.ts +9 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @lute-lang/player-web
|
|
2
2
|
|
|
3
|
-
The Lute 0.
|
|
3
|
+
The Lute 0.41 browser visual-novel player. It supplies PixiJS 8 rendering, Web Audio, screens, and keyboard/mouse/touch/gamepad input for the renderer-free `@lute-lang/player` core.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
|
-
bun add @lute-lang/player-web@0.
|
|
9
|
-
# npm install @lute-lang/player-web@0.
|
|
8
|
+
bun add @lute-lang/player-web@0.41.1 @lute-lang/player@0.41.1 @lute-lang/runtime@0.41.1 effect@4.0.2 @effect/platform-browser@4.0.2 pixi.js@8.22.0 @pixi/sound@6.0.1
|
|
9
|
+
# npm install @lute-lang/player-web@0.41.1 @lute-lang/player@0.41.1 @lute-lang/runtime@0.41.1 effect@4.0.2 @effect/platform-browser@4.0.2 pixi.js@8.22.0 @pixi/sound@6.0.1
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
The package requires exact `effect@4.0.2` and `@effect/platform-browser@4.0.2` peers. Runtime and player packages are released in lockstep.
|
|
@@ -32,7 +32,13 @@ const view = await player.view()
|
|
|
32
32
|
await player.unmount()
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
`bindings` accepts a decoded `Bindings` object, YAML text, or `{ url }`. `assetsUrl` overrides the file's `assetsBase`; `host`
|
|
35
|
+
`bindings` accepts a decoded `Bindings` object, YAML text, or `{ url }`. `assetsUrl` overrides the file's `assetsBase`; `host` is the core's `PlayerHost`: `bridge` handlers return a `BridgeAnswer` (`{ fields, writes? }`), `plugin` handlers run as waiting cues, and `idle` answers idle waits. `mountPlayer` returns Promise methods `act`, `view`, and `unmount`. The Effect-native `LuteWeb.mount` is available when the application already manages an Effect scope; its handle is the core's `LutePlayerHandle` (`actions`, `view`, `send`, `restore`, `shutdown`, plus the runtime `session` and its `program`).
|
|
36
|
+
|
|
37
|
+
## Cue handling
|
|
38
|
+
|
|
39
|
+
- **Names.** The dialogue name plate and backlog rows show `speakerName`, the cast display name from the bundle index (`cast: { id: name }`); name colours stay keyed by the speaker id.
|
|
40
|
+
- **Music.** `PixiAudio` honours `AudioPlay.restart`: `restart: true` plays the file from its start; without it, the music file already playing only changes volume, fading over `fade`, and a different file crossfades.
|
|
41
|
+
- **Custom vfx.** A `StageCue.Vfx` whose `effect.effect` is `"custom"` (a project vfx bound as `{ effect: custom }`, or a vfx type with no binding) has no Pixi effect: the presenter plays nothing and logs `effect.name` once at debug level (`PresenterOptions.debug`, default `console.debug`). Render project effects with your own `Presenter`.
|
|
36
42
|
|
|
37
43
|
## API and guides
|
|
38
44
|
|
|
@@ -43,7 +43,11 @@ export declare class PixiAudio implements AudioPort {
|
|
|
43
43
|
private currentVolumes;
|
|
44
44
|
private readonly gate;
|
|
45
45
|
constructor(options: PixiAudioOptions);
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Play a channel sound, waiting for finite sounds to end. Music without
|
|
48
|
+
* `restart` whose file is the one already playing only changes its volume,
|
|
49
|
+
* fading over `fade`, without restarting it (spec 0.41.0 §7.7).
|
|
50
|
+
*/
|
|
47
51
|
readonly play: (channel: AudioChannel, soundToPlay: AudioPlay) => Effect.Effect<void>;
|
|
48
52
|
/** Fade and stop every playback currently in a channel. */
|
|
49
53
|
readonly stop: (channel: AudioChannel, fade: number) => Effect.Effect<void>;
|
|
@@ -52,7 +56,19 @@ export declare class PixiAudio implements AudioPort {
|
|
|
52
56
|
private effectiveVolume;
|
|
53
57
|
private removeRecord;
|
|
54
58
|
private readonly interruptFade;
|
|
59
|
+
private applyVolume;
|
|
60
|
+
/** Fade `record`'s live factor to `to` over `duration` seconds, starting from wherever it is now. */
|
|
55
61
|
private readonly startFade;
|
|
62
|
+
/**
|
|
63
|
+
* Step `record` through `advance(progress)` over `duration` seconds on a
|
|
64
|
+
* detached fiber, applying its volume after each step.
|
|
65
|
+
*/
|
|
66
|
+
private readonly ramp;
|
|
67
|
+
/**
|
|
68
|
+
* Move a playing record to a new input volume, fading over `fade` seconds
|
|
69
|
+
* from its current level (finishing an interrupted fade-in); it keeps playing.
|
|
70
|
+
*/
|
|
71
|
+
private readonly changeVolume;
|
|
56
72
|
private readonly fadeOutAndStop;
|
|
57
73
|
private readonly stopVoiceImmediately;
|
|
58
74
|
private readonly replaceMusic;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
2
|
import { Container } from "pixi.js";
|
|
3
3
|
import type { Behaviour } from "../engine/types.js";
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Names in the player effect catalogue. `custom` (a project vfx, spec 0.41.0
|
|
6
|
+
* §7.7) is not one: the presenter handles it before reaching the catalogue.
|
|
7
|
+
*/
|
|
5
8
|
export type EffectName = "fade" | "dissolve" | "wipeLeft" | "wipeRight" | "wipeUp" | "wipeDown" | "slideLeft" | "slideRight" | "cut" | "fadeIn" | "fadeOut" | "slideIn" | "slideOut" | "hop" | "shake" | "none" | "zoom" | "pan" | "flash" | "blur" | "tint";
|
|
6
9
|
/** Values accepted in a binding effect parameter object. */
|
|
7
10
|
export type EffectParams = Readonly<Record<string, number | string | boolean>>;
|
package/dist/index.js
CHANGED
|
@@ -1328,6 +1328,13 @@ class PixiAudio {
|
|
|
1328
1328
|
if (!SubscriptionRef2.getUnsafe(self.unlocked)) {
|
|
1329
1329
|
yield* Deferred2.await(self.gate);
|
|
1330
1330
|
}
|
|
1331
|
+
if (channel === "music" && !soundToPlay.restart) {
|
|
1332
|
+
const playing = self.active.music.filter((record2) => !record2.stopping).at(-1);
|
|
1333
|
+
if (playing !== undefined && playing.file === soundToPlay.file) {
|
|
1334
|
+
yield* self.changeVolume(playing, clamp2(soundToPlay.volume), soundToPlay.fade);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1331
1338
|
const loaded = yield* self.backend.load(soundToPlay.file).pipe(Effect6.catch((error) => {
|
|
1332
1339
|
self.logger(`Audio file missing or undecodable: ${soundToPlay.file}`, error);
|
|
1333
1340
|
return Effect6.fail(error);
|
|
@@ -1350,14 +1357,17 @@ class PixiAudio {
|
|
|
1350
1357
|
yield* self.replaceMusic(soundToPlay.fade);
|
|
1351
1358
|
const record = {
|
|
1352
1359
|
channel,
|
|
1360
|
+
file: soundToPlay.file,
|
|
1353
1361
|
inputVolume,
|
|
1362
|
+
factor: positive(soundToPlay.fade) > 0 ? 0 : 1,
|
|
1354
1363
|
playback: playback.value,
|
|
1355
|
-
fade: undefined
|
|
1364
|
+
fade: undefined,
|
|
1365
|
+
stopping: false
|
|
1356
1366
|
};
|
|
1357
1367
|
self.active[channel].push(record);
|
|
1358
|
-
|
|
1368
|
+
self.applyVolume(record);
|
|
1359
1369
|
if (positive(soundToPlay.fade) > 0) {
|
|
1360
|
-
record.fade = yield* self.startFade(record,
|
|
1370
|
+
record.fade = yield* self.startFade(record, 1, soundToPlay.fade, false);
|
|
1361
1371
|
}
|
|
1362
1372
|
if (!soundToPlay.loop) {
|
|
1363
1373
|
yield* record.playback.ended;
|
|
@@ -1369,8 +1379,10 @@ class PixiAudio {
|
|
|
1369
1379
|
const records = self.active[channel].slice();
|
|
1370
1380
|
if (records.length === 0)
|
|
1371
1381
|
return;
|
|
1372
|
-
for (const record of records)
|
|
1382
|
+
for (const record of records) {
|
|
1383
|
+
record.stopping = true;
|
|
1373
1384
|
yield* self.interruptFade(record);
|
|
1385
|
+
}
|
|
1374
1386
|
const duration = positive(fade);
|
|
1375
1387
|
if (duration === 0) {
|
|
1376
1388
|
for (const record of records) {
|
|
@@ -1389,9 +1401,8 @@ class PixiAudio {
|
|
|
1389
1401
|
voice: clamp2(volumes.voice)
|
|
1390
1402
|
};
|
|
1391
1403
|
for (const channel of ["music", "sfx", "voice"]) {
|
|
1392
|
-
for (const record of this.active[channel])
|
|
1393
|
-
|
|
1394
|
-
}
|
|
1404
|
+
for (const record of this.active[channel])
|
|
1405
|
+
this.applyVolume(record);
|
|
1395
1406
|
}
|
|
1396
1407
|
});
|
|
1397
1408
|
effectiveVolume(channel, inputVolume) {
|
|
@@ -1406,22 +1417,49 @@ class PixiAudio {
|
|
|
1406
1417
|
interruptFade = (record) => record.fade === undefined ? Effect6.void : Fiber2.interrupt(record.fade).pipe(Effect6.asVoid, Effect6.tap(() => Effect6.sync(() => {
|
|
1407
1418
|
record.fade = undefined;
|
|
1408
1419
|
})));
|
|
1409
|
-
|
|
1420
|
+
applyVolume(record) {
|
|
1421
|
+
record.playback.setVolume(this.effectiveVolume(record.channel, record.inputVolume) * record.factor);
|
|
1422
|
+
}
|
|
1423
|
+
startFade = (record, to, duration, stopAtEnd) => {
|
|
1424
|
+
const from = record.factor;
|
|
1425
|
+
return this.ramp(record, duration, (progress) => {
|
|
1426
|
+
record.factor = from + (to - from) * progress;
|
|
1427
|
+
}, stopAtEnd);
|
|
1428
|
+
};
|
|
1429
|
+
ramp = (record, duration, advance, stopAtEnd) => {
|
|
1410
1430
|
const steps = Math.max(1, Math.ceil(duration * 20));
|
|
1411
1431
|
const interval = duration / steps;
|
|
1412
1432
|
const fade = Effect6.forEach(Array.from({ length: steps }, (_, index) => index + 1), (step) => Effect6.sleep(interval).pipe(Effect6.flatMap(() => Effect6.sync(() => {
|
|
1413
|
-
|
|
1414
|
-
|
|
1433
|
+
advance(step / steps);
|
|
1434
|
+
this.applyVolume(record);
|
|
1415
1435
|
}))), { discard: true }).pipe(Effect6.flatMap(() => Effect6.sync(() => {
|
|
1416
|
-
|
|
1436
|
+
advance(1);
|
|
1437
|
+
this.applyVolume(record);
|
|
1417
1438
|
if (stopAtEnd)
|
|
1418
1439
|
record.playback.stop();
|
|
1419
1440
|
})));
|
|
1420
1441
|
return Effect6.forkDetach(fade);
|
|
1421
1442
|
};
|
|
1443
|
+
changeVolume = (record, inputVolume, fade) => Effect6.gen({ self: this }, function* () {
|
|
1444
|
+
const self = this;
|
|
1445
|
+
yield* self.interruptFade(record);
|
|
1446
|
+
const duration = positive(fade);
|
|
1447
|
+
if (duration === 0) {
|
|
1448
|
+
record.inputVolume = inputVolume;
|
|
1449
|
+
record.factor = 1;
|
|
1450
|
+
self.applyVolume(record);
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
const fromVolume = record.inputVolume;
|
|
1454
|
+
const fromFactor = record.factor;
|
|
1455
|
+
record.fade = yield* self.ramp(record, duration, (progress) => {
|
|
1456
|
+
record.inputVolume = fromVolume + (inputVolume - fromVolume) * progress;
|
|
1457
|
+
record.factor = fromFactor + (1 - fromFactor) * progress;
|
|
1458
|
+
}, false);
|
|
1459
|
+
});
|
|
1422
1460
|
fadeOutAndStop = (record, duration) => Effect6.gen({ self: this }, function* () {
|
|
1423
1461
|
const self = this;
|
|
1424
|
-
const fiber = yield* self.startFade(record,
|
|
1462
|
+
const fiber = yield* self.startFade(record, 0, duration, true);
|
|
1425
1463
|
yield* Fiber2.join(fiber);
|
|
1426
1464
|
self.removeRecord(record);
|
|
1427
1465
|
});
|
|
@@ -1438,12 +1476,13 @@ class PixiAudio {
|
|
|
1438
1476
|
const self = this;
|
|
1439
1477
|
const old = self.active.music.slice();
|
|
1440
1478
|
for (const record of old) {
|
|
1479
|
+
record.stopping = true;
|
|
1441
1480
|
yield* self.interruptFade(record);
|
|
1442
1481
|
if (positive(fade) === 0) {
|
|
1443
1482
|
record.playback.stop();
|
|
1444
1483
|
self.removeRecord(record);
|
|
1445
1484
|
} else {
|
|
1446
|
-
const fiber = yield* self.startFade(record,
|
|
1485
|
+
const fiber = yield* self.startFade(record, 0, fade, true);
|
|
1447
1486
|
record.fade = fiber;
|
|
1448
1487
|
yield* Effect6.forkDetach(Fiber2.join(fiber).pipe(Effect6.flatMap(() => Effect6.sync(() => self.removeRecord(record)))));
|
|
1449
1488
|
}
|
|
@@ -2875,6 +2914,7 @@ class PixiPresenter {
|
|
|
2875
2914
|
bindings;
|
|
2876
2915
|
theme;
|
|
2877
2916
|
logger;
|
|
2917
|
+
debug;
|
|
2878
2918
|
actors = new Map;
|
|
2879
2919
|
background;
|
|
2880
2920
|
cg;
|
|
@@ -2885,6 +2925,7 @@ class PixiPresenter {
|
|
|
2885
2925
|
choice;
|
|
2886
2926
|
choiceActions;
|
|
2887
2927
|
missing = new Set;
|
|
2928
|
+
customVfx = new Set;
|
|
2888
2929
|
uiHidden = false;
|
|
2889
2930
|
constructor(options) {
|
|
2890
2931
|
this.pixi = options.pixi;
|
|
@@ -2894,6 +2935,7 @@ class PixiPresenter {
|
|
|
2894
2935
|
this.bindings = options.bindings;
|
|
2895
2936
|
this.theme = options.theme;
|
|
2896
2937
|
this.logger = options.logger ?? ((message, error) => console.warn(message, error));
|
|
2938
|
+
this.debug = options.debug ?? ((message) => console.debug(message));
|
|
2897
2939
|
const design = this.pixi.design;
|
|
2898
2940
|
this.background = new Background(this.pixi.layers.background, this.world, this.bindings, this.assets, design, this.theme, (message) => this.logMissing(message));
|
|
2899
2941
|
this.cg = new CgLayer(this.pixi.layers.cg, this.world, this.bindings, this.assets, design, this.theme, (message) => this.logMissing(message));
|
|
@@ -2971,7 +3013,7 @@ class PixiPresenter {
|
|
|
2971
3013
|
return Effect17.sync(() => this.camera.apply(cue.framing, cue.focus)).pipe(Effect17.flatMap(() => this.camera.move(cue.move ?? cue.transition, cue.duration, instant)));
|
|
2972
3014
|
}
|
|
2973
3015
|
case "Vfx":
|
|
2974
|
-
return this.vfx.play(cue.effect, cue.duration, instant);
|
|
3016
|
+
return cue.effect.effect === "custom" ? this.logCustomVfx(cue.effect.name ?? "") : this.vfx.play(cue.effect, cue.duration, instant);
|
|
2975
3017
|
}
|
|
2976
3018
|
};
|
|
2977
3019
|
upsertActor = (actor, action, instant, duration) => {
|
|
@@ -3008,6 +3050,12 @@ class PixiPresenter {
|
|
|
3008
3050
|
this.missing.add(message);
|
|
3009
3051
|
this.logger(message);
|
|
3010
3052
|
};
|
|
3053
|
+
logCustomVfx = (name) => Effect17.sync(() => {
|
|
3054
|
+
if (this.customVfx.has(name))
|
|
3055
|
+
return;
|
|
3056
|
+
this.customVfx.add(name);
|
|
3057
|
+
this.debug(`Custom vfx "${name}" has no Pixi effect; nothing is played`);
|
|
3058
|
+
});
|
|
3011
3059
|
}
|
|
3012
3060
|
|
|
3013
3061
|
// src/input/map.ts
|
|
@@ -3319,7 +3367,7 @@ var runMount = (element, options, bindings, assetsBase) => {
|
|
|
3319
3367
|
})));
|
|
3320
3368
|
if (title !== undefined && title !== null) {
|
|
3321
3369
|
const file = resolveAsset(bindings, assetsBase, title.music);
|
|
3322
|
-
yield* Effect19.forkScoped(audio.play("music", { file, loop: true, volume: 1, fade: 0 }).pipe(Effect19.catch(() => Effect19.void)));
|
|
3370
|
+
yield* Effect19.forkScoped(audio.play("music", { file, loop: true, volume: 1, fade: 0, restart: true }).pipe(Effect19.catch(() => Effect19.void)));
|
|
3323
3371
|
}
|
|
3324
3372
|
return player;
|
|
3325
3373
|
});
|
|
@@ -16,6 +16,8 @@ export interface PresenterOptions {
|
|
|
16
16
|
readonly theme: Theme;
|
|
17
17
|
readonly titleBackground?: string;
|
|
18
18
|
readonly logger?: (message: string, error?: unknown) => void;
|
|
19
|
+
/** Debug-level log; defaults to `console.debug`. Receives one line per custom vfx name. */
|
|
20
|
+
readonly debug?: (message: string) => void;
|
|
19
21
|
}
|
|
20
22
|
/** Pixi implementation of the renderer-free player Presenter port. */
|
|
21
23
|
/** Presenter service key. */
|
|
@@ -43,6 +45,7 @@ export declare class PixiPresenter implements Presenter {
|
|
|
43
45
|
private readonly bindings;
|
|
44
46
|
private readonly theme;
|
|
45
47
|
private readonly logger;
|
|
48
|
+
private readonly debug;
|
|
46
49
|
private readonly actors;
|
|
47
50
|
private readonly background;
|
|
48
51
|
private readonly cg;
|
|
@@ -53,6 +56,7 @@ export declare class PixiPresenter implements Presenter {
|
|
|
53
56
|
private readonly choice;
|
|
54
57
|
private readonly choiceActions;
|
|
55
58
|
private readonly missing;
|
|
59
|
+
private readonly customVfx;
|
|
56
60
|
private uiHidden;
|
|
57
61
|
constructor(options: PresenterOptions);
|
|
58
62
|
private defaultSettings;
|
|
@@ -62,5 +66,10 @@ export declare class PixiPresenter implements Presenter {
|
|
|
62
66
|
private readonly restoreStage;
|
|
63
67
|
private readonly showChoiceInternal;
|
|
64
68
|
private readonly logMissing;
|
|
69
|
+
/**
|
|
70
|
+
* A custom vfx (spec 0.41.0 §7.7) has no catalogue effect here: the Pixi
|
|
71
|
+
* presenter plays nothing and logs each name once at debug level.
|
|
72
|
+
*/
|
|
73
|
+
private readonly logCustomVfx;
|
|
65
74
|
}
|
|
66
75
|
export declare const presenterLayer: (bindings: Bindings, theme: Theme, assets: TextureAssets) => Layer.Layer<PixiPresenter, never, PixiApp | UiShape | World>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lute-lang/player-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.1",
|
|
4
4
|
"description": "The Lute web visual-novel player: PixiJS 8 renderer, Web Audio, screens and input over @lute-lang/player.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"demo": "bun scripts/demo.ts"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@lute-lang/player": "0.
|
|
32
|
-
"@lute-lang/runtime": "0.
|
|
31
|
+
"@lute-lang/player": "0.41.1",
|
|
32
|
+
"@lute-lang/runtime": "0.41.1",
|
|
33
33
|
"pixi.js": "8.22.0",
|
|
34
34
|
"@pixi/sound": "6.0.1"
|
|
35
35
|
},
|