@jgengine/react 0.6.0 → 0.8.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/dist/map.js ADDED
@@ -0,0 +1,210 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from "react";
3
+ import { DEFAULT_MARKER_KINDS, markerKindStyle, } from "@jgengine/core/world/markers";
4
+ import { bearingToCardinal, clampToMinimapEdge, compassBearing, projectToMinimap, relativeBearing, } from "@jgengine/core/world/minimap";
5
+ export function useMarkers(markers) {
6
+ return useSyncExternalStore(markers.subscribe, markers.snapshot, markers.snapshot);
7
+ }
8
+ export function useFog(fog) {
9
+ return useSyncExternalStore(fog.subscribe, fog.cells, fog.cells);
10
+ }
11
+ /**
12
+ * Framed circular minimap: optional baked terrain background, reveal-on-event
13
+ * fog overlay, categorized marker icons, and a facing arrow. Reads a core
14
+ * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
15
+ */
16
+ export function Minimap({ markers, center, worldRadius, fog, size = 176, heading = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, className, title = "Map", children, }) {
17
+ const markerList = useMarkers(markers);
18
+ const fogCells = useSyncExternalStore(fog?.subscribe ?? NO_SUBSCRIBE, fog?.cells ?? NULL_CELLS, fog?.cells ?? NULL_CELLS);
19
+ const view = {
20
+ center,
21
+ worldRadius,
22
+ size,
23
+ ...(rotate ? { rotate: -heading } : {}),
24
+ };
25
+ const half = size / 2;
26
+ const clipId = `mm-clip-${size}`;
27
+ const sorted = [...markerList].sort((a, b) => markerKindStyle(a.kind, kindStyles).priority - markerKindStyle(b.kind, kindStyles).priority);
28
+ const fogRects = [];
29
+ if (fogCells !== null) {
30
+ const cellPx = (fogCells.cellSize / worldRadius) * half;
31
+ for (let row = 0; row < fogCells.rows; row += 1) {
32
+ for (let col = 0; col < fogCells.cols; col += 1) {
33
+ if (fogCells.revealed[row * fogCells.cols + col])
34
+ continue;
35
+ const worldX = fogCells.minX + (col + 0.5) * fogCells.cellSize;
36
+ const worldZ = fogCells.minZ + (row + 0.5) * fogCells.cellSize;
37
+ const projected = projectToMinimap([worldX, worldZ], view);
38
+ if (projected.distance > worldRadius + fogCells.cellSize)
39
+ continue;
40
+ fogRects.push(_jsx("rect", { x: projected.x - cellPx / 2 - 0.5, y: projected.y - cellPx / 2 - 0.5, width: cellPx + 1, height: cellPx + 1, fill: "#0b0f14", opacity: 0.82 }, `fog-${col}-${row}`));
41
+ }
42
+ }
43
+ }
44
+ return (_jsxs("div", { className: className, "data-minimap": true, style: {
45
+ width: size,
46
+ borderRadius: 14,
47
+ padding: 8,
48
+ background: "linear-gradient(160deg, rgba(24,28,36,0.94), rgba(12,15,20,0.94))",
49
+ border: "1px solid rgba(148,163,184,0.28)",
50
+ boxShadow: "0 10px 30px rgba(0,0,0,0.45)",
51
+ color: "#e2e8f0",
52
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
53
+ }, children: [_jsxs("div", { style: {
54
+ display: "flex",
55
+ justifyContent: "space-between",
56
+ alignItems: "center",
57
+ marginBottom: 6,
58
+ fontSize: 10,
59
+ letterSpacing: 1.4,
60
+ textTransform: "uppercase",
61
+ color: "rgba(203,213,225,0.75)",
62
+ }, children: [_jsx("span", { children: title }), _jsx("span", { style: { color: "rgba(148,163,184,0.6)" }, children: bearingToCardinal(heading) })] }), _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "data-minimap-canvas": true, children: [_jsx("defs", { children: _jsx("clipPath", { id: clipId, children: _jsx("circle", { cx: half, cy: half, r: half - 1 }) }) }), _jsxs("g", { clipPath: `url(#${clipId})`, children: [_jsx("circle", { cx: half, cy: half, r: half - 1, fill: "#1c2733" }), background !== undefined
63
+ ? (() => {
64
+ if (mapBounds === undefined) {
65
+ return (_jsx("image", { href: background, x: 0, y: 0, width: size, height: size, preserveAspectRatio: "xMidYMid slice", opacity: 0.9 }));
66
+ }
67
+ const pxPerWorld = half / worldRadius;
68
+ return (_jsx("image", { href: background, x: half + (mapBounds.minX - center[0]) * pxPerWorld, y: half + (mapBounds.minZ - center[1]) * pxPerWorld, width: (mapBounds.maxX - mapBounds.minX) * pxPerWorld, height: (mapBounds.maxZ - mapBounds.minZ) * pxPerWorld, preserveAspectRatio: "none", opacity: 0.92 }));
69
+ })()
70
+ : null, fogRects, _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.12)" }), sorted.map((marker) => {
71
+ const style = markerKindStyle(marker.kind, kindStyles);
72
+ const projected = projectToMinimap(marker.position, view);
73
+ const at = projected.inside ? projected : clampToMinimapEdge(projected, size);
74
+ return (_jsxs("g", { "data-marker-kind": marker.kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 7, fill: "rgba(2,6,12,0.55)" }), _jsx("text", { x: at.x, y: at.y + 3.5, textAnchor: "middle", fontSize: 10, fill: style.color, style: { fontWeight: 700 }, children: style.glyph })] }, marker.id));
75
+ }), _jsx("g", { transform: `translate(${half} ${half}) rotate(${(rotate ? 0 : heading) * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-9 L6,7 L0,3 L-6,7 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 0.75 }) })] }), _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.4)", strokeWidth: 1.5 }), _jsx("text", { x: half, y: 13, textAnchor: "middle", fontSize: 11, fill: "rgba(226,232,240,0.9)", style: { fontWeight: 700 }, children: "N" })] }), children] }));
76
+ }
77
+ const NO_SUBSCRIBE = () => () => undefined;
78
+ const NULL_CELLS = () => null;
79
+ const EMPTY_MARKERS_VALUE = [];
80
+ const EMPTY_MARKERS = () => EMPTY_MARKERS_VALUE;
81
+ const COMPASS_TICKS = [
82
+ { cardinal: "N", bearing: 0 },
83
+ { cardinal: "NE", bearing: Math.PI / 4 },
84
+ { cardinal: "E", bearing: Math.PI / 2 },
85
+ { cardinal: "SE", bearing: (3 * Math.PI) / 4 },
86
+ { cardinal: "S", bearing: Math.PI },
87
+ { cardinal: "SW", bearing: (5 * Math.PI) / 4 },
88
+ { cardinal: "W", bearing: (3 * Math.PI) / 2 },
89
+ { cardinal: "NW", bearing: (7 * Math.PI) / 4 },
90
+ ];
91
+ /**
92
+ * Horizontal compass strip centered on the player's facing bearing, with the
93
+ * eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
94
+ */
95
+ export function Compass({ heading, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }) {
96
+ const markerList = useSyncExternalStore(markers?.subscribe ?? NO_SUBSCRIBE, markers?.snapshot ?? EMPTY_MARKERS, markers?.snapshot ?? EMPTY_MARKERS);
97
+ const half = fov / 2;
98
+ const toX = (bearing) => {
99
+ const delta = relativeBearing(bearing, heading);
100
+ if (Math.abs(delta) > half)
101
+ return null;
102
+ return width / 2 + (delta / half) * (width / 2);
103
+ };
104
+ return (_jsxs("div", { className: className, "data-compass": true, style: {
105
+ width,
106
+ height: 34,
107
+ position: "relative",
108
+ overflow: "hidden",
109
+ borderRadius: 8,
110
+ border: "1px solid rgba(148,163,184,0.28)",
111
+ background: "linear-gradient(180deg, rgba(18,22,30,0.92), rgba(10,13,18,0.92))",
112
+ color: "#e2e8f0",
113
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
114
+ }, children: [_jsx("div", { style: {
115
+ position: "absolute",
116
+ left: "50%",
117
+ top: 0,
118
+ bottom: 0,
119
+ width: 1,
120
+ background: "rgba(74,222,128,0.85)",
121
+ } }), COMPASS_TICKS.map((tick) => {
122
+ const x = toX(tick.bearing);
123
+ if (x === null)
124
+ return null;
125
+ const major = tick.cardinal.length === 1;
126
+ return (_jsx("span", { style: {
127
+ position: "absolute",
128
+ left: x,
129
+ top: major ? 7 : 11,
130
+ transform: "translateX(-50%)",
131
+ fontSize: major ? 13 : 9,
132
+ fontWeight: major ? 700 : 500,
133
+ color: major ? "#f8fafc" : "rgba(148,163,184,0.75)",
134
+ }, children: tick.cardinal }, tick.cardinal));
135
+ }), center !== undefined
136
+ ? markerList.map((marker) => {
137
+ const bearing = compassBearing(center, [marker.position[0], marker.position[2]]);
138
+ const x = toX(bearing);
139
+ if (x === null)
140
+ return null;
141
+ const style = markerKindStyle(marker.kind, kindStyles);
142
+ return (_jsx("span", { "data-compass-marker": marker.kind, style: {
143
+ position: "absolute",
144
+ left: x,
145
+ bottom: 2,
146
+ transform: "translateX(-50%)",
147
+ fontSize: 11,
148
+ color: style.color,
149
+ fontWeight: 700,
150
+ }, children: style.glyph }, marker.id));
151
+ })
152
+ : null] }));
153
+ }
154
+ /**
155
+ * Full-bounds top-down world map (the "press M" overlay): baked terrain
156
+ * background, reveal-on-event fog, all markers with labels, and the player.
157
+ * Rectangular linear projection over the supplied world `bounds`.
158
+ */
159
+ export function WorldMap({ markers, bounds, player, heading = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, className, title = "World Map", onClose, }) {
160
+ const markerList = useMarkers(markers);
161
+ const fogCells = useSyncExternalStore(fog?.subscribe ?? NO_SUBSCRIBE, fog?.cells ?? NULL_CELLS, fog?.cells ?? NULL_CELLS);
162
+ const worldW = bounds.maxX - bounds.minX;
163
+ const worldD = bounds.maxZ - bounds.minZ;
164
+ const mapH = height ?? Math.round((width * worldD) / worldW);
165
+ const project = (x, z) => ({
166
+ x: ((x - bounds.minX) / worldW) * width,
167
+ y: ((z - bounds.minZ) / worldD) * mapH,
168
+ });
169
+ return (_jsxs("div", { className: className, "data-world-map": true, style: {
170
+ borderRadius: 16,
171
+ padding: 14,
172
+ background: "linear-gradient(160deg, rgba(20,24,32,0.97), rgba(10,13,18,0.97))",
173
+ border: "1px solid rgba(148,163,184,0.35)",
174
+ boxShadow: "0 24px 60px rgba(0,0,0,0.6)",
175
+ color: "#e2e8f0",
176
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
177
+ }, children: [_jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 700, letterSpacing: 1.5, textTransform: "uppercase" }, children: title }), onClose !== undefined ? (_jsx("button", { type: "button", onClick: onClose, style: {
178
+ border: "1px solid rgba(148,163,184,0.4)",
179
+ borderRadius: 6,
180
+ background: "transparent",
181
+ color: "rgba(226,232,240,0.85)",
182
+ fontSize: 11,
183
+ padding: "3px 9px",
184
+ cursor: "pointer",
185
+ }, children: "Close" })) : null] }), _jsxs("svg", { width: width, height: mapH, viewBox: `0 0 ${width} ${mapH}`, "data-world-map-canvas": true, style: { borderRadius: 10 }, children: [_jsx("rect", { x: 0, y: 0, width: width, height: mapH, fill: "#16202b" }), background !== undefined ? (_jsx("image", { href: background, x: 0, y: 0, width: width, height: mapH, preserveAspectRatio: "none", opacity: 0.95 })) : null, fogCells !== null
186
+ ? (() => {
187
+ const cellW = (fogCells.cellSize / worldW) * width;
188
+ const cellH = (fogCells.cellSize / worldD) * mapH;
189
+ const rects = [];
190
+ for (let row = 0; row < fogCells.rows; row += 1) {
191
+ for (let col = 0; col < fogCells.cols; col += 1) {
192
+ if (fogCells.revealed[row * fogCells.cols + col])
193
+ continue;
194
+ const at = project(fogCells.minX + col * fogCells.cellSize, fogCells.minZ + row * fogCells.cellSize);
195
+ rects.push(_jsx("rect", { x: at.x, y: at.y, width: cellW + 0.5, height: cellH + 0.5, fill: "#0b0f14", opacity: 0.86 }, `wfog-${col}-${row}`));
196
+ }
197
+ }
198
+ return rects;
199
+ })()
200
+ : null, markerList.map((marker) => {
201
+ const at = project(marker.position[0], marker.position[2]);
202
+ const style = markerKindStyle(marker.kind, kindStyles);
203
+ return (_jsxs("g", { "data-world-marker": marker.kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 9, fill: "rgba(2,6,12,0.6)", stroke: style.color, strokeWidth: 1.2 }), _jsx("text", { x: at.x, y: at.y + 4, textAnchor: "middle", fontSize: 12, fill: style.color, style: { fontWeight: 700 }, children: style.glyph }), marker.label !== undefined ? (_jsx("text", { x: at.x + 12, y: at.y + 4, fontSize: 11, fill: "rgba(226,232,240,0.85)", children: marker.label })) : null] }, marker.id));
204
+ }), player !== undefined
205
+ ? (() => {
206
+ const at = project(player[0], player[1]);
207
+ return (_jsx("g", { transform: `translate(${at.x} ${at.y}) rotate(${heading * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-11 L7,9 L0,4 L-7,9 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 1 }) }));
208
+ })()
209
+ : null] })] }));
210
+ }
@@ -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
+ }