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,1768 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
4
|
+
import type { RealtimeChannel } from "@supabase/supabase-js";
|
|
5
|
+
import type {
|
|
6
|
+
Cartesian3,
|
|
7
|
+
ConstantPositionProperty,
|
|
8
|
+
ConstantProperty,
|
|
9
|
+
Entity,
|
|
10
|
+
HeadingPitchRoll,
|
|
11
|
+
HeadingPitchRange,
|
|
12
|
+
Viewer,
|
|
13
|
+
Matrix4,
|
|
14
|
+
Cartesian4,
|
|
15
|
+
} from "cesium";
|
|
16
|
+
import MiniMap from "./FlightMiniMap";
|
|
17
|
+
|
|
18
|
+
const CESIUM_CDN = "https://cesium.com/downloads/cesiumjs/releases/1.139/Build/Cesium/";
|
|
19
|
+
|
|
20
|
+
// ─── Wallet-gated plane skins ─────────────────────────────────────────────────
|
|
21
|
+
const PLANE_SKINS = [
|
|
22
|
+
{ id: "default", uri: "/plane.glb", name: "Cessna", minSol: 0, badge: "FREE", color: "#ffffff" },
|
|
23
|
+
{ id: "fighter", uri: "/plane.glb", name: "F-22 Raptor", minSol: 1, badge: "1+ SOL", color: "#E35930" },
|
|
24
|
+
{ id: "stealth", uri: "/plane.glb", name: "B-2 Spirit", minSol: 5, badge: "5+ SOL", color: "#8B5CF6" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
interface CesiumFlightProps {
|
|
28
|
+
walletAddress: string | null;
|
|
29
|
+
onExit: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type FlightStatus = "intro" | "loading" | "ready" | "crashed" | "landed" | "error";
|
|
33
|
+
type CameraMode = "follow" | "close" | "cinematic" | "cockpit" | "drone";
|
|
34
|
+
type QualityPreset = "performance" | "balanced" | "quality";
|
|
35
|
+
type WeatherType = "clear" | "cloudy" | "stormy";
|
|
36
|
+
|
|
37
|
+
type CesiumModule = typeof import("cesium");
|
|
38
|
+
|
|
39
|
+
// ─── Interfaces ───────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface FlightPhysicsState {
|
|
42
|
+
currentSpeed: number;
|
|
43
|
+
targetSpeed: number;
|
|
44
|
+
heading: number;
|
|
45
|
+
pitch: number;
|
|
46
|
+
roll: number;
|
|
47
|
+
verticalVelocity: number;
|
|
48
|
+
stallFactor: number; // 0 = flying normally, 1 = full stall
|
|
49
|
+
gForce: number; // current G-load (1 = normal)
|
|
50
|
+
lastVertVelocity: number; // for G computation
|
|
51
|
+
onGround: boolean;
|
|
52
|
+
landingQuality: number; // 0-100 when landing detected
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface FlightInputState {
|
|
56
|
+
throttle: boolean;
|
|
57
|
+
brake: boolean;
|
|
58
|
+
turnLeft: boolean;
|
|
59
|
+
turnRight: boolean;
|
|
60
|
+
rollLeft: boolean;
|
|
61
|
+
rollRight: boolean;
|
|
62
|
+
altitudeUp: boolean;
|
|
63
|
+
altitudeDown: boolean;
|
|
64
|
+
fire: boolean;
|
|
65
|
+
targetSpeed?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface CameraTrackState {
|
|
69
|
+
heading: number;
|
|
70
|
+
pitch: number;
|
|
71
|
+
roll: number;
|
|
72
|
+
appliedRoll: number;
|
|
73
|
+
fov: number;
|
|
74
|
+
lastVehicleHeading: number;
|
|
75
|
+
hpRange: HeadingPitchRange;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PhysicsScratch {
|
|
79
|
+
transform: Matrix4;
|
|
80
|
+
enu: Matrix4;
|
|
81
|
+
worldFwd: Cartesian3;
|
|
82
|
+
upCol: Cartesian4;
|
|
83
|
+
up: Cartesian3;
|
|
84
|
+
vertDelta: Cartesian3;
|
|
85
|
+
totalDelta: Cartesian3;
|
|
86
|
+
newPos: Cartesian3;
|
|
87
|
+
localFwd: Cartesian3;
|
|
88
|
+
cockpitPos: Cartesian3;
|
|
89
|
+
cockpitLook: Cartesian3;
|
|
90
|
+
cockpitDir: Cartesian3;
|
|
91
|
+
cockpitUpCol: Cartesian4;
|
|
92
|
+
cockpitUp: Cartesian3;
|
|
93
|
+
cockpitLocalPos: Cartesian3;
|
|
94
|
+
cockpitLocalLook: Cartesian3;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface RemotePilot {
|
|
98
|
+
positionProperty: ConstantPositionProperty;
|
|
99
|
+
orientationProperty: ConstantProperty;
|
|
100
|
+
entity: Entity;
|
|
101
|
+
lastSeen: number;
|
|
102
|
+
hp: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ProjectileEntity {
|
|
106
|
+
positionProperty: ConstantPositionProperty;
|
|
107
|
+
entity: Entity;
|
|
108
|
+
heading: number;
|
|
109
|
+
pitch: number;
|
|
110
|
+
speed: number;
|
|
111
|
+
createdAt: number;
|
|
112
|
+
shooterWallet: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface WeatherState {
|
|
116
|
+
type: WeatherType;
|
|
117
|
+
windX: number; // ENU east m/s
|
|
118
|
+
windY: number; // ENU north m/s
|
|
119
|
+
turbulence: number; // random force magnitude m/s
|
|
120
|
+
rainIntensity: number; // 0-1
|
|
121
|
+
fogDensity: number; // 0-1
|
|
122
|
+
nextChange: number; // ms timestamp for next weather shift
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface CombatState {
|
|
126
|
+
score: number;
|
|
127
|
+
kills: number;
|
|
128
|
+
deaths: number;
|
|
129
|
+
streak: number;
|
|
130
|
+
lastFireTs: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface CockpitSolanaData {
|
|
134
|
+
solPrice: number | null;
|
|
135
|
+
tps: number | null;
|
|
136
|
+
slot: number | null;
|
|
137
|
+
epoch: number | null;
|
|
138
|
+
epochProgress: string | null;
|
|
139
|
+
walletBalance: number | null;
|
|
140
|
+
updatedAt: number | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface CesiumGameState {
|
|
144
|
+
viewer: Viewer;
|
|
145
|
+
aircraft: Entity;
|
|
146
|
+
positionProperty: ConstantPositionProperty;
|
|
147
|
+
orientationProperty: ConstantProperty;
|
|
148
|
+
position: Cartesian3;
|
|
149
|
+
hpRoll: HeadingPitchRoll;
|
|
150
|
+
physics: FlightPhysicsState;
|
|
151
|
+
camera: CameraTrackState;
|
|
152
|
+
cameraMode: CameraMode;
|
|
153
|
+
crashed: boolean;
|
|
154
|
+
landed: boolean;
|
|
155
|
+
scratch: PhysicsScratch;
|
|
156
|
+
Cesium: CesiumModule;
|
|
157
|
+
remotePilots: Map<string, RemotePilot>;
|
|
158
|
+
projectiles: ProjectileEntity[];
|
|
159
|
+
weather: WeatherState;
|
|
160
|
+
combat: CombatState;
|
|
161
|
+
lastTerrainAlt: number;
|
|
162
|
+
terrainCheckTimer: number;
|
|
163
|
+
selectedSkin: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── World locations ──────────────────────────────────────────────────────────
|
|
167
|
+
const LOCATIONS = [
|
|
168
|
+
{ name: "London", flag: "🇬🇧", lon: -0.1278, lat: 51.5074, alt: 500, hdg: 90 },
|
|
169
|
+
{ name: "New York", flag: "🇺🇸", lon: -74.0060, lat: 40.7128, alt: 600, hdg: 45 },
|
|
170
|
+
{ name: "Tokyo", flag: "🇯🇵", lon: 139.6917, lat: 35.6895, alt: 550, hdg: 180 },
|
|
171
|
+
{ name: "Dubai", flag: "🇦🇪", lon: 55.2708, lat: 25.2048, alt: 700, hdg: 270 },
|
|
172
|
+
{ name: "Paris", flag: "🇫🇷", lon: 2.3522, lat: 48.8566, alt: 500, hdg: 135 },
|
|
173
|
+
{ name: "Sydney", flag: "🇦🇺", lon: 151.2093, lat: -33.8688, alt: 600, hdg: 0 },
|
|
174
|
+
{ name: "San Francisco", flag: "🇺🇸", lon: -122.4194, lat: 37.7749, alt: 550, hdg: 315 },
|
|
175
|
+
{ name: "Rio de Janeiro", flag: "🇧🇷", lon: -43.1729, lat: -22.9068, alt: 700, hdg: 90 },
|
|
176
|
+
{ name: "Singapore", flag: "🇸🇬", lon: 103.8198, lat: 1.3521, alt: 500, hdg: 180 },
|
|
177
|
+
{ name: "Barcelona", flag: "🇪🇸", lon: 2.1734, lat: 41.3851, alt: 550, hdg: 225 },
|
|
178
|
+
{ name: "Giza", flag: "🇪🇬", lon: 31.1342, lat: 29.9792, alt: 800, hdg: 0 },
|
|
179
|
+
{ name: "Reykjavik", flag: "🇮🇸", lon: -21.9426, lat: 64.1466, alt: 600, hdg: 90 },
|
|
180
|
+
{ name: "Solana Beach", flag: "⚡", lon: -117.2700, lat: 32.9915, alt: 500, hdg: 270 },
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
// ─── Real airports (ICAO, exact coords, runway heading) ─────────────────────
|
|
184
|
+
const AIRPORTS = [
|
|
185
|
+
{ name: "London Heathrow (EGLL)", flag: "🛫", lon: -0.4543, lat: 51.4775, alt: 83, hdg: 91 },
|
|
186
|
+
{ name: "JFK New York (KJFK)", flag: "🛫", lon: -73.7781, lat: 40.6413, alt: 13, hdg: 40 },
|
|
187
|
+
{ name: "Tokyo Narita (RJAA)", flag: "🛫", lon: 140.3929, lat: 35.7647, alt: 43, hdg: 160 },
|
|
188
|
+
{ name: "Dubai Intl (OMDB)", flag: "🛫", lon: 55.3644, lat: 25.2532, alt: 19, hdg: 120 },
|
|
189
|
+
{ name: "Paris CDG (LFPG)", flag: "🛫", lon: 2.5479, lat: 49.0097, alt: 119, hdg: 270 },
|
|
190
|
+
{ name: "Sydney (YSSY)", flag: "🛫", lon: 151.1772, lat: -33.9461, alt: 6, hdg: 160 },
|
|
191
|
+
{ name: "SFO San Francisco (KSFO)", flag: "🛫", lon: -122.3789, lat: 37.6213, alt: 4, hdg: 280 },
|
|
192
|
+
{ name: "Singapore Changi (WSSS)", flag: "🛫", lon: 103.9894, lat: 1.3644, alt: 7, hdg: 20 },
|
|
193
|
+
{ name: "Mexico City (MMMX)", flag: "🛫", lon: -99.0721, lat: 19.4363, alt: 2230, hdg: 230 },
|
|
194
|
+
{ name: "O'Hare Chicago (KORD)", flag: "🛫", lon: -87.9073, lat: 41.9742, alt: 205, hdg: 90 },
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
const CAM_LABELS: Record<CameraMode, string> = { follow: "FOLLOW", close: "CLOSE", cinematic: "CINEMA", cockpit: "COCKPIT", drone: "DRONE" };
|
|
198
|
+
const CAM_CYCLE: CameraMode[] = ["follow", "close", "cinematic", "cockpit", "drone"];
|
|
199
|
+
|
|
200
|
+
const CAM_PARAMS: Record<CameraMode, { dist: number; basePitch: number; bankFactor: number; lerpK: number; baseFOV: number; maxFOV: number }> = {
|
|
201
|
+
follow: { dist: 25, basePitch: -15, bankFactor: 0.5, lerpK: 0.04, baseFOV: 60, maxFOV: 100 },
|
|
202
|
+
close: { dist: 8, basePitch: -10, bankFactor: 0.7, lerpK: 0.08, baseFOV: 70, maxFOV: 110 },
|
|
203
|
+
cinematic: { dist: 80, basePitch: -22, bankFactor: 0.3, lerpK: 0.02, baseFOV: 50, maxFOV: 85 },
|
|
204
|
+
cockpit: { dist: 2, basePitch: -2, bankFactor: 0.1, lerpK: 0.16, baseFOV: 72, maxFOV: 92 },
|
|
205
|
+
drone: { dist: 80, basePitch: -30, bankFactor: 0.0, lerpK: 0.02, baseFOV: 55, maxFOV: 80 },
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const WEATHER_PRESETS: Record<WeatherType, Omit<WeatherState, "nextChange">> = {
|
|
209
|
+
clear: { type: "clear", windX: 2, windY: 1, turbulence: 0.5, rainIntensity: 0, fogDensity: 0 },
|
|
210
|
+
cloudy: { type: "cloudy", windX: 8, windY: -4, turbulence: 2.0, rainIntensity: 0.2, fogDensity: 0.3 },
|
|
211
|
+
stormy: { type: "stormy", windX: 20, windY: 15, turbulence: 6.0, rainIntensity: 0.8, fogDensity: 0.6 },
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function isMobileDevice(): boolean {
|
|
215
|
+
if (typeof window === "undefined") return false;
|
|
216
|
+
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth < 768;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Simple Web Audio sounds ──────────────────────────────────────────────────
|
|
220
|
+
let audioCtx: AudioContext | null = null;
|
|
221
|
+
function getAudioCtx() {
|
|
222
|
+
if (!audioCtx) audioCtx = new AudioContext();
|
|
223
|
+
return audioCtx;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function playStallBeep() {
|
|
227
|
+
try {
|
|
228
|
+
const ctx = getAudioCtx();
|
|
229
|
+
const osc = ctx.createOscillator();
|
|
230
|
+
const gain = ctx.createGain();
|
|
231
|
+
osc.connect(gain); gain.connect(ctx.destination);
|
|
232
|
+
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
|
233
|
+
osc.frequency.setValueAtTime(440, ctx.currentTime + 0.1);
|
|
234
|
+
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
|
235
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25);
|
|
236
|
+
osc.start(ctx.currentTime);
|
|
237
|
+
osc.stop(ctx.currentTime + 0.25);
|
|
238
|
+
} catch { /* ignore */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function playGunshot() {
|
|
242
|
+
try {
|
|
243
|
+
const ctx = getAudioCtx();
|
|
244
|
+
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.1, ctx.sampleRate);
|
|
245
|
+
const d = buf.getChannelData(0);
|
|
246
|
+
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.02));
|
|
247
|
+
const src = ctx.createBufferSource();
|
|
248
|
+
const gain = ctx.createGain();
|
|
249
|
+
src.buffer = buf;
|
|
250
|
+
src.connect(gain); gain.connect(ctx.destination);
|
|
251
|
+
gain.gain.setValueAtTime(0.4, ctx.currentTime);
|
|
252
|
+
src.start();
|
|
253
|
+
} catch { /* ignore */ }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function playExplosion() {
|
|
257
|
+
try {
|
|
258
|
+
const ctx = getAudioCtx();
|
|
259
|
+
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.5, ctx.sampleRate);
|
|
260
|
+
const d = buf.getChannelData(0);
|
|
261
|
+
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.15));
|
|
262
|
+
const src = ctx.createBufferSource();
|
|
263
|
+
const gain = ctx.createGain();
|
|
264
|
+
src.buffer = buf;
|
|
265
|
+
src.connect(gain); gain.connect(ctx.destination);
|
|
266
|
+
gain.gain.setValueAtTime(0.6, ctx.currentTime);
|
|
267
|
+
src.start();
|
|
268
|
+
} catch { /* ignore */ }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function playLanding() {
|
|
272
|
+
try {
|
|
273
|
+
const ctx = getAudioCtx();
|
|
274
|
+
const osc = ctx.createOscillator();
|
|
275
|
+
const gain = ctx.createGain();
|
|
276
|
+
osc.type = "triangle";
|
|
277
|
+
osc.connect(gain); gain.connect(ctx.destination);
|
|
278
|
+
osc.frequency.setValueAtTime(220, ctx.currentTime);
|
|
279
|
+
osc.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.3);
|
|
280
|
+
gain.gain.setValueAtTime(0.2, ctx.currentTime);
|
|
281
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
|
|
282
|
+
osc.start(ctx.currentTime);
|
|
283
|
+
osc.stop(ctx.currentTime + 0.5);
|
|
284
|
+
} catch { /* ignore */ }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
288
|
+
export default function CesiumFlight({ walletAddress, onExit }: CesiumFlightProps) {
|
|
289
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
290
|
+
const [status, setStatus] = useState<FlightStatus>("intro");
|
|
291
|
+
const [initError, setInitError] = useState<string | null>(null);
|
|
292
|
+
const [hud, setHud] = useState({
|
|
293
|
+
speed: 0, altitude: 0, heading: 0, throttlePct: 0,
|
|
294
|
+
gForce: 1.0, stallPct: 0, terrainAlt: 0, score: 0, kills: 0,
|
|
295
|
+
});
|
|
296
|
+
const [camMode, setCamMode] = useState<CameraMode>("follow");
|
|
297
|
+
const [showControls, setShowControls] = useState(false);
|
|
298
|
+
const [showLocations, setShowLocations] = useState(false);
|
|
299
|
+
const [showSkins, setShowSkins] = useState(false);
|
|
300
|
+
const [quality, setQuality] = useState<QualityPreset>("performance");
|
|
301
|
+
const [mapPos, setMapPos] = useState({ lon: -0.1278, lat: 51.5074, heading: 0 });
|
|
302
|
+
const [weatherType, setWeatherType] = useState<WeatherType>("clear");
|
|
303
|
+
const [stallWarning, setStallWarning] = useState(false);
|
|
304
|
+
const [landingScore, setLandingScore] = useState<number | null>(null);
|
|
305
|
+
const [solBalance, setSolBalance] = useState(0);
|
|
306
|
+
const [selectedSkin, setSelectedSkin] = useState("default");
|
|
307
|
+
const [cockpitSolana, setCockpitSolana] = useState<CockpitSolanaData>({
|
|
308
|
+
solPrice: null,
|
|
309
|
+
tps: null,
|
|
310
|
+
slot: null,
|
|
311
|
+
epoch: null,
|
|
312
|
+
epochProgress: null,
|
|
313
|
+
walletBalance: null,
|
|
314
|
+
updatedAt: null,
|
|
315
|
+
});
|
|
316
|
+
const isMobile = isMobileDevice();
|
|
317
|
+
|
|
318
|
+
const gameRef = useRef<CesiumGameState | null>(null);
|
|
319
|
+
const inputRef = useRef<FlightInputState>({
|
|
320
|
+
throttle: false, brake: false, turnLeft: false, turnRight: false,
|
|
321
|
+
rollLeft: false, rollRight: false, altitudeUp: false, altitudeDown: false, fire: false,
|
|
322
|
+
});
|
|
323
|
+
const animFrameRef = useRef(0);
|
|
324
|
+
const channelRef = useRef<RealtimeChannel | null>(null);
|
|
325
|
+
const lastBroadcastRef = useRef(0);
|
|
326
|
+
const lastHudRef = useRef(0);
|
|
327
|
+
const lastStallBeep = useRef(0);
|
|
328
|
+
const qualityRef = useRef<QualityPreset>(quality);
|
|
329
|
+
const walletAddressRef = useRef<string | null>(walletAddress);
|
|
330
|
+
const selectedSkinRef = useRef(selectedSkin);
|
|
331
|
+
const pendingLaunchRef = useRef<{ lon: number; lat: number; alt: number; heading: number } | null>(null);
|
|
332
|
+
const [initTrigger, setInitTrigger] = useState(0);
|
|
333
|
+
|
|
334
|
+
useEffect(() => { qualityRef.current = quality; }, [quality]);
|
|
335
|
+
useEffect(() => { walletAddressRef.current = walletAddress; }, [walletAddress]);
|
|
336
|
+
useEffect(() => { selectedSkinRef.current = selectedSkin; }, [selectedSkin]);
|
|
337
|
+
|
|
338
|
+
// If launched from globe map "fly here", auto-start directly into flight sim.
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
if (typeof window === "undefined") return;
|
|
341
|
+
const win = window as Window & { __solanapolis_fly_coords?: { lon: number; lat: number; alt: number; heading: number } };
|
|
342
|
+
const launch = win.__solanapolis_fly_coords;
|
|
343
|
+
if (!launch) return;
|
|
344
|
+
pendingLaunchRef.current = launch;
|
|
345
|
+
delete win.__solanapolis_fly_coords;
|
|
346
|
+
setStatus("loading");
|
|
347
|
+
setInitTrigger((n) => n + 1);
|
|
348
|
+
}, []);
|
|
349
|
+
|
|
350
|
+
// ─── Fetch SOL balance ──────────────────────────────────────────────────────
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!walletAddress) {
|
|
353
|
+
setSolBalance(0);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
fetch(`/api/wallet/${walletAddress}/balances`)
|
|
357
|
+
.then(r => r.json())
|
|
358
|
+
.then(d => {
|
|
359
|
+
const bal = typeof d.solBalance === "number" ? d.solBalance : null;
|
|
360
|
+
if (bal !== null) setSolBalance(bal);
|
|
361
|
+
})
|
|
362
|
+
.catch(() => { });
|
|
363
|
+
}, [walletAddress]);
|
|
364
|
+
|
|
365
|
+
// ─── Solana telemetry for cockpit HUD ───────────────────────────────────────
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (status !== "ready") return;
|
|
368
|
+
let active = true;
|
|
369
|
+
|
|
370
|
+
async function fetchCockpitSolana() {
|
|
371
|
+
try {
|
|
372
|
+
const [networkRes, treasuryRes, walletRes] = await Promise.allSettled([
|
|
373
|
+
fetch("/api/network-stats"),
|
|
374
|
+
fetch("/api/treasury"),
|
|
375
|
+
walletAddress ? fetch(`/api/wallet/${walletAddress}/balances`) : Promise.resolve(null),
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
if (!active) return;
|
|
379
|
+
|
|
380
|
+
const next: Partial<CockpitSolanaData> = { updatedAt: Date.now() };
|
|
381
|
+
|
|
382
|
+
if (networkRes.status === "fulfilled" && networkRes.value?.ok) {
|
|
383
|
+
const n = await networkRes.value.json();
|
|
384
|
+
next.tps = typeof n.tps === "number" ? n.tps : null;
|
|
385
|
+
next.slot = typeof n.slot === "number" ? n.slot : null;
|
|
386
|
+
next.epoch = typeof n.epoch === "number" ? n.epoch : null;
|
|
387
|
+
next.epochProgress = typeof n.epochProgress === "string" ? n.epochProgress : null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (treasuryRes.status === "fulfilled" && treasuryRes.value?.ok) {
|
|
391
|
+
const t = await treasuryRes.value.json();
|
|
392
|
+
next.solPrice = typeof t.solPrice === "number" ? t.solPrice : null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (!walletAddress) {
|
|
396
|
+
next.walletBalance = null;
|
|
397
|
+
} else if (walletRes.status === "fulfilled" && walletRes.value?.ok) {
|
|
398
|
+
const w = await walletRes.value.json();
|
|
399
|
+
next.walletBalance = typeof w.solBalance === "number" ? w.solBalance : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
setCockpitSolana(prev => ({ ...prev, ...next }));
|
|
403
|
+
} catch {
|
|
404
|
+
// keep previous values on telemetry fetch failures
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
fetchCockpitSolana();
|
|
409
|
+
const interval = setInterval(fetchCockpitSolana, 15000);
|
|
410
|
+
return () => {
|
|
411
|
+
active = false;
|
|
412
|
+
clearInterval(interval);
|
|
413
|
+
};
|
|
414
|
+
}, [status, walletAddress]);
|
|
415
|
+
|
|
416
|
+
const handleExit = useCallback(() => onExit(), [onExit]);
|
|
417
|
+
|
|
418
|
+
// ─── Teleport ───────────────────────────────────────────────────────────────
|
|
419
|
+
const teleportTo = useCallback((lon: number, lat: number, alt: number, hdg: number) => {
|
|
420
|
+
const game = gameRef.current;
|
|
421
|
+
if (!game) return;
|
|
422
|
+
const { Cesium } = game;
|
|
423
|
+
game.position = Cesium.Cartesian3.fromDegrees(lon, lat, Math.max(alt, 30));
|
|
424
|
+
game.physics.heading = Cesium.Math.toRadians(hdg);
|
|
425
|
+
game.physics.pitch = 0; game.physics.roll = 0;
|
|
426
|
+
game.physics.currentSpeed = 200; game.physics.targetSpeed = 200;
|
|
427
|
+
game.physics.verticalVelocity = 0; game.physics.stallFactor = 0;
|
|
428
|
+
game.crashed = false; game.landed = false;
|
|
429
|
+
game.hpRoll.heading = game.physics.heading;
|
|
430
|
+
game.hpRoll.pitch = 0; game.hpRoll.roll = 0;
|
|
431
|
+
game.positionProperty.setValue(game.position);
|
|
432
|
+
game.orientationProperty.setValue(Cesium.Transforms.headingPitchRollQuaternion(game.position, game.hpRoll));
|
|
433
|
+
game.camera.heading = game.physics.heading + Math.PI;
|
|
434
|
+
game.camera.lastVehicleHeading = game.physics.heading;
|
|
435
|
+
setStatus("ready");
|
|
436
|
+
setShowLocations(false);
|
|
437
|
+
}, []);
|
|
438
|
+
|
|
439
|
+
// ─── Cesium init ────────────────────────────────────────────────────────────
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
if (initTrigger === 0) return;
|
|
442
|
+
if (!containerRef.current) return;
|
|
443
|
+
let mounted = true;
|
|
444
|
+
|
|
445
|
+
async function initialize() {
|
|
446
|
+
try {
|
|
447
|
+
(window as Window & { CESIUM_BASE_URL?: string }).CESIUM_BASE_URL = CESIUM_CDN;
|
|
448
|
+
if (!document.querySelector("#cesium-css")) {
|
|
449
|
+
const link = document.createElement("link");
|
|
450
|
+
link.id = "cesium-css"; link.rel = "stylesheet";
|
|
451
|
+
link.href = `${CESIUM_CDN}Widgets/widgets.css`;
|
|
452
|
+
document.head.appendChild(link);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const Cesium: CesiumModule = await import("cesium");
|
|
456
|
+
if (!mounted || !containerRef.current) return;
|
|
457
|
+
|
|
458
|
+
const token = process.env.NEXT_PUBLIC_CESIUM_ION_TOKEN;
|
|
459
|
+
if (token) Cesium.Ion.defaultAccessToken = token;
|
|
460
|
+
|
|
461
|
+
const viewer = new Cesium.Viewer(containerRef.current, {
|
|
462
|
+
animation: false, baseLayerPicker: false, fullscreenButton: false,
|
|
463
|
+
geocoder: false, homeButton: false, infoBox: false, sceneModePicker: false,
|
|
464
|
+
selectionIndicator: false, timeline: false, navigationHelpButton: false,
|
|
465
|
+
vrButton: false, scene3DOnly: true, requestRenderMode: false,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
viewer.imageryLayers.removeAll();
|
|
469
|
+
if (token) {
|
|
470
|
+
try {
|
|
471
|
+
viewer.imageryLayers.addImageryProvider(await Cesium.IonImageryProvider.fromAssetId(2));
|
|
472
|
+
} catch {
|
|
473
|
+
viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
|
|
474
|
+
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", maximumLevel: 19,
|
|
475
|
+
}));
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
|
|
479
|
+
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", maximumLevel: 19,
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
(viewer.cesiumWidget.creditContainer as HTMLElement).style.display = "none";
|
|
483
|
+
|
|
484
|
+
try { viewer.terrainProvider = await Cesium.createWorldTerrainAsync({ requestVertexNormals: true }); } catch { /* ellipsoid */ }
|
|
485
|
+
|
|
486
|
+
if (token && qualityRef.current !== "performance") {
|
|
487
|
+
try {
|
|
488
|
+
const buildings = await Cesium.Cesium3DTileset.fromIonAssetId(96188);
|
|
489
|
+
viewer.scene.primitives.add(buildings);
|
|
490
|
+
} catch { /* skip */ }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
applyQualityToScene(viewer, qualityRef.current);
|
|
494
|
+
if (!mounted) { viewer.destroy(); return; }
|
|
495
|
+
|
|
496
|
+
const launch = pendingLaunchRef.current;
|
|
497
|
+
const spawnLon = launch?.lon ?? -0.1278;
|
|
498
|
+
const spawnLat = launch?.lat ?? 51.5074;
|
|
499
|
+
const spawnAlt = Math.max(launch?.alt ?? 500, 80);
|
|
500
|
+
const spawnHeading = Cesium.Math.toRadians(launch?.heading ?? 0);
|
|
501
|
+
|
|
502
|
+
const spawnPos = Cesium.Cartesian3.fromDegrees(spawnLon, spawnLat, spawnAlt);
|
|
503
|
+
const hpRoll = new Cesium.HeadingPitchRoll(spawnHeading, 0, 0);
|
|
504
|
+
const positionProperty = new Cesium.ConstantPositionProperty(spawnPos);
|
|
505
|
+
const orientationProperty = new Cesium.ConstantProperty(
|
|
506
|
+
Cesium.Transforms.headingPitchRollQuaternion(spawnPos, hpRoll)
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const skin = PLANE_SKINS.find(s => s.id === selectedSkinRef.current) ?? PLANE_SKINS[0];
|
|
510
|
+
const aircraft = viewer.entities.add({
|
|
511
|
+
position: positionProperty,
|
|
512
|
+
orientation: orientationProperty,
|
|
513
|
+
model: { uri: skin.uri, scale: 5.0, minimumPixelSize: 64, maximumScale: 20, shadows: Cesium.ShadowMode.DISABLED },
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
viewer.camera.lookAt(
|
|
517
|
+
spawnPos,
|
|
518
|
+
new Cesium.HeadingPitchRange(spawnHeading + Math.PI, Cesium.Math.toRadians(-15), 25),
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
const scratch: PhysicsScratch = {
|
|
522
|
+
transform: new Cesium.Matrix4(), enu: new Cesium.Matrix4(),
|
|
523
|
+
worldFwd: new Cesium.Cartesian3(), upCol: new Cesium.Cartesian4(),
|
|
524
|
+
up: new Cesium.Cartesian3(), vertDelta: new Cesium.Cartesian3(),
|
|
525
|
+
totalDelta: new Cesium.Cartesian3(), newPos: new Cesium.Cartesian3(),
|
|
526
|
+
localFwd: new Cesium.Cartesian3(1, 0, 0),
|
|
527
|
+
cockpitPos: new Cesium.Cartesian3(),
|
|
528
|
+
cockpitLook: new Cesium.Cartesian3(),
|
|
529
|
+
cockpitDir: new Cesium.Cartesian3(),
|
|
530
|
+
cockpitUpCol: new Cesium.Cartesian4(),
|
|
531
|
+
cockpitUp: new Cesium.Cartesian3(),
|
|
532
|
+
cockpitLocalPos: new Cesium.Cartesian3(0, 1.2, 2.3),
|
|
533
|
+
cockpitLocalLook: new Cesium.Cartesian3(0, 1.1, 240),
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const cameraState: CameraTrackState = {
|
|
537
|
+
heading: spawnHeading + Math.PI, pitch: Cesium.Math.toRadians(-15), roll: 0, appliedRoll: 0,
|
|
538
|
+
fov: Cesium.Math.toRadians(60), lastVehicleHeading: 0,
|
|
539
|
+
hpRange: new Cesium.HeadingPitchRange(Math.PI, Cesium.Math.toRadians(-15), 25),
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const weather: WeatherState = {
|
|
543
|
+
...WEATHER_PRESETS.clear,
|
|
544
|
+
nextChange: Date.now() + 60000 + Math.random() * 120000,
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const game: CesiumGameState = {
|
|
548
|
+
viewer, aircraft, positionProperty, orientationProperty,
|
|
549
|
+
position: Cesium.Cartesian3.clone(spawnPos),
|
|
550
|
+
hpRoll, physics: {
|
|
551
|
+
currentSpeed: 200, targetSpeed: 200, heading: spawnHeading, pitch: 0, roll: 0,
|
|
552
|
+
verticalVelocity: 0, stallFactor: 0, gForce: 1.0, lastVertVelocity: 0,
|
|
553
|
+
onGround: false, landingQuality: 0,
|
|
554
|
+
},
|
|
555
|
+
camera: cameraState, cameraMode: "follow",
|
|
556
|
+
crashed: false, landed: false,
|
|
557
|
+
scratch, Cesium, remotePilots: new Map(),
|
|
558
|
+
projectiles: [],
|
|
559
|
+
weather,
|
|
560
|
+
combat: { score: 0, kills: 0, deaths: 0, streak: 0, lastFireTs: 0 },
|
|
561
|
+
lastTerrainAlt: 0, terrainCheckTimer: 0,
|
|
562
|
+
selectedSkin: selectedSkinRef.current,
|
|
563
|
+
};
|
|
564
|
+
gameRef.current = game;
|
|
565
|
+
pendingLaunchRef.current = null;
|
|
566
|
+
|
|
567
|
+
// ── Multiplayer channel ────────────────────────────────────────────
|
|
568
|
+
const supabase = (await import("@/lib/supabase")).createClient();
|
|
569
|
+
const mpChannel = supabase.channel("heliopolis-cesium-planes");
|
|
570
|
+
|
|
571
|
+
mpChannel.on("broadcast", { event: "cesium-position" }, ({ payload }) => {
|
|
572
|
+
if (!mounted) return;
|
|
573
|
+
const { wallet, lat, lon, alt, heading, pitch, roll } = payload as {
|
|
574
|
+
wallet: string; lat: number; lon: number; alt: number;
|
|
575
|
+
heading: number; pitch: number; roll: number;
|
|
576
|
+
};
|
|
577
|
+
if (wallet === walletAddressRef.current) return;
|
|
578
|
+
|
|
579
|
+
const pos = Cesium.Cartesian3.fromDegrees(lon, lat, alt);
|
|
580
|
+
const hpr = new Cesium.HeadingPitchRoll(heading, pitch, roll);
|
|
581
|
+
const quat = Cesium.Transforms.headingPitchRollQuaternion(pos, hpr);
|
|
582
|
+
const existing = game.remotePilots.get(wallet);
|
|
583
|
+
if (existing) {
|
|
584
|
+
existing.positionProperty.setValue(pos);
|
|
585
|
+
existing.orientationProperty.setValue(quat);
|
|
586
|
+
existing.lastSeen = Date.now();
|
|
587
|
+
} else {
|
|
588
|
+
const rPosProp = new Cesium.ConstantPositionProperty(pos);
|
|
589
|
+
const rOriProp = new Cesium.ConstantProperty(quat);
|
|
590
|
+
const entity = viewer.entities.add({
|
|
591
|
+
position: rPosProp, orientation: rOriProp,
|
|
592
|
+
model: { uri: "/plane.glb", scale: 5.0, minimumPixelSize: 48, maximumScale: 20, shadows: Cesium.ShadowMode.DISABLED },
|
|
593
|
+
label: {
|
|
594
|
+
text: wallet.slice(0, 4) + "…" + wallet.slice(-4),
|
|
595
|
+
font: "11px monospace",
|
|
596
|
+
fillColor: Cesium.Color.fromCssColorString("#E35930"),
|
|
597
|
+
outlineColor: Cesium.Color.BLACK, outlineWidth: 2,
|
|
598
|
+
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
|
599
|
+
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
|
600
|
+
pixelOffset: new Cesium.Cartesian2(0, -20),
|
|
601
|
+
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 5000),
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
game.remotePilots.set(wallet, { positionProperty: rPosProp, orientationProperty: rOriProp, entity, lastSeen: Date.now(), hp: 100 });
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// ── Incoming projectile hits ───────────────────────────────────────
|
|
609
|
+
mpChannel.on("broadcast", { event: "cesium-hit" }, ({ payload }) => {
|
|
610
|
+
if (!mounted) return;
|
|
611
|
+
const { target } = payload as { target: string; shooter: string };
|
|
612
|
+
if (target === walletAddressRef.current) {
|
|
613
|
+
game.combat.deaths += 1;
|
|
614
|
+
playExplosion();
|
|
615
|
+
if (mounted) setStatus("crashed");
|
|
616
|
+
game.crashed = true;
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
mpChannel.subscribe();
|
|
621
|
+
channelRef.current = mpChannel;
|
|
622
|
+
|
|
623
|
+
setStatus("ready");
|
|
624
|
+
|
|
625
|
+
let lastTs = performance.now();
|
|
626
|
+
function loop(ts: number) {
|
|
627
|
+
if (!mounted) return;
|
|
628
|
+
const dt = Math.min((ts - lastTs) / 1000, 0.05);
|
|
629
|
+
lastTs = ts;
|
|
630
|
+
|
|
631
|
+
if (!game.crashed && !game.landed) {
|
|
632
|
+
// Weather evolution
|
|
633
|
+
updateWeather(game, ts, viewer, setWeatherType);
|
|
634
|
+
|
|
635
|
+
physicsUpdate(game, inputRef.current, dt);
|
|
636
|
+
|
|
637
|
+
// Terrain probe (every 500ms)
|
|
638
|
+
game.terrainCheckTimer += dt;
|
|
639
|
+
if (game.terrainCheckTimer > 0.5) {
|
|
640
|
+
game.terrainCheckTimer = 0;
|
|
641
|
+
probeTerrainHeight(game);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Projectiles
|
|
645
|
+
if (inputRef.current.fire && ts - game.combat.lastFireTs > 300) {
|
|
646
|
+
fireProjectile(game);
|
|
647
|
+
game.combat.lastFireTs = ts;
|
|
648
|
+
inputRef.current.fire = false;
|
|
649
|
+
}
|
|
650
|
+
updateProjectiles(game, dt, ts, walletAddressRef, channelRef);
|
|
651
|
+
|
|
652
|
+
// Stall warning sound
|
|
653
|
+
if (game.physics.stallFactor > 0.5 && ts - lastStallBeep.current > 600) {
|
|
654
|
+
lastStallBeep.current = ts;
|
|
655
|
+
playStallBeep();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Broadcast position
|
|
659
|
+
if (walletAddressRef.current && channelRef.current && ts - lastBroadcastRef.current > 100) {
|
|
660
|
+
lastBroadcastRef.current = ts;
|
|
661
|
+
const carto = Cesium.Cartographic.fromCartesian(game.position);
|
|
662
|
+
channelRef.current.send({
|
|
663
|
+
type: "broadcast", event: "cesium-position",
|
|
664
|
+
payload: {
|
|
665
|
+
wallet: walletAddressRef.current,
|
|
666
|
+
lat: Cesium.Math.toDegrees(carto.latitude),
|
|
667
|
+
lon: Cesium.Math.toDegrees(carto.longitude),
|
|
668
|
+
alt: carto.height,
|
|
669
|
+
heading: game.physics.heading,
|
|
670
|
+
pitch: game.physics.pitch,
|
|
671
|
+
roll: game.physics.roll,
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (game.crashed) setStatus("crashed");
|
|
677
|
+
if (game.landed) {
|
|
678
|
+
setLandingScore(game.physics.landingQuality);
|
|
679
|
+
setStatus("landed");
|
|
680
|
+
playLanding();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
followCamera(game);
|
|
685
|
+
|
|
686
|
+
if (ts - lastHudRef.current > 100) {
|
|
687
|
+
lastHudRef.current = ts;
|
|
688
|
+
const c = Cesium.Cartographic.fromCartesian(game.position);
|
|
689
|
+
const hdg = Math.round(Cesium.Math.toDegrees(game.physics.heading) + 360) % 360;
|
|
690
|
+
const throttlePct = Math.round(((game.physics.currentSpeed - 80) / (1200 - 80)) * 100);
|
|
691
|
+
const stallPct = Math.round(game.physics.stallFactor * 100);
|
|
692
|
+
setStallWarning(game.physics.stallFactor > 0.4);
|
|
693
|
+
setHud({
|
|
694
|
+
speed: Math.round(game.physics.currentSpeed),
|
|
695
|
+
altitude: Math.max(0, Math.round(c.height)),
|
|
696
|
+
heading: hdg,
|
|
697
|
+
throttlePct: Math.max(0, Math.min(100, throttlePct)),
|
|
698
|
+
gForce: Math.round(game.physics.gForce * 10) / 10,
|
|
699
|
+
stallPct,
|
|
700
|
+
terrainAlt: Math.max(0, Math.round(c.height - game.lastTerrainAlt)),
|
|
701
|
+
score: game.combat.score,
|
|
702
|
+
kills: game.combat.kills,
|
|
703
|
+
});
|
|
704
|
+
setMapPos({
|
|
705
|
+
lon: Cesium.Math.toDegrees(c.longitude),
|
|
706
|
+
lat: Cesium.Math.toDegrees(c.latitude),
|
|
707
|
+
heading: hdg,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
animFrameRef.current = requestAnimationFrame(loop);
|
|
712
|
+
}
|
|
713
|
+
animFrameRef.current = requestAnimationFrame(loop);
|
|
714
|
+
} catch (e) {
|
|
715
|
+
console.error("Cesium initialize failed:", e);
|
|
716
|
+
if (mounted) { setInitError(String(e)); setStatus("error"); }
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
initialize().catch((e) => {
|
|
721
|
+
if (mounted) { setInitError(String(e)); setStatus("error"); }
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
return () => {
|
|
725
|
+
mounted = false;
|
|
726
|
+
cancelAnimationFrame(animFrameRef.current);
|
|
727
|
+
channelRef.current?.unsubscribe();
|
|
728
|
+
try { gameRef.current?.viewer.destroy(); } catch { /* ignore */ }
|
|
729
|
+
gameRef.current = null;
|
|
730
|
+
};
|
|
731
|
+
}, [initTrigger]);
|
|
732
|
+
|
|
733
|
+
// ─── Apply quality change live ────────────────────────────────────────────
|
|
734
|
+
useEffect(() => {
|
|
735
|
+
const game = gameRef.current;
|
|
736
|
+
if (!game) return;
|
|
737
|
+
applyQualityToScene(game.viewer, quality);
|
|
738
|
+
}, [quality]);
|
|
739
|
+
|
|
740
|
+
// ─── Keyboard input ───────────────────────────────────────────────────────
|
|
741
|
+
useEffect(() => {
|
|
742
|
+
function onKey(e: KeyboardEvent, down: boolean) {
|
|
743
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
744
|
+
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
|
745
|
+
const inp = inputRef.current;
|
|
746
|
+
switch (e.key) {
|
|
747
|
+
case "w": case "W": case "ArrowUp": inp.throttle = down; break;
|
|
748
|
+
case "s": case "S": case "ArrowDown": inp.brake = down; break;
|
|
749
|
+
case "a": case "A": case "ArrowLeft": inp.turnLeft = down; break;
|
|
750
|
+
case "d": case "D": case "ArrowRight": inp.turnRight = down; break;
|
|
751
|
+
case "q": case "Q": inp.rollLeft = down; break;
|
|
752
|
+
case "e": case "E": inp.rollRight = down; break;
|
|
753
|
+
case " ": case "PageUp": inp.altitudeUp = down; e.preventDefault(); break;
|
|
754
|
+
case "PageDown": inp.altitudeDown = down; break;
|
|
755
|
+
case "f": case "F": if (down) inp.fire = true; break;
|
|
756
|
+
case "c": case "C":
|
|
757
|
+
if (down && gameRef.current) {
|
|
758
|
+
const next = CAM_CYCLE[(CAM_CYCLE.indexOf(gameRef.current.cameraMode) + 1) % CAM_CYCLE.length];
|
|
759
|
+
gameRef.current.cameraMode = next;
|
|
760
|
+
setCamMode(next);
|
|
761
|
+
}
|
|
762
|
+
break;
|
|
763
|
+
case "r": case "R":
|
|
764
|
+
if (down && (gameRef.current?.crashed || gameRef.current?.landed)) handleRestart();
|
|
765
|
+
break;
|
|
766
|
+
case "n": case "N":
|
|
767
|
+
if (down && gameRef.current) {
|
|
768
|
+
const types: WeatherType[] = ["clear", "cloudy", "stormy"];
|
|
769
|
+
const cur = gameRef.current.weather.type;
|
|
770
|
+
const next = types[(types.indexOf(cur) + 1) % types.length] as WeatherType;
|
|
771
|
+
applyWeatherPreset(gameRef.current, next, gameRef.current.viewer, setWeatherType);
|
|
772
|
+
}
|
|
773
|
+
break;
|
|
774
|
+
case "?": if (down) setShowControls(v => !v); break;
|
|
775
|
+
case "l": case "L": if (down) setShowLocations(v => !v); break;
|
|
776
|
+
case "k": case "K": if (down) setShowSkins(v => !v); break;
|
|
777
|
+
case "Escape": if (down) handleExit(); break;
|
|
778
|
+
}
|
|
779
|
+
if (e.key.startsWith("Arrow")) e.preventDefault();
|
|
780
|
+
}
|
|
781
|
+
const dn = (e: KeyboardEvent) => onKey(e, true);
|
|
782
|
+
const up = (e: KeyboardEvent) => onKey(e, false);
|
|
783
|
+
window.addEventListener("keydown", dn);
|
|
784
|
+
window.addEventListener("keyup", up);
|
|
785
|
+
return () => { window.removeEventListener("keydown", dn); window.removeEventListener("keyup", up); };
|
|
786
|
+
}, [handleExit]);
|
|
787
|
+
|
|
788
|
+
// ─── Restart ─────────────────────────────────────────────────────────────
|
|
789
|
+
function handleRestart() {
|
|
790
|
+
const game = gameRef.current;
|
|
791
|
+
if (!game) return;
|
|
792
|
+
const { Cesium } = game;
|
|
793
|
+
game.crashed = false; game.landed = false;
|
|
794
|
+
game.position = Cesium.Cartesian3.fromDegrees(-0.1278, 51.5074, 500);
|
|
795
|
+
game.physics = {
|
|
796
|
+
currentSpeed: 200, targetSpeed: 200, heading: 0, pitch: 0, roll: 0,
|
|
797
|
+
verticalVelocity: 0, stallFactor: 0, gForce: 1, lastVertVelocity: 0,
|
|
798
|
+
onGround: false, landingQuality: 0,
|
|
799
|
+
};
|
|
800
|
+
game.hpRoll.heading = 0; game.hpRoll.pitch = 0; game.hpRoll.roll = 0;
|
|
801
|
+
game.camera.heading = Math.PI; game.camera.lastVehicleHeading = 0;
|
|
802
|
+
game.positionProperty.setValue(game.position);
|
|
803
|
+
setLandingScore(null);
|
|
804
|
+
setStatus("ready");
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ─── Render ───────────────────────────────────────────────────────────────
|
|
808
|
+
return (
|
|
809
|
+
<div className="fixed inset-0 z-50 bg-black">
|
|
810
|
+
<div ref={containerRef} className="w-full h-full" />
|
|
811
|
+
|
|
812
|
+
{/* Rain overlay */}
|
|
813
|
+
{status === "ready" && <RainOverlay intensity={weatherType === "stormy" ? 0.8 : weatherType === "cloudy" ? 0.2 : 0} />}
|
|
814
|
+
|
|
815
|
+
{/* Stall warning vignette */}
|
|
816
|
+
{stallWarning && status === "ready" && (
|
|
817
|
+
<div className="absolute inset-0 pointer-events-none animate-pulse" style={{
|
|
818
|
+
background: "radial-gradient(ellipse at center, transparent 40%, rgba(255,60,60,0.25) 100%)",
|
|
819
|
+
}} />
|
|
820
|
+
)}
|
|
821
|
+
|
|
822
|
+
{/* G-force vignette */}
|
|
823
|
+
{status === "ready" && hud.gForce > 3 && (
|
|
824
|
+
<div className="absolute inset-0 pointer-events-none" style={{
|
|
825
|
+
background: `radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,${Math.min(0.7, (hud.gForce - 3) * 0.2)}) 100%)`,
|
|
826
|
+
}} />
|
|
827
|
+
)}
|
|
828
|
+
|
|
829
|
+
{/* ── Intro ─────────────────────────────────────────────────────────── */}
|
|
830
|
+
{status === "intro" && (
|
|
831
|
+
<IntroScreen
|
|
832
|
+
isMobile={isMobile}
|
|
833
|
+
onStart={() => { setStatus("loading"); setInitTrigger(n => n + 1); }}
|
|
834
|
+
onExit={handleExit}
|
|
835
|
+
/>
|
|
836
|
+
)}
|
|
837
|
+
|
|
838
|
+
{/* ── Loading ───────────────────────────────────────────────────────── */}
|
|
839
|
+
{status === "loading" && (
|
|
840
|
+
<div className="absolute inset-0 flex items-center justify-center bg-[#0a0a12]">
|
|
841
|
+
<div className="text-center">
|
|
842
|
+
<div className="w-12 h-12 border-2 border-[#E35930]/30 border-t-[#E35930] rounded-full animate-spin mx-auto mb-5" />
|
|
843
|
+
<h2 className="text-xl font-bold mb-1" style={{ color: "#E35930" }}>SOLANApolis</h2>
|
|
844
|
+
<p className="text-white/60 text-sm mb-1">Loading flight simulator…</p>
|
|
845
|
+
<p className="text-white/25 text-xs tracking-wide">Real-world terrain · Cesium · Solana</p>
|
|
846
|
+
</div>
|
|
847
|
+
</div>
|
|
848
|
+
)}
|
|
849
|
+
|
|
850
|
+
{/* ── In-flight HUD ─────────────────────────────────────────────────── */}
|
|
851
|
+
{status === "ready" && (
|
|
852
|
+
<>
|
|
853
|
+
{/* Top-centre HUD */}
|
|
854
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-4 bg-black/75 backdrop-blur-xl border border-white/10 rounded-2xl px-5 py-2.5 pointer-events-none">
|
|
855
|
+
<HudStat label="SPEED" value={hud.speed} unit="km/h" warn={hud.speed < 130} />
|
|
856
|
+
<Sep />
|
|
857
|
+
<HudStat label="ALT" value={hud.altitude} unit="m" />
|
|
858
|
+
<Sep />
|
|
859
|
+
<HudStat label="AGL" value={hud.terrainAlt} unit="m" warn={hud.terrainAlt < 50} />
|
|
860
|
+
<Sep />
|
|
861
|
+
<HudStat label="HDG" value={hud.heading} unit="°" />
|
|
862
|
+
<Sep />
|
|
863
|
+
{/* G-force meter */}
|
|
864
|
+
<div className="text-center min-w-[44px]">
|
|
865
|
+
<div className="text-white/40 text-[10px] font-medium tracking-widest mb-0.5">G</div>
|
|
866
|
+
<div className={`font-mono text-base font-bold ${Math.abs(hud.gForce) > 4 ? "text-red-400" : Math.abs(hud.gForce) > 2.5 ? "text-yellow-400" : "text-white"}`}>
|
|
867
|
+
{hud.gForce.toFixed(1)}
|
|
868
|
+
</div>
|
|
869
|
+
</div>
|
|
870
|
+
<Sep />
|
|
871
|
+
{/* Throttle */}
|
|
872
|
+
<div className="text-center min-w-[48px]">
|
|
873
|
+
<div className="text-white/40 text-[10px] font-medium tracking-widest mb-1">THR</div>
|
|
874
|
+
<div className="w-10 h-1.5 bg-white/10 rounded-full overflow-hidden mx-auto">
|
|
875
|
+
<div className="h-full rounded-full transition-all duration-100"
|
|
876
|
+
style={{ width: `${hud.throttlePct}%`, background: hud.throttlePct > 80 ? "#ff4400" : hud.throttlePct > 50 ? "#E35930" : "#22c55e" }} />
|
|
877
|
+
</div>
|
|
878
|
+
<div className="text-white/60 font-mono text-xs mt-0.5">{hud.throttlePct}%</div>
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
|
|
882
|
+
{/* Stall warning */}
|
|
883
|
+
{stallWarning && (
|
|
884
|
+
<div className="absolute top-24 left-1/2 -translate-x-1/2 pointer-events-none">
|
|
885
|
+
<div className="bg-red-600/90 text-white text-xs font-bold tracking-widest px-4 py-1.5 rounded-lg animate-pulse border border-red-400/50">
|
|
886
|
+
⚠ STALL — NOSE DOWN — ADD THROTTLE
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
)}
|
|
890
|
+
|
|
891
|
+
{/* Score / combat HUD */}
|
|
892
|
+
{hud.kills > 0 && (
|
|
893
|
+
<div className="absolute top-20 right-4 bg-black/60 border border-[#E35930]/30 rounded-xl px-3 py-2 text-right pointer-events-none">
|
|
894
|
+
<div className="text-[10px] text-white/40 tracking-widest">SCORE</div>
|
|
895
|
+
<div className="text-[#E35930] font-mono font-bold text-lg">{hud.score}</div>
|
|
896
|
+
<div className="text-white/50 text-xs">{hud.kills} kills</div>
|
|
897
|
+
</div>
|
|
898
|
+
)}
|
|
899
|
+
|
|
900
|
+
{/* Top-right: camera + quality + exit */}
|
|
901
|
+
<div className="absolute top-4 right-4 flex items-center gap-2">
|
|
902
|
+
{/* Weather indicator */}
|
|
903
|
+
<div className="px-2 py-2 bg-black/60 border border-white/10 rounded-xl text-xs font-mono text-white/40">
|
|
904
|
+
{weatherType === "stormy" ? "⛈" : weatherType === "cloudy" ? "☁️" : "☀️"}
|
|
905
|
+
</div>
|
|
906
|
+
<button
|
|
907
|
+
onClick={() => {
|
|
908
|
+
if (!gameRef.current) return;
|
|
909
|
+
const next = CAM_CYCLE[(CAM_CYCLE.indexOf(gameRef.current.cameraMode) + 1) % CAM_CYCLE.length];
|
|
910
|
+
gameRef.current.cameraMode = next; setCamMode(next);
|
|
911
|
+
}}
|
|
912
|
+
className="px-3 py-2 bg-black/60 hover:bg-black/80 border border-white/10 hover:border-white/25 rounded-xl text-[10px] tracking-widest text-white/50 hover:text-white/80 font-mono transition-colors cursor-pointer"
|
|
913
|
+
>
|
|
914
|
+
📷 {CAM_LABELS[camMode]}
|
|
915
|
+
</button>
|
|
916
|
+
<select value={quality} onChange={e => setQuality(e.target.value as QualityPreset)}
|
|
917
|
+
className="px-2 py-2 bg-black/60 border border-white/10 rounded-xl text-[10px] text-white/40 font-mono cursor-pointer appearance-none">
|
|
918
|
+
<option value="performance">PERF</option>
|
|
919
|
+
<option value="balanced">BAL</option>
|
|
920
|
+
<option value="quality">MAX</option>
|
|
921
|
+
</select>
|
|
922
|
+
<button onClick={handleExit}
|
|
923
|
+
className="px-4 py-2 bg-black/70 backdrop-blur-xl border border-white/10 rounded-xl text-sm text-white/70 hover:text-white hover:border-white/25 transition-colors cursor-pointer">
|
|
924
|
+
← City
|
|
925
|
+
</button>
|
|
926
|
+
</div>
|
|
927
|
+
|
|
928
|
+
{/* Top-left */}
|
|
929
|
+
<div className="absolute top-4 left-4 flex items-center gap-2">
|
|
930
|
+
{walletAddress && (
|
|
931
|
+
<div className="px-3 py-2 bg-purple-500/10 border border-purple-400/20 rounded-xl text-xs text-purple-300/80 font-mono">
|
|
932
|
+
✈ {walletAddress.slice(0, 4)}…{walletAddress.slice(-4)}
|
|
933
|
+
{solBalance > 0 && <span className="ml-1.5 text-purple-400/60">{solBalance.toFixed(2)} SOL</span>}
|
|
934
|
+
</div>
|
|
935
|
+
)}
|
|
936
|
+
{/* Location picker */}
|
|
937
|
+
<div className="relative">
|
|
938
|
+
<button onClick={() => setShowLocations(v => !v)}
|
|
939
|
+
className="px-3 py-2 bg-black/60 hover:bg-black/80 border border-white/10 hover:border-white/25 rounded-xl text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer">
|
|
940
|
+
🌍 Fly To
|
|
941
|
+
</button>
|
|
942
|
+
{showLocations && (
|
|
943
|
+
<div className="absolute top-full left-0 mt-2 w-64 bg-black/90 backdrop-blur-xl border border-white/10 rounded-2xl overflow-hidden shadow-2xl z-10">
|
|
944
|
+
<div className="px-4 py-2 border-b border-white/10">
|
|
945
|
+
<p className="text-[10px] text-white/40 tracking-widest font-medium">CITIES</p>
|
|
946
|
+
</div>
|
|
947
|
+
<div className="max-h-48 overflow-y-auto">
|
|
948
|
+
{LOCATIONS.map(loc => (
|
|
949
|
+
<button key={loc.name} onClick={() => teleportTo(loc.lon, loc.lat, loc.alt, loc.hdg)}
|
|
950
|
+
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-white/5 text-left transition-colors cursor-pointer">
|
|
951
|
+
<span className="text-sm">{loc.flag}</span>
|
|
952
|
+
<div><p className="text-xs text-white/80">{loc.name}</p><p className="text-[10px] text-white/30">{loc.alt}m · {loc.hdg}°</p></div>
|
|
953
|
+
</button>
|
|
954
|
+
))}
|
|
955
|
+
</div>
|
|
956
|
+
<div className="px-4 py-2 border-t border-white/10">
|
|
957
|
+
<p className="text-[10px] text-white/40 tracking-widest font-medium">AIRPORTS</p>
|
|
958
|
+
</div>
|
|
959
|
+
<div className="max-h-48 overflow-y-auto">
|
|
960
|
+
{AIRPORTS.map(ap => (
|
|
961
|
+
<button key={ap.name} onClick={() => teleportTo(ap.lon, ap.lat, ap.alt + 200, ap.hdg)}
|
|
962
|
+
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-white/5 text-left transition-colors cursor-pointer">
|
|
963
|
+
<span className="text-sm">{ap.flag}</span>
|
|
964
|
+
<div>
|
|
965
|
+
<p className="text-xs text-white/80">{ap.name}</p>
|
|
966
|
+
<p className="text-[10px] text-white/30">Runway {ap.hdg}° · {ap.alt}m elev</p>
|
|
967
|
+
</div>
|
|
968
|
+
</button>
|
|
969
|
+
))}
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
)}
|
|
973
|
+
</div>
|
|
974
|
+
{/* Skins */}
|
|
975
|
+
<div className="relative">
|
|
976
|
+
<button onClick={() => setShowSkins(v => !v)}
|
|
977
|
+
className="px-3 py-2 bg-black/60 hover:bg-black/80 border border-white/10 hover:border-white/25 rounded-xl text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer">
|
|
978
|
+
✈ Skins
|
|
979
|
+
</button>
|
|
980
|
+
{showSkins && (
|
|
981
|
+
<div className="absolute top-full left-0 mt-2 w-52 bg-black/90 backdrop-blur-xl border border-white/10 rounded-2xl overflow-hidden shadow-2xl z-10">
|
|
982
|
+
<div className="px-4 py-2.5 border-b border-white/10">
|
|
983
|
+
<p className="text-[10px] text-white/40 tracking-widest">SELECT AIRCRAFT</p>
|
|
984
|
+
</div>
|
|
985
|
+
{PLANE_SKINS.map(skin => {
|
|
986
|
+
const locked = skin.minSol > solBalance;
|
|
987
|
+
return (
|
|
988
|
+
<button key={skin.id}
|
|
989
|
+
onClick={() => { if (!locked) { setSelectedSkin(skin.id); setShowSkins(false); } }}
|
|
990
|
+
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${locked ? "opacity-40 cursor-not-allowed" : "hover:bg-white/5 cursor-pointer"} ${selectedSkin === skin.id ? "bg-white/8 border-l-2 border-[#E35930]" : ""}`}>
|
|
991
|
+
<div>
|
|
992
|
+
<p className="text-xs text-white/80">{skin.name}</p>
|
|
993
|
+
<p className="text-[10px] text-white/30">{locked ? `🔒 ${skin.badge}` : "✓ Unlocked"}</p>
|
|
994
|
+
</div>
|
|
995
|
+
{selectedSkin === skin.id && <span className="text-[#E35930] text-xs">●</span>}
|
|
996
|
+
</button>
|
|
997
|
+
);
|
|
998
|
+
})}
|
|
999
|
+
<div className="px-4 py-2 border-t border-white/10">
|
|
1000
|
+
<p className="text-[9px] text-white/25">Changes apply on next launch</p>
|
|
1001
|
+
</div>
|
|
1002
|
+
</div>
|
|
1003
|
+
)}
|
|
1004
|
+
</div>
|
|
1005
|
+
</div>
|
|
1006
|
+
|
|
1007
|
+
{/* Bottom-right: mini-map */}
|
|
1008
|
+
<div className="absolute bottom-6 right-5">
|
|
1009
|
+
<MiniMap lon={mapPos.lon} lat={mapPos.lat} heading={mapPos.heading} />
|
|
1010
|
+
</div>
|
|
1011
|
+
|
|
1012
|
+
{/* Bottom-left: controls */}
|
|
1013
|
+
{showControls ? (
|
|
1014
|
+
<div className="absolute bottom-6 left-5 bg-black/70 backdrop-blur-xl border border-white/10 rounded-2xl px-5 py-4 text-xs text-white/50 space-y-1.5 max-w-[220px]">
|
|
1015
|
+
<div className="flex justify-between items-center mb-2">
|
|
1016
|
+
<p className="text-white/70 font-semibold">Controls</p>
|
|
1017
|
+
<button onClick={() => setShowControls(false)} className="text-white/30 hover:text-white/70 cursor-pointer text-base ml-4 leading-none">✕</button>
|
|
1018
|
+
</div>
|
|
1019
|
+
{[
|
|
1020
|
+
["W / ↑", "Throttle up"],
|
|
1021
|
+
["S / ↓", "Brake / slow"],
|
|
1022
|
+
["A / D", "Bank left / right"],
|
|
1023
|
+
["Space", "Climb"],
|
|
1024
|
+
["F", "Fire weapon 💥"],
|
|
1025
|
+
["N", "Cycle weather"],
|
|
1026
|
+
["C", "Camera mode (incl. cockpit)"],
|
|
1027
|
+
["K", "Plane skins"],
|
|
1028
|
+
["L", "Locations / airports"],
|
|
1029
|
+
["R", "Restart (crash)"],
|
|
1030
|
+
["Esc", "Return to city"],
|
|
1031
|
+
].map(([key, action]) => (
|
|
1032
|
+
<p key={key}><span className="text-white/65">{key}</span><span className="text-white/25"> — </span>{action}</p>
|
|
1033
|
+
))}
|
|
1034
|
+
<p className="text-white/20 text-[10px] tracking-wide pt-1">? to toggle</p>
|
|
1035
|
+
</div>
|
|
1036
|
+
) : (
|
|
1037
|
+
<button onClick={() => setShowControls(true)}
|
|
1038
|
+
className="absolute bottom-6 left-5 px-3 py-2 bg-black/50 border border-white/10 rounded-xl text-xs text-white/35 hover:text-white/60 cursor-pointer">
|
|
1039
|
+
? Controls
|
|
1040
|
+
</button>
|
|
1041
|
+
)}
|
|
1042
|
+
|
|
1043
|
+
{/* Fire button (mobile) */}
|
|
1044
|
+
{isMobile && (
|
|
1045
|
+
<button
|
|
1046
|
+
onTouchStart={() => { inputRef.current.fire = true; }}
|
|
1047
|
+
className="absolute bottom-24 left-5 w-14 h-14 bg-red-600/80 border border-red-400/40 rounded-full text-white text-xl flex items-center justify-center shadow-lg cursor-pointer"
|
|
1048
|
+
style={{ touchAction: "none" }}
|
|
1049
|
+
>
|
|
1050
|
+
🔫
|
|
1051
|
+
</button>
|
|
1052
|
+
)}
|
|
1053
|
+
|
|
1054
|
+
{isMobile && (
|
|
1055
|
+
<ThrottleSlider onChange={(pct) => {
|
|
1056
|
+
inputRef.current.targetSpeed = 80 + (pct / 100) * (1200 - 80);
|
|
1057
|
+
inputRef.current.throttle = pct > 55;
|
|
1058
|
+
inputRef.current.brake = pct < 45;
|
|
1059
|
+
}} />
|
|
1060
|
+
)}
|
|
1061
|
+
|
|
1062
|
+
{/* Cockpit-only overlays */}
|
|
1063
|
+
{camMode === "cockpit" && (
|
|
1064
|
+
<>
|
|
1065
|
+
{/* Cockpit frame */}
|
|
1066
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
1067
|
+
<div
|
|
1068
|
+
className="absolute top-0 left-0 right-0 h-10"
|
|
1069
|
+
style={{
|
|
1070
|
+
background: "linear-gradient(180deg, rgba(12,12,18,0.92) 0%, rgba(12,12,18,0.55) 62%, transparent 100%)",
|
|
1071
|
+
}}
|
|
1072
|
+
/>
|
|
1073
|
+
<div
|
|
1074
|
+
className="absolute bottom-0 left-0 right-0 h-36"
|
|
1075
|
+
style={{
|
|
1076
|
+
background: "linear-gradient(0deg, rgba(12,12,18,0.96) 0%, rgba(12,12,18,0.78) 46%, rgba(12,12,18,0.35) 72%, transparent 100%)",
|
|
1077
|
+
}}
|
|
1078
|
+
/>
|
|
1079
|
+
<div
|
|
1080
|
+
className="absolute top-0 bottom-0 left-0 w-14"
|
|
1081
|
+
style={{
|
|
1082
|
+
background: "linear-gradient(90deg, rgba(12,12,18,0.88) 0%, rgba(12,12,18,0.42) 70%, transparent 100%)",
|
|
1083
|
+
}}
|
|
1084
|
+
/>
|
|
1085
|
+
<div
|
|
1086
|
+
className="absolute top-0 bottom-0 right-0 w-14"
|
|
1087
|
+
style={{
|
|
1088
|
+
background: "linear-gradient(-90deg, rgba(12,12,18,0.88) 0%, rgba(12,12,18,0.42) 70%, transparent 100%)",
|
|
1089
|
+
}}
|
|
1090
|
+
/>
|
|
1091
|
+
</div>
|
|
1092
|
+
|
|
1093
|
+
{/* Center reticle */}
|
|
1094
|
+
<div className="absolute inset-0 pointer-events-none flex items-center justify-center">
|
|
1095
|
+
<div className="relative w-16 h-16">
|
|
1096
|
+
<div className="absolute inset-0 border border-[#E35930]/25 rounded-full" />
|
|
1097
|
+
<div className="absolute top-1/2 left-0 w-5 h-px bg-[#E35930]/55" />
|
|
1098
|
+
<div className="absolute top-1/2 right-0 w-5 h-px bg-[#E35930]/55" />
|
|
1099
|
+
<div className="absolute left-1/2 top-0 w-px h-5 bg-[#E35930]/55" />
|
|
1100
|
+
<div className="absolute left-1/2 bottom-0 w-px h-5 bg-[#E35930]/55" />
|
|
1101
|
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-[#E35930]" />
|
|
1102
|
+
</div>
|
|
1103
|
+
</div>
|
|
1104
|
+
|
|
1105
|
+
{/* Solana data panel */}
|
|
1106
|
+
<div className="absolute bottom-6 left-5 bg-black/72 backdrop-blur-xl border border-purple-400/20 rounded-2xl px-4 py-3 text-xs min-w-[220px] pointer-events-none">
|
|
1107
|
+
<div className="flex items-center justify-between mb-2">
|
|
1108
|
+
<div className="flex items-center gap-2">
|
|
1109
|
+
<span className="w-1.5 h-1.5 rounded-full bg-purple-400 animate-pulse" />
|
|
1110
|
+
<span className="text-[10px] tracking-widest text-purple-300/70 font-semibold">SOLANA LINK</span>
|
|
1111
|
+
</div>
|
|
1112
|
+
<span className="text-[10px] text-white/30 font-mono">{cockpitSolana.updatedAt ? "LIVE" : "SYNC"}</span>
|
|
1113
|
+
</div>
|
|
1114
|
+
<div className="space-y-1.5 font-mono text-[11px]">
|
|
1115
|
+
<div className="flex justify-between gap-3"><span className="text-white/35">SOL/USD</span><span className="text-white/85">{cockpitSolana.solPrice != null ? `$${cockpitSolana.solPrice.toFixed(2)}` : "—"}</span></div>
|
|
1116
|
+
<div className="flex justify-between gap-3"><span className="text-white/35">TPS</span><span className="text-emerald-300/90">{cockpitSolana.tps != null ? cockpitSolana.tps.toLocaleString() : "—"}</span></div>
|
|
1117
|
+
<div className="flex justify-between gap-3"><span className="text-white/35">SLOT</span><span className="text-white/70">{cockpitSolana.slot != null ? `#${cockpitSolana.slot.toLocaleString()}` : "—"}</span></div>
|
|
1118
|
+
<div className="flex justify-between gap-3"><span className="text-white/35">EPOCH</span><span className="text-white/70">{cockpitSolana.epoch != null ? `${cockpitSolana.epoch}${cockpitSolana.epochProgress ? ` · ${cockpitSolana.epochProgress}%` : ""}` : "—"}</span></div>
|
|
1119
|
+
{walletAddress && (
|
|
1120
|
+
<div className="flex justify-between gap-3 pt-1 border-t border-white/10">
|
|
1121
|
+
<span className="text-white/35">WALLET</span>
|
|
1122
|
+
<span className="text-amber-300/90">{cockpitSolana.walletBalance != null ? `${cockpitSolana.walletBalance.toFixed(4)} SOL` : "—"}</span>
|
|
1123
|
+
</div>
|
|
1124
|
+
)}
|
|
1125
|
+
</div>
|
|
1126
|
+
</div>
|
|
1127
|
+
</>
|
|
1128
|
+
)}
|
|
1129
|
+
</>
|
|
1130
|
+
)}
|
|
1131
|
+
|
|
1132
|
+
{/* ── Crash screen ──────────────────────────────────────────────────── */}
|
|
1133
|
+
{status === "crashed" && (
|
|
1134
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/85 backdrop-blur-sm">
|
|
1135
|
+
<div className="text-center bg-black/80 border border-red-500/20 rounded-3xl px-10 py-8 shadow-2xl">
|
|
1136
|
+
<div className="text-5xl mb-4 animate-bounce">💥</div>
|
|
1137
|
+
<h2 className="text-2xl font-bold text-white mb-1">Crashed!</h2>
|
|
1138
|
+
<p className="text-white/50 text-sm mb-1">Don't worry, happens to the best pilots</p>
|
|
1139
|
+
{hud.kills > 0 && <p className="text-[#E35930] text-sm mb-1">Score: {hud.score} · {hud.kills} kills</p>}
|
|
1140
|
+
<p className="text-[10px] text-white/25 mb-6 tracking-widest">SOLANApolis · powered by Helius on Solana</p>
|
|
1141
|
+
<div className="flex gap-3 justify-center mb-4">
|
|
1142
|
+
<button onClick={handleRestart}
|
|
1143
|
+
className="px-5 py-2.5 bg-[#E35930]/15 hover:bg-[#E35930]/25 border border-[#E35930]/30 rounded-xl text-sm text-[#E35930] transition-colors cursor-pointer">
|
|
1144
|
+
✈ Fly Again
|
|
1145
|
+
</button>
|
|
1146
|
+
<button onClick={handleExit}
|
|
1147
|
+
className="px-5 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-sm text-white/60 transition-colors cursor-pointer">
|
|
1148
|
+
Return to City
|
|
1149
|
+
</button>
|
|
1150
|
+
</div>
|
|
1151
|
+
<p className="text-[10px] text-white/20 tracking-widest">Press R to restart</p>
|
|
1152
|
+
</div>
|
|
1153
|
+
</div>
|
|
1154
|
+
)}
|
|
1155
|
+
|
|
1156
|
+
{/* ── Landing screen ────────────────────────────────────────────────── */}
|
|
1157
|
+
{status === "landed" && (
|
|
1158
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/75 backdrop-blur-sm">
|
|
1159
|
+
<div className="text-center bg-black/80 border border-green-500/20 rounded-3xl px-10 py-8 shadow-2xl">
|
|
1160
|
+
<div className="text-5xl mb-4">🛬</div>
|
|
1161
|
+
<h2 className="text-2xl font-bold text-white mb-1">Landed!</h2>
|
|
1162
|
+
{landingScore !== null && (
|
|
1163
|
+
<div className="mb-4">
|
|
1164
|
+
<div className="text-4xl font-bold mb-1" style={{ color: landingScore > 80 ? "#22c55e" : landingScore > 50 ? "#E35930" : "#ef4444" }}>
|
|
1165
|
+
{landingScore}/100
|
|
1166
|
+
</div>
|
|
1167
|
+
<p className="text-white/40 text-xs">
|
|
1168
|
+
{landingScore > 80 ? "Smooth landing! ✓" : landingScore > 50 ? "Acceptable landing" : "Rough — work on your approach"}
|
|
1169
|
+
</p>
|
|
1170
|
+
</div>
|
|
1171
|
+
)}
|
|
1172
|
+
<div className="flex gap-3 justify-center">
|
|
1173
|
+
<button onClick={handleRestart}
|
|
1174
|
+
className="px-5 py-2.5 bg-green-600/20 hover:bg-green-600/30 border border-green-500/30 rounded-xl text-sm text-green-400 transition-colors cursor-pointer">
|
|
1175
|
+
✈ Fly Again
|
|
1176
|
+
</button>
|
|
1177
|
+
<button onClick={handleExit}
|
|
1178
|
+
className="px-5 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-sm text-white/60 transition-colors cursor-pointer">
|
|
1179
|
+
Return to City
|
|
1180
|
+
</button>
|
|
1181
|
+
</div>
|
|
1182
|
+
</div>
|
|
1183
|
+
</div>
|
|
1184
|
+
)}
|
|
1185
|
+
|
|
1186
|
+
{/* ── Error screen ──────────────────────────────────────────────────── */}
|
|
1187
|
+
{status === "error" && (
|
|
1188
|
+
<div className="absolute inset-0 flex items-center justify-center bg-[#0a0a12]">
|
|
1189
|
+
<div className="text-center bg-black/80 border border-red-500/20 rounded-3xl px-10 py-8 shadow-2xl max-w-md">
|
|
1190
|
+
<div className="text-4xl mb-4">⚠️</div>
|
|
1191
|
+
<h2 className="text-xl font-bold text-white mb-2">Flight Sim Error</h2>
|
|
1192
|
+
<p className="text-white/50 text-sm mb-4">{initError || "Something went wrong loading the flight simulator."}</p>
|
|
1193
|
+
<div className="flex gap-3 justify-center">
|
|
1194
|
+
<button onClick={() => { setInitError(null); setStatus("loading"); setInitTrigger(n => n + 1); }}
|
|
1195
|
+
className="px-5 py-2.5 bg-[#E35930]/15 hover:bg-[#E35930]/25 border border-[#E35930]/30 rounded-xl text-sm text-[#E35930] transition-colors cursor-pointer">
|
|
1196
|
+
✈ Retry
|
|
1197
|
+
</button>
|
|
1198
|
+
<button onClick={handleExit}
|
|
1199
|
+
className="px-5 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-sm text-white/60 transition-colors cursor-pointer">
|
|
1200
|
+
Return to City
|
|
1201
|
+
</button>
|
|
1202
|
+
</div>
|
|
1203
|
+
</div>
|
|
1204
|
+
</div>
|
|
1205
|
+
)}
|
|
1206
|
+
|
|
1207
|
+
{(showLocations || showSkins) && (
|
|
1208
|
+
<div className="absolute inset-0 z-[5]" onClick={() => { setShowLocations(false); setShowSkins(false); }} />
|
|
1209
|
+
)}
|
|
1210
|
+
</div>
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ─── Sub-components ────────────────────────────────────────────────────────────
|
|
1215
|
+
|
|
1216
|
+
function Sep() { return <div className="w-px h-8 bg-white/10" />; }
|
|
1217
|
+
|
|
1218
|
+
function HudStat({ label, value, unit, warn }: { label: string; value: number; unit: string; warn?: boolean }) {
|
|
1219
|
+
return (
|
|
1220
|
+
<div className="text-center min-w-[56px]">
|
|
1221
|
+
<div className="text-white/40 text-[10px] font-medium tracking-widest mb-0.5">{label}</div>
|
|
1222
|
+
<div className={`font-mono text-base font-bold leading-none ${warn ? "text-red-400 animate-pulse" : "text-white"}`}>
|
|
1223
|
+
{value}<span className="text-white/35 text-xs ml-0.5">{unit}</span>
|
|
1224
|
+
</div>
|
|
1225
|
+
</div>
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function ThrottleSlider({ onChange }: { onChange: (pct: number) => void }) {
|
|
1230
|
+
const [throttle, setThrottle] = useState(50);
|
|
1231
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
1232
|
+
const sliderRef = useRef<HTMLDivElement>(null);
|
|
1233
|
+
const update = (clientY: number) => {
|
|
1234
|
+
if (!sliderRef.current) return;
|
|
1235
|
+
const rect = sliderRef.current.getBoundingClientRect();
|
|
1236
|
+
const pct = Math.max(0, Math.min(100, 100 - ((clientY - rect.top) / rect.height) * 100));
|
|
1237
|
+
setThrottle(pct); onChange(pct);
|
|
1238
|
+
};
|
|
1239
|
+
return (
|
|
1240
|
+
<div ref={sliderRef}
|
|
1241
|
+
className="fixed right-0 top-0 h-full w-20 flex items-center justify-center z-40"
|
|
1242
|
+
onTouchStart={e => { e.stopPropagation(); setIsDragging(true); update(e.touches[0].clientY); }}
|
|
1243
|
+
onTouchMove={e => { if (isDragging) { e.stopPropagation(); update(e.touches[0].clientY); } }}
|
|
1244
|
+
onTouchEnd={e => { e.stopPropagation(); setIsDragging(false); }}
|
|
1245
|
+
style={{ touchAction: "none" }}>
|
|
1246
|
+
<div className="relative h-[50vh] w-2 bg-white/10 rounded-full overflow-hidden">
|
|
1247
|
+
<div className="absolute bottom-0 left-0 right-0 rounded-full transition-all duration-75"
|
|
1248
|
+
style={{ height: `${throttle}%`, background: throttle > 80 ? "#ff4400" : throttle > 50 ? "#E35930" : "#22c55e" }} />
|
|
1249
|
+
</div>
|
|
1250
|
+
<div className="absolute top-1/2 -translate-y-1/2 right-2 text-white/40 text-[10px] font-mono select-none">{Math.round(throttle)}</div>
|
|
1251
|
+
</div>
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** CSS-only rain overlay — no Cesium overhead */
|
|
1256
|
+
function RainOverlay({ intensity }: { intensity: number }) {
|
|
1257
|
+
if (intensity <= 0) return null;
|
|
1258
|
+
const drops = Math.round(intensity * 60);
|
|
1259
|
+
return (
|
|
1260
|
+
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ opacity: intensity }}>
|
|
1261
|
+
{Array.from({ length: drops }, (_, i) => (
|
|
1262
|
+
<div key={i} className="absolute"
|
|
1263
|
+
style={{
|
|
1264
|
+
left: `${Math.random() * 100}%`,
|
|
1265
|
+
top: `-${Math.random() * 20}%`,
|
|
1266
|
+
width: 1,
|
|
1267
|
+
height: `${8 + Math.random() * 14}px`,
|
|
1268
|
+
background: "rgba(180,210,255,0.5)",
|
|
1269
|
+
animation: `rain-fall ${0.6 + Math.random() * 0.4}s linear ${Math.random() * 2}s infinite`,
|
|
1270
|
+
transform: `rotate(${15 + Math.random() * 10}deg)`,
|
|
1271
|
+
}}
|
|
1272
|
+
/>
|
|
1273
|
+
))}
|
|
1274
|
+
<style>{`@keyframes rain-fall { 0% { transform: rotate(20deg) translateY(0); } 100% { transform: rotate(20deg) translateY(110vh); } }`}</style>
|
|
1275
|
+
</div>
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function IntroScreen({ isMobile, onStart, onExit }: { isMobile: boolean; onStart: () => void; onExit: () => void }) {
|
|
1280
|
+
return (
|
|
1281
|
+
<div className="absolute inset-0 z-10 bg-[#0a0a12]/95 backdrop-blur-lg flex items-center justify-center">
|
|
1282
|
+
<div className="max-w-lg w-full mx-4">
|
|
1283
|
+
<div className="bg-black/60 border border-white/10 rounded-3xl p-8 space-y-5 shadow-2xl">
|
|
1284
|
+
<div className="text-center space-y-1">
|
|
1285
|
+
<div className="text-4xl mb-3">✈️</div>
|
|
1286
|
+
<h1 className="text-2xl font-bold" style={{ color: "#E35930" }}>SOLANApolis Flight</h1>
|
|
1287
|
+
<p className="text-white/50 text-sm">Real-world terrain · Multiplayer · Combat · Weather</p>
|
|
1288
|
+
</div>
|
|
1289
|
+
{isMobile ? (
|
|
1290
|
+
<div className="space-y-2.5">
|
|
1291
|
+
{[["↔️", "Swipe left/right", "Roll / bank"], ["↕️", "Swipe up/down", "Climb / descend"], ["🎚️", "Right slider", "Throttle"], ["🔫", "Fire button", "Shoot enemies"]].map(([icon, action, desc]) => (
|
|
1292
|
+
<div key={action} className="flex items-center gap-3 bg-white/5 border border-white/10 rounded-xl px-4 py-3">
|
|
1293
|
+
<span className="text-2xl">{icon}</span><div><p className="text-sm font-medium text-white/80">{action}</p><p className="text-xs text-white/40">{desc}</p></div>
|
|
1294
|
+
</div>
|
|
1295
|
+
))}
|
|
1296
|
+
</div>
|
|
1297
|
+
) : (
|
|
1298
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1299
|
+
<div className="space-y-2">
|
|
1300
|
+
<p className="text-[10px] text-white/30 tracking-widest font-medium uppercase">Flight</p>
|
|
1301
|
+
{[["W / ↑", "Throttle"], ["S / ↓", "Brake"], ["A / D", "Roll"], ["Space", "Climb"], ["F", "Fire!"]].map(([k, a]) => (
|
|
1302
|
+
<div key={k} className="flex justify-between items-center gap-2">
|
|
1303
|
+
<kbd className="px-2 py-0.5 text-[10px] bg-white/5 border border-white/10 rounded-md font-mono text-white/70">{k}</kbd>
|
|
1304
|
+
<span className="text-xs text-white/50 flex-1 text-right">{a}</span>
|
|
1305
|
+
</div>
|
|
1306
|
+
))}
|
|
1307
|
+
</div>
|
|
1308
|
+
<div className="space-y-2">
|
|
1309
|
+
<p className="text-[10px] text-white/30 tracking-widest font-medium uppercase">System</p>
|
|
1310
|
+
{[["C", "Camera"], ["N", "Weather"], ["K", "Skins"], ["L", "Fly To"], ["Esc", "Exit"]].map(([k, a]) => (
|
|
1311
|
+
<div key={k} className="flex justify-between items-center gap-2">
|
|
1312
|
+
<kbd className="px-2 py-0.5 text-[10px] bg-white/5 border border-white/10 rounded-md font-mono text-white/70">{k}</kbd>
|
|
1313
|
+
<span className="text-xs text-white/50 flex-1 text-right">{a}</span>
|
|
1314
|
+
</div>
|
|
1315
|
+
))}
|
|
1316
|
+
</div>
|
|
1317
|
+
</div>
|
|
1318
|
+
)}
|
|
1319
|
+
<div className="bg-white/5 border border-white/10 rounded-xl p-3 space-y-1.5">
|
|
1320
|
+
<p className="text-[10px] text-[#E35930] tracking-widest font-semibold uppercase">Tips</p>
|
|
1321
|
+
{["Speed below 130 km/h triggers stall — add throttle fast", "F key fires at enemy pilots — hit = score points", "Land at airports for bonus scoring"].map(tip => (
|
|
1322
|
+
<p key={tip} className="text-xs text-white/50"><span className="text-[#E35930]/60 mr-1.5">•</span>{tip}</p>
|
|
1323
|
+
))}
|
|
1324
|
+
</div>
|
|
1325
|
+
<div className="flex gap-3">
|
|
1326
|
+
<button onClick={onStart}
|
|
1327
|
+
className="flex-1 py-3 bg-[#E35930] hover:bg-[#E35930]/90 rounded-xl text-sm font-semibold text-white transition-colors cursor-pointer">
|
|
1328
|
+
Start Flying
|
|
1329
|
+
</button>
|
|
1330
|
+
<button onClick={onExit}
|
|
1331
|
+
className="px-5 py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-sm text-white/60 transition-colors cursor-pointer">
|
|
1332
|
+
← City
|
|
1333
|
+
</button>
|
|
1334
|
+
</div>
|
|
1335
|
+
</div>
|
|
1336
|
+
</div>
|
|
1337
|
+
</div>
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
1342
|
+
|
|
1343
|
+
function lerpAngle(start: number, end: number, factor: number): number {
|
|
1344
|
+
const TWO_PI = Math.PI * 2;
|
|
1345
|
+
start = ((start % TWO_PI) + TWO_PI) % TWO_PI;
|
|
1346
|
+
end = ((end % TWO_PI) + TWO_PI) % TWO_PI;
|
|
1347
|
+
let delta = end - start;
|
|
1348
|
+
if (delta > Math.PI) delta -= TWO_PI;
|
|
1349
|
+
if (delta < -Math.PI) delta += TWO_PI;
|
|
1350
|
+
return ((start + delta * factor) % TWO_PI + TWO_PI) % TWO_PI;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function angularDelta(current: number, previous: number): number {
|
|
1354
|
+
const TWO_PI = Math.PI * 2;
|
|
1355
|
+
current = ((current % TWO_PI) + TWO_PI) % TWO_PI;
|
|
1356
|
+
previous = ((previous % TWO_PI) + TWO_PI) % TWO_PI;
|
|
1357
|
+
let d = current - previous;
|
|
1358
|
+
if (d > Math.PI) d -= TWO_PI;
|
|
1359
|
+
if (d < -Math.PI) d += TWO_PI;
|
|
1360
|
+
return d;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function applyQualityToScene(viewer: Viewer, preset: QualityPreset) {
|
|
1364
|
+
const globe = viewer.scene.globe as typeof viewer.scene.globe & { maximumScreenSpaceError: number };
|
|
1365
|
+
if (preset === "performance") {
|
|
1366
|
+
globe.maximumScreenSpaceError = 32;
|
|
1367
|
+
viewer.scene.fog.enabled = false;
|
|
1368
|
+
} else if (preset === "balanced") {
|
|
1369
|
+
globe.maximumScreenSpaceError = 16;
|
|
1370
|
+
viewer.scene.fog.enabled = true;
|
|
1371
|
+
} else {
|
|
1372
|
+
globe.maximumScreenSpaceError = 8;
|
|
1373
|
+
viewer.scene.fog.enabled = true;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// ─── Weather ──────────────────────────────────────────────────────────────────
|
|
1378
|
+
|
|
1379
|
+
function applyWeatherPreset(game: CesiumGameState, type: WeatherType, viewer: Viewer, setWeatherType: (t: WeatherType) => void) {
|
|
1380
|
+
const preset = WEATHER_PRESETS[type];
|
|
1381
|
+
game.weather = { ...preset, nextChange: Date.now() + 60000 + Math.random() * 180000 };
|
|
1382
|
+
setWeatherType(type);
|
|
1383
|
+
|
|
1384
|
+
// Fog
|
|
1385
|
+
viewer.scene.fog.enabled = type !== "clear";
|
|
1386
|
+
if (type === "stormy") {
|
|
1387
|
+
(viewer.scene.fog as typeof viewer.scene.fog & { density: number }).density = 0.0008;
|
|
1388
|
+
} else if (type === "cloudy") {
|
|
1389
|
+
(viewer.scene.fog as typeof viewer.scene.fog & { density: number }).density = 0.0003;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function updateWeather(game: CesiumGameState, now: number, viewer: Viewer, setWeatherType: (t: WeatherType) => void) {
|
|
1394
|
+
if (now < game.weather.nextChange) return;
|
|
1395
|
+
const types: WeatherType[] = ["clear", "cloudy", "stormy", "clear", "clear"]; // clear more likely
|
|
1396
|
+
const next = types[Math.floor(Math.random() * types.length)] as WeatherType;
|
|
1397
|
+
applyWeatherPreset(game, next, viewer, setWeatherType);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// ─── Terrain probing ──────────────────────────────────────────────────────────
|
|
1401
|
+
|
|
1402
|
+
function probeTerrainHeight(game: CesiumGameState) {
|
|
1403
|
+
const { Cesium, viewer, position } = game;
|
|
1404
|
+
if (!viewer.terrainProvider) return;
|
|
1405
|
+
const carto = Cesium.Cartographic.fromCartesian(position);
|
|
1406
|
+
Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, [Cesium.Cartographic.clone(carto)])
|
|
1407
|
+
.then(updated => { if (updated[0]) game.lastTerrainAlt = updated[0].height ?? 0; })
|
|
1408
|
+
.catch(() => { });
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// ─── Weapon system ────────────────────────────────────────────────────────────
|
|
1412
|
+
|
|
1413
|
+
function fireProjectile(game: CesiumGameState) {
|
|
1414
|
+
const { Cesium, viewer, position, physics } = game;
|
|
1415
|
+
const pos = Cesium.Cartesian3.clone(position);
|
|
1416
|
+
const posProp = new Cesium.ConstantPositionProperty(pos);
|
|
1417
|
+
const entity = viewer.entities.add({
|
|
1418
|
+
position: posProp,
|
|
1419
|
+
point: { pixelSize: 6, color: Cesium.Color.fromCssColorString("#ff4400"), outlineColor: Cesium.Color.fromCssColorString("#ff8800"), outlineWidth: 2 },
|
|
1420
|
+
});
|
|
1421
|
+
game.projectiles.push({
|
|
1422
|
+
positionProperty: posProp, entity,
|
|
1423
|
+
heading: physics.heading, pitch: physics.pitch,
|
|
1424
|
+
speed: physics.currentSpeed * 3.5,
|
|
1425
|
+
createdAt: performance.now(),
|
|
1426
|
+
shooterWallet: "self",
|
|
1427
|
+
});
|
|
1428
|
+
playGunshot();
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function updateProjectiles(
|
|
1432
|
+
game: CesiumGameState,
|
|
1433
|
+
dt: number,
|
|
1434
|
+
now: number,
|
|
1435
|
+
walletRef: { current: string | null },
|
|
1436
|
+
channelRef: { current: RealtimeChannel | null }
|
|
1437
|
+
) {
|
|
1438
|
+
const { Cesium, viewer, projectiles, remotePilots, combat } = game;
|
|
1439
|
+
const HIT_RADIUS = 60; // metres
|
|
1440
|
+
const PROJECTILE_TTL = 5000; // ms
|
|
1441
|
+
|
|
1442
|
+
for (let i = projectiles.length - 1; i >= 0; i--) {
|
|
1443
|
+
const proj = projectiles[i];
|
|
1444
|
+
|
|
1445
|
+
// Remove expired
|
|
1446
|
+
if (now - proj.createdAt > PROJECTILE_TTL) {
|
|
1447
|
+
viewer.entities.remove(proj.entity);
|
|
1448
|
+
projectiles.splice(i, 1);
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Advance position in heading/pitch direction
|
|
1453
|
+
const hpr = new Cesium.HeadingPitchRoll(proj.heading, proj.pitch, 0);
|
|
1454
|
+
const transform = Cesium.Transforms.headingPitchRollToFixedFrame(
|
|
1455
|
+
proj.positionProperty.getValue(Cesium.JulianDate.now()) ?? game.position,
|
|
1456
|
+
hpr, Cesium.Ellipsoid.WGS84
|
|
1457
|
+
);
|
|
1458
|
+
const speedMs = (proj.speed / 3.6) * dt;
|
|
1459
|
+
// Cesium local forward axis is +X for HeadingPitchRoll transforms.
|
|
1460
|
+
const localFwd = new Cesium.Cartesian3(speedMs, 0, 0);
|
|
1461
|
+
const worldFwd = Cesium.Matrix4.multiplyByPoint(transform, localFwd, new Cesium.Cartesian3());
|
|
1462
|
+
const curPos = proj.positionProperty.getValue(Cesium.JulianDate.now()) ?? game.position;
|
|
1463
|
+
const delta = Cesium.Cartesian3.subtract(worldFwd, curPos, new Cesium.Cartesian3());
|
|
1464
|
+
const newPos = Cesium.Cartesian3.add(curPos, delta, new Cesium.Cartesian3());
|
|
1465
|
+
|
|
1466
|
+
// Remove if hit terrain
|
|
1467
|
+
const carto = Cesium.Cartographic.fromCartesian(newPos);
|
|
1468
|
+
if (carto.height < game.lastTerrainAlt + 2) {
|
|
1469
|
+
viewer.entities.remove(proj.entity);
|
|
1470
|
+
projectiles.splice(i, 1);
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
proj.positionProperty.setValue(newPos);
|
|
1475
|
+
|
|
1476
|
+
// Hit detection against remote pilots
|
|
1477
|
+
remotePilots.forEach((pilot, wallet) => {
|
|
1478
|
+
const pilotPos = pilot.positionProperty.getValue(Cesium.JulianDate.now());
|
|
1479
|
+
if (!pilotPos) return;
|
|
1480
|
+
const dist = Cesium.Cartesian3.distance(newPos, pilotPos);
|
|
1481
|
+
if (dist < HIT_RADIUS) {
|
|
1482
|
+
// Hit!
|
|
1483
|
+
combat.score += 100;
|
|
1484
|
+
combat.kills += 1;
|
|
1485
|
+
combat.streak += 1;
|
|
1486
|
+
playExplosion();
|
|
1487
|
+
// Broadcast hit to the target pilot
|
|
1488
|
+
channelRef.current?.send({
|
|
1489
|
+
type: "broadcast", event: "cesium-hit",
|
|
1490
|
+
payload: { target: wallet, shooter: walletRef.current ?? "unknown" },
|
|
1491
|
+
});
|
|
1492
|
+
viewer.entities.remove(proj.entity);
|
|
1493
|
+
projectiles.splice(i, 1);
|
|
1494
|
+
// Visual flash at hit location
|
|
1495
|
+
const flash = viewer.entities.add({
|
|
1496
|
+
position: pilotPos,
|
|
1497
|
+
point: { pixelSize: 40, color: Cesium.Color.fromCssColorString("#ff8800").withAlpha(0.8) },
|
|
1498
|
+
});
|
|
1499
|
+
setTimeout(() => { try { viewer.entities.remove(flash); } catch { } }, 600);
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// ─── Physics — stall / lift / drag / ground effect / auto-level / weather ─────
|
|
1506
|
+
function physicsUpdate(game: CesiumGameState, input: FlightInputState, dt: number) {
|
|
1507
|
+
const Cesium = game.Cesium;
|
|
1508
|
+
const p = game.physics;
|
|
1509
|
+
const s = game.scratch;
|
|
1510
|
+
const w = game.weather;
|
|
1511
|
+
|
|
1512
|
+
const stallSpeed = 130; // km/h — below this, lift starts to fail
|
|
1513
|
+
const minSpeed = 80;
|
|
1514
|
+
const maxSpeed = 1200;
|
|
1515
|
+
const speedRate = 100;
|
|
1516
|
+
const turnRate = Cesium.Math.toRadians(45);
|
|
1517
|
+
const climbRate = 30;
|
|
1518
|
+
const gravity = 9.8; // m/s²
|
|
1519
|
+
const liftCoeff = 0.00012; // vertical m/s per (km/h)² — tuned so 200km/h sustains flight
|
|
1520
|
+
const maxRoll = Cesium.Math.toRadians(55);
|
|
1521
|
+
const dragCoeff = 0.00004;
|
|
1522
|
+
|
|
1523
|
+
// ── Speed ──
|
|
1524
|
+
if (input.targetSpeed !== undefined) {
|
|
1525
|
+
p.targetSpeed = Math.max(minSpeed, Math.min(maxSpeed, input.targetSpeed));
|
|
1526
|
+
} else {
|
|
1527
|
+
const tDelta = (input.throttle ? 1 : 0) - (input.brake ? 1 : 0);
|
|
1528
|
+
if (tDelta !== 0) p.targetSpeed += tDelta * speedRate * dt;
|
|
1529
|
+
p.targetSpeed = Math.max(minSpeed, Math.min(maxSpeed, p.targetSpeed));
|
|
1530
|
+
}
|
|
1531
|
+
const speedStep = Cesium.Math.clamp(p.targetSpeed - p.currentSpeed, -speedRate * dt, speedRate * dt);
|
|
1532
|
+
p.currentSpeed += speedStep;
|
|
1533
|
+
|
|
1534
|
+
// Drag — slows you proportionally to speed squared
|
|
1535
|
+
const drag = dragCoeff * p.currentSpeed * p.currentSpeed * dt;
|
|
1536
|
+
p.currentSpeed = Math.max(minSpeed, p.currentSpeed - drag);
|
|
1537
|
+
|
|
1538
|
+
// ── Roll ──
|
|
1539
|
+
let rollInput = 0;
|
|
1540
|
+
if (input.rollLeft || input.turnLeft) rollInput -= 1;
|
|
1541
|
+
if (input.rollRight || input.turnRight) rollInput += 1;
|
|
1542
|
+
|
|
1543
|
+
// Turbulence randomly jostles roll
|
|
1544
|
+
if (w.turbulence > 0) rollInput += (Math.random() * 2 - 1) * w.turbulence * 0.03 * dt;
|
|
1545
|
+
|
|
1546
|
+
p.roll += (rollInput * maxRoll - p.roll) * 0.15;
|
|
1547
|
+
|
|
1548
|
+
// ── Heading — roll-induced yaw ──
|
|
1549
|
+
const rollFactor = p.roll / maxRoll;
|
|
1550
|
+
p.heading = Cesium.Math.zeroToTwoPi(p.heading + rollFactor * turnRate * dt);
|
|
1551
|
+
|
|
1552
|
+
// Wind effect on heading (lateral wind pushes plane)
|
|
1553
|
+
const windStrength = Math.sqrt(w.windX * w.windX + w.windY * w.windY);
|
|
1554
|
+
if (windStrength > 0) {
|
|
1555
|
+
p.heading = Cesium.Math.zeroToTwoPi(p.heading + (w.windX * 0.00002 + w.windY * 0.00002) * dt);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// ── Stall ──
|
|
1559
|
+
p.stallFactor = Math.max(0, 1 - p.currentSpeed / stallSpeed);
|
|
1560
|
+
|
|
1561
|
+
// ── Vertical velocity ──
|
|
1562
|
+
const prevVert = p.verticalVelocity;
|
|
1563
|
+
let climbInput = 0;
|
|
1564
|
+
if (input.altitudeUp) climbInput += 1;
|
|
1565
|
+
if (input.altitudeDown) climbInput -= 1;
|
|
1566
|
+
|
|
1567
|
+
// Turbulence vertical force
|
|
1568
|
+
if (w.turbulence > 0) climbInput += (Math.random() * 2 - 1) * w.turbulence * 0.02;
|
|
1569
|
+
|
|
1570
|
+
p.verticalVelocity += (climbInput * climbRate - p.verticalVelocity) * 0.1;
|
|
1571
|
+
|
|
1572
|
+
// Gravity minus lift
|
|
1573
|
+
const lift = liftCoeff * p.currentSpeed * p.currentSpeed;
|
|
1574
|
+
const netGravity = gravity - lift;
|
|
1575
|
+
p.verticalVelocity -= netGravity * dt;
|
|
1576
|
+
|
|
1577
|
+
// Stall: lose lift → nose pitches down, rapid sink
|
|
1578
|
+
if (p.stallFactor > 0) {
|
|
1579
|
+
p.verticalVelocity -= p.stallFactor * gravity * 2 * dt;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// Ground effect: within 20m of terrain → extra lift
|
|
1583
|
+
const carto0 = Cesium.Cartographic.fromCartesian(game.position);
|
|
1584
|
+
const heightAGL = Math.max(0, carto0.height - game.lastTerrainAlt);
|
|
1585
|
+
if (heightAGL < 20 && heightAGL > 0 && p.currentSpeed > stallSpeed * 0.8) {
|
|
1586
|
+
const geFactor = (20 - heightAGL) / 20 * 0.4;
|
|
1587
|
+
p.verticalVelocity += geFactor * gravity * dt;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// Auto-level: if no vertical input and not stalled, gently level off
|
|
1591
|
+
if (!input.altitudeUp && !input.altitudeDown && p.stallFactor < 0.3 && Math.abs(p.verticalVelocity) > 1) {
|
|
1592
|
+
p.verticalVelocity *= (1 - 0.008 * dt * 60);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// ── G-force = 1 + deltaV/g ──
|
|
1596
|
+
p.gForce = 1 + (p.verticalVelocity - prevVert) / (gravity * dt);
|
|
1597
|
+
p.lastVertVelocity = prevVert;
|
|
1598
|
+
|
|
1599
|
+
// Pitch (visual)
|
|
1600
|
+
const targetPitch = Cesium.Math.toRadians(30) * climbInput - p.stallFactor * Cesium.Math.toRadians(20);
|
|
1601
|
+
p.pitch += (targetPitch - p.pitch) * 0.08;
|
|
1602
|
+
|
|
1603
|
+
game.hpRoll.heading = p.heading;
|
|
1604
|
+
game.hpRoll.pitch = p.pitch;
|
|
1605
|
+
game.hpRoll.roll = p.roll;
|
|
1606
|
+
|
|
1607
|
+
// ── Position translation ──
|
|
1608
|
+
Cesium.Transforms.headingPitchRollToFixedFrame(
|
|
1609
|
+
game.position, game.hpRoll, Cesium.Ellipsoid.WGS84, undefined, s.transform
|
|
1610
|
+
);
|
|
1611
|
+
const speedMs = (p.currentSpeed / 3.6) * dt;
|
|
1612
|
+
// Cesium local forward axis is +X for HeadingPitchRoll transforms.
|
|
1613
|
+
s.localFwd.x = speedMs; s.localFwd.y = 0; s.localFwd.z = 0;
|
|
1614
|
+
Cesium.Matrix4.multiplyByPoint(s.transform, s.localFwd, s.worldFwd);
|
|
1615
|
+
Cesium.Cartesian3.subtract(s.worldFwd, game.position, s.worldFwd);
|
|
1616
|
+
|
|
1617
|
+
Cesium.Transforms.eastNorthUpToFixedFrame(game.position, Cesium.Ellipsoid.WGS84, s.enu);
|
|
1618
|
+
Cesium.Matrix4.getColumn(s.enu, 2, s.upCol);
|
|
1619
|
+
s.up.x = s.upCol.x; s.up.y = s.upCol.y; s.up.z = s.upCol.z;
|
|
1620
|
+
Cesium.Cartesian3.multiplyByScalar(s.up, p.verticalVelocity * dt, s.vertDelta);
|
|
1621
|
+
Cesium.Cartesian3.add(s.worldFwd, s.vertDelta, s.totalDelta);
|
|
1622
|
+
Cesium.Cartesian3.add(game.position, s.totalDelta, s.newPos);
|
|
1623
|
+
|
|
1624
|
+
const carto = Cesium.Cartographic.fromCartesian(s.newPos);
|
|
1625
|
+
const groundAlt = game.lastTerrainAlt;
|
|
1626
|
+
|
|
1627
|
+
if (carto.height < groundAlt + 2) {
|
|
1628
|
+
// Check for landing vs crash
|
|
1629
|
+
const approachSpeed = p.currentSpeed;
|
|
1630
|
+
const approachPitch = Math.abs(p.pitch);
|
|
1631
|
+
const approachVert = Math.abs(p.verticalVelocity);
|
|
1632
|
+
|
|
1633
|
+
if (approachSpeed < 200 && approachPitch < 0.25 && approachVert < 8) {
|
|
1634
|
+
// Smooth enough — landing!
|
|
1635
|
+
const speedScore = Math.max(0, 100 - (approachSpeed - 100) * 0.5);
|
|
1636
|
+
const pitchScore = Math.max(0, 100 - approachPitch * 200);
|
|
1637
|
+
const sinkScore = Math.max(0, 100 - approachVert * 8);
|
|
1638
|
+
p.landingQuality = Math.round((speedScore + pitchScore + sinkScore) / 3);
|
|
1639
|
+
game.landed = true;
|
|
1640
|
+
} else {
|
|
1641
|
+
game.crashed = true;
|
|
1642
|
+
}
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
game.position = Cesium.Cartesian3.clone(s.newPos, game.position);
|
|
1647
|
+
game.positionProperty.setValue(game.position);
|
|
1648
|
+
game.orientationProperty.setValue(Cesium.Transforms.headingPitchRollQuaternion(game.position, game.hpRoll));
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// ─── Follow camera ────────────────────────────────────────────────────────────
|
|
1652
|
+
function followCamera(game: CesiumGameState) {
|
|
1653
|
+
const { viewer, physics: p, position, hpRoll, cameraMode, camera: cam, Cesium, scratch: s } = game;
|
|
1654
|
+
if (!viewer || !position) return;
|
|
1655
|
+
|
|
1656
|
+
if (cameraMode === "cockpit") {
|
|
1657
|
+
Cesium.Transforms.headingPitchRollToFixedFrame(
|
|
1658
|
+
position,
|
|
1659
|
+
hpRoll,
|
|
1660
|
+
Cesium.Ellipsoid.WGS84,
|
|
1661
|
+
undefined,
|
|
1662
|
+
s.transform,
|
|
1663
|
+
);
|
|
1664
|
+
|
|
1665
|
+
Cesium.Matrix4.multiplyByPoint(s.transform, s.cockpitLocalPos, s.cockpitPos);
|
|
1666
|
+
Cesium.Matrix4.multiplyByPoint(s.transform, s.cockpitLocalLook, s.cockpitLook);
|
|
1667
|
+
Cesium.Cartesian3.subtract(s.cockpitLook, s.cockpitPos, s.cockpitDir);
|
|
1668
|
+
Cesium.Cartesian3.normalize(s.cockpitDir, s.cockpitDir);
|
|
1669
|
+
|
|
1670
|
+
// Use local Y axis as camera up to avoid collinearity with forward vector.
|
|
1671
|
+
Cesium.Matrix4.getColumn(s.transform, 1, s.cockpitUpCol);
|
|
1672
|
+
s.cockpitUp.x = s.cockpitUpCol.x;
|
|
1673
|
+
s.cockpitUp.y = s.cockpitUpCol.y;
|
|
1674
|
+
s.cockpitUp.z = s.cockpitUpCol.z;
|
|
1675
|
+
Cesium.Cartesian3.normalize(s.cockpitUp, s.cockpitUp);
|
|
1676
|
+
|
|
1677
|
+
viewer.camera.setView({
|
|
1678
|
+
destination: s.cockpitPos,
|
|
1679
|
+
orientation: {
|
|
1680
|
+
direction: s.cockpitDir,
|
|
1681
|
+
up: s.cockpitUp,
|
|
1682
|
+
},
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
const params = CAM_PARAMS.cockpit;
|
|
1686
|
+
const baseFOV = Cesium.Math.toRadians(params.baseFOV);
|
|
1687
|
+
const maxFOV = Cesium.Math.toRadians(params.maxFOV);
|
|
1688
|
+
const t = Math.min(p.currentSpeed / 600, 1.0);
|
|
1689
|
+
const targetFOV = baseFOV + (maxFOV - baseFOV) * t * t;
|
|
1690
|
+
cam.fov += (targetFOV - cam.fov) * 0.12;
|
|
1691
|
+
const frustum = viewer.camera.frustum;
|
|
1692
|
+
if (frustum instanceof Cesium.PerspectiveFrustum) frustum.fov = cam.fov;
|
|
1693
|
+
|
|
1694
|
+
cam.lastVehicleHeading = p.heading;
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// ─── Drone camera — auto-orbits around the aircraft ────────────────────────
|
|
1699
|
+
if (cameraMode === "drone") {
|
|
1700
|
+
// Slowly orbit around the plane (0.3 rad/s)
|
|
1701
|
+
cam.heading = Cesium.Math.zeroToTwoPi(cam.heading + 0.3 * 0.016);
|
|
1702
|
+
const params = CAM_PARAMS.drone;
|
|
1703
|
+
const orbitDist = params.dist;
|
|
1704
|
+
const orbitHeight = 60;
|
|
1705
|
+
|
|
1706
|
+
// Place camera in orbit around vehicle position
|
|
1707
|
+
const offsetX = Math.sin(cam.heading) * orbitDist;
|
|
1708
|
+
const offsetZ = Math.cos(cam.heading) * orbitDist;
|
|
1709
|
+
const offsetCartesian = new Cesium.Cartesian3(offsetX, offsetZ, orbitHeight);
|
|
1710
|
+
|
|
1711
|
+
Cesium.Transforms.headingPitchRollToFixedFrame(
|
|
1712
|
+
position, hpRoll, Cesium.Ellipsoid.WGS84, undefined, s.transform,
|
|
1713
|
+
);
|
|
1714
|
+
const camPos = Cesium.Matrix4.multiplyByPoint(s.transform, offsetCartesian, new Cesium.Cartesian3());
|
|
1715
|
+
|
|
1716
|
+
// Look at the plane
|
|
1717
|
+
const lookDir = Cesium.Cartesian3.subtract(position, camPos, new Cesium.Cartesian3());
|
|
1718
|
+
Cesium.Cartesian3.normalize(lookDir, lookDir);
|
|
1719
|
+
|
|
1720
|
+
// Up vector from transform Y axis
|
|
1721
|
+
Cesium.Matrix4.getColumn(s.transform, 2, s.cockpitUpCol);
|
|
1722
|
+
const droneUp = new Cesium.Cartesian3(s.cockpitUpCol.x, s.cockpitUpCol.y, s.cockpitUpCol.z);
|
|
1723
|
+
Cesium.Cartesian3.normalize(droneUp, droneUp);
|
|
1724
|
+
|
|
1725
|
+
viewer.camera.setView({
|
|
1726
|
+
destination: camPos,
|
|
1727
|
+
orientation: { direction: lookDir, up: droneUp },
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
// FOV
|
|
1731
|
+
const baseFOV = Cesium.Math.toRadians(params.baseFOV);
|
|
1732
|
+
const maxFOV = Cesium.Math.toRadians(params.maxFOV);
|
|
1733
|
+
const t = Math.min(p.currentSpeed / 600, 1.0);
|
|
1734
|
+
const targetFOV = baseFOV + (maxFOV - baseFOV) * t * t;
|
|
1735
|
+
cam.fov += (targetFOV - cam.fov) * 0.05;
|
|
1736
|
+
const frustum = viewer.camera.frustum;
|
|
1737
|
+
if (frustum instanceof Cesium.PerspectiveFrustum) frustum.fov = cam.fov;
|
|
1738
|
+
|
|
1739
|
+
cam.lastVehicleHeading = p.heading;
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
const params = CAM_PARAMS[cameraMode];
|
|
1744
|
+
const targetHeading = Cesium.Math.zeroToTwoPi(p.heading + Math.PI);
|
|
1745
|
+
const targetPitch = Cesium.Math.toRadians(params.basePitch) + p.pitch * 0.3;
|
|
1746
|
+
cam.heading = lerpAngle(cam.heading, targetHeading, params.lerpK);
|
|
1747
|
+
cam.pitch = cam.pitch + (targetPitch - cam.pitch) * params.lerpK;
|
|
1748
|
+
const hdgDelta = angularDelta(p.heading, cam.lastVehicleHeading);
|
|
1749
|
+
const targetRoll = -hdgDelta * params.bankFactor * 15 + hpRoll.roll * params.bankFactor * 0.5;
|
|
1750
|
+
cam.roll = cam.roll + (targetRoll - cam.roll) * 0.08;
|
|
1751
|
+
cam.hpRange.heading = cam.heading;
|
|
1752
|
+
cam.hpRange.pitch = cam.pitch;
|
|
1753
|
+
cam.hpRange.range = params.dist;
|
|
1754
|
+
viewer.camera.lookAt(position, cam.hpRange);
|
|
1755
|
+
const rollDiff = cam.roll - cam.appliedRoll;
|
|
1756
|
+
if (Math.abs(rollDiff) > 0.001) {
|
|
1757
|
+
try { viewer.camera.twistRight(rollDiff); } catch { /* ignore */ }
|
|
1758
|
+
cam.appliedRoll = cam.roll;
|
|
1759
|
+
}
|
|
1760
|
+
const baseFOV = Cesium.Math.toRadians(params.baseFOV);
|
|
1761
|
+
const maxFOV = Cesium.Math.toRadians(params.maxFOV);
|
|
1762
|
+
const t = Math.min(p.currentSpeed / 600, 1.0);
|
|
1763
|
+
const targetFOV = baseFOV + (maxFOV - baseFOV) * t * t;
|
|
1764
|
+
cam.fov += (targetFOV - cam.fov) * 0.05;
|
|
1765
|
+
const frustum = viewer.camera.frustum;
|
|
1766
|
+
if (frustum instanceof Cesium.PerspectiveFrustum) frustum.fov = cam.fov;
|
|
1767
|
+
cam.lastVehicleHeading = p.heading;
|
|
1768
|
+
}
|