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,434 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, useCallback, useMemo } from "react";
|
|
4
|
+
import { useFrame, ThreeEvent } from "@react-three/fiber";
|
|
5
|
+
import * as THREE from "three";
|
|
6
|
+
import { PlacedWallet } from "@/types/wallet";
|
|
7
|
+
import {
|
|
8
|
+
getBuildingDimensions,
|
|
9
|
+
getBuildingColor,
|
|
10
|
+
getWindowCols,
|
|
11
|
+
getWindowFillRatio,
|
|
12
|
+
getLitRatio,
|
|
13
|
+
getInstanceSeed,
|
|
14
|
+
getWindowRows,
|
|
15
|
+
getWalletWorldPosition,
|
|
16
|
+
} from "@/lib/building-math";
|
|
17
|
+
import { createBuildingMaterial } from "@/lib/building-shader";
|
|
18
|
+
import { createHouseMaterial } from "@/lib/house-shader";
|
|
19
|
+
import { SkyscraperType, TierDef, SKYSCRAPER_DEFS } from "@/lib/skyscraper-types";
|
|
20
|
+
import { WindowHoverInfo } from "./WindowTooltip";
|
|
21
|
+
|
|
22
|
+
interface InstancedSkyscrapersProps {
|
|
23
|
+
wallets: PlacedWallet[];
|
|
24
|
+
skyscraperTypes: SkyscraperType[]; // parallel to wallets
|
|
25
|
+
onSelectWallet: (wallet: PlacedWallet, position: [number, number, number]) => void;
|
|
26
|
+
onHoverWindow?: (info: WindowHoverInfo | null) => void;
|
|
27
|
+
timeRef: React.MutableRefObject<number>;
|
|
28
|
+
selectedAddress?: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---- Precomputed data per wallet ----
|
|
32
|
+
interface WalletData {
|
|
33
|
+
wallet: PlacedWallet;
|
|
34
|
+
type: Exclude<SkyscraperType, "box">;
|
|
35
|
+
dims: { width: number; height: number; depth: number };
|
|
36
|
+
basePos: [number, number, number]; // ground-level center (y = height/2 in original, but we'll compute per-tier)
|
|
37
|
+
color: string;
|
|
38
|
+
seed: number;
|
|
39
|
+
yRotation: number; // 0, π/2, π, or 3π/2
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One InstancedMesh per (type, tierIndex) — all wallets of the same type share instances
|
|
43
|
+
interface TierGroup {
|
|
44
|
+
type: Exclude<SkyscraperType, "box">;
|
|
45
|
+
tierIndex: number;
|
|
46
|
+
tierDef: TierDef;
|
|
47
|
+
walletIndices: number[]; // indices into walletData array
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const _dummy = new THREE.Object3D();
|
|
51
|
+
const _color = new THREE.Color();
|
|
52
|
+
|
|
53
|
+
export default function InstancedSkyscrapers({
|
|
54
|
+
wallets,
|
|
55
|
+
skyscraperTypes,
|
|
56
|
+
onSelectWallet,
|
|
57
|
+
onHoverWindow,
|
|
58
|
+
timeRef,
|
|
59
|
+
selectedAddress,
|
|
60
|
+
}: InstancedSkyscrapersProps) {
|
|
61
|
+
const timeUniformRef = useRef<{ value: number }>({ value: 0 });
|
|
62
|
+
const elapsedUniformRef = useRef<{ value: number }>({ value: 0 });
|
|
63
|
+
const buildingMat = useMemo(
|
|
64
|
+
() => createBuildingMaterial(timeUniformRef.current, elapsedUniformRef.current),
|
|
65
|
+
[],
|
|
66
|
+
);
|
|
67
|
+
const solidMat = useMemo(
|
|
68
|
+
() => createHouseMaterial(timeUniformRef.current, 0.85, 1.0),
|
|
69
|
+
[],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Refs for all tier meshes — stored as flat array, indexed by tierGroups order
|
|
73
|
+
const meshRefs = useRef<(THREE.InstancedMesh | null)[]>([]);
|
|
74
|
+
const hoveredWalletIdx = useRef<number | null>(null);
|
|
75
|
+
|
|
76
|
+
// Precompute wallet data
|
|
77
|
+
const walletData: WalletData[] = useMemo(() => {
|
|
78
|
+
return wallets.map((w, i) => {
|
|
79
|
+
const type = skyscraperTypes[i] as Exclude<SkyscraperType, "box">;
|
|
80
|
+
const dims = getBuildingDimensions(w);
|
|
81
|
+
const basePos = getWalletWorldPosition(w, dims);
|
|
82
|
+
const color = getBuildingColor(dims.height);
|
|
83
|
+
const seed = getInstanceSeed(w.address);
|
|
84
|
+
// Deterministic 90° rotation: 0, 1, 2, or 3 quarter-turns
|
|
85
|
+
const yRotation = Math.floor(seed * 4) * (Math.PI / 2);
|
|
86
|
+
return { wallet: w, type, dims, basePos, color, seed, yRotation };
|
|
87
|
+
});
|
|
88
|
+
}, [wallets, skyscraperTypes]);
|
|
89
|
+
|
|
90
|
+
// Group wallets by (type, tierIndex)
|
|
91
|
+
const tierGroups: TierGroup[] = useMemo(() => {
|
|
92
|
+
const groups: TierGroup[] = [];
|
|
93
|
+
const typeSet = new Set(walletData.map((d) => d.type));
|
|
94
|
+
|
|
95
|
+
for (const type of typeSet) {
|
|
96
|
+
const tiers = SKYSCRAPER_DEFS[type];
|
|
97
|
+
for (let ti = 0; ti < tiers.length; ti++) {
|
|
98
|
+
const walletIndices: number[] = [];
|
|
99
|
+
for (let wi = 0; wi < walletData.length; wi++) {
|
|
100
|
+
if (walletData[wi].type === type) walletIndices.push(wi);
|
|
101
|
+
}
|
|
102
|
+
if (walletIndices.length > 0) {
|
|
103
|
+
groups.push({ type, tierIndex: ti, tierDef: tiers[ti], walletIndices });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return groups;
|
|
108
|
+
}, [walletData]);
|
|
109
|
+
|
|
110
|
+
// Build a reverse lookup: for each tier group mesh index + instance id → walletData index
|
|
111
|
+
const meshToWallet = useMemo(() => {
|
|
112
|
+
return tierGroups.map((g) => g.walletIndices);
|
|
113
|
+
}, [tierGroups]);
|
|
114
|
+
|
|
115
|
+
// Also build: for each walletData index → list of (meshIdx, instanceId) so we can highlight all tiers
|
|
116
|
+
const walletToMeshInstances = useMemo(() => {
|
|
117
|
+
const map: { meshIdx: number; instanceId: number }[][] = new Array(walletData.length);
|
|
118
|
+
for (let i = 0; i < walletData.length; i++) map[i] = [];
|
|
119
|
+
for (let mi = 0; mi < tierGroups.length; mi++) {
|
|
120
|
+
const indices = tierGroups[mi].walletIndices;
|
|
121
|
+
for (let ii = 0; ii < indices.length; ii++) {
|
|
122
|
+
map[indices[ii]].push({ meshIdx: mi, instanceId: ii });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return map;
|
|
126
|
+
}, [tierGroups, walletData.length]);
|
|
127
|
+
|
|
128
|
+
// Set instance matrices and attributes for all tier meshes
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
for (let mi = 0; mi < tierGroups.length; mi++) {
|
|
131
|
+
const mesh = meshRefs.current[mi];
|
|
132
|
+
const group = tierGroups[mi];
|
|
133
|
+
if (!mesh) continue;
|
|
134
|
+
|
|
135
|
+
const { tierDef, walletIndices } = group;
|
|
136
|
+
const count = walletIndices.length;
|
|
137
|
+
|
|
138
|
+
const colors = new Float32Array(count * 3);
|
|
139
|
+
const windowColsArr = new Float32Array(count);
|
|
140
|
+
const fillRatioArr = new Float32Array(count);
|
|
141
|
+
const litRatioArr = new Float32Array(count);
|
|
142
|
+
const seedArr = new Float32Array(count);
|
|
143
|
+
const floorsArr = new Float32Array(count);
|
|
144
|
+
const highlightArr = new Float32Array(count);
|
|
145
|
+
|
|
146
|
+
for (let ii = 0; ii < count; ii++) {
|
|
147
|
+
const wd = walletData[walletIndices[ii]];
|
|
148
|
+
const { dims, basePos, color, seed, yRotation, wallet: w } = wd;
|
|
149
|
+
|
|
150
|
+
// Compute tier dimensions in world space
|
|
151
|
+
const tierW = dims.width * tierDef.widthFrac;
|
|
152
|
+
const tierD = dims.depth * tierDef.depthFrac;
|
|
153
|
+
const tierH = dims.height * tierDef.heightFrac;
|
|
154
|
+
|
|
155
|
+
// Compute tier center position
|
|
156
|
+
// basePos[1] is dims.height/2 (center of full building box)
|
|
157
|
+
// We need ground-level Y = 0, then offset by tier
|
|
158
|
+
const groundY = 0;
|
|
159
|
+
const tierCenterY = groundY + dims.height * tierDef.yBaseFrac + tierH / 2;
|
|
160
|
+
|
|
161
|
+
// Apply X/Z offsets (relative to building dims), rotated by yRotation
|
|
162
|
+
const rawOffsetX = dims.width * tierDef.xOffsetFrac;
|
|
163
|
+
const rawOffsetZ = dims.depth * tierDef.zOffsetFrac;
|
|
164
|
+
const cosR = Math.cos(yRotation);
|
|
165
|
+
const sinR = Math.sin(yRotation);
|
|
166
|
+
const rotatedOffsetX = rawOffsetX * cosR - rawOffsetZ * sinR;
|
|
167
|
+
const rotatedOffsetZ = rawOffsetX * sinR + rawOffsetZ * cosR;
|
|
168
|
+
|
|
169
|
+
_dummy.position.set(
|
|
170
|
+
basePos[0] + rotatedOffsetX,
|
|
171
|
+
tierCenterY,
|
|
172
|
+
basePos[2] + rotatedOffsetZ,
|
|
173
|
+
);
|
|
174
|
+
_dummy.scale.set(tierW, tierH, tierD);
|
|
175
|
+
_dummy.rotation.set(0, yRotation, 0);
|
|
176
|
+
_dummy.updateMatrix();
|
|
177
|
+
mesh.setMatrixAt(ii, _dummy.matrix);
|
|
178
|
+
|
|
179
|
+
// Color
|
|
180
|
+
_color.set(color);
|
|
181
|
+
colors[ii * 3] = _color.r;
|
|
182
|
+
colors[ii * 3 + 1] = _color.g;
|
|
183
|
+
colors[ii * 3 + 2] = _color.b;
|
|
184
|
+
|
|
185
|
+
if (tierDef.hasWindows) {
|
|
186
|
+
// Window attributes — computed from TIER dimensions for correct density
|
|
187
|
+
windowColsArr[ii] = getWindowCols(tierW);
|
|
188
|
+
fillRatioArr[ii] = getWindowFillRatio(w.uniqueTokensSwapped ?? 0);
|
|
189
|
+
litRatioArr[ii] = getLitRatio(w.latestBlocktime);
|
|
190
|
+
seedArr[ii] = seed;
|
|
191
|
+
floorsArr[ii] = getWindowRows(Math.round(tierH / 0.3)); // floors in this tier
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
196
|
+
|
|
197
|
+
if (tierDef.hasWindows) {
|
|
198
|
+
mesh.geometry.setAttribute(
|
|
199
|
+
"instanceBuildingColor",
|
|
200
|
+
new THREE.InstancedBufferAttribute(colors, 3),
|
|
201
|
+
);
|
|
202
|
+
mesh.geometry.setAttribute(
|
|
203
|
+
"instanceHighlight",
|
|
204
|
+
new THREE.InstancedBufferAttribute(highlightArr, 1),
|
|
205
|
+
);
|
|
206
|
+
mesh.geometry.setAttribute(
|
|
207
|
+
"instanceWindowCols",
|
|
208
|
+
new THREE.InstancedBufferAttribute(windowColsArr, 1),
|
|
209
|
+
);
|
|
210
|
+
mesh.geometry.setAttribute(
|
|
211
|
+
"instanceFillRatio",
|
|
212
|
+
new THREE.InstancedBufferAttribute(fillRatioArr, 1),
|
|
213
|
+
);
|
|
214
|
+
mesh.geometry.setAttribute(
|
|
215
|
+
"instanceLitRatio",
|
|
216
|
+
new THREE.InstancedBufferAttribute(litRatioArr, 1),
|
|
217
|
+
);
|
|
218
|
+
mesh.geometry.setAttribute(
|
|
219
|
+
"instanceSeed",
|
|
220
|
+
new THREE.InstancedBufferAttribute(seedArr, 1),
|
|
221
|
+
);
|
|
222
|
+
mesh.geometry.setAttribute(
|
|
223
|
+
"instanceFloors",
|
|
224
|
+
new THREE.InstancedBufferAttribute(floorsArr, 1),
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
// Solid-color tiers use instanceColor for the house-shader
|
|
228
|
+
mesh.instanceColor = new THREE.InstancedBufferAttribute(colors, 3);
|
|
229
|
+
mesh.geometry.setAttribute(
|
|
230
|
+
"instanceHighlight",
|
|
231
|
+
new THREE.InstancedBufferAttribute(highlightArr, 1),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}, [tierGroups, walletData]);
|
|
236
|
+
|
|
237
|
+
// Update time uniforms
|
|
238
|
+
useFrame((state) => {
|
|
239
|
+
timeUniformRef.current.value = timeRef.current;
|
|
240
|
+
elapsedUniformRef.current.value = state.clock.elapsedTime;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ---- Highlight helpers ----
|
|
244
|
+
const setWalletHighlight = useCallback(
|
|
245
|
+
(walletIdx: number, value: number) => {
|
|
246
|
+
const entries = walletToMeshInstances[walletIdx];
|
|
247
|
+
if (!entries) return;
|
|
248
|
+
for (const { meshIdx, instanceId } of entries) {
|
|
249
|
+
const mesh = meshRefs.current[meshIdx];
|
|
250
|
+
if (!mesh) continue;
|
|
251
|
+
const attr = mesh.geometry.getAttribute("instanceHighlight") as THREE.InstancedBufferAttribute;
|
|
252
|
+
if (attr) {
|
|
253
|
+
attr.setX(instanceId, value);
|
|
254
|
+
attr.needsUpdate = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
[walletToMeshInstances],
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// ---- Resolve which wallet was hit ----
|
|
262
|
+
const resolveWallet = useCallback(
|
|
263
|
+
(meshIdx: number, instanceId: number): number | undefined => {
|
|
264
|
+
const indices = meshToWallet[meshIdx];
|
|
265
|
+
return indices?.[instanceId];
|
|
266
|
+
},
|
|
267
|
+
[meshToWallet],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// ---- Pointer handlers ----
|
|
271
|
+
const makePointerMove = useCallback(
|
|
272
|
+
(meshIdx: number) => (e: ThreeEvent<PointerEvent>) => {
|
|
273
|
+
e.stopPropagation();
|
|
274
|
+
const id = e.instanceId;
|
|
275
|
+
if (id === undefined) {
|
|
276
|
+
onHoverWindow?.(null);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const walletIdx = resolveWallet(meshIdx, id);
|
|
281
|
+
if (walletIdx === undefined) return;
|
|
282
|
+
|
|
283
|
+
const prev = hoveredWalletIdx.current;
|
|
284
|
+
if (prev !== walletIdx) {
|
|
285
|
+
if (prev !== null) setWalletHighlight(prev, 0.0);
|
|
286
|
+
setWalletHighlight(walletIdx, 1.0);
|
|
287
|
+
hoveredWalletIdx.current = walletIdx;
|
|
288
|
+
document.body.style.cursor = "pointer";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!onHoverWindow) return;
|
|
292
|
+
|
|
293
|
+
const wd = walletData[walletIdx];
|
|
294
|
+
const w = wd.wallet;
|
|
295
|
+
|
|
296
|
+
if (w.address !== selectedAddress) {
|
|
297
|
+
onHoverWindow({
|
|
298
|
+
address: w.address,
|
|
299
|
+
tokenIndex: 0,
|
|
300
|
+
screenX: e.nativeEvent.clientX,
|
|
301
|
+
screenY: e.nativeEvent.clientY,
|
|
302
|
+
mode: "building",
|
|
303
|
+
identityName: w.identityName,
|
|
304
|
+
identityType: w.identityType,
|
|
305
|
+
identityCategory: w.identityCategory,
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Selected building — window-level tooltip on windowed tiers
|
|
311
|
+
const tierDef = tierGroups[meshIdx]?.tierDef;
|
|
312
|
+
if (!tierDef?.hasWindows) {
|
|
313
|
+
onHoverWindow(null);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const faceNormal = e.face?.normal;
|
|
318
|
+
if (!faceNormal || Math.abs(faceNormal.y) > 0.5) {
|
|
319
|
+
onHoverWindow(null);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Approximate: use tier dimensions for local coord conversion
|
|
324
|
+
const tierW = wd.dims.width * tierDef.widthFrac;
|
|
325
|
+
const tierH = wd.dims.height * tierDef.heightFrac;
|
|
326
|
+
const tierD = wd.dims.depth * tierDef.depthFrac;
|
|
327
|
+
const tierCenterY = wd.dims.height * tierDef.yBaseFrac + tierH / 2;
|
|
328
|
+
|
|
329
|
+
const rawOffsetX = wd.dims.width * tierDef.xOffsetFrac;
|
|
330
|
+
const rawOffsetZ = wd.dims.depth * tierDef.zOffsetFrac;
|
|
331
|
+
const cosR = Math.cos(wd.yRotation);
|
|
332
|
+
const sinR = Math.sin(wd.yRotation);
|
|
333
|
+
const rotatedOffsetX = rawOffsetX * cosR - rawOffsetZ * sinR;
|
|
334
|
+
const rotatedOffsetZ = rawOffsetX * sinR + rawOffsetZ * cosR;
|
|
335
|
+
|
|
336
|
+
const tierCenterX = wd.basePos[0] + rotatedOffsetX;
|
|
337
|
+
const tierCenterZ = wd.basePos[2] + rotatedOffsetZ;
|
|
338
|
+
|
|
339
|
+
// Transform hit point into tier-local coords, accounting for rotation
|
|
340
|
+
const dx = e.point.x - tierCenterX;
|
|
341
|
+
const dz = e.point.z - tierCenterZ;
|
|
342
|
+
// Un-rotate
|
|
343
|
+
const localX = (dx * cosR + dz * sinR) / tierW;
|
|
344
|
+
const localY = (e.point.y - tierCenterY) / tierH;
|
|
345
|
+
const localZ = (-dx * sinR + dz * cosR) / tierD;
|
|
346
|
+
|
|
347
|
+
// Determine face UV — same approach as InstancedBuildings
|
|
348
|
+
// Need to figure out which local face was hit by examining the un-rotated normal
|
|
349
|
+
const nx = faceNormal.x * cosR + faceNormal.z * sinR;
|
|
350
|
+
let faceU: number, faceV: number;
|
|
351
|
+
if (Math.abs(nx) > 0.5) {
|
|
352
|
+
faceU = localZ + 0.5;
|
|
353
|
+
faceV = localY + 0.5;
|
|
354
|
+
} else {
|
|
355
|
+
faceU = localX + 0.5;
|
|
356
|
+
faceV = localY + 0.5;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const cols = getWindowCols(tierW);
|
|
360
|
+
const rows = getWindowRows(Math.round(tierH / 0.3));
|
|
361
|
+
if (cols < 1 || rows < 1) { onHoverWindow(null); return; }
|
|
362
|
+
|
|
363
|
+
const colIdx = Math.floor(faceU * cols);
|
|
364
|
+
const rowIdx = Math.floor(faceV * rows);
|
|
365
|
+
const cellU = faceU * cols - colIdx;
|
|
366
|
+
const cellV = faceV * rows - rowIdx;
|
|
367
|
+
|
|
368
|
+
if (!(cellU > 0.2 && cellU < 0.8 && cellV > 0.2 && cellV < 0.8)) {
|
|
369
|
+
onHoverWindow(null);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const tokenIndex = Math.abs(
|
|
374
|
+
(colIdx * 127 + rowIdx * 311 + Math.floor(wd.seed * 10007)) | 0,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
onHoverWindow({
|
|
378
|
+
address: w.address,
|
|
379
|
+
tokenIndex,
|
|
380
|
+
screenX: e.nativeEvent.clientX,
|
|
381
|
+
screenY: e.nativeEvent.clientY,
|
|
382
|
+
mode: "token",
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
[walletData, tierGroups, resolveWallet, setWalletHighlight, onHoverWindow, selectedAddress],
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const handlePointerOut = useCallback(() => {
|
|
389
|
+
const prev = hoveredWalletIdx.current;
|
|
390
|
+
if (prev !== null) {
|
|
391
|
+
setWalletHighlight(prev, 0.0);
|
|
392
|
+
}
|
|
393
|
+
hoveredWalletIdx.current = null;
|
|
394
|
+
document.body.style.cursor = "default";
|
|
395
|
+
onHoverWindow?.(null);
|
|
396
|
+
}, [setWalletHighlight, onHoverWindow]);
|
|
397
|
+
|
|
398
|
+
const makeClick = useCallback(
|
|
399
|
+
(meshIdx: number) => (e: ThreeEvent<MouseEvent>) => {
|
|
400
|
+
e.stopPropagation();
|
|
401
|
+
const id = e.instanceId;
|
|
402
|
+
if (id === undefined) return;
|
|
403
|
+
const walletIdx = resolveWallet(meshIdx, id);
|
|
404
|
+
if (walletIdx === undefined) return;
|
|
405
|
+
const wd = walletData[walletIdx];
|
|
406
|
+
onSelectWallet(wd.wallet, wd.basePos);
|
|
407
|
+
},
|
|
408
|
+
[walletData, resolveWallet, onSelectWallet],
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
if (wallets.length === 0) return null;
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<group>
|
|
415
|
+
{tierGroups.map((group, mi) => (
|
|
416
|
+
<instancedMesh
|
|
417
|
+
key={`${group.type}-${group.tierIndex}-${group.walletIndices.length}`}
|
|
418
|
+
ref={(el) => { meshRefs.current[mi] = el; }}
|
|
419
|
+
args={[undefined!, undefined!, group.walletIndices.length]}
|
|
420
|
+
frustumCulled={false}
|
|
421
|
+
onPointerMove={makePointerMove(mi)}
|
|
422
|
+
onPointerOut={handlePointerOut}
|
|
423
|
+
onClick={makeClick(mi)}
|
|
424
|
+
>
|
|
425
|
+
<boxGeometry args={[1, 1, 1]} />
|
|
426
|
+
<primitive
|
|
427
|
+
object={group.tierDef.hasWindows ? buildingMat : solidMat}
|
|
428
|
+
attach="material"
|
|
429
|
+
/>
|
|
430
|
+
</instancedMesh>
|
|
431
|
+
))}
|
|
432
|
+
</group>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, useMemo } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
|
|
6
|
+
interface TreeData {
|
|
7
|
+
pos: [number, number, number];
|
|
8
|
+
scale: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Renders all trees in the scene using two InstancedMesh draw calls
|
|
13
|
+
* (one for trunks, one for canopies) instead of 2 meshes per tree.
|
|
14
|
+
*/
|
|
15
|
+
export default function InstancedTrees({ trees }: { trees: TreeData[] }) {
|
|
16
|
+
const trunkRef = useRef<THREE.InstancedMesh>(null);
|
|
17
|
+
const canopyRef = useRef<THREE.InstancedMesh>(null);
|
|
18
|
+
const dummy = useMemo(() => new THREE.Object3D(), []);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const trunk = trunkRef.current;
|
|
22
|
+
const canopy = canopyRef.current;
|
|
23
|
+
if (!trunk || !canopy) return;
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < trees.length; i++) {
|
|
26
|
+
const { pos, scale } = trees[i];
|
|
27
|
+
|
|
28
|
+
// Position-based variation (matches original Tree component logic)
|
|
29
|
+
const seed = Math.abs(pos[0] * 73.1 + pos[2] * 37.7);
|
|
30
|
+
const v = 0.85 + ((seed % 100) / 100) * 0.3;
|
|
31
|
+
|
|
32
|
+
const trunkH = 0.6 * scale * v;
|
|
33
|
+
const canopyH = 1.4 * scale * v;
|
|
34
|
+
|
|
35
|
+
// Trunk instance
|
|
36
|
+
dummy.position.set(pos[0], trunkH / 2, pos[2]);
|
|
37
|
+
dummy.rotation.set(0, 0, 0);
|
|
38
|
+
dummy.scale.set(scale, scale * v, scale);
|
|
39
|
+
dummy.updateMatrix();
|
|
40
|
+
trunk.setMatrixAt(i, dummy.matrix);
|
|
41
|
+
|
|
42
|
+
// Canopy instance
|
|
43
|
+
dummy.position.set(pos[0], trunkH + canopyH / 2.5, pos[2]);
|
|
44
|
+
dummy.scale.set(scale * v, scale * v, scale * v);
|
|
45
|
+
dummy.updateMatrix();
|
|
46
|
+
canopy.setMatrixAt(i, dummy.matrix);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
trunk.instanceMatrix.needsUpdate = true;
|
|
50
|
+
canopy.instanceMatrix.needsUpdate = true;
|
|
51
|
+
}, [trees, dummy]);
|
|
52
|
+
|
|
53
|
+
if (trees.length === 0) return null;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<instancedMesh ref={trunkRef} args={[undefined!, undefined!, trees.length]} frustumCulled={false}>
|
|
58
|
+
<cylinderGeometry args={[0.08, 0.096, 0.6, 6]} />
|
|
59
|
+
<meshStandardMaterial color="#6b4a35" />
|
|
60
|
+
</instancedMesh>
|
|
61
|
+
<instancedMesh ref={canopyRef} args={[undefined!, undefined!, trees.length]} frustumCulled={false}>
|
|
62
|
+
<coneGeometry args={[0.6, 1.4, 7]} />
|
|
63
|
+
<meshStandardMaterial color="#2e8b40" />
|
|
64
|
+
</instancedMesh>
|
|
65
|
+
</>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from "react";
|
|
4
|
+
|
|
5
|
+
interface LeaderboardEntry {
|
|
6
|
+
wallet_address: string;
|
|
7
|
+
best_score: number;
|
|
8
|
+
total_score: number;
|
|
9
|
+
total_hits: number;
|
|
10
|
+
total_sessions: number;
|
|
11
|
+
total_playtime_s: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface LeaderboardPanelProps {
|
|
15
|
+
visible: boolean;
|
|
16
|
+
currentWallet?: string | null;
|
|
17
|
+
currentScore?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function truncateAddress(addr: string): string {
|
|
21
|
+
if (addr.length <= 10) return addr;
|
|
22
|
+
return addr.slice(0, 4) + "…" + addr.slice(-4);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatPlaytime(secs: number): string {
|
|
26
|
+
if (secs < 60) return `${secs}s`;
|
|
27
|
+
const mins = Math.floor(secs / 60);
|
|
28
|
+
if (mins < 60) return `${mins}m`;
|
|
29
|
+
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function LeaderboardPanel({ visible, currentWallet, currentScore }: LeaderboardPanelProps) {
|
|
33
|
+
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
|
|
34
|
+
const [onlineCount, setOnlineCount] = useState(0);
|
|
35
|
+
const [loading, setLoading] = useState(false);
|
|
36
|
+
|
|
37
|
+
const fetchLeaderboard = useCallback(async () => {
|
|
38
|
+
try {
|
|
39
|
+
setLoading(true);
|
|
40
|
+
const res = await fetch("/api/leaderboard?limit=10");
|
|
41
|
+
if (!res.ok) return;
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
setEntries(data.leaderboard ?? []);
|
|
44
|
+
setOnlineCount(data.onlineCount ?? 0);
|
|
45
|
+
} catch {
|
|
46
|
+
// Silently fail
|
|
47
|
+
} finally {
|
|
48
|
+
setLoading(false);
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!visible) return;
|
|
54
|
+
fetchLeaderboard();
|
|
55
|
+
const interval = setInterval(fetchLeaderboard, 15000); // refresh every 15s
|
|
56
|
+
return () => clearInterval(interval);
|
|
57
|
+
}, [visible, fetchLeaderboard]);
|
|
58
|
+
|
|
59
|
+
if (!visible) return null;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="fixed bottom-4 right-4 z-50 w-72 bg-black/70 backdrop-blur-xl border border-white/[0.08] rounded-2xl overflow-hidden">
|
|
63
|
+
{/* Header */}
|
|
64
|
+
<div className="px-4 py-3 border-b border-white/[0.06]">
|
|
65
|
+
<div className="flex items-center justify-between">
|
|
66
|
+
<h3 className="text-sm font-bold text-white/90 tracking-tight">🏆 Leaderboard</h3>
|
|
67
|
+
<div className="flex items-center gap-1.5">
|
|
68
|
+
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
|
69
|
+
<span className="text-[10px] text-white/40">{onlineCount} online</span>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Leaderboard rows */}
|
|
75
|
+
<div className="max-h-48 overflow-y-auto">
|
|
76
|
+
{loading && entries.length === 0 ? (
|
|
77
|
+
<div className="px-4 py-6 text-center text-white/30 text-xs">Loading...</div>
|
|
78
|
+
) : entries.length === 0 ? (
|
|
79
|
+
<div className="px-4 py-6 text-center text-white/30 text-xs">No scores yet — be the first!</div>
|
|
80
|
+
) : (
|
|
81
|
+
entries.map((entry, i) => {
|
|
82
|
+
const isMe = currentWallet && entry.wallet_address === currentWallet;
|
|
83
|
+
const rankEmoji = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `${i + 1}.`;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
key={entry.wallet_address}
|
|
88
|
+
className={`flex items-center gap-2 px-4 py-2 border-b border-white/[0.04] transition-colors ${
|
|
89
|
+
isMe ? "bg-[#E35930]/10" : "hover:bg-white/[0.02]"
|
|
90
|
+
}`}
|
|
91
|
+
>
|
|
92
|
+
<span className="text-xs w-6 text-center">{rankEmoji}</span>
|
|
93
|
+
<div className="flex-1 min-w-0">
|
|
94
|
+
<div className="flex items-center gap-1.5">
|
|
95
|
+
<span className={`text-xs font-medium truncate ${isMe ? "text-[#E35930]" : "text-white/70"}`}>
|
|
96
|
+
{truncateAddress(entry.wallet_address)}
|
|
97
|
+
</span>
|
|
98
|
+
{isMe && (
|
|
99
|
+
<span className="text-[8px] bg-[#E35930]/20 text-[#E35930] px-1 rounded">YOU</span>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
103
|
+
<span className="text-[9px] text-white/25">
|
|
104
|
+
{entry.total_hits} hits
|
|
105
|
+
</span>
|
|
106
|
+
<span className="text-[9px] text-white/25">
|
|
107
|
+
{entry.total_sessions} games
|
|
108
|
+
</span>
|
|
109
|
+
<span className="text-[9px] text-white/25">
|
|
110
|
+
{formatPlaytime(entry.total_playtime_s)}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<span className="text-sm font-bold text-white/80 tabular-nums">
|
|
115
|
+
{entry.best_score.toLocaleString()}
|
|
116
|
+
</span>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
})
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Current session score (if playing) */}
|
|
124
|
+
{currentScore !== undefined && currentScore > 0 && (
|
|
125
|
+
<div className="px-4 py-2 border-t border-white/[0.06] bg-[#E35930]/5">
|
|
126
|
+
<div className="flex items-center justify-between">
|
|
127
|
+
<span className="text-[10px] text-white/40">Current session</span>
|
|
128
|
+
<span className="text-sm font-bold text-[#E35930] tabular-nums">
|
|
129
|
+
{currentScore.toLocaleString()}
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|