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,1289 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
4
|
+
import { Canvas, useFrame } from "@react-three/fiber";
|
|
5
|
+
import { OrbitControls } from "@react-three/drei";
|
|
6
|
+
import { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
|
7
|
+
import * as THREE from "three";
|
|
8
|
+
import CityGrid from "./CityGrid";
|
|
9
|
+
import SceneLighting from "./SceneLighting";
|
|
10
|
+
import SelectionBeam from "./SelectionBeam";
|
|
11
|
+
import WalletPanel from "./WalletPanel";
|
|
12
|
+
import AuthPanel from "./AuthPanel";
|
|
13
|
+
import WalletSearch from "./WalletSearch";
|
|
14
|
+
import { WalletBuilding, PlacedWallet } from "@/types/wallet";
|
|
15
|
+
import { getBuildingDimensions, getWalletWorldPosition } from "@/lib/building-math";
|
|
16
|
+
import WindowTooltip, { WindowHoverInfo } from "./WindowTooltip";
|
|
17
|
+
import InstancedCars, { TrackedCarInfo } from "./InstancedCars";
|
|
18
|
+
import SwapPanel from "./SwapPanel";
|
|
19
|
+
import { useSwapEvents } from "@/lib/swap-events";
|
|
20
|
+
import HowItWorksModal from "./HowItWorksModal";
|
|
21
|
+
import { useAuth } from "@/context/AuthContext";
|
|
22
|
+
import { lazy, Suspense, useMemo } from "react";
|
|
23
|
+
import PlayerCar from "./PlayerCar";
|
|
24
|
+
import PlayerPlane from "./PlayerPlane";
|
|
25
|
+
import { buildCollisionMap } from "@/lib/collision-map";
|
|
26
|
+
import ActivityFeed from "./ActivityFeed";
|
|
27
|
+
import NetworkStats from "./NetworkStats";
|
|
28
|
+
import SwapParticles from "./SwapParticles";
|
|
29
|
+
import NewBuildingSpotlight from "./NewBuildingSpotlight";
|
|
30
|
+
import InstancedCityPlanes from "./InstancedCityPlanes";
|
|
31
|
+
import InstancedResidentCars from "./InstancedResidentCars";
|
|
32
|
+
import CitySlotsBadge from "./CitySlotsBadge";
|
|
33
|
+
import { getSlotCounts } from "@/lib/city-slots";
|
|
34
|
+
import ProjectileRenderer from "./ProjectileRenderer";
|
|
35
|
+
import GameHUD from "./GameHUD";
|
|
36
|
+
import { ProjectileManager } from "@/lib/projectile-system";
|
|
37
|
+
import { MultiplayerManager } from "@/lib/multiplayer-manager";
|
|
38
|
+
import LeaderboardPanel from "./LeaderboardPanel";
|
|
39
|
+
import GameChat from "./GameChat";
|
|
40
|
+
import BeachScene from "./BeachScene";
|
|
41
|
+
import { PlaneEngine, initAudio, playClick } from "@/lib/sound-engine";
|
|
42
|
+
import ParcelReward from "./ParcelReward";
|
|
43
|
+
import ParcelChallengeBanner from "./ParcelChallengeBanner";
|
|
44
|
+
import TreasureGate from "./TreasureGate";
|
|
45
|
+
import CockpitHUD from "./CockpitHUD";
|
|
46
|
+
import RealPlayerTags from "./RealPlayerTags";
|
|
47
|
+
import CityLandmarks from "./CityLandmarks";
|
|
48
|
+
import AITownNPCs from "./AITownNPCs";
|
|
49
|
+
import DubaiDistrict from "./DubaiDistrict";
|
|
50
|
+
|
|
51
|
+
const CitizenCardModal = lazy(() => import("./CitizenCardModal"));
|
|
52
|
+
const CesiumFlight = lazy(() => import("./CesiumFlight"));
|
|
53
|
+
const CesiumGlobe = lazy(() => import("./CesiumGlobe"));
|
|
54
|
+
|
|
55
|
+
const _trackTarget = new THREE.Vector3();
|
|
56
|
+
const _prevTarget = new THREE.Vector3();
|
|
57
|
+
const _delta = new THREE.Vector3();
|
|
58
|
+
const _desiredCamPos = new THREE.Vector3();
|
|
59
|
+
|
|
60
|
+
// Chase camera constants
|
|
61
|
+
const CHASE_DISTANCE = 18;
|
|
62
|
+
const CHASE_HEIGHT = 10;
|
|
63
|
+
const CHASE_LOOK_AHEAD = 8;
|
|
64
|
+
const CHASE_CAM_LERP = 0.06;
|
|
65
|
+
|
|
66
|
+
// Flight camera constants
|
|
67
|
+
const FLIGHT_DISTANCE = 25;
|
|
68
|
+
const FLIGHT_HEIGHT = 8;
|
|
69
|
+
const FLIGHT_LOOK_AHEAD = 12;
|
|
70
|
+
const FLIGHT_CAM_LERP = 0.04;
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
function CameraControls({
|
|
75
|
+
targetPosition,
|
|
76
|
+
trackingRef,
|
|
77
|
+
isTracking,
|
|
78
|
+
playerCarMode,
|
|
79
|
+
flyMode,
|
|
80
|
+
headingRef,
|
|
81
|
+
}: {
|
|
82
|
+
targetPosition: [number, number, number] | null;
|
|
83
|
+
trackingRef: React.MutableRefObject<[number, number, number] | null>;
|
|
84
|
+
isTracking: boolean;
|
|
85
|
+
playerCarMode: boolean;
|
|
86
|
+
flyMode: boolean;
|
|
87
|
+
headingRef: React.MutableRefObject<number>;
|
|
88
|
+
}) {
|
|
89
|
+
const controlsRef = useRef<OrbitControlsImpl>(null);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const controls = controlsRef.current;
|
|
93
|
+
if (!controls) return;
|
|
94
|
+
|
|
95
|
+
if (flyMode || playerCarMode) {
|
|
96
|
+
// Disable all user camera manipulation in chase-cam or flight-cam mode
|
|
97
|
+
controls.enableRotate = false;
|
|
98
|
+
controls.enablePan = false;
|
|
99
|
+
controls.enableZoom = false;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (isTracking) {
|
|
104
|
+
// Orbit mode for AI car tracking — useFrame handles position
|
|
105
|
+
controls.enableRotate = true;
|
|
106
|
+
controls.enablePan = false;
|
|
107
|
+
controls.enableZoom = true;
|
|
108
|
+
controls.mouseButtons = {
|
|
109
|
+
LEFT: THREE.MOUSE.ROTATE,
|
|
110
|
+
MIDDLE: THREE.MOUSE.DOLLY,
|
|
111
|
+
RIGHT: THREE.MOUSE.PAN,
|
|
112
|
+
};
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
controls.enableZoom = true;
|
|
117
|
+
|
|
118
|
+
if (targetPosition) {
|
|
119
|
+
const target = new THREE.Vector3(...targetPosition);
|
|
120
|
+
const start = controls.target.clone();
|
|
121
|
+
const startTime = performance.now();
|
|
122
|
+
const duration = 400;
|
|
123
|
+
|
|
124
|
+
function animate() {
|
|
125
|
+
const elapsed = performance.now() - startTime;
|
|
126
|
+
const t = Math.min(elapsed / duration, 1);
|
|
127
|
+
const ease = 1 - Math.pow(1 - t, 3);
|
|
128
|
+
|
|
129
|
+
controls!.target.lerpVectors(start, target, ease);
|
|
130
|
+
controls!.update();
|
|
131
|
+
|
|
132
|
+
if (t < 1) {
|
|
133
|
+
requestAnimationFrame(animate);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
animate();
|
|
137
|
+
|
|
138
|
+
controls.enableRotate = true;
|
|
139
|
+
controls.enablePan = false;
|
|
140
|
+
controls.mouseButtons = {
|
|
141
|
+
LEFT: THREE.MOUSE.ROTATE,
|
|
142
|
+
MIDDLE: THREE.MOUSE.DOLLY,
|
|
143
|
+
RIGHT: THREE.MOUSE.PAN,
|
|
144
|
+
};
|
|
145
|
+
} else {
|
|
146
|
+
controls.enableRotate = true;
|
|
147
|
+
controls.enablePan = true;
|
|
148
|
+
controls.mouseButtons = {
|
|
149
|
+
LEFT: THREE.MOUSE.PAN,
|
|
150
|
+
MIDDLE: THREE.MOUSE.DOLLY,
|
|
151
|
+
RIGHT: THREE.MOUSE.ROTATE,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}, [targetPosition, isTracking, playerCarMode, flyMode]);
|
|
155
|
+
|
|
156
|
+
useFrame(() => {
|
|
157
|
+
const controls = controlsRef.current;
|
|
158
|
+
if (!controls || !isTracking || !trackingRef.current) return;
|
|
159
|
+
|
|
160
|
+
const pos = trackingRef.current;
|
|
161
|
+
|
|
162
|
+
if (flyMode) {
|
|
163
|
+
// Flight camera: behind & slightly above the plane
|
|
164
|
+
const heading = headingRef.current;
|
|
165
|
+
_desiredCamPos.set(
|
|
166
|
+
pos[0] - Math.sin(heading) * FLIGHT_DISTANCE,
|
|
167
|
+
pos[1] + FLIGHT_HEIGHT,
|
|
168
|
+
pos[2] - Math.cos(heading) * FLIGHT_DISTANCE,
|
|
169
|
+
);
|
|
170
|
+
controls.object.position.lerp(_desiredCamPos, FLIGHT_CAM_LERP);
|
|
171
|
+
|
|
172
|
+
_trackTarget.set(
|
|
173
|
+
pos[0] + Math.sin(heading) * FLIGHT_LOOK_AHEAD,
|
|
174
|
+
pos[1],
|
|
175
|
+
pos[2] + Math.cos(heading) * FLIGHT_LOOK_AHEAD,
|
|
176
|
+
);
|
|
177
|
+
controls.target.lerp(_trackTarget, FLIGHT_CAM_LERP);
|
|
178
|
+
controls.update();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (playerCarMode) {
|
|
183
|
+
// Chase camera: position behind & above the car, look ahead of it
|
|
184
|
+
const heading = headingRef.current;
|
|
185
|
+
_desiredCamPos.set(
|
|
186
|
+
pos[0] - Math.sin(heading) * CHASE_DISTANCE,
|
|
187
|
+
pos[1] + CHASE_HEIGHT,
|
|
188
|
+
pos[2] - Math.cos(heading) * CHASE_DISTANCE,
|
|
189
|
+
);
|
|
190
|
+
controls.object.position.lerp(_desiredCamPos, CHASE_CAM_LERP);
|
|
191
|
+
|
|
192
|
+
// Look ahead of the car
|
|
193
|
+
_trackTarget.set(
|
|
194
|
+
pos[0] + Math.sin(heading) * CHASE_LOOK_AHEAD,
|
|
195
|
+
pos[1] + 1,
|
|
196
|
+
pos[2] + Math.cos(heading) * CHASE_LOOK_AHEAD,
|
|
197
|
+
);
|
|
198
|
+
controls.target.lerp(_trackTarget, CHASE_CAM_LERP);
|
|
199
|
+
controls.update();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// AI car tracking — translate both target and camera by the same delta
|
|
204
|
+
_trackTarget.set(pos[0], pos[1], pos[2]);
|
|
205
|
+
_prevTarget.copy(controls.target);
|
|
206
|
+
controls.target.lerp(_trackTarget, 0.08);
|
|
207
|
+
_delta.subVectors(controls.target, _prevTarget);
|
|
208
|
+
controls.object.position.add(_delta);
|
|
209
|
+
controls.update();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<OrbitControls
|
|
214
|
+
ref={controlsRef}
|
|
215
|
+
makeDefault
|
|
216
|
+
minDistance={10}
|
|
217
|
+
maxDistance={500}
|
|
218
|
+
maxPolarAngle={Math.PI / 2.1}
|
|
219
|
+
enableDamping
|
|
220
|
+
dampingFactor={0.05}
|
|
221
|
+
enableRotate={false}
|
|
222
|
+
enablePan={true}
|
|
223
|
+
panSpeed={1.5}
|
|
224
|
+
screenSpacePanning={false}
|
|
225
|
+
/>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const TIME_PRESETS = [
|
|
230
|
+
{ id: "sunrise", label: "Sunrise", time: 0.0 },
|
|
231
|
+
{ id: "day", label: "Day", time: 0.25 },
|
|
232
|
+
{ id: "sunset", label: "Sunset", time: 0.5 },
|
|
233
|
+
{ id: "night", label: "Night", time: 0.75 },
|
|
234
|
+
] as const;
|
|
235
|
+
|
|
236
|
+
export default function CityScene() {
|
|
237
|
+
const [selectedWallet, setSelectedWallet] = useState<WalletBuilding | null>(null);
|
|
238
|
+
const [selectedPosition, setSelectedPosition] = useState<[number, number, number] | null>(null);
|
|
239
|
+
const [wallets, setWallets] = useState<PlacedWallet[]>([]);
|
|
240
|
+
const [loading, setLoading] = useState(true);
|
|
241
|
+
const [windowHover, setWindowHover] = useState<WindowHoverInfo | null>(null);
|
|
242
|
+
const [showCard, setShowCard] = useState(false);
|
|
243
|
+
const [buildingToast, setBuildingToast] = useState<{ message: string; type: 'loading' | 'success' | 'error' } | null>(null);
|
|
244
|
+
const { profile, phantomAddress, phantomConnected, openPhantomModal } = useAuth();
|
|
245
|
+
const activeWalletAddress = phantomAddress ?? profile?.wallet_address ?? null;
|
|
246
|
+
const walletConnected = phantomConnected || !!activeWalletAddress;
|
|
247
|
+
|
|
248
|
+
// Treasure gate — show the play-to-win game before entering the city
|
|
249
|
+
const [treasureGatePlayed, setTreasureGatePlayed] = useState(() => {
|
|
250
|
+
if (typeof window !== 'undefined') {
|
|
251
|
+
return !!sessionStorage.getItem('treasure-gate-played');
|
|
252
|
+
}
|
|
253
|
+
return false;
|
|
254
|
+
});
|
|
255
|
+
const showTreasureGate = !!activeWalletAddress && !treasureGatePlayed && !loading;
|
|
256
|
+
|
|
257
|
+
function handleTreasureGateComplete() {
|
|
258
|
+
sessionStorage.setItem('treasure-gate-played', '1');
|
|
259
|
+
setTreasureGatePlayed(true);
|
|
260
|
+
// Auto-claim building after entering the city from treasure gate
|
|
261
|
+
// handleClaimBuilding has its own guards, so always attempt
|
|
262
|
+
setTimeout(() => {
|
|
263
|
+
if (activeWalletAddress) {
|
|
264
|
+
handleClaimBuilding();
|
|
265
|
+
}
|
|
266
|
+
}, 800);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Day/night cycle state (refs to avoid per-frame re-renders)
|
|
270
|
+
// Start at sunset (0.50) for dramatic first impression — blazing orange sky
|
|
271
|
+
const timeRef = useRef(0.50);
|
|
272
|
+
const autoModeRef = useRef(true);
|
|
273
|
+
const [activePreset, setActivePreset] = useState("cycle");
|
|
274
|
+
|
|
275
|
+
// Swap events — mutable ref queue, no re-renders
|
|
276
|
+
const swapQueueRef = useSwapEvents(wallets);
|
|
277
|
+
|
|
278
|
+
// Particle burst queue for swap effects
|
|
279
|
+
const swapBurstRef = useRef<Array<{ x: number; y: number; z: number; color: string }>>([]);
|
|
280
|
+
|
|
281
|
+
// Tracked car state — trackedCar controls the panel UI,
|
|
282
|
+
// cameraFollowSlot controls which car the camera follows (persists after panel close)
|
|
283
|
+
const [trackedCar, setTrackedCar] = useState<TrackedCarInfo | null>(null);
|
|
284
|
+
const trackedCarPosRef = useRef<[number, number, number] | null>(null);
|
|
285
|
+
const [cameraFollowSlot, setCameraFollowSlot] = useState<number | null>(null);
|
|
286
|
+
|
|
287
|
+
// Player car mode
|
|
288
|
+
const [carMode, setCarMode] = useState(false);
|
|
289
|
+
const playerCarPosRef = useRef<[number, number, number] | null>(null);
|
|
290
|
+
const playerCarHeadingRef = useRef(0);
|
|
291
|
+
const carSpeedRef = useRef(0);
|
|
292
|
+
|
|
293
|
+
// In-city flight mode (Three.js)
|
|
294
|
+
const [flyMode, setFlyMode] = useState(false);
|
|
295
|
+
// World globe mode (Cesium globe)
|
|
296
|
+
const [worldFlyMode, setWorldFlyMode] = useState(false);
|
|
297
|
+
// Cesium real-world flight sim (separate from in-city fly)
|
|
298
|
+
const [cesiumFlightMode, setCesiumFlightMode] = useState(false);
|
|
299
|
+
const planePosRef = useRef<[number, number, number] | null>(null);
|
|
300
|
+
const planeHeadingRef = useRef(0);
|
|
301
|
+
const planeEngineRef = useRef<PlaneEngine | null>(null);
|
|
302
|
+
|
|
303
|
+
// Plane engine sound lifecycle
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (flyMode) {
|
|
306
|
+
initAudio();
|
|
307
|
+
const eng = new PlaneEngine();
|
|
308
|
+
eng.start();
|
|
309
|
+
eng.setThrottle(0.5);
|
|
310
|
+
planeEngineRef.current = eng;
|
|
311
|
+
} else {
|
|
312
|
+
planeEngineRef.current?.stop();
|
|
313
|
+
planeEngineRef.current = null;
|
|
314
|
+
}
|
|
315
|
+
return () => {
|
|
316
|
+
planeEngineRef.current?.stop();
|
|
317
|
+
planeEngineRef.current = null;
|
|
318
|
+
};
|
|
319
|
+
}, [flyMode]);
|
|
320
|
+
|
|
321
|
+
// New building spotlights — track recently added wallet addresses
|
|
322
|
+
const [newSpotlights, setNewSpotlights] = useState<Array<{ address: string; position: [number, number, number]; height: number; id: number }>>([]);
|
|
323
|
+
const spotlightIdRef = useRef(0);
|
|
324
|
+
|
|
325
|
+
const collisionMap = useMemo(
|
|
326
|
+
() => (carMode ? buildCollisionMap(wallets) : null),
|
|
327
|
+
[wallets, carMode],
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Projectile system + position tracking for HUD
|
|
331
|
+
const [projectileManager] = useState(() => new ProjectileManager());
|
|
332
|
+
const planePositionsRef = useRef<Array<{ x: number; y: number; z: number }>>([]);
|
|
333
|
+
const residentCarPositionsRef = useRef<Array<{ x: number; y: number; z: number }>>([]);
|
|
334
|
+
const planePitchRef = useRef(0);
|
|
335
|
+
const planeSpeedRef = useRef(0);
|
|
336
|
+
|
|
337
|
+
// Building center positions for minimap (computed once when wallets change)
|
|
338
|
+
const buildingPositions = useMemo(() => {
|
|
339
|
+
return wallets.map(w => {
|
|
340
|
+
const dims = getBuildingDimensions(w);
|
|
341
|
+
const pos = getWalletWorldPosition(w, dims);
|
|
342
|
+
return { x: pos[0], z: pos[2] };
|
|
343
|
+
});
|
|
344
|
+
}, [wallets]);
|
|
345
|
+
|
|
346
|
+
// Active combat mode flag
|
|
347
|
+
const combatActive = carMode || flyMode;
|
|
348
|
+
|
|
349
|
+
// Multiplayer manager — handles realtime sync and session persistence
|
|
350
|
+
const [mpManager] = useState(() => new MultiplayerManager());
|
|
351
|
+
|
|
352
|
+
// Connect multiplayer when wallet is available
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
if (activeWalletAddress) {
|
|
355
|
+
mpManager.connect(activeWalletAddress);
|
|
356
|
+
}
|
|
357
|
+
return () => { mpManager.disconnect(); };
|
|
358
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
359
|
+
}, [activeWalletAddress]);
|
|
360
|
+
|
|
361
|
+
// Keep multiplayer position/score getters up to date
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
mpManager.getPosition = () => {
|
|
364
|
+
const pos = carMode ? playerCarPosRef.current : planePosRef.current;
|
|
365
|
+
if (!pos) return null;
|
|
366
|
+
return {
|
|
367
|
+
x: pos[0], y: pos[1], z: pos[2],
|
|
368
|
+
heading: carMode ? playerCarHeadingRef.current : planeHeadingRef.current,
|
|
369
|
+
};
|
|
370
|
+
};
|
|
371
|
+
mpManager.getScore = () => projectileManager.score;
|
|
372
|
+
}, [carMode, mpManager, projectileManager]);
|
|
373
|
+
|
|
374
|
+
const prevWalletAddresses = useRef<Set<string>>(new Set());
|
|
375
|
+
|
|
376
|
+
const fetchWallets = useCallback(async (): Promise<PlacedWallet[]> => {
|
|
377
|
+
try {
|
|
378
|
+
const res = await fetch(`/api/wallets?t=${Date.now()}`);
|
|
379
|
+
if (!res.ok) return [];
|
|
380
|
+
const data = await res.json();
|
|
381
|
+
const fresh: PlacedWallet[] = data.wallets ?? [];
|
|
382
|
+
|
|
383
|
+
// Detect newly added wallets and add spotlights
|
|
384
|
+
const prevAddrs = prevWalletAddresses.current;
|
|
385
|
+
for (const w of fresh) {
|
|
386
|
+
if (!prevAddrs.has(w.address) && prevAddrs.size > 0) {
|
|
387
|
+
const dims = getBuildingDimensions(w);
|
|
388
|
+
const pos = getWalletWorldPosition(w, dims);
|
|
389
|
+
const id = ++spotlightIdRef.current;
|
|
390
|
+
setNewSpotlights(prev => [...prev, { address: w.address, position: pos, height: dims.height, id }]);
|
|
391
|
+
// Auto-remove after 8 seconds
|
|
392
|
+
setTimeout(() => {
|
|
393
|
+
setNewSpotlights(prev => prev.filter(s => s.id !== id));
|
|
394
|
+
}, 8000);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
prevWalletAddresses.current = new Set(fresh.map(w => w.address));
|
|
398
|
+
|
|
399
|
+
setWallets(fresh);
|
|
400
|
+
return fresh;
|
|
401
|
+
} catch {
|
|
402
|
+
return [];
|
|
403
|
+
} finally {
|
|
404
|
+
setLoading(false);
|
|
405
|
+
}
|
|
406
|
+
}, []);
|
|
407
|
+
|
|
408
|
+
// Fetch placed wallets on mount
|
|
409
|
+
useEffect(() => {
|
|
410
|
+
fetchWallets();
|
|
411
|
+
}, [fetchWallets]);
|
|
412
|
+
|
|
413
|
+
// Check if wallet needs building when Phantom connects
|
|
414
|
+
const [buildingInProgress, setBuildingInProgress] = useState(false);
|
|
415
|
+
const [claimArrival, setClaimArrival] = useState<{ address: string; phase: 'driving' | 'reveal' } | null>(null);
|
|
416
|
+
const [hasBuilding, setHasBuilding] = useState(false);
|
|
417
|
+
const [onChainVerify, setOnChainVerify] = useState<{
|
|
418
|
+
phase: 'hashing' | 'verifying' | 'confirmed' | 'failed';
|
|
419
|
+
hash: string;
|
|
420
|
+
blockSlot: number;
|
|
421
|
+
steps: { label: string; done: boolean }[];
|
|
422
|
+
} | null>(null);
|
|
423
|
+
|
|
424
|
+
useEffect(() => {
|
|
425
|
+
if (!activeWalletAddress) {
|
|
426
|
+
setHasBuilding(false);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const exists = wallets.find(w => w.address === activeWalletAddress);
|
|
430
|
+
setHasBuilding(!!exists);
|
|
431
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
432
|
+
}, [activeWalletAddress, wallets]);
|
|
433
|
+
|
|
434
|
+
/** Generate a fake tx hash from wallet address for visual effect */
|
|
435
|
+
function genTxHash(addr: string): string {
|
|
436
|
+
let h = 0xdeadbeef;
|
|
437
|
+
for (let i = 0; i < addr.length; i++) h = Math.imul(h ^ addr.charCodeAt(i), 2654435761);
|
|
438
|
+
const hex = Array.from({ length: 64 }, (_, i) => ((h >> (i % 28)) & 0xf).toString(16)).join('');
|
|
439
|
+
return hex;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** User clicks "Claim My Building" — on-chain verify animation + car arrival */
|
|
443
|
+
async function handleClaimBuilding() {
|
|
444
|
+
if (!activeWalletAddress || buildingInProgress) return;
|
|
445
|
+
setBuildingInProgress(true);
|
|
446
|
+
|
|
447
|
+
const txHash = genTxHash(activeWalletAddress);
|
|
448
|
+
const blockSlot = 280_000_000 + Math.floor(Math.random() * 1_000_000);
|
|
449
|
+
const steps = [
|
|
450
|
+
{ label: 'Reading wallet history', done: false },
|
|
451
|
+
{ label: 'Hashing on-chain data', done: false },
|
|
452
|
+
{ label: 'Verifying block inclusion', done: false },
|
|
453
|
+
{ label: 'Confirming parcel assignment', done: false },
|
|
454
|
+
];
|
|
455
|
+
|
|
456
|
+
// Phase 1: On-chain verification animation
|
|
457
|
+
setOnChainVerify({ phase: 'hashing', hash: txHash, blockSlot, steps });
|
|
458
|
+
|
|
459
|
+
// Animate steps one by one
|
|
460
|
+
for (let i = 0; i < steps.length; i++) {
|
|
461
|
+
await new Promise(r => setTimeout(r, 800 + Math.random() * 400));
|
|
462
|
+
setOnChainVerify(prev => {
|
|
463
|
+
if (!prev) return prev;
|
|
464
|
+
const updated = prev.steps.map((s, j) => j <= i ? { ...s, done: true } : s);
|
|
465
|
+
return { ...prev, steps: updated, phase: i < 2 ? 'hashing' : 'verifying' };
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Phase 2: Actually fetch wallet / create building
|
|
470
|
+
try {
|
|
471
|
+
const res = await fetch(`/api/wallet/${activeWalletAddress}`);
|
|
472
|
+
if (res.ok) {
|
|
473
|
+
const data = await res.json();
|
|
474
|
+
setBuildingToast({ message: data.isNew ? 'Building your parcel...' : 'Building found!', type: 'loading' });
|
|
475
|
+
|
|
476
|
+
// Show confirmed
|
|
477
|
+
setOnChainVerify(prev => prev ? { ...prev, phase: 'confirmed' } : prev);
|
|
478
|
+
await new Promise(r => setTimeout(r, 1200));
|
|
479
|
+
setOnChainVerify(null);
|
|
480
|
+
|
|
481
|
+
const freshWallets = await fetchWallets();
|
|
482
|
+
const placed = freshWallets.find((w: PlacedWallet) => w.address === activeWalletAddress);
|
|
483
|
+
if (placed) {
|
|
484
|
+
// Start the car arrival animation
|
|
485
|
+
setClaimArrival({ address: activeWalletAddress, phase: 'driving' });
|
|
486
|
+
const dims = getBuildingDimensions(placed);
|
|
487
|
+
const pos = getWalletWorldPosition(placed, dims);
|
|
488
|
+
handleSelectWallet(placed, pos);
|
|
489
|
+
setBuildingToast({ message: 'Arriving at your building...', type: 'loading' });
|
|
490
|
+
|
|
491
|
+
// Car drives in (3s)
|
|
492
|
+
setTimeout(() => {
|
|
493
|
+
setClaimArrival(prev => prev ? { ...prev, phase: 'reveal' } : null);
|
|
494
|
+
setBuildingToast({ message: 'Welcome to Solanapolis, citizen!', type: 'success' });
|
|
495
|
+
}, 3000);
|
|
496
|
+
|
|
497
|
+
// Show ID card after reveal (5s total)
|
|
498
|
+
setTimeout(() => {
|
|
499
|
+
setClaimArrival(null);
|
|
500
|
+
setBuildingToast(null);
|
|
501
|
+
setShowCard(true);
|
|
502
|
+
}, 5500);
|
|
503
|
+
} else {
|
|
504
|
+
setBuildingToast({ message: 'Building created!', type: 'success' });
|
|
505
|
+
setTimeout(() => setBuildingToast(null), 4000);
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
setOnChainVerify(prev => prev ? { ...prev, phase: 'failed' } : prev);
|
|
509
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
510
|
+
setOnChainVerify(null);
|
|
511
|
+
const err = await res.json().catch(() => ({ error: 'Failed' }));
|
|
512
|
+
setBuildingToast({ message: err.error || 'Could not create building', type: 'error' });
|
|
513
|
+
setTimeout(() => setBuildingToast(null), 5000);
|
|
514
|
+
}
|
|
515
|
+
} catch {
|
|
516
|
+
setOnChainVerify(prev => prev ? { ...prev, phase: 'failed' } : prev);
|
|
517
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
518
|
+
setOnChainVerify(null);
|
|
519
|
+
setBuildingToast({ message: 'Network error — try refreshing', type: 'error' });
|
|
520
|
+
setTimeout(() => setBuildingToast(null), 5000);
|
|
521
|
+
} finally {
|
|
522
|
+
setBuildingInProgress(false);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
function handleTimePreset(id: string, time: number) {
|
|
529
|
+
timeRef.current = time;
|
|
530
|
+
autoModeRef.current = false;
|
|
531
|
+
setActivePreset(id);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function handleCycle() {
|
|
535
|
+
autoModeRef.current = true;
|
|
536
|
+
setActivePreset("cycle");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
function handleSelectWallet(wallet: WalletBuilding, position: [number, number, number]) {
|
|
541
|
+
setCarMode(false);
|
|
542
|
+
setFlyMode(false);
|
|
543
|
+
setWorldFlyMode(false);
|
|
544
|
+
playerCarPosRef.current = null;
|
|
545
|
+
planePosRef.current = null;
|
|
546
|
+
setSelectedWallet(wallet);
|
|
547
|
+
setSelectedPosition(position);
|
|
548
|
+
setTrackedCar(null);
|
|
549
|
+
setCameraFollowSlot(null);
|
|
550
|
+
trackedCarPosRef.current = null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function handleDeselect() {
|
|
554
|
+
setSelectedWallet(null);
|
|
555
|
+
setSelectedPosition(null);
|
|
556
|
+
setTrackedCar(null);
|
|
557
|
+
setCameraFollowSlot(null);
|
|
558
|
+
trackedCarPosRef.current = null;
|
|
559
|
+
setFlyMode(false);
|
|
560
|
+
setWorldFlyMode(false);
|
|
561
|
+
planePosRef.current = null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
566
|
+
if (e.key === "Escape") {
|
|
567
|
+
if (cesiumFlightMode) {
|
|
568
|
+
setCesiumFlightMode(false);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (worldFlyMode) {
|
|
572
|
+
setWorldFlyMode(false);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (flyMode) {
|
|
576
|
+
setFlyMode(false);
|
|
577
|
+
planePosRef.current = null;
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (carMode) {
|
|
581
|
+
setCarMode(false);
|
|
582
|
+
playerCarPosRef.current = null;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
handleDeselect();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
window.addEventListener("keydown", onKeyDown);
|
|
589
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
590
|
+
}, [carMode, flyMode, worldFlyMode, cesiumFlightMode]);
|
|
591
|
+
|
|
592
|
+
function handleClickAddress(address: string) {
|
|
593
|
+
const wallet = wallets.find(w => w.address === address);
|
|
594
|
+
if (!wallet) return;
|
|
595
|
+
const dims = getBuildingDimensions(wallet);
|
|
596
|
+
const pos = getWalletWorldPosition(wallet, dims);
|
|
597
|
+
handleSelectWallet(wallet, pos);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function handleClickCar(info: TrackedCarInfo) {
|
|
601
|
+
setCarMode(false);
|
|
602
|
+
setFlyMode(false);
|
|
603
|
+
setWorldFlyMode(false);
|
|
604
|
+
playerCarPosRef.current = null;
|
|
605
|
+
planePosRef.current = null;
|
|
606
|
+
setTrackedCar(info);
|
|
607
|
+
setCameraFollowSlot(info.slotIndex);
|
|
608
|
+
setSelectedWallet(null);
|
|
609
|
+
setSelectedPosition(null);
|
|
610
|
+
setWindowHover(null);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function handleCarDied(slotIndex: number) {
|
|
614
|
+
setTrackedCar(prev => prev?.slotIndex === slotIndex ? null : prev);
|
|
615
|
+
setCameraFollowSlot(prev => prev === slotIndex ? null : prev);
|
|
616
|
+
trackedCarPosRef.current = null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function endCombatSession() {
|
|
620
|
+
mpManager.endSession(
|
|
621
|
+
projectileManager.score,
|
|
622
|
+
projectileManager.hits.filter(h => h.targetType === "plane").length,
|
|
623
|
+
projectileManager.hits.filter(h => h.targetType === "car").length,
|
|
624
|
+
projectileManager.projectiles.length,
|
|
625
|
+
);
|
|
626
|
+
projectileManager.reset();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return (
|
|
630
|
+
<div className="relative w-full h-screen">
|
|
631
|
+
{windowHover && <WindowTooltip {...windowHover} />}
|
|
632
|
+
|
|
633
|
+
{/* Top left — branding */}
|
|
634
|
+
<div className="absolute top-3 left-3 sm:top-5 sm:left-5 z-10 bg-black/50 backdrop-blur-xl border border-white/[0.08] rounded-2xl px-3.5 py-2.5 sm:px-5 sm:py-3.5">
|
|
635
|
+
<div className="flex items-center gap-2.5">
|
|
636
|
+
<img src="/helius-icon.svg" alt="Helius" className="w-6 h-6 sm:w-7 sm:h-7" />
|
|
637
|
+
<div>
|
|
638
|
+
<h1 className="text-lg sm:text-xl font-bold tracking-tight leading-tight" style={{ color: "#E35930" }}>SOLANApolis</h1>
|
|
639
|
+
<p className="text-[9px] text-white/30 tracking-wider leading-none hidden sm:block">powered by Helius on Solana</p>
|
|
640
|
+
</div>
|
|
641
|
+
</div>
|
|
642
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
643
|
+
<CitySlotsBadge counts={getSlotCounts(wallets.length)} loading={loading} />
|
|
644
|
+
<HowItWorksModal />
|
|
645
|
+
{(() => {
|
|
646
|
+
const myAddr = profile?.wallet_address || activeWalletAddress;
|
|
647
|
+
const myWallet = myAddr
|
|
648
|
+
? wallets.find((w) => w.address === myAddr && (!w.ingestionStatus || w.ingestionStatus === "complete"))
|
|
649
|
+
: null;
|
|
650
|
+
if (!myWallet) return null;
|
|
651
|
+
return (
|
|
652
|
+
<>
|
|
653
|
+
<button
|
|
654
|
+
onClick={() => {
|
|
655
|
+
const dims = getBuildingDimensions(myWallet as PlacedWallet);
|
|
656
|
+
const pos = getWalletWorldPosition(myWallet as PlacedWallet, dims);
|
|
657
|
+
handleSelectWallet(myWallet, pos);
|
|
658
|
+
}}
|
|
659
|
+
className="px-2 py-0.5 bg-purple-500/15 hover:bg-purple-500/25 border border-purple-400/20 rounded-lg text-[10px] font-medium text-purple-300 transition-colors cursor-pointer"
|
|
660
|
+
>
|
|
661
|
+
My Building
|
|
662
|
+
</button>
|
|
663
|
+
<button
|
|
664
|
+
onClick={() => setShowCard(true)}
|
|
665
|
+
className="px-2 py-0.5 bg-[#E35930]/15 hover:bg-[#E35930]/25 border border-[#E35930]/20 rounded-lg text-[10px] font-medium text-[#E35930] transition-colors cursor-pointer"
|
|
666
|
+
>
|
|
667
|
+
ID Card
|
|
668
|
+
</button>
|
|
669
|
+
</>
|
|
670
|
+
);
|
|
671
|
+
})()}
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
{/* Building creation toast */}
|
|
676
|
+
{buildingToast && (
|
|
677
|
+
<div className="fixed top-28 left-1/2 -translate-x-1/2 z-40 animate-[fadeSlideDown_0.4s_ease-out]">
|
|
678
|
+
<div className={`flex items-center gap-3 px-5 py-3 rounded-2xl backdrop-blur-2xl border shadow-xl text-sm font-medium ${buildingToast.type === 'loading'
|
|
679
|
+
? 'bg-purple-900/80 border-purple-500/30 text-purple-200 shadow-purple-900/30'
|
|
680
|
+
: buildingToast.type === 'success'
|
|
681
|
+
? 'bg-emerald-900/80 border-emerald-500/30 text-emerald-200 shadow-emerald-900/30'
|
|
682
|
+
: 'bg-red-900/80 border-red-500/30 text-red-200 shadow-red-900/30'
|
|
683
|
+
}`}>
|
|
684
|
+
{buildingToast.type === 'loading' && (
|
|
685
|
+
<div className="w-4 h-4 border-2 border-purple-400/30 border-t-purple-300 rounded-full animate-spin" />
|
|
686
|
+
)}
|
|
687
|
+
<span>{buildingToast.message}</span>
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
)}
|
|
691
|
+
|
|
692
|
+
{/* Claim My Building — persistent button when wallet connected but no building */}
|
|
693
|
+
{walletConnected && activeWalletAddress && !hasBuilding && !claimArrival && (
|
|
694
|
+
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 z-40 animate-[fadeSlideUp_0.5s_ease-out]">
|
|
695
|
+
<button
|
|
696
|
+
onClick={handleClaimBuilding}
|
|
697
|
+
disabled={buildingInProgress}
|
|
698
|
+
className="group relative flex items-center gap-3 px-6 py-3.5 rounded-2xl text-sm font-bold text-white transition-all cursor-pointer active:scale-[0.97] disabled:opacity-70 disabled:cursor-wait"
|
|
699
|
+
style={{
|
|
700
|
+
background: 'linear-gradient(135deg, #E35930 0%, #c94420 50%, #E35930 100%)',
|
|
701
|
+
backgroundSize: '200% 200%',
|
|
702
|
+
animation: buildingInProgress ? 'none' : 'claimShimmer 3s ease infinite',
|
|
703
|
+
boxShadow: '0 0 30px rgba(227,89,48,0.4), 0 0 60px rgba(227,89,48,0.15)',
|
|
704
|
+
}}
|
|
705
|
+
>
|
|
706
|
+
{/* Pulsing glow ring */}
|
|
707
|
+
<div className="absolute -inset-1 rounded-2xl opacity-60 blur-md" style={{ background: 'linear-gradient(135deg, #E35930, #8b5cf6)' }} />
|
|
708
|
+
<div className="relative flex items-center gap-3">
|
|
709
|
+
{buildingInProgress ? (
|
|
710
|
+
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
711
|
+
) : (
|
|
712
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
713
|
+
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
|
|
714
|
+
<path d="M9 9v.01" /><path d="M9 12v.01" /><path d="M9 15v.01" /><path d="M9 18v.01" />
|
|
715
|
+
</svg>
|
|
716
|
+
)}
|
|
717
|
+
<span className="text-base tracking-tight">
|
|
718
|
+
{buildingInProgress ? 'Building...' : 'Claim My Building'}
|
|
719
|
+
</span>
|
|
720
|
+
</div>
|
|
721
|
+
</button>
|
|
722
|
+
<p className="text-center text-[10px] text-white/30 mt-2">
|
|
723
|
+
Your wallet shapes a unique skyscraper in the city
|
|
724
|
+
</p>
|
|
725
|
+
</div>
|
|
726
|
+
)}
|
|
727
|
+
|
|
728
|
+
{/* On-chain verification overlay */}
|
|
729
|
+
{onChainVerify && (
|
|
730
|
+
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
|
731
|
+
<div className="relative w-[380px] max-w-[90vw] bg-gradient-to-b from-gray-900/95 to-black/95 border border-white/10 rounded-3xl p-6 shadow-2xl overflow-hidden">
|
|
732
|
+
{/* Scanning line animation */}
|
|
733
|
+
<div className="absolute inset-0 overflow-hidden rounded-3xl pointer-events-none">
|
|
734
|
+
<div
|
|
735
|
+
className="absolute left-0 right-0 h-px bg-gradient-to-r from-transparent via-[#E35930] to-transparent"
|
|
736
|
+
style={{ animation: 'scanLine 2s ease-in-out infinite', top: '0%' }}
|
|
737
|
+
/>
|
|
738
|
+
</div>
|
|
739
|
+
|
|
740
|
+
{/* Header */}
|
|
741
|
+
<div className="flex items-center gap-3 mb-5">
|
|
742
|
+
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-lg ${onChainVerify.phase === 'confirmed'
|
|
743
|
+
? 'bg-emerald-500/20'
|
|
744
|
+
: onChainVerify.phase === 'failed'
|
|
745
|
+
? 'bg-red-500/20'
|
|
746
|
+
: 'bg-[#E35930]/20'
|
|
747
|
+
}`}>
|
|
748
|
+
{onChainVerify.phase === 'confirmed' ? '✓' : onChainVerify.phase === 'failed' ? '✕' : '⛓'}
|
|
749
|
+
</div>
|
|
750
|
+
<div>
|
|
751
|
+
<h3 className="text-sm font-bold text-white tracking-tight">
|
|
752
|
+
{onChainVerify.phase === 'confirmed'
|
|
753
|
+
? 'Verified on Solana'
|
|
754
|
+
: onChainVerify.phase === 'failed'
|
|
755
|
+
? 'Verification Failed'
|
|
756
|
+
: 'Verifying On-Chain...'}
|
|
757
|
+
</h3>
|
|
758
|
+
<p className="text-[10px] text-white/30 font-mono">
|
|
759
|
+
Block #{onChainVerify.blockSlot.toLocaleString()}
|
|
760
|
+
</p>
|
|
761
|
+
</div>
|
|
762
|
+
</div>
|
|
763
|
+
|
|
764
|
+
{/* Transaction hash */}
|
|
765
|
+
<div className="bg-black/40 rounded-xl px-3 py-2 mb-4 border border-white/[0.06]">
|
|
766
|
+
<p className="text-[8px] uppercase tracking-widest text-white/20 mb-1">TX HASH</p>
|
|
767
|
+
<p className="text-[10px] font-mono text-[#E35930]/70 break-all leading-relaxed">
|
|
768
|
+
{onChainVerify.hash.slice(0, 32)}...
|
|
769
|
+
</p>
|
|
770
|
+
</div>
|
|
771
|
+
|
|
772
|
+
{/* Verification steps */}
|
|
773
|
+
<div className="space-y-2.5 mb-4">
|
|
774
|
+
{onChainVerify.steps.map((step, i) => (
|
|
775
|
+
<div key={i} className="flex items-center gap-3">
|
|
776
|
+
<div className={`w-5 h-5 rounded-full flex items-center justify-center shrink-0 transition-all duration-500 ${step.done
|
|
777
|
+
? 'bg-emerald-500/20 text-emerald-400'
|
|
778
|
+
: onChainVerify.steps[i - 1]?.done || i === 0
|
|
779
|
+
? 'bg-[#E35930]/20 text-[#E35930]'
|
|
780
|
+
: 'bg-white/5 text-white/15'
|
|
781
|
+
}`}>
|
|
782
|
+
{step.done ? (
|
|
783
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
784
|
+
<path d="M20 6L9 17l-5-5" />
|
|
785
|
+
</svg>
|
|
786
|
+
) : (onChainVerify.steps[i - 1]?.done || i === 0) && !step.done ? (
|
|
787
|
+
<div className="w-2.5 h-2.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
|
788
|
+
) : (
|
|
789
|
+
<div className="w-1.5 h-1.5 rounded-full bg-current opacity-30" />
|
|
790
|
+
)}
|
|
791
|
+
</div>
|
|
792
|
+
<span className={`text-xs transition-all duration-500 ${step.done ? 'text-white/70' : 'text-white/25'
|
|
793
|
+
}`}>
|
|
794
|
+
{step.label}
|
|
795
|
+
</span>
|
|
796
|
+
{step.done && (
|
|
797
|
+
<span className="ml-auto text-[8px] font-mono text-emerald-400/50">
|
|
798
|
+
{(Math.random() * 0.4 + 0.1).toFixed(3)}s
|
|
799
|
+
</span>
|
|
800
|
+
)}
|
|
801
|
+
</div>
|
|
802
|
+
))}
|
|
803
|
+
</div>
|
|
804
|
+
|
|
805
|
+
{/* Result bar */}
|
|
806
|
+
{onChainVerify.phase === 'confirmed' && (
|
|
807
|
+
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-xl px-4 py-3 text-center animate-[fadeSlideUp_0.5s_ease-out]">
|
|
808
|
+
<p className="text-xs font-bold text-emerald-300">Parcel Claimed Successfully</p>
|
|
809
|
+
<p className="text-[9px] text-emerald-400/40 mt-0.5">Your building is being generated on-chain</p>
|
|
810
|
+
</div>
|
|
811
|
+
)}
|
|
812
|
+
{onChainVerify.phase === 'failed' && (
|
|
813
|
+
<div className="bg-red-500/10 border border-red-500/20 rounded-xl px-4 py-3 text-center animate-[fadeSlideUp_0.5s_ease-out]">
|
|
814
|
+
<p className="text-xs font-bold text-red-300">Verification Failed</p>
|
|
815
|
+
<p className="text-[9px] text-red-400/40 mt-0.5">Please try again</p>
|
|
816
|
+
</div>
|
|
817
|
+
)}
|
|
818
|
+
|
|
819
|
+
{/* Progress bar */}
|
|
820
|
+
{(onChainVerify.phase === 'hashing' || onChainVerify.phase === 'verifying') && (
|
|
821
|
+
<div className="h-1 rounded-full bg-white/5 overflow-hidden">
|
|
822
|
+
<div
|
|
823
|
+
className="h-full rounded-full transition-all duration-700 ease-out"
|
|
824
|
+
style={{
|
|
825
|
+
width: `${(onChainVerify.steps.filter(s => s.done).length / onChainVerify.steps.length) * 100}%`,
|
|
826
|
+
background: 'linear-gradient(90deg, #E35930, #8b5cf6)',
|
|
827
|
+
}}
|
|
828
|
+
/>
|
|
829
|
+
</div>
|
|
830
|
+
)}
|
|
831
|
+
</div>
|
|
832
|
+
</div>
|
|
833
|
+
)}
|
|
834
|
+
|
|
835
|
+
{/* Car arrival cinematic overlay */}
|
|
836
|
+
{claimArrival && (
|
|
837
|
+
<div className="fixed inset-0 z-50 pointer-events-none">
|
|
838
|
+
{/* Cinematic letterbox bars */}
|
|
839
|
+
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-black/80 to-transparent" />
|
|
840
|
+
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-black/80 to-transparent" />
|
|
841
|
+
|
|
842
|
+
{/* Status text */}
|
|
843
|
+
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-center">
|
|
844
|
+
<p className={`text-sm font-bold tracking-wider uppercase transition-all duration-1000 ${claimArrival.phase === 'driving'
|
|
845
|
+
? 'text-[#E35930] animate-pulse'
|
|
846
|
+
: 'text-white animate-[fadeSlideUp_0.5s_ease-out]'
|
|
847
|
+
}`}>
|
|
848
|
+
{claimArrival.phase === 'driving' ? 'Arriving at your building...' : 'Welcome to Solanapolis'}
|
|
849
|
+
</p>
|
|
850
|
+
<p className="font-mono text-xs text-white/30 mt-1">
|
|
851
|
+
{claimArrival.address.slice(0, 6)}...{claimArrival.address.slice(-4)}
|
|
852
|
+
</p>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
)}
|
|
856
|
+
|
|
857
|
+
{/* Citizen ID Card modal */}
|
|
858
|
+
{showCard && (() => {
|
|
859
|
+
const myAddr = profile?.wallet_address || activeWalletAddress;
|
|
860
|
+
const myWallet = myAddr ? wallets.find((w) => w.address === myAddr) : null;
|
|
861
|
+
return myWallet ? (
|
|
862
|
+
<Suspense fallback={null}>
|
|
863
|
+
<CitizenCardModal
|
|
864
|
+
wallet={myWallet}
|
|
865
|
+
identityName={
|
|
866
|
+
profile?.x_username
|
|
867
|
+
? `@${profile.x_username}`
|
|
868
|
+
: myWallet.identityName || null
|
|
869
|
+
}
|
|
870
|
+
onClose={() => setShowCard(false)}
|
|
871
|
+
/>
|
|
872
|
+
</Suspense>
|
|
873
|
+
) : null;
|
|
874
|
+
})()}
|
|
875
|
+
|
|
876
|
+
{/* Top center — search */}
|
|
877
|
+
<div className="absolute top-[4.5rem] left-3 right-3 sm:top-5 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-10">
|
|
878
|
+
<WalletSearch
|
|
879
|
+
wallets={wallets}
|
|
880
|
+
onSelect={handleSelectWallet}
|
|
881
|
+
onRefetch={fetchWallets}
|
|
882
|
+
/>
|
|
883
|
+
</div>
|
|
884
|
+
|
|
885
|
+
{/* Left side — activity feed */}
|
|
886
|
+
<div className="hidden sm:block">
|
|
887
|
+
<ActivityFeed wallets={wallets} onClickAddress={handleClickAddress} />
|
|
888
|
+
</div>
|
|
889
|
+
|
|
890
|
+
{/* Top right — auth + connect wallet */}
|
|
891
|
+
<div className="absolute top-3 right-3 sm:top-5 sm:right-5 z-10 flex flex-col items-end gap-3">
|
|
892
|
+
{phantomConnected || !!profile ? (
|
|
893
|
+
<AuthPanel onClickAddress={handleClickAddress} />
|
|
894
|
+
) : (
|
|
895
|
+
<button
|
|
896
|
+
onClick={openPhantomModal}
|
|
897
|
+
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-purple-600/80 to-purple-500/80 hover:from-purple-500/90 hover:to-purple-400/90 backdrop-blur-xl border border-purple-400/20 rounded-2xl text-sm text-white font-medium transition-all cursor-pointer shadow-lg shadow-purple-900/30"
|
|
898
|
+
>
|
|
899
|
+
<svg width="16" height="16" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
900
|
+
<rect width="128" height="128" rx="26" fill="#AB9FF2" />
|
|
901
|
+
<path fillRule="evenodd" clipRule="evenodd" d="M55.6416 82.1477C50.8744 89.4525 42.8862 98.6966 32.2568 98.6966C27.232 98.6966 22.4004 96.628 22.4004 87.6424C22.4004 64.7584 53.6445 29.3335 82.6339 29.3335C99.1257 29.3335 105.697 40.7755 105.697 53.7689C105.697 70.4471 94.8739 89.5171 84.1156 89.5171C80.7013 89.5171 79.0264 87.6424 79.0264 84.6688C79.0264 83.8931 79.1552 83.0527 79.4129 82.1477C75.7409 88.4182 68.6546 94.2361 62.0192 94.2361C57.1877 94.2361 54.7397 91.1979 54.7397 86.9314C54.7397 85.3799 55.0618 83.7638 55.6416 82.1477ZM80.6133 53.3182C80.6133 57.1044 78.3795 58.9975 75.8806 58.9975C73.3438 58.9975 71.1479 57.1044 71.1479 53.3182C71.1479 49.532 73.3438 47.6389 75.8806 47.6389C78.3795 47.6389 80.6133 49.532 80.6133 53.3182ZM94.8102 53.3184C94.8102 57.1046 92.5763 58.9977 90.0775 58.9977C87.5407 58.9977 85.3447 57.1046 85.3447 53.3184C85.3447 49.5323 87.5407 47.6392 90.0775 47.6392C92.5763 47.6392 94.8102 49.5323 94.8102 53.3184Z" fill="#FFFDF8" />
|
|
902
|
+
</svg>
|
|
903
|
+
Connect Wallet
|
|
904
|
+
</button>
|
|
905
|
+
)}
|
|
906
|
+
{/* Wallet / Swap panel: top-right on sm+, hidden here on mobile */}
|
|
907
|
+
<div className="hidden sm:block">
|
|
908
|
+
<WalletPanel wallet={selectedWallet} onClose={handleDeselect} />
|
|
909
|
+
<SwapPanel swap={trackedCar} onClose={handleDeselect} />
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
|
|
913
|
+
{/* Wallet / Swap panel: bottom sheet on mobile */}
|
|
914
|
+
<div className="block sm:hidden fixed bottom-0 left-0 right-0 z-30">
|
|
915
|
+
<WalletPanel wallet={selectedWallet} onClose={handleDeselect} />
|
|
916
|
+
<SwapPanel swap={trackedCar} onClose={handleDeselect} />
|
|
917
|
+
</div>
|
|
918
|
+
|
|
919
|
+
{/* Camera controls help — hidden on mobile and during combat (HUD takes over) */}
|
|
920
|
+
<div className={`hidden sm:block absolute bottom-6 left-5 z-10 bg-black/50 backdrop-blur-xl border border-white/[0.08] rounded-2xl px-4 py-3 text-xs text-white/35 space-y-1.5 transition-opacity ${combatActive || worldFlyMode ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}>
|
|
921
|
+
<p className="text-white/50 font-medium mb-1">Controls</p>
|
|
922
|
+
{flyMode ? (
|
|
923
|
+
<>
|
|
924
|
+
<p><span className="text-white/45">W / ↑</span> Throttle up</p>
|
|
925
|
+
<p><span className="text-white/45">S / ↓</span> Throttle down</p>
|
|
926
|
+
<p><span className="text-white/45">A / ←</span> Bank left</p>
|
|
927
|
+
<p><span className="text-white/45">D / →</span> Bank right</p>
|
|
928
|
+
<p><span className="text-white/45">Q / Space</span> Climb</p>
|
|
929
|
+
<p><span className="text-white/45">E</span> Descend</p>
|
|
930
|
+
<p><span className="text-white/45">Esc</span> Exit flight</p>
|
|
931
|
+
</>
|
|
932
|
+
) : carMode ? (
|
|
933
|
+
<>
|
|
934
|
+
<p><span className="text-white/45">W / ↑</span> Accelerate</p>
|
|
935
|
+
<p><span className="text-white/45">S / ↓</span> Brake / Reverse</p>
|
|
936
|
+
<p><span className="text-white/45">A / ←</span> Steer left</p>
|
|
937
|
+
<p><span className="text-white/45">D / →</span> Steer right</p>
|
|
938
|
+
<p><span className="text-white/45">Space</span> Brake</p>
|
|
939
|
+
<p><span className="text-white/45">Esc</span> Exit drive</p>
|
|
940
|
+
</>
|
|
941
|
+
) : (
|
|
942
|
+
<>
|
|
943
|
+
<p><span className="text-white/45">Left-click</span> {selectedWallet ? "Orbit" : "Pan"}</p>
|
|
944
|
+
<p><span className="text-white/45">Right-click</span> {selectedWallet ? "Pan" : "Rotate"}</p>
|
|
945
|
+
<p><span className="text-white/45">Scroll</span> Zoom</p>
|
|
946
|
+
</>
|
|
947
|
+
)}
|
|
948
|
+
<div className="flex gap-1.5 mt-2">
|
|
949
|
+
<button
|
|
950
|
+
onClick={() => {
|
|
951
|
+
initAudio();
|
|
952
|
+
playClick();
|
|
953
|
+
if (carMode) {
|
|
954
|
+
setCarMode(false);
|
|
955
|
+
playerCarPosRef.current = null;
|
|
956
|
+
endCombatSession();
|
|
957
|
+
} else {
|
|
958
|
+
if (flyMode) {
|
|
959
|
+
endCombatSession();
|
|
960
|
+
}
|
|
961
|
+
handleDeselect();
|
|
962
|
+
setWorldFlyMode(false);
|
|
963
|
+
setFlyMode(false);
|
|
964
|
+
planePosRef.current = null;
|
|
965
|
+
setCarMode(true);
|
|
966
|
+
mpManager.startSession("car");
|
|
967
|
+
}
|
|
968
|
+
}}
|
|
969
|
+
className={`flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer border ${carMode
|
|
970
|
+
? "bg-[#E35930]/20 border-[#E35930]/40 text-[#E35930] hover:bg-[#E35930]/30"
|
|
971
|
+
: "bg-white/5 border-white/10 text-white/50 hover:text-white/70 hover:bg-white/10"
|
|
972
|
+
}`}
|
|
973
|
+
>
|
|
974
|
+
{carMode ? "Exit Drive" : "Drive"}
|
|
975
|
+
</button>
|
|
976
|
+
<button
|
|
977
|
+
onClick={() => {
|
|
978
|
+
initAudio();
|
|
979
|
+
playClick();
|
|
980
|
+
if (flyMode) {
|
|
981
|
+
setFlyMode(false);
|
|
982
|
+
planePosRef.current = null;
|
|
983
|
+
endCombatSession();
|
|
984
|
+
} else {
|
|
985
|
+
if (carMode) {
|
|
986
|
+
endCombatSession();
|
|
987
|
+
}
|
|
988
|
+
handleDeselect();
|
|
989
|
+
setWorldFlyMode(false);
|
|
990
|
+
setCarMode(false);
|
|
991
|
+
playerCarPosRef.current = null;
|
|
992
|
+
setFlyMode(true);
|
|
993
|
+
mpManager.startSession("plane");
|
|
994
|
+
}
|
|
995
|
+
}}
|
|
996
|
+
title={flyMode ? 'Exit flight mode' : 'Fly your plane around the city'}
|
|
997
|
+
className={`flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer border ${flyMode
|
|
998
|
+
? "bg-blue-500/20 border-blue-400/40 text-blue-400 hover:bg-blue-500/30"
|
|
999
|
+
: "bg-white/5 border-white/10 text-white/50 hover:text-white/70 hover:bg-white/10"
|
|
1000
|
+
}`}
|
|
1001
|
+
>
|
|
1002
|
+
{flyMode ? "Exit Flight" : "✈ Fly"}
|
|
1003
|
+
</button>
|
|
1004
|
+
<button
|
|
1005
|
+
onClick={() => {
|
|
1006
|
+
initAudio();
|
|
1007
|
+
playClick();
|
|
1008
|
+
if (worldFlyMode) {
|
|
1009
|
+
setWorldFlyMode(false);
|
|
1010
|
+
} else {
|
|
1011
|
+
if (carMode || flyMode) {
|
|
1012
|
+
endCombatSession();
|
|
1013
|
+
}
|
|
1014
|
+
handleDeselect();
|
|
1015
|
+
setWorldFlyMode(true);
|
|
1016
|
+
}
|
|
1017
|
+
}}
|
|
1018
|
+
className={`flex-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer border ${worldFlyMode
|
|
1019
|
+
? "bg-purple-500/20 border-purple-400/40 text-purple-300 hover:bg-purple-500/30"
|
|
1020
|
+
: "bg-white/5 border-white/10 text-white/50 hover:text-white/70 hover:bg-white/10"
|
|
1021
|
+
}`}
|
|
1022
|
+
>
|
|
1023
|
+
{worldFlyMode ? "Exit World" : "🌍 World"}
|
|
1024
|
+
</button>
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
|
|
1028
|
+
{/* Bottom right — credits (hidden during combat / globe overlays) */}
|
|
1029
|
+
<div className={`hidden sm:flex absolute bottom-20 right-5 z-10 flex-col items-end gap-1.5 transition-opacity ${combatActive || worldFlyMode ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}>
|
|
1030
|
+
<a href="https://www.helius.dev/" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-white/30 hover:text-white/55 text-xs transition-colors">
|
|
1031
|
+
<span>Powered by</span>
|
|
1032
|
+
<img src="/helius-logo.svg" alt="Helius" className="h-4" />
|
|
1033
|
+
</a>
|
|
1034
|
+
<p className="text-[10px] text-white/20 tracking-wide">
|
|
1035
|
+
shoutout <span className="text-[#E35930]/60 font-medium">Nathan</span> 🔥
|
|
1036
|
+
</p>
|
|
1037
|
+
</div>
|
|
1038
|
+
|
|
1039
|
+
{/* Network stats HUD */}
|
|
1040
|
+
<NetworkStats />
|
|
1041
|
+
|
|
1042
|
+
{/* Time control buttons */}
|
|
1043
|
+
<div className="absolute bottom-4 sm:bottom-6 left-1/2 -translate-x-1/2 z-10 flex gap-1 bg-black/50 backdrop-blur-xl border border-white/[0.08] rounded-full px-2.5 py-2">
|
|
1044
|
+
{TIME_PRESETS.map((preset) => (
|
|
1045
|
+
<button
|
|
1046
|
+
key={preset.id}
|
|
1047
|
+
onClick={() => handleTimePreset(preset.id, preset.time)}
|
|
1048
|
+
className={`px-2.5 sm:px-3.5 py-1.5 min-h-[44px] sm:min-h-0 rounded-full text-xs font-medium transition-colors cursor-pointer ${activePreset === preset.id
|
|
1049
|
+
? "bg-white/15 text-white"
|
|
1050
|
+
: "text-white/40 hover:text-white/70"
|
|
1051
|
+
}`}
|
|
1052
|
+
>
|
|
1053
|
+
{preset.label}
|
|
1054
|
+
</button>
|
|
1055
|
+
))}
|
|
1056
|
+
<button
|
|
1057
|
+
onClick={handleCycle}
|
|
1058
|
+
className={`px-2.5 sm:px-3.5 py-1.5 min-h-[44px] sm:min-h-0 rounded-full text-xs font-medium transition-colors cursor-pointer ${activePreset === "cycle"
|
|
1059
|
+
? "bg-white/15 text-white"
|
|
1060
|
+
: "text-white/40 hover:text-white/70"
|
|
1061
|
+
}`}
|
|
1062
|
+
>
|
|
1063
|
+
Cycle
|
|
1064
|
+
</button>
|
|
1065
|
+
</div>
|
|
1066
|
+
|
|
1067
|
+
<Canvas
|
|
1068
|
+
camera={{ position: [200, 150, 200], fov: 50, near: 5, far: 2000 }}
|
|
1069
|
+
onPointerMissed={handleDeselect}
|
|
1070
|
+
>
|
|
1071
|
+
<SceneLighting timeRef={timeRef} autoModeRef={autoModeRef} />
|
|
1072
|
+
|
|
1073
|
+
<CityGrid
|
|
1074
|
+
wallets={wallets}
|
|
1075
|
+
timeRef={timeRef}
|
|
1076
|
+
onSelectWallet={handleSelectWallet}
|
|
1077
|
+
onHoverWindow={setWindowHover}
|
|
1078
|
+
selectedAddress={selectedWallet?.address ?? null}
|
|
1079
|
+
/>
|
|
1080
|
+
|
|
1081
|
+
<InstancedCars
|
|
1082
|
+
swapQueueRef={swapQueueRef}
|
|
1083
|
+
wallets={wallets}
|
|
1084
|
+
timeRef={timeRef}
|
|
1085
|
+
onHoverSwap={setWindowHover}
|
|
1086
|
+
onClickCar={handleClickCar}
|
|
1087
|
+
trackedCarSlot={cameraFollowSlot}
|
|
1088
|
+
trackedCarPosRef={trackedCarPosRef}
|
|
1089
|
+
onCarDied={handleCarDied}
|
|
1090
|
+
selectedAddress={selectedWallet?.address ?? null}
|
|
1091
|
+
swapBurstRef={swapBurstRef}
|
|
1092
|
+
/>
|
|
1093
|
+
|
|
1094
|
+
{/* Resident planes — first 100 wallets each get a plane circling their building */}
|
|
1095
|
+
<InstancedCityPlanes
|
|
1096
|
+
wallets={wallets}
|
|
1097
|
+
timeRef={timeRef}
|
|
1098
|
+
onHoverPlane={setWindowHover}
|
|
1099
|
+
onClickPlane={handleSelectWallet}
|
|
1100
|
+
positionsRef={planePositionsRef}
|
|
1101
|
+
/>
|
|
1102
|
+
|
|
1103
|
+
{/* Resident cars — first 250 wallets each get a car driving the streets */}
|
|
1104
|
+
<InstancedResidentCars
|
|
1105
|
+
wallets={wallets}
|
|
1106
|
+
timeRef={timeRef}
|
|
1107
|
+
onHoverCar={setWindowHover}
|
|
1108
|
+
onClickCar={handleSelectWallet}
|
|
1109
|
+
positionsRef={residentCarPositionsRef}
|
|
1110
|
+
/>
|
|
1111
|
+
|
|
1112
|
+
{/* Beach, ocean, seagulls, boats — southern shoreline */}
|
|
1113
|
+
<BeachScene />
|
|
1114
|
+
|
|
1115
|
+
{/* Famous crypto-themed landmarks around the city */}
|
|
1116
|
+
<CityLandmarks />
|
|
1117
|
+
|
|
1118
|
+
<PlayerCar
|
|
1119
|
+
active={carMode}
|
|
1120
|
+
collisionMap={collisionMap}
|
|
1121
|
+
positionRef={playerCarPosRef}
|
|
1122
|
+
headingRef={playerCarHeadingRef}
|
|
1123
|
+
speedRef={carSpeedRef}
|
|
1124
|
+
/>
|
|
1125
|
+
|
|
1126
|
+
{/* In-city player plane */}
|
|
1127
|
+
<PlayerPlane
|
|
1128
|
+
active={flyMode}
|
|
1129
|
+
positionRef={planePosRef}
|
|
1130
|
+
headingRef={planeHeadingRef}
|
|
1131
|
+
pitchRef={planePitchRef}
|
|
1132
|
+
speedRef={planeSpeedRef}
|
|
1133
|
+
walletAddress={activeWalletAddress}
|
|
1134
|
+
/>
|
|
1135
|
+
|
|
1136
|
+
{selectedWallet && selectedPosition && (
|
|
1137
|
+
<SelectionBeam
|
|
1138
|
+
position={selectedPosition}
|
|
1139
|
+
buildingHeight={getBuildingDimensions(selectedWallet).height}
|
|
1140
|
+
/>
|
|
1141
|
+
)}
|
|
1142
|
+
|
|
1143
|
+
{/* New building spotlights */}
|
|
1144
|
+
{newSpotlights.map(s => (
|
|
1145
|
+
<NewBuildingSpotlight
|
|
1146
|
+
key={s.id}
|
|
1147
|
+
position={s.position}
|
|
1148
|
+
buildingHeight={s.height}
|
|
1149
|
+
/>
|
|
1150
|
+
))}
|
|
1151
|
+
|
|
1152
|
+
<SwapParticles swapBurstRef={swapBurstRef} />
|
|
1153
|
+
|
|
1154
|
+
{/* Projectile system — active in car or plane mode */}
|
|
1155
|
+
<ProjectileRenderer
|
|
1156
|
+
manager={projectileManager}
|
|
1157
|
+
planePositionsRef={planePositionsRef}
|
|
1158
|
+
carPositionsRef={residentCarPositionsRef}
|
|
1159
|
+
active={combatActive}
|
|
1160
|
+
playerPosRef={carMode ? playerCarPosRef : planePosRef}
|
|
1161
|
+
playerHeadingRef={carMode ? playerCarHeadingRef : planeHeadingRef}
|
|
1162
|
+
playerPitchRef={planePitchRef}
|
|
1163
|
+
mode={carMode ? "car" : "plane"}
|
|
1164
|
+
/>
|
|
1165
|
+
|
|
1166
|
+
<CameraControls
|
|
1167
|
+
targetPosition={selectedPosition}
|
|
1168
|
+
trackingRef={flyMode ? planePosRef : carMode ? playerCarPosRef : trackedCarPosRef}
|
|
1169
|
+
isTracking={flyMode || carMode || cameraFollowSlot !== null}
|
|
1170
|
+
playerCarMode={carMode}
|
|
1171
|
+
flyMode={flyMode}
|
|
1172
|
+
headingRef={flyMode ? planeHeadingRef : playerCarHeadingRef}
|
|
1173
|
+
/>
|
|
1174
|
+
|
|
1175
|
+
{/* AI Town NPCs — LLM-powered agents walking the streets */}
|
|
1176
|
+
<AITownNPCs />
|
|
1177
|
+
|
|
1178
|
+
{/* Dubai Marina district — influencers, lambos, explosions */}
|
|
1179
|
+
<DubaiDistrict />
|
|
1180
|
+
|
|
1181
|
+
{/* Real multiplayer players — in-world floating labels/markers */}
|
|
1182
|
+
<RealPlayerTags
|
|
1183
|
+
active={combatActive}
|
|
1184
|
+
remotePlayers={mpManager.remotePlayers}
|
|
1185
|
+
/>
|
|
1186
|
+
</Canvas>
|
|
1187
|
+
|
|
1188
|
+
{/* Cesium globe overlay — interactive 3D Earth with OSM Buildings + metadata */}
|
|
1189
|
+
{worldFlyMode && !flyMode && !cesiumFlightMode && (
|
|
1190
|
+
<Suspense fallback={
|
|
1191
|
+
<div className="fixed inset-0 z-50 bg-[#0a0a12] flex items-center justify-center">
|
|
1192
|
+
<div className="text-center">
|
|
1193
|
+
<div className="w-10 h-10 border-2 border-emerald-500/30 border-t-emerald-400 rounded-full animate-spin mx-auto mb-4" />
|
|
1194
|
+
<p className="text-white/60 text-sm">Loading globe…</p>
|
|
1195
|
+
</div>
|
|
1196
|
+
</div>
|
|
1197
|
+
}>
|
|
1198
|
+
<CesiumGlobe
|
|
1199
|
+
walletAddress={activeWalletAddress}
|
|
1200
|
+
onExit={() => setWorldFlyMode(false)}
|
|
1201
|
+
onFlyAt={(lon: number, lat: number, alt: number, heading: number) => {
|
|
1202
|
+
setWorldFlyMode(false);
|
|
1203
|
+
setCesiumFlightMode(true);
|
|
1204
|
+
(window as Window & { __solanapolis_fly_coords?: { lon: number; lat: number; alt: number; heading: number } }).__solanapolis_fly_coords = { lon, lat, alt, heading };
|
|
1205
|
+
}}
|
|
1206
|
+
/>
|
|
1207
|
+
</Suspense>
|
|
1208
|
+
)}
|
|
1209
|
+
|
|
1210
|
+
{/* Cesium flight simulator overlay — only from World globe "Fly At" */}
|
|
1211
|
+
{cesiumFlightMode && (
|
|
1212
|
+
<Suspense fallback={
|
|
1213
|
+
<div className="fixed inset-0 z-50 bg-[#0a0a12] flex items-center justify-center">
|
|
1214
|
+
<div className="text-center">
|
|
1215
|
+
<div className="w-10 h-10 border-2 border-[#E35930]/30 border-t-[#E35930] rounded-full animate-spin mx-auto mb-4" />
|
|
1216
|
+
<p className="text-white/60 text-sm">Loading flight simulator...</p>
|
|
1217
|
+
</div>
|
|
1218
|
+
</div>
|
|
1219
|
+
}>
|
|
1220
|
+
<CesiumFlight
|
|
1221
|
+
walletAddress={activeWalletAddress}
|
|
1222
|
+
onExit={() => { setCesiumFlightMode(false); }}
|
|
1223
|
+
/>
|
|
1224
|
+
</Suspense>
|
|
1225
|
+
)}
|
|
1226
|
+
|
|
1227
|
+
{/* Game HUD — minimap + score (active in car or plane mode, not Cesium) */}
|
|
1228
|
+
{combatActive && (
|
|
1229
|
+
<GameHUD
|
|
1230
|
+
active={combatActive}
|
|
1231
|
+
mode={carMode ? "car" : "plane"}
|
|
1232
|
+
manager={projectileManager}
|
|
1233
|
+
playerPosRef={carMode ? playerCarPosRef : planePosRef}
|
|
1234
|
+
playerHeadingRef={carMode ? playerCarHeadingRef : planeHeadingRef}
|
|
1235
|
+
planePositionsRef={planePositionsRef}
|
|
1236
|
+
carPositionsRef={residentCarPositionsRef}
|
|
1237
|
+
remotePlayers={mpManager.remotePlayers}
|
|
1238
|
+
buildingPositions={buildingPositions}
|
|
1239
|
+
/>
|
|
1240
|
+
)}
|
|
1241
|
+
|
|
1242
|
+
{/* Cockpit HUD — first-person view with Solana data (car + plane) */}
|
|
1243
|
+
<CockpitHUD
|
|
1244
|
+
active={(flyMode && !cesiumFlightMode) || carMode}
|
|
1245
|
+
mode={carMode ? "car" : "plane"}
|
|
1246
|
+
positionRef={carMode ? playerCarPosRef : planePosRef}
|
|
1247
|
+
headingRef={carMode ? playerCarHeadingRef : planeHeadingRef}
|
|
1248
|
+
pitchRef={flyMode ? planePitchRef : undefined}
|
|
1249
|
+
speedRef={carMode ? carSpeedRef : planeSpeedRef}
|
|
1250
|
+
walletAddress={activeWalletAddress}
|
|
1251
|
+
hp={projectileManager.playerHP}
|
|
1252
|
+
maxHP={projectileManager.playerMaxHP}
|
|
1253
|
+
destroyed={projectileManager.destroyed}
|
|
1254
|
+
respawnRemaining={projectileManager.getRespawnRemaining()}
|
|
1255
|
+
/>
|
|
1256
|
+
|
|
1257
|
+
{/* Leaderboard panel — visible in combat mode */}
|
|
1258
|
+
<LeaderboardPanel
|
|
1259
|
+
visible={combatActive}
|
|
1260
|
+
currentWallet={activeWalletAddress}
|
|
1261
|
+
currentScore={projectileManager.score}
|
|
1262
|
+
/>
|
|
1263
|
+
|
|
1264
|
+
{/* In-game chat — requires wallet + building in the city */}
|
|
1265
|
+
<GameChat
|
|
1266
|
+
walletAddress={activeWalletAddress}
|
|
1267
|
+
displayName={profile?.x_username || null}
|
|
1268
|
+
hasBuilding={!!activeWalletAddress && wallets.some(w => w.address === activeWalletAddress)}
|
|
1269
|
+
/>
|
|
1270
|
+
|
|
1271
|
+
{/* Parcel Reward — first 16 wallets get hidden SOL */}
|
|
1272
|
+
<ParcelReward walletAddress={activeWalletAddress} />
|
|
1273
|
+
|
|
1274
|
+
{/* Treasure hunt challenge banner */}
|
|
1275
|
+
<ParcelChallengeBanner
|
|
1276
|
+
walletConnected={walletConnected}
|
|
1277
|
+
walletCount={wallets.length}
|
|
1278
|
+
/>
|
|
1279
|
+
|
|
1280
|
+
{/* Treasure Gate — play-to-win overlay before entering the city */}
|
|
1281
|
+
{showTreasureGate && activeWalletAddress && (
|
|
1282
|
+
<TreasureGate
|
|
1283
|
+
walletAddress={activeWalletAddress}
|
|
1284
|
+
onEnterCity={handleTreasureGateComplete}
|
|
1285
|
+
/>
|
|
1286
|
+
)}
|
|
1287
|
+
</div>
|
|
1288
|
+
);
|
|
1289
|
+
}
|