solanapolis 1.0.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/README.md +518 -0
- package/bin/solanapolis.js +197 -0
- package/convex/_generated/api.d.ts +175 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/agent/conversation.ts +352 -0
- package/convex/agent/embeddingsCache.ts +110 -0
- package/convex/agent/memory.ts +450 -0
- package/convex/agent/schema.ts +53 -0
- package/convex/aiChat.ts +54 -0
- package/convex/aiTown/agent.ts +382 -0
- package/convex/aiTown/agentDescription.ts +27 -0
- package/convex/aiTown/agentInputs.ts +155 -0
- package/convex/aiTown/agentOperations.ts +178 -0
- package/convex/aiTown/conversation.ts +395 -0
- package/convex/aiTown/conversationMembership.ts +38 -0
- package/convex/aiTown/game.ts +371 -0
- package/convex/aiTown/ids.ts +32 -0
- package/convex/aiTown/inputHandler.ts +9 -0
- package/convex/aiTown/inputs.ts +25 -0
- package/convex/aiTown/insertInput.ts +20 -0
- package/convex/aiTown/location.ts +32 -0
- package/convex/aiTown/main.ts +154 -0
- package/convex/aiTown/movement.ts +189 -0
- package/convex/aiTown/player.ts +310 -0
- package/convex/aiTown/playerDescription.ts +35 -0
- package/convex/aiTown/schema.ts +79 -0
- package/convex/aiTown/world.ts +65 -0
- package/convex/aiTown/worldMap.ts +74 -0
- package/convex/chat.ts +79 -0
- package/convex/constants.ts +78 -0
- package/convex/convex.config.ts +6 -0
- package/convex/crons.ts +89 -0
- package/convex/engine/abstractGame.ts +199 -0
- package/convex/engine/historicalObject.ts +355 -0
- package/convex/engine/schema.ts +56 -0
- package/convex/http.ts +36 -0
- package/convex/init.ts +110 -0
- package/convex/messages.ts +53 -0
- package/convex/npcCarAgents.ts +415 -0
- package/convex/schema.ts +61 -0
- package/convex/streaming.ts +23 -0
- package/convex/testing.ts +202 -0
- package/convex/tsconfig.json +18 -0
- package/convex/util/FastIntegerCompression.ts +221 -0
- package/convex/util/assertNever.ts +4 -0
- package/convex/util/asyncMap.ts +20 -0
- package/convex/util/compression.ts +71 -0
- package/convex/util/geometry.ts +132 -0
- package/convex/util/isSimpleObject.ts +11 -0
- package/convex/util/llm.ts +724 -0
- package/convex/util/minheap.ts +38 -0
- package/convex/util/object.ts +22 -0
- package/convex/util/sleep.ts +3 -0
- package/convex/util/types.ts +33 -0
- package/convex/util/xxhash.ts +228 -0
- package/convex/world.ts +257 -0
- package/data/animations/campfire.json +45 -0
- package/data/animations/gentlesparkle.json +37 -0
- package/data/animations/gentlesplash.json +61 -0
- package/data/animations/gentlewaterfall.json +61 -0
- package/data/animations/windmill.json +78 -0
- package/data/characters.ts +121 -0
- package/data/convertMap.js +74 -0
- package/data/gentle.js +330 -0
- package/data/spritesheets/f1.ts +75 -0
- package/data/spritesheets/f2.ts +75 -0
- package/data/spritesheets/f3.ts +75 -0
- package/data/spritesheets/f4.ts +75 -0
- package/data/spritesheets/f5.ts +75 -0
- package/data/spritesheets/f6.ts +75 -0
- package/data/spritesheets/f7.ts +75 -0
- package/data/spritesheets/f8.ts +75 -0
- package/data/spritesheets/p1.ts +59 -0
- package/data/spritesheets/p2.ts +59 -0
- package/data/spritesheets/p3.ts +59 -0
- package/data/spritesheets/player.ts +59 -0
- package/data/spritesheets/types.ts +26 -0
- package/eslint.config.mjs +37 -0
- package/next.config.ts +7 -0
- package/package.json +85 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/helius-icon.svg +84 -0
- package/public/helius-logo.svg +85 -0
- package/public/next.svg +1 -0
- package/public/plane.glb +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/clear-city.ts +74 -0
- package/scripts/seed-wallets.ts +185 -0
- package/scripts/setup-webhook.ts +73 -0
- package/src/app/api/auth/callback/route.ts +6 -0
- package/src/app/api/auth/link-wallet/route.ts +6 -0
- package/src/app/api/auth/phantom/route.ts +6 -0
- package/src/app/api/broadcast-position/route.ts +59 -0
- package/src/app/api/leaderboard/route.ts +85 -0
- package/src/app/api/network-stats/route.ts +86 -0
- package/src/app/api/parcel-reward/route.ts +181 -0
- package/src/app/api/queue-status/route.ts +30 -0
- package/src/app/api/snapshots/route.ts +37 -0
- package/src/app/api/transactions/enhanced/route.ts +57 -0
- package/src/app/api/treasury/route.ts +83 -0
- package/src/app/api/wallet/[address]/balances/route.ts +124 -0
- package/src/app/api/wallet/[address]/identity/route.ts +32 -0
- package/src/app/api/wallet/[address]/route.ts +216 -0
- package/src/app/api/wallet/[address]/traded-tokens/route.ts +41 -0
- package/src/app/api/wallets/route.ts +68 -0
- package/src/app/api/webhooks/helius/route.ts +76 -0
- package/src/app/auth/callback/page.tsx +29 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +39 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/page.tsx +16 -0
- package/src/components/AITownNPCs.tsx +206 -0
- package/src/components/ActivityFeed.tsx +189 -0
- package/src/components/AuthPanel.tsx +163 -0
- package/src/components/BeachScene.tsx +280 -0
- package/src/components/Building.tsx +138 -0
- package/src/components/CesiumFlight.tsx +1768 -0
- package/src/components/CesiumGlobe.tsx +616 -0
- package/src/components/CitizenCard.tsx +442 -0
- package/src/components/CitizenCardModal.tsx +153 -0
- package/src/components/CityGrid.tsx +313 -0
- package/src/components/CityLandmarks.tsx +427 -0
- package/src/components/CityScene.tsx +1289 -0
- package/src/components/CitySlotsBadge.tsx +68 -0
- package/src/components/CockpitHUD.tsx +460 -0
- package/src/components/ConvexWrapper.tsx +19 -0
- package/src/components/DubaiDistrict.tsx +630 -0
- package/src/components/FlightMiniMap.tsx +133 -0
- package/src/components/GameChat.tsx +383 -0
- package/src/components/GameHUD.tsx +393 -0
- package/src/components/Ground.tsx +14 -0
- package/src/components/HowItWorksModal.tsx +251 -0
- package/src/components/IngestionBanner.tsx +123 -0
- package/src/components/InstancedBuildings.tsx +316 -0
- package/src/components/InstancedCars.tsx +504 -0
- package/src/components/InstancedCityPlanes.tsx +259 -0
- package/src/components/InstancedHouses.tsx +246 -0
- package/src/components/InstancedLampPosts.tsx +201 -0
- package/src/components/InstancedResidentCars.tsx +357 -0
- package/src/components/InstancedRoadDashes.tsx +42 -0
- package/src/components/InstancedSkyscrapers.tsx +434 -0
- package/src/components/InstancedTrees.tsx +67 -0
- package/src/components/LeaderboardPanel.tsx +136 -0
- package/src/components/MultiplayerPlanes.tsx +128 -0
- package/src/components/NetworkStats.tsx +83 -0
- package/src/components/NewBuildingSpotlight.tsx +93 -0
- package/src/components/ParcelChallengeBanner.tsx +242 -0
- package/src/components/ParcelReward.tsx +191 -0
- package/src/components/Park.tsx +42 -0
- package/src/components/PhantomWrapper.tsx +22 -0
- package/src/components/PixelStreamViewer.tsx +335 -0
- package/src/components/PlaneMode.tsx +190 -0
- package/src/components/PlayerCar.tsx +211 -0
- package/src/components/PlayerPlane.tsx +255 -0
- package/src/components/ProjectileRenderer.tsx +249 -0
- package/src/components/QueueStatusBanner.tsx +86 -0
- package/src/components/RealPlayerTags.tsx +82 -0
- package/src/components/SceneLighting.tsx +382 -0
- package/src/components/SelectionBeam.tsx +59 -0
- package/src/components/SwapPanel.tsx +104 -0
- package/src/components/SwapParticles.tsx +237 -0
- package/src/components/TreasureGate.tsx +505 -0
- package/src/components/WalletPanel.tsx +421 -0
- package/src/components/WalletSearch.tsx +244 -0
- package/src/components/WelcomeOverlay.tsx +135 -0
- package/src/components/WindowTooltip.tsx +498 -0
- package/src/context/AuthContext.tsx +230 -0
- package/src/lib/bot-detection.ts +125 -0
- package/src/lib/building-math.ts +136 -0
- package/src/lib/building-shader.ts +253 -0
- package/src/lib/car-paths.ts +244 -0
- package/src/lib/car-system.ts +182 -0
- package/src/lib/city-constants.ts +29 -0
- package/src/lib/city-slots.ts +35 -0
- package/src/lib/city-zoning.ts +64 -0
- package/src/lib/collision-map.ts +147 -0
- package/src/lib/day-night.ts +252 -0
- package/src/lib/export-card.ts +28 -0
- package/src/lib/helius-webhook.ts +90 -0
- package/src/lib/helius.ts +74 -0
- package/src/lib/house-shader.ts +119 -0
- package/src/lib/mock-data.ts +56 -0
- package/src/lib/multiplayer-manager.ts +329 -0
- package/src/lib/plane-physics.ts +66 -0
- package/src/lib/player-car.ts +147 -0
- package/src/lib/player-plane.ts +200 -0
- package/src/lib/projectile-system.ts +272 -0
- package/src/lib/skyscraper-types.ts +52 -0
- package/src/lib/sound-engine.ts +464 -0
- package/src/lib/supabase-admin.ts +9 -0
- package/src/lib/supabase.ts +8 -0
- package/src/lib/swap-events.ts +70 -0
- package/src/middleware.ts +37 -0
- package/src/types/phantom.d.ts +16 -0
- package/src/types/wallet.ts +20 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
|
|
5
|
+
interface ParcelRewardProps {
|
|
6
|
+
walletAddress: string | null;
|
|
7
|
+
/** Called when the reward is claimed */
|
|
8
|
+
onClaimed?: (amount: number) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface RewardInfo {
|
|
12
|
+
eligible: boolean;
|
|
13
|
+
rank?: number;
|
|
14
|
+
totalSlots?: number;
|
|
15
|
+
rewardSOL?: number;
|
|
16
|
+
claimed?: boolean;
|
|
17
|
+
message?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parcel Reward — treasure chest that appears for early wallet holders (first 50).
|
|
22
|
+
* Shows a gold pulsing reward popup when their building is generated.
|
|
23
|
+
* They can click to claim the hidden SOL from the treasury.
|
|
24
|
+
*/
|
|
25
|
+
export default function ParcelReward({ walletAddress, onClaimed }: ParcelRewardProps) {
|
|
26
|
+
const [reward, setReward] = useState<RewardInfo | null>(null);
|
|
27
|
+
const [claiming, setClaiming] = useState(false);
|
|
28
|
+
const [claimResult, setClaimResult] = useState<string | null>(null);
|
|
29
|
+
const [dismissed, setDismissed] = useState(false);
|
|
30
|
+
const [visible, setVisible] = useState(false);
|
|
31
|
+
|
|
32
|
+
// Check reward eligibility when wallet connects
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!walletAddress) {
|
|
35
|
+
setReward(null);
|
|
36
|
+
setDismissed(false);
|
|
37
|
+
setClaimResult(null);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function checkReward() {
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(`/api/parcel-reward?wallet=${walletAddress}`);
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
setReward(data);
|
|
46
|
+
if (data.eligible && !data.claimed) {
|
|
47
|
+
// Delay entrance for dramatic effect
|
|
48
|
+
setTimeout(() => setVisible(true), 2000);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Silently fail
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
checkReward();
|
|
56
|
+
}, [walletAddress]);
|
|
57
|
+
|
|
58
|
+
const handleClaim = useCallback(async () => {
|
|
59
|
+
if (!walletAddress || claiming) return;
|
|
60
|
+
setClaiming(true);
|
|
61
|
+
setClaimResult(null);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch("/api/parcel-reward", {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ wallet: walletAddress, action: "claim" }),
|
|
68
|
+
});
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
|
|
71
|
+
if (data.success) {
|
|
72
|
+
setClaimResult(`🎉 ${data.amount} SOL sent!`);
|
|
73
|
+
setReward((prev) => prev ? { ...prev, claimed: true } : prev);
|
|
74
|
+
onClaimed?.(data.amount);
|
|
75
|
+
|
|
76
|
+
// Auto-dismiss after showing success
|
|
77
|
+
setTimeout(() => setDismissed(true), 5000);
|
|
78
|
+
} else {
|
|
79
|
+
setClaimResult(data.error || "Claim failed");
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
setClaimResult("Network error — try again");
|
|
83
|
+
} finally {
|
|
84
|
+
setClaiming(false);
|
|
85
|
+
}
|
|
86
|
+
}, [walletAddress, claiming, onClaimed]);
|
|
87
|
+
|
|
88
|
+
// Don't render if not eligible, already dismissed, or no reward
|
|
89
|
+
if (!reward?.eligible || dismissed || !visible) return null;
|
|
90
|
+
|
|
91
|
+
const { rank, totalSlots, rewardSOL, claimed } = reward;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="fixed bottom-32 left-1/2 -translate-x-1/2 z-40 animate-[slideUp_0.6s_ease-out]">
|
|
95
|
+
<div className="relative bg-gradient-to-br from-amber-900/90 via-yellow-900/80 to-amber-800/90 backdrop-blur-2xl border border-amber-500/30 rounded-3xl px-6 py-5 shadow-2xl shadow-amber-900/50 min-w-[320px] max-w-[400px]">
|
|
96
|
+
{/* Glow effect */}
|
|
97
|
+
<div className="absolute -inset-1 bg-gradient-to-r from-amber-400/20 via-yellow-400/10 to-amber-500/20 rounded-3xl blur-xl -z-10 animate-pulse" />
|
|
98
|
+
|
|
99
|
+
{/* Close button */}
|
|
100
|
+
<button
|
|
101
|
+
onClick={() => setDismissed(true)}
|
|
102
|
+
className="absolute top-3 right-3 w-6 h-6 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white/40 hover:text-white/70 text-xs transition-all cursor-pointer"
|
|
103
|
+
>
|
|
104
|
+
✕
|
|
105
|
+
</button>
|
|
106
|
+
|
|
107
|
+
{/* Header */}
|
|
108
|
+
<div className="flex items-center gap-3 mb-3">
|
|
109
|
+
<div className="text-3xl animate-bounce">🎁</div>
|
|
110
|
+
<div>
|
|
111
|
+
<h3 className="text-sm font-bold text-amber-200 tracking-tight">
|
|
112
|
+
Hidden Parcel Reward!
|
|
113
|
+
</h3>
|
|
114
|
+
<p className="text-[10px] text-amber-300/50">
|
|
115
|
+
Parcel #{rank} of {totalSlots} • Early Builder Bonus
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{/* Reward amount */}
|
|
121
|
+
<div className="bg-black/30 rounded-2xl px-4 py-3 mb-3 border border-amber-500/10">
|
|
122
|
+
<div className="flex items-center justify-between">
|
|
123
|
+
<div>
|
|
124
|
+
<p className="text-[9px] uppercase tracking-wider text-amber-400/40 mb-0.5">
|
|
125
|
+
Hidden in your parcel
|
|
126
|
+
</p>
|
|
127
|
+
<p className="text-2xl font-bold text-amber-200 tabular-nums">
|
|
128
|
+
{rewardSOL} <span className="text-base text-amber-400/60">SOL</span>
|
|
129
|
+
</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="text-4xl opacity-80">
|
|
132
|
+
💰
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Claim button or status */}
|
|
138
|
+
{claimResult ? (
|
|
139
|
+
<div className={`text-center py-2 rounded-xl text-sm font-medium ${
|
|
140
|
+
claimResult.includes("🎉")
|
|
141
|
+
? "bg-emerald-500/20 text-emerald-300 border border-emerald-500/20"
|
|
142
|
+
: "bg-red-500/20 text-red-300 border border-red-500/20"
|
|
143
|
+
}`}>
|
|
144
|
+
{claimResult}
|
|
145
|
+
</div>
|
|
146
|
+
) : claimed ? (
|
|
147
|
+
<div className="text-center py-2 bg-emerald-500/10 rounded-xl text-sm text-emerald-300/70 border border-emerald-500/10">
|
|
148
|
+
✅ Already claimed
|
|
149
|
+
</div>
|
|
150
|
+
) : (
|
|
151
|
+
<button
|
|
152
|
+
onClick={handleClaim}
|
|
153
|
+
disabled={claiming}
|
|
154
|
+
className={`w-full py-3 rounded-xl text-sm font-bold transition-all cursor-pointer ${
|
|
155
|
+
claiming
|
|
156
|
+
? "bg-amber-600/30 text-amber-300/50"
|
|
157
|
+
: "bg-gradient-to-r from-amber-500 to-yellow-500 hover:from-amber-400 hover:to-yellow-400 text-black shadow-lg shadow-amber-500/30 hover:shadow-amber-400/40 active:scale-95"
|
|
158
|
+
}`}
|
|
159
|
+
>
|
|
160
|
+
{claiming ? (
|
|
161
|
+
<span className="flex items-center justify-center gap-2">
|
|
162
|
+
<span className="w-4 h-4 border-2 border-amber-300/30 border-t-amber-300 rounded-full animate-spin" />
|
|
163
|
+
Claiming...
|
|
164
|
+
</span>
|
|
165
|
+
) : (
|
|
166
|
+
"⚡ Claim Reward"
|
|
167
|
+
)}
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
170
|
+
|
|
171
|
+
{/* Fine print */}
|
|
172
|
+
<p className="text-[8px] text-amber-400/25 text-center mt-2">
|
|
173
|
+
First {totalSlots} wallets get 0.069420 SOL hidden in their city parcels
|
|
174
|
+
</p>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<style jsx>{`
|
|
178
|
+
@keyframes slideUp {
|
|
179
|
+
from {
|
|
180
|
+
opacity: 0;
|
|
181
|
+
transform: translate(-50%, 20px);
|
|
182
|
+
}
|
|
183
|
+
to {
|
|
184
|
+
opacity: 1;
|
|
185
|
+
transform: translate(-50%, 0);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
`}</style>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
interface ParkProps {
|
|
4
|
+
position: [number, number, number];
|
|
5
|
+
width: number;
|
|
6
|
+
depth: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A park block — green ground with walking paths.
|
|
11
|
+
* Trees are rendered externally via InstancedTrees for performance.
|
|
12
|
+
*/
|
|
13
|
+
export default function Park({ position, width, depth }: ParkProps) {
|
|
14
|
+
return (
|
|
15
|
+
<group>
|
|
16
|
+
{/* Green ground for the park */}
|
|
17
|
+
<mesh
|
|
18
|
+
position={[position[0], 0.01, position[2]]}
|
|
19
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
20
|
+
>
|
|
21
|
+
<planeGeometry args={[width, depth]} />
|
|
22
|
+
<meshStandardMaterial color="#2d6b2d" />
|
|
23
|
+
</mesh>
|
|
24
|
+
|
|
25
|
+
{/* Walking path through the park */}
|
|
26
|
+
<mesh
|
|
27
|
+
position={[position[0], 0.03, position[2]]}
|
|
28
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
29
|
+
>
|
|
30
|
+
<planeGeometry args={[0.8, depth * 0.85]} />
|
|
31
|
+
<meshStandardMaterial color="#6b6055" />
|
|
32
|
+
</mesh>
|
|
33
|
+
<mesh
|
|
34
|
+
position={[position[0], 0.03, position[2]]}
|
|
35
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
36
|
+
>
|
|
37
|
+
<planeGeometry args={[width * 0.85, 0.8]} />
|
|
38
|
+
<meshStandardMaterial color="#6b6055" />
|
|
39
|
+
</mesh>
|
|
40
|
+
</group>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { PhantomProvider, darkTheme, AddressType } from "@phantom/react-sdk";
|
|
3
|
+
|
|
4
|
+
export default function PhantomWrapper({ children }: { children: React.ReactNode }) {
|
|
5
|
+
return (
|
|
6
|
+
<PhantomProvider
|
|
7
|
+
config={{
|
|
8
|
+
providers: ["google", "apple", "injected"],
|
|
9
|
+
appId: "a09f21aa-0afd-4493-a64d-ff32d210f47a",
|
|
10
|
+
addressTypes: [AddressType.solana],
|
|
11
|
+
authOptions: {
|
|
12
|
+
redirectUrl: "https://solanapolis.com/auth/callback",
|
|
13
|
+
},
|
|
14
|
+
}}
|
|
15
|
+
theme={darkTheme}
|
|
16
|
+
appName="solanapolis"
|
|
17
|
+
appIcon="/helius-icon.svg"
|
|
18
|
+
>
|
|
19
|
+
{children}
|
|
20
|
+
</PhantomProvider>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PixelStreamViewer — embeds the UE5 SOLANApolis build via Pixel Streaming.
|
|
5
|
+
*
|
|
6
|
+
* The UE5 process runs a WebRTC signaling server (default ws://localhost:8888).
|
|
7
|
+
* This component negotiates the WebRTC offer/answer, receives the H.264 stream,
|
|
8
|
+
* and forwards keyboard/mouse/touch events back to UE5 using the standard
|
|
9
|
+
* Pixel Streaming input protocol.
|
|
10
|
+
*
|
|
11
|
+
* Setup:
|
|
12
|
+
* 1. Build and launch the UE5 SOLANApolis project with -PixelStreamingIP=0.0.0.0 -PixelStreamingPort=8888
|
|
13
|
+
* 2. Set NEXT_PUBLIC_PIXEL_STREAMING_URL in .env.local (ws://your-server:8888)
|
|
14
|
+
* 3. Drop <PixelStreamViewer /> anywhere in your scene
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
18
|
+
|
|
19
|
+
const SIGNALING_URL =
|
|
20
|
+
process.env.NEXT_PUBLIC_PIXEL_STREAMING_URL ?? "ws://localhost:8888";
|
|
21
|
+
|
|
22
|
+
// ── Pixel Streaming message types ────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const PS_MSG = {
|
|
25
|
+
IceCandidate: 50,
|
|
26
|
+
Answer: 51,
|
|
27
|
+
// UE → browser
|
|
28
|
+
Config: 100,
|
|
29
|
+
PlayerCount: 101,
|
|
30
|
+
QualityControl: 102,
|
|
31
|
+
// Input (browser → UE)
|
|
32
|
+
UIInteraction: 50,
|
|
33
|
+
KeyDown: 60,
|
|
34
|
+
KeyUp: 61,
|
|
35
|
+
MouseMove: 70,
|
|
36
|
+
MouseDown: 71,
|
|
37
|
+
MouseUp: 72,
|
|
38
|
+
MouseWheel: 73,
|
|
39
|
+
TouchStart: 80,
|
|
40
|
+
TouchEnd: 81,
|
|
41
|
+
TouchMove: 82,
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
function encodeKeyDown(keyCode: number, isRepeat: boolean): Uint8Array {
|
|
45
|
+
const buf = new Uint8Array(4);
|
|
46
|
+
buf[0] = PS_MSG.KeyDown;
|
|
47
|
+
buf[1] = keyCode;
|
|
48
|
+
buf[2] = isRepeat ? 1 : 0;
|
|
49
|
+
buf[3] = 0;
|
|
50
|
+
return buf;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function encodeKeyUp(keyCode: number): Uint8Array {
|
|
54
|
+
const buf = new Uint8Array(3);
|
|
55
|
+
buf[0] = PS_MSG.KeyUp;
|
|
56
|
+
buf[1] = keyCode;
|
|
57
|
+
buf[2] = 0;
|
|
58
|
+
return buf;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function encodeMouseMove(
|
|
62
|
+
x: number, y: number, dx: number, dy: number,
|
|
63
|
+
width: number, height: number
|
|
64
|
+
): Uint8Array {
|
|
65
|
+
const buf = new DataView(new ArrayBuffer(9));
|
|
66
|
+
buf.setUint8(0, PS_MSG.MouseMove);
|
|
67
|
+
buf.setUint16(1, Math.round((x / width) * 65535), true);
|
|
68
|
+
buf.setUint16(3, Math.round((y / height) * 65535), true);
|
|
69
|
+
buf.setInt16(5, Math.round(dx), true);
|
|
70
|
+
buf.setInt16(7, Math.round(dy), true);
|
|
71
|
+
return new Uint8Array(buf.buffer);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function encodeMouseButton(type: number, button: number, x: number, y: number,
|
|
75
|
+
width: number, height: number): Uint8Array {
|
|
76
|
+
const buf = new DataView(new ArrayBuffer(6));
|
|
77
|
+
buf.setUint8(0, type);
|
|
78
|
+
buf.setUint8(1, button);
|
|
79
|
+
buf.setUint16(2, Math.round((x / width) * 65535), true);
|
|
80
|
+
buf.setUint16(4, Math.round((y / height) * 65535), true);
|
|
81
|
+
return new Uint8Array(buf.buffer);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
interface Props {
|
|
87
|
+
/** Called when UE5 stream is connected */
|
|
88
|
+
onConnected?: () => void;
|
|
89
|
+
/** Called when stream disconnects */
|
|
90
|
+
onDisconnected?: () => void;
|
|
91
|
+
className?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type StreamStatus = "idle" | "connecting" | "connected" | "error";
|
|
95
|
+
|
|
96
|
+
export default function PixelStreamViewer({ onConnected, onDisconnected, className }: Props) {
|
|
97
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
98
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
99
|
+
const pcRef = useRef<RTCPeerConnection | null>(null);
|
|
100
|
+
const dcRef = useRef<RTCDataChannel | null>(null);
|
|
101
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
102
|
+
|
|
103
|
+
const [status, setStatus] = useState<StreamStatus>("idle");
|
|
104
|
+
const [error, setError] = useState<string>("");
|
|
105
|
+
|
|
106
|
+
// ── WebRTC + WebSocket setup ───────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
const connect = useCallback(() => {
|
|
109
|
+
setStatus("connecting");
|
|
110
|
+
setError("");
|
|
111
|
+
|
|
112
|
+
const ws = new WebSocket(SIGNALING_URL);
|
|
113
|
+
wsRef.current = ws;
|
|
114
|
+
|
|
115
|
+
const pc = new RTCPeerConnection({
|
|
116
|
+
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
|
117
|
+
});
|
|
118
|
+
pcRef.current = pc;
|
|
119
|
+
|
|
120
|
+
// Data channel for input events (UE5 expects channel named "cirrus")
|
|
121
|
+
const dc = pc.createDataChannel("cirrus", { ordered: true });
|
|
122
|
+
dcRef.current = dc;
|
|
123
|
+
|
|
124
|
+
// ── Video track ─────────────────────────────────────────────────────────
|
|
125
|
+
pc.ontrack = (evt) => {
|
|
126
|
+
if (videoRef.current && evt.streams[0]) {
|
|
127
|
+
videoRef.current.srcObject = evt.streams[0];
|
|
128
|
+
videoRef.current.play().catch(() => {/* autoplay blocked — user must click */ });
|
|
129
|
+
setStatus("connected");
|
|
130
|
+
onConnected?.();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── ICE ─────────────────────────────────────────────────────────────────
|
|
135
|
+
pc.onicecandidate = (evt) => {
|
|
136
|
+
if (evt.candidate && ws.readyState === WebSocket.OPEN) {
|
|
137
|
+
ws.send(JSON.stringify({ type: "iceCandidate", candidate: evt.candidate }));
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
pc.oniceconnectionstatechange = () => {
|
|
142
|
+
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
|
143
|
+
setStatus("error");
|
|
144
|
+
setError("WebRTC connection lost");
|
|
145
|
+
onDisconnected?.();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ── Signaling messages ───────────────────────────────────────────────────
|
|
150
|
+
ws.onmessage = async (evt) => {
|
|
151
|
+
let msg: Record<string, unknown>;
|
|
152
|
+
try { msg = JSON.parse(evt.data as string); } catch { return; }
|
|
153
|
+
|
|
154
|
+
if (msg.type === "config") {
|
|
155
|
+
// UE5 sends config first — then we create an offer
|
|
156
|
+
const offer = await pc.createOffer({ offerToReceiveVideo: true, offerToReceiveAudio: true });
|
|
157
|
+
await pc.setLocalDescription(offer);
|
|
158
|
+
ws.send(JSON.stringify({ type: "offer", sdp: pc.localDescription }));
|
|
159
|
+
} else if (msg.type === "answer") {
|
|
160
|
+
const answer = (msg as unknown as { sdp?: RTCSessionDescriptionInit }).sdp;
|
|
161
|
+
if (!answer) return;
|
|
162
|
+
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
|
163
|
+
} else if (msg.type === "iceCandidate") {
|
|
164
|
+
try {
|
|
165
|
+
await pc.addIceCandidate(new RTCIceCandidate((msg.candidate as RTCIceCandidateInit)));
|
|
166
|
+
} catch { /* ignore */ }
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
ws.onerror = () => {
|
|
171
|
+
setStatus("error");
|
|
172
|
+
setError(`Cannot reach UE5 at ${SIGNALING_URL} — is the game running?`);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
ws.onclose = () => {
|
|
176
|
+
if (status !== "connected") {
|
|
177
|
+
setStatus("error");
|
|
178
|
+
setError("Signaling server closed connection");
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}, [onConnected, onDisconnected, status]);
|
|
182
|
+
|
|
183
|
+
const disconnect = useCallback(() => {
|
|
184
|
+
dcRef.current?.close();
|
|
185
|
+
pcRef.current?.close();
|
|
186
|
+
wsRef.current?.close();
|
|
187
|
+
if (videoRef.current) videoRef.current.srcObject = null;
|
|
188
|
+
setStatus("idle");
|
|
189
|
+
onDisconnected?.();
|
|
190
|
+
}, [onDisconnected]);
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
return () => { disconnect(); };
|
|
194
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
195
|
+
|
|
196
|
+
// ── Input forwarding ──────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
const sendInput = useCallback((data: Uint8Array) => {
|
|
199
|
+
if (dcRef.current?.readyState === "open") {
|
|
200
|
+
const payload = new Uint8Array(data.byteLength);
|
|
201
|
+
payload.set(data);
|
|
202
|
+
dcRef.current.send(payload);
|
|
203
|
+
}
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
206
|
+
const getContainerSize = () => {
|
|
207
|
+
const el = containerRef.current;
|
|
208
|
+
return el ? { width: el.clientWidth, height: el.clientHeight } : { width: 1920, height: 1080 };
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
sendInput(encodeKeyDown(e.keyCode, e.repeat));
|
|
214
|
+
}, [sendInput]);
|
|
215
|
+
|
|
216
|
+
const handleKeyUp = useCallback((e: React.KeyboardEvent) => {
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
sendInput(encodeKeyUp(e.keyCode));
|
|
219
|
+
}, [sendInput]);
|
|
220
|
+
|
|
221
|
+
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
222
|
+
const { width, height } = getContainerSize();
|
|
223
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
224
|
+
sendInput(encodeMouseMove(
|
|
225
|
+
e.clientX - rect.left, e.clientY - rect.top,
|
|
226
|
+
e.movementX, e.movementY,
|
|
227
|
+
width, height
|
|
228
|
+
));
|
|
229
|
+
}, [sendInput]);
|
|
230
|
+
|
|
231
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
232
|
+
const { width, height } = getContainerSize();
|
|
233
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
234
|
+
sendInput(encodeMouseButton(PS_MSG.MouseDown, e.button,
|
|
235
|
+
e.clientX - rect.left, e.clientY - rect.top, width, height));
|
|
236
|
+
}, [sendInput]);
|
|
237
|
+
|
|
238
|
+
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
|
239
|
+
const { width, height } = getContainerSize();
|
|
240
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
241
|
+
sendInput(encodeMouseButton(PS_MSG.MouseUp, e.button,
|
|
242
|
+
e.clientX - rect.left, e.clientY - rect.top, width, height));
|
|
243
|
+
}, [sendInput]);
|
|
244
|
+
|
|
245
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div
|
|
249
|
+
ref={containerRef}
|
|
250
|
+
className={className}
|
|
251
|
+
style={{ position: "relative", background: "#000", overflow: "hidden" }}
|
|
252
|
+
tabIndex={0}
|
|
253
|
+
onKeyDown={handleKeyDown}
|
|
254
|
+
onKeyUp={handleKeyUp}
|
|
255
|
+
onMouseMove={handleMouseMove}
|
|
256
|
+
onMouseDown={handleMouseDown}
|
|
257
|
+
onMouseUp={handleMouseUp}
|
|
258
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
259
|
+
>
|
|
260
|
+
{/* Stream video */}
|
|
261
|
+
<video
|
|
262
|
+
ref={videoRef}
|
|
263
|
+
autoPlay
|
|
264
|
+
playsInline
|
|
265
|
+
muted={false}
|
|
266
|
+
style={{ width: "100%", height: "100%", objectFit: "cover", display: status === "connected" ? "block" : "none" }}
|
|
267
|
+
/>
|
|
268
|
+
|
|
269
|
+
{/* Idle / loading overlay */}
|
|
270
|
+
{status !== "connected" && (
|
|
271
|
+
<div style={{
|
|
272
|
+
position: "absolute", inset: 0, display: "flex", flexDirection: "column",
|
|
273
|
+
alignItems: "center", justifyContent: "center", gap: 16,
|
|
274
|
+
background: "linear-gradient(135deg, #0a0a12 0%, #12091e 100%)",
|
|
275
|
+
color: "#fff",
|
|
276
|
+
}}>
|
|
277
|
+
{/* Logo */}
|
|
278
|
+
<div style={{ fontSize: 28, fontWeight: 700, letterSpacing: "0.15em", color: "#E35930" }}>
|
|
279
|
+
SOLANApolis
|
|
280
|
+
</div>
|
|
281
|
+
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.5)", letterSpacing: "0.2em" }}>
|
|
282
|
+
UNREAL ENGINE 5 · PIXEL STREAMING
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{status === "error" && (
|
|
286
|
+
<div style={{
|
|
287
|
+
marginTop: 8, padding: "8px 16px", background: "rgba(255,60,60,0.15)",
|
|
288
|
+
border: "1px solid rgba(255,60,60,0.3)", borderRadius: 8,
|
|
289
|
+
fontSize: 12, color: "#ff6b6b", maxWidth: 360, textAlign: "center",
|
|
290
|
+
}}>
|
|
291
|
+
{error}
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{status === "connecting" && (
|
|
296
|
+
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.4)", letterSpacing: "0.15em" }}>
|
|
297
|
+
Connecting to UE5…
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
<button
|
|
302
|
+
onClick={status === "connecting" ? disconnect : connect}
|
|
303
|
+
style={{
|
|
304
|
+
marginTop: 8, padding: "10px 28px",
|
|
305
|
+
background: status === "connecting" ? "rgba(255,255,255,0.08)" : "#E35930",
|
|
306
|
+
border: "none", borderRadius: 8, color: "#fff",
|
|
307
|
+
fontSize: 13, fontWeight: 600, cursor: "pointer", letterSpacing: "0.08em",
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
{status === "connecting" ? "Cancel" : status === "error" ? "Retry" : "Launch UE5"}
|
|
311
|
+
</button>
|
|
312
|
+
|
|
313
|
+
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.2)", textAlign: "center", maxWidth: 280 }}>
|
|
314
|
+
Requires UE5 SOLANApolis running with Pixel Streaming enabled
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
{/* Connected HUD badge */}
|
|
320
|
+
{status === "connected" && (
|
|
321
|
+
<div style={{
|
|
322
|
+
position: "absolute", top: 12, right: 12,
|
|
323
|
+
display: "flex", alignItems: "center", gap: 6,
|
|
324
|
+
background: "rgba(0,0,0,0.6)", backdropFilter: "blur(8px)",
|
|
325
|
+
border: "1px solid rgba(227,89,48,0.3)", borderRadius: 20,
|
|
326
|
+
padding: "4px 10px", fontSize: 10, color: "rgba(255,255,255,0.7)",
|
|
327
|
+
pointerEvents: "none",
|
|
328
|
+
}}>
|
|
329
|
+
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#4caf50", display: "inline-block" }} />
|
|
330
|
+
UE5 LIVE
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|