@jgengine/react 0.7.0 → 0.9.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.
- package/CHANGELOG.md +63 -0
- package/dist/chat.d.ts +52 -0
- package/dist/chat.js +68 -0
- package/dist/chatBubbles.d.ts +15 -0
- package/dist/chatBubbles.js +45 -0
- package/dist/components.d.ts +1 -0
- package/dist/components.js +35 -22
- package/dist/display.d.ts +14 -0
- package/dist/display.js +45 -0
- package/dist/dragLayer.d.ts +6 -1
- package/dist/dragLayer.js +64 -31
- package/dist/engineStore.d.ts +1 -1
- package/dist/engineStore.js +10 -2
- package/dist/fogOverlay.d.ts +21 -0
- package/dist/fogOverlay.js +34 -0
- package/dist/gameIcons.d.ts +12 -0
- package/dist/gameIcons.js +282 -0
- package/dist/hooks.d.ts +59 -2
- package/dist/hooks.js +170 -12
- package/dist/hudLayout.d.ts +61 -0
- package/dist/hudLayout.js +488 -0
- package/dist/hudViewport.d.ts +21 -0
- package/dist/hudViewport.js +17 -0
- package/dist/identity.d.ts +65 -0
- package/dist/identity.js +94 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/liveBind.d.ts +4 -10
- package/dist/liveBind.js +28 -16
- package/dist/map.d.ts +27 -7
- package/dist/map.js +152 -43
- package/dist/preview.d.ts +6 -0
- package/dist/preview.js +1 -0
- package/dist/selectSnapshot.d.ts +7 -0
- package/dist/selectSnapshot.js +16 -0
- package/dist/settings.d.ts +75 -0
- package/dist/settings.js +61 -0
- package/dist/skillCheckPaint.d.ts +4 -0
- package/dist/skillCheckPaint.js +28 -0
- package/dist/social.d.ts +108 -0
- package/dist/social.js +119 -0
- package/dist/voice.d.ts +58 -0
- package/dist/voice.js +130 -0
- package/llms.txt +1633 -0
- package/package.json +11 -6
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type SettingCategory, type SettingKind, type SettingOption, type SettingsStore, type SettingsSurface, type SettingsVariant, type SettingValue } from "@jgengine/core/settings/settingsModel";
|
|
3
|
+
export declare function SettingsProvider({ store, children }: {
|
|
4
|
+
store: SettingsStore;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
7
|
+
/** The shared settings store, or a standalone one when no provider is mounted (game code read outside the shell). */
|
|
8
|
+
export declare function useSettingsStore(): SettingsStore;
|
|
9
|
+
/** Read + write one persisted setting; re-renders when the value changes anywhere. */
|
|
10
|
+
export declare function useSetting<T extends SettingValue>(id: string, fallback: T): readonly [T, (value: SettingValue) => void];
|
|
11
|
+
export interface SettingsRow {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
kind: SettingKind;
|
|
15
|
+
value: SettingValue;
|
|
16
|
+
min?: number;
|
|
17
|
+
max?: number;
|
|
18
|
+
step?: number;
|
|
19
|
+
options?: readonly SettingOption[];
|
|
20
|
+
format?: (value: number) => string;
|
|
21
|
+
set: (value: SettingValue) => void;
|
|
22
|
+
}
|
|
23
|
+
export interface SettingsKeybindRow {
|
|
24
|
+
action: string;
|
|
25
|
+
label: string;
|
|
26
|
+
bindingLabel: string;
|
|
27
|
+
isDefault: boolean;
|
|
28
|
+
rebind: (code: string) => void;
|
|
29
|
+
reset: () => void;
|
|
30
|
+
}
|
|
31
|
+
export interface SettingsCategoryView {
|
|
32
|
+
id: SettingCategory;
|
|
33
|
+
label: string;
|
|
34
|
+
rows: SettingsRow[];
|
|
35
|
+
keybinds: SettingsKeybindRow[];
|
|
36
|
+
}
|
|
37
|
+
/** A resolved game-state action — `run` is already bound to the game context and closes the menu. */
|
|
38
|
+
export interface SettingsActionView {
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
kind: "default" | "danger";
|
|
42
|
+
description?: string;
|
|
43
|
+
run: () => void;
|
|
44
|
+
}
|
|
45
|
+
/** The live settings controller — every category/row/keybind/action plus open-state. Render it any way you like or drive the engine menu. */
|
|
46
|
+
export interface SettingsController {
|
|
47
|
+
categories: SettingsCategoryView[];
|
|
48
|
+
/** Game-state actions (Restart, Quit…) — the menu shows them as its first "Game" tab. */
|
|
49
|
+
actions: SettingsActionView[];
|
|
50
|
+
variant: SettingsVariant;
|
|
51
|
+
surface: SettingsSurface | false;
|
|
52
|
+
isOpen: boolean;
|
|
53
|
+
open: () => void;
|
|
54
|
+
close: () => void;
|
|
55
|
+
setOpen: (open: boolean) => void;
|
|
56
|
+
}
|
|
57
|
+
export declare function SettingsControllerProvider({ controller, children, }: {
|
|
58
|
+
controller: SettingsController;
|
|
59
|
+
children: ReactNode;
|
|
60
|
+
}): import("react").JSX.Element;
|
|
61
|
+
/** The engine settings controller for the current game — render your own settings UI from `categories`, or open the built-in menu with `open()`. Null-safe stub when mounted outside the shell. */
|
|
62
|
+
export declare function useSettings(): SettingsController;
|
|
63
|
+
/** True when the game has any setting or game-action to show — gate your own settings entry on it. */
|
|
64
|
+
export declare function useHasSettings(): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Inline settings entry — drop it anywhere in your game's menu or HUD; it opens
|
|
67
|
+
* the themed settings menu. Headless: pass `className` for placement/skin and
|
|
68
|
+
* `children` to replace the default gear glyph. Renders nothing when the game
|
|
69
|
+
* has no settings to show, so it never leaves a dead button behind.
|
|
70
|
+
*/
|
|
71
|
+
export declare function SettingsTrigger({ className, children, label, }: {
|
|
72
|
+
className?: string;
|
|
73
|
+
children?: ReactNode;
|
|
74
|
+
label?: string;
|
|
75
|
+
}): import("react").JSX.Element | null;
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useSyncExternalStore } from "react";
|
|
3
|
+
import { createSettingsStore, } from "@jgengine/core/settings/settingsModel";
|
|
4
|
+
const SettingsStoreContext = createContext(null);
|
|
5
|
+
export function SettingsProvider({ store, children }) {
|
|
6
|
+
return _jsx(SettingsStoreContext.Provider, { value: store, children: children });
|
|
7
|
+
}
|
|
8
|
+
/** The shared settings store, or a standalone one when no provider is mounted (game code read outside the shell). */
|
|
9
|
+
export function useSettingsStore() {
|
|
10
|
+
const store = useContext(SettingsStoreContext);
|
|
11
|
+
return store ?? fallbackStore();
|
|
12
|
+
}
|
|
13
|
+
let standalone = null;
|
|
14
|
+
function fallbackStore() {
|
|
15
|
+
if (standalone === null)
|
|
16
|
+
standalone = createSettingsStore();
|
|
17
|
+
return standalone;
|
|
18
|
+
}
|
|
19
|
+
/** Read + write one persisted setting; re-renders when the value changes anywhere. */
|
|
20
|
+
export function useSetting(id, fallback) {
|
|
21
|
+
const store = useSettingsStore();
|
|
22
|
+
const value = useSyncExternalStore(store.subscribe, () => store.get(id, fallback), () => fallback);
|
|
23
|
+
const set = useCallback((next) => store.set(id, next), [store, id]);
|
|
24
|
+
return [value, set];
|
|
25
|
+
}
|
|
26
|
+
const SettingsControllerContext = createContext(null);
|
|
27
|
+
export function SettingsControllerProvider({ controller, children, }) {
|
|
28
|
+
return _jsx(SettingsControllerContext.Provider, { value: controller, children: children });
|
|
29
|
+
}
|
|
30
|
+
/** The engine settings controller for the current game — render your own settings UI from `categories`, or open the built-in menu with `open()`. Null-safe stub when mounted outside the shell. */
|
|
31
|
+
export function useSettings() {
|
|
32
|
+
return useContext(SettingsControllerContext) ?? EMPTY_CONTROLLER;
|
|
33
|
+
}
|
|
34
|
+
const noop = () => undefined;
|
|
35
|
+
const EMPTY_CONTROLLER = {
|
|
36
|
+
categories: [],
|
|
37
|
+
actions: [],
|
|
38
|
+
variant: "panel",
|
|
39
|
+
surface: false,
|
|
40
|
+
isOpen: false,
|
|
41
|
+
open: noop,
|
|
42
|
+
close: noop,
|
|
43
|
+
setOpen: noop,
|
|
44
|
+
};
|
|
45
|
+
/** True when the game has any setting or game-action to show — gate your own settings entry on it. */
|
|
46
|
+
export function useHasSettings() {
|
|
47
|
+
const s = useSettings();
|
|
48
|
+
return s.categories.length > 0 || s.actions.length > 0;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Inline settings entry — drop it anywhere in your game's menu or HUD; it opens
|
|
52
|
+
* the themed settings menu. Headless: pass `className` for placement/skin and
|
|
53
|
+
* `children` to replace the default gear glyph. Renders nothing when the game
|
|
54
|
+
* has no settings to show, so it never leaves a dead button behind.
|
|
55
|
+
*/
|
|
56
|
+
export function SettingsTrigger({ className, children, label = "Settings", }) {
|
|
57
|
+
const settings = useSettings();
|
|
58
|
+
if (settings.categories.length === 0 && settings.actions.length === 0)
|
|
59
|
+
return null;
|
|
60
|
+
return (_jsx("button", { type: "button", "aria-label": label, onClick: settings.open, className: className, children: children ?? (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", width: "1em", height: "1em", "aria-hidden": true, children: [_jsx("circle", { cx: "12", cy: "12", r: "3" }), _jsx("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" })] })) }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SkillCheckConfig, SkillCheckResult } from "@jgengine/core/interaction/skillCheck";
|
|
2
|
+
import type { QteStep } from "@jgengine/core/interaction/qte";
|
|
3
|
+
export declare function paintSkillCheckDom(root: HTMLElement, zone: HTMLElement, marker: HTMLElement, config: SkillCheckConfig, result: SkillCheckResult): void;
|
|
4
|
+
export declare function paintQteStepDom(elements: ReadonlyMap<string, HTMLElement>, steps: readonly QteStep[], elapsed: number, activeId: string | null, stepClassName?: string, activeClassName?: string, doneClassName?: string): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function paintSkillCheckDom(root, zone, marker, config, result) {
|
|
2
|
+
const zoneLeft = (result.zone.start / config.trackWidth) * 100;
|
|
3
|
+
const zoneWidth = ((result.zone.end - result.zone.start) / config.trackWidth) * 100;
|
|
4
|
+
const markerLeft = (result.markerPosition / config.trackWidth) * 100;
|
|
5
|
+
zone.style.left = `${zoneLeft}%`;
|
|
6
|
+
zone.style.width = `${zoneWidth}%`;
|
|
7
|
+
marker.style.left = `${markerLeft}%`;
|
|
8
|
+
root.dataset.inZone = result.success ? "true" : "false";
|
|
9
|
+
root.dataset.timedOut = result.timedOut ? "true" : "false";
|
|
10
|
+
}
|
|
11
|
+
export function paintQteStepDom(elements, steps, elapsed, activeId, stepClassName, activeClassName, doneClassName) {
|
|
12
|
+
for (const step of steps) {
|
|
13
|
+
const el = elements.get(step.id);
|
|
14
|
+
if (el === undefined)
|
|
15
|
+
continue;
|
|
16
|
+
const isActive = activeId === step.id;
|
|
17
|
+
const isDone = elapsed > step.windowEnd;
|
|
18
|
+
const classes = [stepClassName, isActive ? activeClassName : isDone ? doneClassName : undefined]
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.join(" ");
|
|
21
|
+
if (classes.length > 0)
|
|
22
|
+
el.className = classes;
|
|
23
|
+
else
|
|
24
|
+
el.removeAttribute("class");
|
|
25
|
+
el.dataset.active = isActive ? "true" : "false";
|
|
26
|
+
el.dataset.done = isDone ? "true" : "false";
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/social.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { FriendEntry, FriendRequestEntry, PartyInviteEntry, PartyMemberEntry, WorldInvite, WorldInviteTarget } from "@jgengine/core/game/social";
|
|
3
|
+
import { type MatchFilter, type SessionListing } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
|
+
export declare function PresenceDot({ userId, className }: {
|
|
5
|
+
userId: string;
|
|
6
|
+
className?: string;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
8
|
+
export declare function FriendRow({ friend, className, dotClassName, children, }: {
|
|
9
|
+
friend: FriendEntry;
|
|
10
|
+
className?: string;
|
|
11
|
+
dotClassName?: string;
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
14
|
+
export declare function FriendsList({ className, rowClassName, dotClassName, emptyState, renderFriend, }: {
|
|
15
|
+
className?: string;
|
|
16
|
+
rowClassName?: string;
|
|
17
|
+
dotClassName?: string;
|
|
18
|
+
emptyState?: ReactNode;
|
|
19
|
+
renderFriend?: (friend: FriendEntry) => ReactNode;
|
|
20
|
+
}): import("react").JSX.Element;
|
|
21
|
+
export declare function AddFriendButton({ toUserId, className, children, onRequested, onRejected, }: {
|
|
22
|
+
toUserId: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
onRequested?: (requestId: string) => void;
|
|
26
|
+
onRejected?: (reason: string) => void;
|
|
27
|
+
}): import("react").JSX.Element;
|
|
28
|
+
export declare function FriendRequestsList({ className, rowClassName, acceptClassName, declineClassName, emptyState, renderRequest, }: {
|
|
29
|
+
className?: string;
|
|
30
|
+
rowClassName?: string;
|
|
31
|
+
acceptClassName?: string;
|
|
32
|
+
declineClassName?: string;
|
|
33
|
+
emptyState?: ReactNode;
|
|
34
|
+
renderRequest?: (request: FriendRequestEntry) => ReactNode;
|
|
35
|
+
}): import("react").JSX.Element;
|
|
36
|
+
export declare function PartyMemberRow({ member, className, dotClassName, children, }: {
|
|
37
|
+
member: PartyMemberEntry;
|
|
38
|
+
className?: string;
|
|
39
|
+
dotClassName?: string;
|
|
40
|
+
children?: ReactNode;
|
|
41
|
+
}): import("react").JSX.Element;
|
|
42
|
+
export declare function PartyFrame({ className, rowClassName, dotClassName, emptyState, renderMember, }: {
|
|
43
|
+
className?: string;
|
|
44
|
+
rowClassName?: string;
|
|
45
|
+
dotClassName?: string;
|
|
46
|
+
emptyState?: ReactNode;
|
|
47
|
+
renderMember?: (member: PartyMemberEntry) => ReactNode;
|
|
48
|
+
}): import("react").JSX.Element;
|
|
49
|
+
export declare function PartyInviteToast({ className, acceptClassName, declineClassName, renderInvite, }: {
|
|
50
|
+
className?: string;
|
|
51
|
+
acceptClassName?: string;
|
|
52
|
+
declineClassName?: string;
|
|
53
|
+
renderInvite?: (invite: PartyInviteEntry) => ReactNode;
|
|
54
|
+
}): import("react").JSX.Element | null;
|
|
55
|
+
export declare function LeavePartyButton({ className, children, }: {
|
|
56
|
+
className?: string;
|
|
57
|
+
children?: ReactNode;
|
|
58
|
+
}): import("react").JSX.Element | null;
|
|
59
|
+
export declare function WorldInviteToast({ className, acceptClassName, declineClassName, onAccepted, renderInvite, }: {
|
|
60
|
+
className?: string;
|
|
61
|
+
acceptClassName?: string;
|
|
62
|
+
declineClassName?: string;
|
|
63
|
+
onAccepted: (target: WorldInviteTarget) => void;
|
|
64
|
+
renderInvite?: (invite: WorldInvite) => ReactNode;
|
|
65
|
+
}): import("react").JSX.Element | null;
|
|
66
|
+
export declare function InviteToWorldButton({ toUserId, target, className, children, onInvited, onRejected, }: {
|
|
67
|
+
toUserId: string;
|
|
68
|
+
target: WorldInviteTarget;
|
|
69
|
+
className?: string;
|
|
70
|
+
children?: ReactNode;
|
|
71
|
+
onInvited?: (inviteId: string) => void;
|
|
72
|
+
onRejected?: (reason: string) => void;
|
|
73
|
+
}): import("react").JSX.Element;
|
|
74
|
+
export declare function WorldBrowser({ listings, onJoin, className, rowClassName, joinClassName, emptyState, renderListing, }: {
|
|
75
|
+
listings: readonly SessionListing[];
|
|
76
|
+
onJoin: (listing: SessionListing) => void;
|
|
77
|
+
className?: string;
|
|
78
|
+
rowClassName?: string;
|
|
79
|
+
joinClassName?: string;
|
|
80
|
+
emptyState?: ReactNode;
|
|
81
|
+
renderListing?: (listing: SessionListing) => ReactNode;
|
|
82
|
+
}): import("react").JSX.Element;
|
|
83
|
+
export declare function JoinByCode({ onJoin, className, inputClassName, buttonClassName, placeholder, children, }: {
|
|
84
|
+
onJoin: (code: string) => void;
|
|
85
|
+
className?: string;
|
|
86
|
+
inputClassName?: string;
|
|
87
|
+
buttonClassName?: string;
|
|
88
|
+
placeholder?: string;
|
|
89
|
+
children?: ReactNode;
|
|
90
|
+
}): import("react").JSX.Element;
|
|
91
|
+
export declare function QuickMatchButton({ listings, onJoin, onNoMatch, filter, className, children, }: {
|
|
92
|
+
listings: readonly SessionListing[];
|
|
93
|
+
onJoin: (listing: SessionListing) => void;
|
|
94
|
+
onNoMatch?: () => void;
|
|
95
|
+
filter?: MatchFilter;
|
|
96
|
+
className?: string;
|
|
97
|
+
children?: ReactNode;
|
|
98
|
+
}): import("react").JSX.Element;
|
|
99
|
+
export declare function EmoteWheel({ emotes, radius, open, className, emoteClassName, onPlayed, onRejected, renderEmote, }: {
|
|
100
|
+
emotes: readonly string[];
|
|
101
|
+
radius?: number;
|
|
102
|
+
open?: boolean;
|
|
103
|
+
className?: string;
|
|
104
|
+
emoteClassName?: string;
|
|
105
|
+
onPlayed?: (emoteId: string) => void;
|
|
106
|
+
onRejected?: (reason: string) => void;
|
|
107
|
+
renderEmote?: (emoteId: string) => ReactNode;
|
|
108
|
+
}): import("react").JSX.Element | null;
|
package/dist/social.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { normalizeJoinCode, quickMatch, } from "@jgengine/core/multiplayer/matchmaking";
|
|
4
|
+
import { useGameContext } from "./provider.js";
|
|
5
|
+
import { useFriendRequests, useFriends, useParty, usePartyInvites, usePresence, useWorldInvites, } from "./hooks.js";
|
|
6
|
+
export function PresenceDot({ userId, className }) {
|
|
7
|
+
const presence = usePresence(userId);
|
|
8
|
+
return (_jsx("span", { className: className, "data-presence-dot": true, "data-user": userId, "data-online": presence.online, "data-server": presence.serverId }));
|
|
9
|
+
}
|
|
10
|
+
export function FriendRow({ friend, className, dotClassName, children, }) {
|
|
11
|
+
return (_jsxs("div", { className: className, "data-friend": friend.userId, "data-online": friend.online, children: [_jsx(PresenceDot, { userId: friend.userId, className: dotClassName }), _jsx("span", { "data-friend-name": true, children: friend.userId }), children] }));
|
|
12
|
+
}
|
|
13
|
+
export function FriendsList({ className, rowClassName, dotClassName, emptyState, renderFriend, }) {
|
|
14
|
+
const friends = useFriends();
|
|
15
|
+
return (_jsx("div", { className: className, "data-friends-list": true, children: friends.length === 0
|
|
16
|
+
? emptyState ?? null
|
|
17
|
+
: friends.map((friend) => renderFriend !== undefined ? (renderFriend(friend)) : (_jsx(FriendRow, { friend: friend, className: rowClassName, dotClassName: dotClassName }, friend.userId))) }));
|
|
18
|
+
}
|
|
19
|
+
export function AddFriendButton({ toUserId, className, children, onRequested, onRejected, }) {
|
|
20
|
+
const ctx = useGameContext();
|
|
21
|
+
const denied = ctx.game.social.friends.canRequest(ctx.player.userId, toUserId);
|
|
22
|
+
return (_jsx("button", { type: "button", className: className, "data-add-friend": toUserId, disabled: denied !== null, title: denied?.reason, onClick: () => {
|
|
23
|
+
const result = ctx.game.social.friends.request(ctx.player.userId, toUserId);
|
|
24
|
+
if ("reason" in result)
|
|
25
|
+
onRejected?.(result.reason);
|
|
26
|
+
else
|
|
27
|
+
onRequested?.(result.requestId);
|
|
28
|
+
}, children: children ?? "Add friend" }));
|
|
29
|
+
}
|
|
30
|
+
export function FriendRequestsList({ className, rowClassName, acceptClassName, declineClassName, emptyState, renderRequest, }) {
|
|
31
|
+
const ctx = useGameContext();
|
|
32
|
+
const requests = useFriendRequests();
|
|
33
|
+
return (_jsx("div", { className: className, "data-friend-requests": true, children: requests.length === 0
|
|
34
|
+
? emptyState ?? null
|
|
35
|
+
: requests.map((request) => renderRequest !== undefined ? (renderRequest(request)) : (_jsxs("div", { className: rowClassName, "data-friend-request": request.requestId, children: [_jsx("span", { "data-request-from": true, children: request.fromUserId }), _jsx("button", { type: "button", className: acceptClassName, "data-accept": true, onClick: () => ctx.game.social.friends.accept(ctx.player.userId, request.requestId), children: "Accept" }), _jsx("button", { type: "button", className: declineClassName, "data-decline": true, onClick: () => ctx.game.social.friends.decline(ctx.player.userId, request.requestId), children: "Decline" })] }, request.requestId))) }));
|
|
36
|
+
}
|
|
37
|
+
export function PartyMemberRow({ member, className, dotClassName, children, }) {
|
|
38
|
+
return (_jsxs("div", { className: className, "data-party-member": member.userId, "data-role": member.role, children: [_jsx(PresenceDot, { userId: member.userId, className: dotClassName }), _jsx("span", { "data-member-name": true, children: member.userId }), children] }));
|
|
39
|
+
}
|
|
40
|
+
export function PartyFrame({ className, rowClassName, dotClassName, emptyState, renderMember, }) {
|
|
41
|
+
const members = useParty();
|
|
42
|
+
return (_jsx("div", { className: className, "data-party-frame": true, "data-party-size": members.length, children: members.length === 0
|
|
43
|
+
? emptyState ?? null
|
|
44
|
+
: members.map((member) => renderMember !== undefined ? (renderMember(member)) : (_jsx(PartyMemberRow, { member: member, className: rowClassName, dotClassName: dotClassName }, member.userId))) }));
|
|
45
|
+
}
|
|
46
|
+
export function PartyInviteToast({ className, acceptClassName, declineClassName, renderInvite, }) {
|
|
47
|
+
const ctx = useGameContext();
|
|
48
|
+
const invites = usePartyInvites();
|
|
49
|
+
if (invites.length === 0)
|
|
50
|
+
return null;
|
|
51
|
+
return (_jsx("div", { className: className, "data-party-invites": true, children: invites.map((invite) => renderInvite !== undefined ? (renderInvite(invite)) : (_jsxs("div", { "data-party-invite": invite.inviteId, children: [_jsx("span", { "data-invite-from": true, children: invite.fromUserId }), _jsx("button", { type: "button", className: acceptClassName, "data-accept": true, onClick: () => ctx.game.social.party.accept(ctx.player.userId, invite.inviteId), children: "Join" }), _jsx("button", { type: "button", className: declineClassName, "data-decline": true, onClick: () => ctx.game.social.party.decline(ctx.player.userId, invite.inviteId), children: "Decline" })] }, invite.inviteId))) }));
|
|
52
|
+
}
|
|
53
|
+
export function LeavePartyButton({ className, children, }) {
|
|
54
|
+
const ctx = useGameContext();
|
|
55
|
+
const members = useParty();
|
|
56
|
+
if (members.length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
return (_jsx("button", { type: "button", className: className, "data-leave-party": true, onClick: () => ctx.game.social.party.leave(ctx.player.userId), children: children ?? "Leave party" }));
|
|
59
|
+
}
|
|
60
|
+
export function WorldInviteToast({ className, acceptClassName, declineClassName, onAccepted, renderInvite, }) {
|
|
61
|
+
const ctx = useGameContext();
|
|
62
|
+
const invites = useWorldInvites();
|
|
63
|
+
if (invites.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
return (_jsx("div", { className: className, "data-world-invites": true, children: invites.map((invite) => renderInvite !== undefined ? (renderInvite(invite)) : (_jsxs("div", { "data-world-invite": invite.id, children: [_jsx("span", { "data-invite-from": true, children: invite.fromUserId }), _jsx("span", { "data-invite-server": true, children: invite.serverId }), _jsx("button", { type: "button", className: acceptClassName, "data-accept": true, onClick: () => {
|
|
66
|
+
const result = ctx.game.social.worldInvites.accept(ctx.player.userId, invite.id);
|
|
67
|
+
if ("target" in result)
|
|
68
|
+
onAccepted(result.target);
|
|
69
|
+
}, children: "Join" }), _jsx("button", { type: "button", className: declineClassName, "data-decline": true, onClick: () => ctx.game.social.worldInvites.decline(ctx.player.userId, invite.id), children: "Decline" })] }, invite.id))) }));
|
|
70
|
+
}
|
|
71
|
+
export function InviteToWorldButton({ toUserId, target, className, children, onInvited, onRejected, }) {
|
|
72
|
+
const ctx = useGameContext();
|
|
73
|
+
const denied = ctx.game.social.worldInvites.canInvite(ctx.player.userId, toUserId);
|
|
74
|
+
return (_jsx("button", { type: "button", className: className, "data-invite-to-world": toUserId, disabled: denied !== null, title: denied?.reason, onClick: () => {
|
|
75
|
+
const result = ctx.game.social.worldInvites.invite(ctx.player.userId, toUserId, target);
|
|
76
|
+
if ("reason" in result)
|
|
77
|
+
onRejected?.(result.reason);
|
|
78
|
+
else
|
|
79
|
+
onInvited?.(result.inviteId);
|
|
80
|
+
}, children: children ?? "Invite to world" }));
|
|
81
|
+
}
|
|
82
|
+
export function WorldBrowser({ listings, onJoin, className, rowClassName, joinClassName, emptyState, renderListing, }) {
|
|
83
|
+
return (_jsx("div", { className: className, "data-world-browser": true, children: listings.length === 0
|
|
84
|
+
? emptyState ?? null
|
|
85
|
+
: listings.map((listing) => renderListing !== undefined ? (renderListing(listing)) : (_jsxs("div", { className: rowClassName, "data-world": listing.serverId, "data-mode": listing.mode, "data-full": listing.memberCount >= listing.slotsPerServer, children: [_jsx("span", { "data-world-label": true, children: listing.label ?? listing.serverId }), _jsxs("span", { "data-world-count": true, children: [listing.memberCount, "/", listing.slotsPerServer] }), _jsx("button", { type: "button", className: joinClassName, "data-join": true, disabled: listing.memberCount >= listing.slotsPerServer, onClick: () => onJoin(listing), children: "Join" })] }, listing.serverId))) }));
|
|
86
|
+
}
|
|
87
|
+
export function JoinByCode({ onJoin, className, inputClassName, buttonClassName, placeholder, children, }) {
|
|
88
|
+
const [code, setCode] = useState("");
|
|
89
|
+
function submit(event) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
const normalized = normalizeJoinCode(code);
|
|
92
|
+
if (normalized.length === 0)
|
|
93
|
+
return;
|
|
94
|
+
onJoin(normalized);
|
|
95
|
+
setCode("");
|
|
96
|
+
}
|
|
97
|
+
return (_jsxs("form", { className: className, "data-join-by-code": true, onSubmit: submit, children: [_jsx("input", { className: inputClassName, type: "text", value: code, placeholder: placeholder ?? "Join code", onChange: (event) => setCode(event.target.value) }), _jsx("button", { type: "submit", className: buttonClassName, "data-join": true, children: children ?? "Join" })] }));
|
|
98
|
+
}
|
|
99
|
+
export function QuickMatchButton({ listings, onJoin, onNoMatch, filter, className, children, }) {
|
|
100
|
+
return (_jsx("button", { type: "button", className: className, "data-quick-match": true, onClick: () => {
|
|
101
|
+
const match = quickMatch(listings, filter);
|
|
102
|
+
if (match === null)
|
|
103
|
+
onNoMatch?.();
|
|
104
|
+
else
|
|
105
|
+
onJoin(match);
|
|
106
|
+
}, children: children ?? "Quick match" }));
|
|
107
|
+
}
|
|
108
|
+
export function EmoteWheel({ emotes, radius, open = true, className, emoteClassName, onPlayed, onRejected, renderEmote, }) {
|
|
109
|
+
const ctx = useGameContext();
|
|
110
|
+
if (!open)
|
|
111
|
+
return null;
|
|
112
|
+
return (_jsx("div", { className: className, role: "menu", "data-emote-wheel": true, "data-emote-count": emotes.length, children: emotes.map((emoteId, index) => (_jsx("button", { type: "button", role: "menuitem", className: emoteClassName, "data-emote": emoteId, "data-emote-index": index, onClick: () => {
|
|
113
|
+
const result = ctx.game.social.emotes.play(ctx.player.userId, emoteId, radius);
|
|
114
|
+
if ("reason" in result)
|
|
115
|
+
onRejected?.(result.reason);
|
|
116
|
+
else
|
|
117
|
+
onPlayed?.(emoteId);
|
|
118
|
+
}, children: renderEmote !== undefined ? renderEmote(emoteId) : emoteId }, emoteId))) }));
|
|
119
|
+
}
|
package/dist/voice.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type PushToTalkMode, type PushToTalkStatus, type VoiceParticipant, type VoiceRoute, type VoiceTransport } from "@jgengine/core/multiplayer/voiceContract";
|
|
3
|
+
export interface UseVoiceOptions {
|
|
4
|
+
transport?: VoiceTransport;
|
|
5
|
+
channelId?: string;
|
|
6
|
+
mode?: PushToTalkMode;
|
|
7
|
+
resolveRoutes?: () => readonly VoiceRoute[];
|
|
8
|
+
getUserMedia?: (constraints: MediaStreamConstraints) => Promise<MediaStream>;
|
|
9
|
+
}
|
|
10
|
+
export interface VoiceState {
|
|
11
|
+
supported: boolean;
|
|
12
|
+
micStream: MediaStream | null;
|
|
13
|
+
micError: string | null;
|
|
14
|
+
requestMic(): Promise<boolean>;
|
|
15
|
+
transmitting: boolean;
|
|
16
|
+
status: PushToTalkStatus;
|
|
17
|
+
mode: PushToTalkMode;
|
|
18
|
+
setMode(mode: PushToTalkMode): void;
|
|
19
|
+
muted: boolean;
|
|
20
|
+
setMuted(muted: boolean): void;
|
|
21
|
+
keyDown(): void;
|
|
22
|
+
keyUp(): void;
|
|
23
|
+
participants: readonly VoiceParticipant[];
|
|
24
|
+
routes: readonly VoiceRoute[];
|
|
25
|
+
gainFor(userId: string): number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Mic capture + push-to-talk + channel roster over the VoiceTransport
|
|
29
|
+
* signaling seam. Transmission gates the captured tracks' `enabled` flag; the
|
|
30
|
+
* media plane that actually moves audio bytes (WebRTC/SFU) stays behind the
|
|
31
|
+
* transport, host-supplied. Call once per voice channel and hand the returned
|
|
32
|
+
* state to the voice components.
|
|
33
|
+
*/
|
|
34
|
+
export declare function useVoice(options?: UseVoiceOptions): VoiceState;
|
|
35
|
+
export declare function PushToTalkButton({ voice, className, children, }: {
|
|
36
|
+
voice: VoiceState;
|
|
37
|
+
className?: string;
|
|
38
|
+
children?: ReactNode;
|
|
39
|
+
}): import("react").JSX.Element;
|
|
40
|
+
export declare function MicToggle({ voice, className, mutedLabel, unmutedLabel, }: {
|
|
41
|
+
voice: VoiceState;
|
|
42
|
+
className?: string;
|
|
43
|
+
mutedLabel?: ReactNode;
|
|
44
|
+
unmutedLabel?: ReactNode;
|
|
45
|
+
}): import("react").JSX.Element;
|
|
46
|
+
export declare function SpeakingIndicator({ voice, userId, className, threshold, children, }: {
|
|
47
|
+
voice: VoiceState;
|
|
48
|
+
userId: string;
|
|
49
|
+
className?: string;
|
|
50
|
+
threshold?: number;
|
|
51
|
+
children?: ReactNode;
|
|
52
|
+
}): import("react").JSX.Element;
|
|
53
|
+
export declare function VoiceRoster({ voice, className, participantClassName, renderParticipant, }: {
|
|
54
|
+
voice: VoiceState;
|
|
55
|
+
className?: string;
|
|
56
|
+
participantClassName?: string;
|
|
57
|
+
renderParticipant?: (participant: VoiceParticipant, gain: number) => ReactNode;
|
|
58
|
+
}): import("react").JSX.Element;
|
package/dist/voice.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { createPushToTalk, } from "@jgengine/core/multiplayer/voiceContract";
|
|
4
|
+
/**
|
|
5
|
+
* Mic capture + push-to-talk + channel roster over the VoiceTransport
|
|
6
|
+
* signaling seam. Transmission gates the captured tracks' `enabled` flag; the
|
|
7
|
+
* media plane that actually moves audio bytes (WebRTC/SFU) stays behind the
|
|
8
|
+
* transport, host-supplied. Call once per voice channel and hand the returned
|
|
9
|
+
* state to the voice components.
|
|
10
|
+
*/
|
|
11
|
+
export function useVoice(options) {
|
|
12
|
+
const transport = options?.transport;
|
|
13
|
+
const channelId = options?.channelId ?? "voice";
|
|
14
|
+
const resolveRoutes = options?.resolveRoutes;
|
|
15
|
+
const requestedMode = options?.mode ?? "hold";
|
|
16
|
+
const [transmitting, setTransmitting] = useState(false);
|
|
17
|
+
const [version, setVersion] = useState(0);
|
|
18
|
+
const bump = useCallback(() => setVersion((current) => current + 1), []);
|
|
19
|
+
void version;
|
|
20
|
+
const pttRef = useRef(null);
|
|
21
|
+
if (pttRef.current === null) {
|
|
22
|
+
pttRef.current = createPushToTalk({ mode: requestedMode, onChange: setTransmitting });
|
|
23
|
+
}
|
|
24
|
+
const ptt = pttRef.current;
|
|
25
|
+
const [micStream, setMicStream] = useState(null);
|
|
26
|
+
const [micError, setMicError] = useState(null);
|
|
27
|
+
const micRef = useRef(null);
|
|
28
|
+
const getUserMedia = options?.getUserMedia ??
|
|
29
|
+
(typeof navigator !== "undefined" && navigator.mediaDevices !== undefined
|
|
30
|
+
? (constraints) => navigator.mediaDevices.getUserMedia(constraints)
|
|
31
|
+
: undefined);
|
|
32
|
+
const supported = getUserMedia !== undefined;
|
|
33
|
+
const requestMic = useCallback(async () => {
|
|
34
|
+
if (getUserMedia === undefined) {
|
|
35
|
+
setMicError("microphone capture not supported");
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const stream = await getUserMedia({ audio: true });
|
|
40
|
+
micRef.current = stream;
|
|
41
|
+
setMicStream(stream);
|
|
42
|
+
setMicError(null);
|
|
43
|
+
void transport?.publish(channelId, stream.id);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
setMicError(error instanceof Error ? error.message : "microphone permission denied");
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}, [getUserMedia, transport, channelId]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const stream = micRef.current;
|
|
53
|
+
if (stream === null)
|
|
54
|
+
return;
|
|
55
|
+
for (const track of stream.getAudioTracks())
|
|
56
|
+
track.enabled = transmitting;
|
|
57
|
+
}, [transmitting, micStream]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
return () => {
|
|
60
|
+
const stream = micRef.current;
|
|
61
|
+
if (stream !== null)
|
|
62
|
+
for (const track of stream.getTracks())
|
|
63
|
+
track.stop();
|
|
64
|
+
};
|
|
65
|
+
}, []);
|
|
66
|
+
const [participants, setParticipants] = useState([]);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (transport === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
void transport.join(channelId, micRef.current?.id);
|
|
71
|
+
const unsubscribe = transport.subscribers(channelId, setParticipants);
|
|
72
|
+
return () => {
|
|
73
|
+
unsubscribe();
|
|
74
|
+
void transport.leave(channelId);
|
|
75
|
+
};
|
|
76
|
+
}, [transport, channelId]);
|
|
77
|
+
const routes = resolveRoutes?.() ?? [];
|
|
78
|
+
return useMemo(() => ({
|
|
79
|
+
supported,
|
|
80
|
+
micStream,
|
|
81
|
+
micError,
|
|
82
|
+
requestMic,
|
|
83
|
+
transmitting,
|
|
84
|
+
status: ptt.status(),
|
|
85
|
+
mode: ptt.mode(),
|
|
86
|
+
setMode(mode) {
|
|
87
|
+
ptt.setMode(mode);
|
|
88
|
+
bump();
|
|
89
|
+
},
|
|
90
|
+
muted: ptt.muted(),
|
|
91
|
+
setMuted(muted) {
|
|
92
|
+
ptt.setMuted(muted);
|
|
93
|
+
bump();
|
|
94
|
+
},
|
|
95
|
+
keyDown() {
|
|
96
|
+
ptt.keyDown();
|
|
97
|
+
bump();
|
|
98
|
+
},
|
|
99
|
+
keyUp() {
|
|
100
|
+
ptt.keyUp();
|
|
101
|
+
bump();
|
|
102
|
+
},
|
|
103
|
+
participants,
|
|
104
|
+
routes,
|
|
105
|
+
gainFor(userId) {
|
|
106
|
+
let gain = 0;
|
|
107
|
+
for (const route of routes) {
|
|
108
|
+
if (route.fromUserId === userId && route.gain > gain)
|
|
109
|
+
gain = route.gain;
|
|
110
|
+
}
|
|
111
|
+
return gain;
|
|
112
|
+
},
|
|
113
|
+
}), [supported, micStream, micError, requestMic, transmitting, participants, routes, ptt, bump]);
|
|
114
|
+
}
|
|
115
|
+
export function PushToTalkButton({ voice, className, children, }) {
|
|
116
|
+
return (_jsx("button", { type: "button", className: className, "data-push-to-talk": true, "data-transmitting": voice.transmitting, "data-status": voice.status, onPointerDown: () => voice.keyDown(), onPointerUp: () => voice.keyUp(), onPointerLeave: () => voice.keyUp(), children: children ?? (voice.transmitting ? "Transmitting" : "Push to talk") }));
|
|
117
|
+
}
|
|
118
|
+
export function MicToggle({ voice, className, mutedLabel, unmutedLabel, }) {
|
|
119
|
+
return (_jsx("button", { type: "button", className: className, "data-mic-toggle": true, "data-muted": voice.muted, "aria-pressed": voice.muted, onClick: () => voice.setMuted(!voice.muted), children: voice.muted ? (mutedLabel ?? "Unmute") : (unmutedLabel ?? "Mute") }));
|
|
120
|
+
}
|
|
121
|
+
export function SpeakingIndicator({ voice, userId, className, threshold = 0.01, children, }) {
|
|
122
|
+
const gain = voice.gainFor(userId);
|
|
123
|
+
return (_jsx("span", { className: className, "data-speaking": gain > threshold, "data-gain": gain.toFixed(3), "data-user": userId, children: children }));
|
|
124
|
+
}
|
|
125
|
+
export function VoiceRoster({ voice, className, participantClassName, renderParticipant, }) {
|
|
126
|
+
return (_jsx("div", { className: className, "data-voice-roster": true, children: voice.participants.map((participant) => {
|
|
127
|
+
const gain = voice.gainFor(participant.userId);
|
|
128
|
+
return (_jsx("div", { className: participantClassName, "data-voice-participant": participant.userId, "data-published": participant.streamId !== undefined, "data-speaking": gain > 0.01, children: renderParticipant !== undefined ? renderParticipant(participant, gain) : participant.userId }, participant.userId));
|
|
129
|
+
}) }));
|
|
130
|
+
}
|