@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/chat.d.ts +52 -0
  3. package/dist/chat.js +68 -0
  4. package/dist/chatBubbles.d.ts +15 -0
  5. package/dist/chatBubbles.js +45 -0
  6. package/dist/components.d.ts +1 -0
  7. package/dist/components.js +35 -22
  8. package/dist/display.d.ts +14 -0
  9. package/dist/display.js +45 -0
  10. package/dist/dragLayer.d.ts +6 -1
  11. package/dist/dragLayer.js +64 -31
  12. package/dist/engineStore.d.ts +1 -1
  13. package/dist/engineStore.js +10 -2
  14. package/dist/fogOverlay.d.ts +21 -0
  15. package/dist/fogOverlay.js +34 -0
  16. package/dist/gameIcons.d.ts +12 -0
  17. package/dist/gameIcons.js +282 -0
  18. package/dist/hooks.d.ts +59 -2
  19. package/dist/hooks.js +170 -12
  20. package/dist/hudLayout.d.ts +61 -0
  21. package/dist/hudLayout.js +488 -0
  22. package/dist/hudViewport.d.ts +21 -0
  23. package/dist/hudViewport.js +17 -0
  24. package/dist/identity.d.ts +65 -0
  25. package/dist/identity.js +94 -0
  26. package/dist/index.d.ts +10 -0
  27. package/dist/index.js +10 -0
  28. package/dist/liveBind.d.ts +4 -10
  29. package/dist/liveBind.js +28 -16
  30. package/dist/map.d.ts +27 -7
  31. package/dist/map.js +152 -43
  32. package/dist/preview.d.ts +6 -0
  33. package/dist/preview.js +1 -0
  34. package/dist/selectSnapshot.d.ts +7 -0
  35. package/dist/selectSnapshot.js +16 -0
  36. package/dist/settings.d.ts +75 -0
  37. package/dist/settings.js +61 -0
  38. package/dist/skillCheckPaint.d.ts +4 -0
  39. package/dist/skillCheckPaint.js +28 -0
  40. package/dist/social.d.ts +108 -0
  41. package/dist/social.js +119 -0
  42. package/dist/voice.d.ts +58 -0
  43. package/dist/voice.js +130 -0
  44. package/llms.txt +1633 -0
  45. 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;
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }