UNPKG

caravan-x

Version:

A terminal-based utility for managing Caravan multisig wallets in regtest mode. This tool simplifies development and testing with Caravan by providing an easy-to-use interface

1,780 lines (1,484 loc) 128 kB
/** * Types for CaravanXBlockchain */ interface CaravanState { blocks: any[]; mempool: any[]; caravans: PIXI.Container[]; traders: PIXI.Graphics[]; miners: PIXI.Graphics[]; outposts: PIXI.Graphics[]; focusedEntity: any | null; timeOfDay: number; gameTime: number; weather: "clear" | "cloudy" | "rainy"; weatherTimer: number; weatherDuration: number; isTutorialShown: boolean; soundEnabled: boolean; camera: { x: number; y: number; scale: number; targetScale: number; dragging: boolean; lastPosition: { x: number; y: number } | null; }; animationIds: any[]; // Store animation IDs for cleanup } interface Sound { play: () => void; pause: () => void; stop?: () => void; fade?: (from: number, to: number, duration: number) => void; volume?: (level: number) => void; } interface Sounds { mining: Sound; transaction: Sound; click: Sound; caravan: Sound; ambient: Sound; rain: Sound; } /** * CaravanXBlockchain - A pixel art visualization of the blockchain as a trading caravan journey * * This class creates an interactive visualization where: * - Blocks are represented as caravans traveling along a chain * - Transactions are traders waiting to join caravans * - Mining is visualized as prospectors creating new caravans * - The environment changes with time (day/night cycle, weather) */ class CaravanXBlockchain { // PIXI Application private app: PIXI.Application; // Containers private worldContainer: PIXI.Container; private backgroundLayer: PIXI.Container; private trailLayer: PIXI.Container; private caravanLayer: PIXI.Container; private traderLayer: PIXI.Container; private minerLayer: PIXI.Container; private outpostLayer: PIXI.Container; private effectsLayer: PIXI.Container; private uiLayer: PIXI.Container; // Path data private pathPoints: Array<{ x: number; y: number }> = []; // World elements private sky: PIXI.Graphics | null = null; private sun: PIXI.Graphics | null = null; private moon: PIXI.Graphics | null = null; private stars: PIXI.Container | null = null; private clouds: PIXI.Container | null = null; private trail: PIXI.Graphics | null = null; private ground: PIXI.Graphics | null = null; private mountains: PIXI.Graphics | null = null; // Weather effects private rain: PIXI.Container | null = null; private rainInterval: NodeJS.Timeout | null = null; // Socket connection private socket: any | null = null; // Sounds private sounds: Sounds = { mining: { play: () => {}, pause: () => {} }, transaction: { play: () => {}, pause: () => {} }, click: { play: () => {}, pause: () => {} }, caravan: { play: () => {}, pause: () => {} }, ambient: { play: () => {}, pause: () => {} }, rain: { play: () => {}, pause: () => {} }, }; // Animation tracking private activeAnimations: Set<any> = new Set(); // Debug counters private frameCount: number = 0; // World initialization flag private worldInitialized: boolean = false; // Weather types for fallback private weathers: string[] = ["clear", "cloudy", "rainy"]; // Game state private state: CaravanState; constructor() { // Initialize game state this.state = { blocks: [], mempool: [], caravans: [], traders: [], miners: [], outposts: [], focusedEntity: null, timeOfDay: 8, // 0-24 for day-night cycle gameTime: 0, weather: "clear", // clear, cloudy, rainy weatherTimer: 0, weatherDuration: 300, // 5 minutes in game time before weather changes isTutorialShown: false, soundEnabled: true, camera: { x: 0, y: 0, scale: 1, targetScale: 1, dragging: false, lastPosition: null, }, animationIds: [], // Store animation IDs for cleanup }; // Initialize PIXI Application this.app = new PIXI.Application({ width: window.innerWidth, height: window.innerHeight, backgroundColor: 0x0a0a1a, resolution: window.devicePixelRatio || 1, antialias: false, }); const gameContainer = document.getElementById("game-container"); if (gameContainer) { gameContainer.appendChild(this.app.view); } // Create all containers this.worldContainer = new PIXI.Container(); this.backgroundLayer = new PIXI.Container(); this.trailLayer = new PIXI.Container(); this.caravanLayer = new PIXI.Container(); this.traderLayer = new PIXI.Container(); this.minerLayer = new PIXI.Container(); this.outpostLayer = new PIXI.Container(); this.effectsLayer = new PIXI.Container(); this.uiLayer = new PIXI.Container(); // Add all layers to world container this.app.stage.addChild(this.worldContainer); this.worldContainer.addChild(this.backgroundLayer); this.worldContainer.addChild(this.trailLayer); this.worldContainer.addChild(this.caravanLayer); this.worldContainer.addChild(this.traderLayer); this.worldContainer.addChild(this.minerLayer); this.worldContainer.addChild(this.outpostLayer); this.worldContainer.addChild(this.effectsLayer); this.app.stage.addChild(this.uiLayer); // Initialize sounds this.initializeSounds(); // Socket connection this.initializeSocket(); // Set up event listeners this.setupEventListeners(); // Start game loop this.app.ticker.add(this.gameLoop.bind(this)); // Initialize game this.initialize(); } // Initialize sounds private initializeSounds(): void { try { if (typeof Howl !== "undefined") { this.sounds = { mining: new Howl({ src: ["sounds/mining.mp3"], volume: 0.5, loop: false, }), transaction: new Howl({ src: ["sounds/transaction.mp3"], volume: 0.5, loop: false, }), click: new Howl({ src: ["sounds/click.mp3"], volume: 0.5, loop: false, }), caravan: new Howl({ src: ["sounds/caravan.mp3"], volume: 0.5, loop: false, }), ambient: new Howl({ src: ["sounds/blockchain_ambient.mp3"], volume: 0.3, loop: true, }), rain: new Howl({ src: ["sounds/rain.mp3"], volume: 0.2, loop: true, }), }; } else { console.warn("Howler.js not available, sound disabled"); this.createDummySounds(); } } catch (error) { console.error("Error initializing sounds:", error); this.createDummySounds(); } } // Create dummy sound objects private createDummySounds(): void { this.sounds = { mining: { play: () => {}, pause: () => {}, fade: () => {} }, transaction: { play: () => {}, pause: () => {}, fade: () => {} }, click: { play: () => {}, pause: () => {}, fade: () => {} }, caravan: { play: () => {}, pause: () => {}, fade: () => {} }, ambient: { play: () => {}, pause: () => {} }, rain: { play: () => {}, pause: () => {} }, }; } // Initialize socket connection private initializeSocket(): void { try { if (typeof io !== "undefined") { this.socket = io(); console.log("Socket.io initialized"); } } catch (error) { console.warn("Could not connect to socket.io:", error); this.socket = null; } } // Initialize the game private async initialize(): Promise<void> { try { console.log("Starting initialization..."); // Load assets await this.loadAssets(); console.log("Assets loaded!"); // Create the world this.createWorld(); console.log("World created!"); // Fetch initial blockchain data await this.fetchBlockchainData(); console.log("Blockchain data fetched!"); // Set default camera position to show the blockchain path this.state.camera = { x: -100, // Start slightly to the right so we can see the beginning of the path y: 0, scale: 0.8, targetScale: 0.8, dragging: false, lastPosition: null, }; // Hide loading screen const loadingScreen = document.getElementById("loading-screen"); if (loadingScreen) { loadingScreen.style.display = "none"; } // Add intro animation this.playIntroAnimation(); // Start ambient sound if (this.state.soundEnabled && this.sounds) { this.sounds.ambient.play(); } // Show tutorial if first visit const hasTutorialBeenShown = localStorage.getItem( "caravanXTutorialShown", ); if (!hasTutorialBeenShown) { this.showTutorial(); localStorage.setItem("caravanXTutorialShown", "true"); } console.log("Game initialized successfully"); } catch (error) { console.error("Error initializing game:", error); const loadingText = document.getElementById("loading-text"); if (loadingText) { loadingText.textContent = "Error loading caravan: " + (error as Error).message; } } } // Load game assets private async loadAssets(): Promise<void> { return new Promise((resolve) => { // Simulate loading process let progress = 0; const loadingInterval = setInterval(() => { progress += 5; const loadingBar = document.getElementById("loading-bar"); if (loadingBar) { loadingBar.style.width = `${progress}%`; } const loadingText = document.getElementById("loading-text"); if (loadingText) { loadingText.textContent = `Loading the caravan... ${progress}%`; } if (progress >= 100) { clearInterval(loadingInterval); resolve(); } }, 100); }); } // Create the world environment private createWorld(): void { try { // Create sky background with gradient this.createSky(); console.log("Sky created"); // Create distant mountains this.createMountains(); console.log("Mountains created"); // Create the blockchain trail (the path that caravans follow) this.createBlockchainTrail(); console.log("Trail created"); // Create ground this.createGround(); console.log("Ground created"); // Add environmental elements this.addEnvironmentalElements(); console.log("Environmental elements added"); // Mark world as initialized this.worldInitialized = true; console.log("World marked as initialized!"); } catch (error) { console.error("Error creating world:", error); throw error; } } // Create the sky with gradient and day/night cycle capability private createSky(): void { // Create sky gradient const sky = new PIXI.Graphics(); sky.beginFill(0x4477aa); // Day sky color sky.drawRect( -window.innerWidth, -window.innerHeight, window.innerWidth * 3, window.innerHeight * 2, ); sky.endFill(); this.backgroundLayer.addChild(sky); this.sky = sky; // Add sun const sun = new PIXI.Graphics(); sun.beginFill(0xffdd88); sun.drawCircle(0, 0, 40); sun.endFill(); sun.x = window.innerWidth * 0.7; sun.y = 100; this.backgroundLayer.addChild(sun); this.sun = sun; // Add moon const moon = new PIXI.Graphics(); moon.beginFill(0xddddff); moon.drawCircle(0, 0, 30); moon.endFill(); moon.x = window.innerWidth * 0.3; moon.y = 100; moon.alpha = 0; this.backgroundLayer.addChild(moon); this.moon = moon; // Add stars container this.stars = new PIXI.Container(); this.backgroundLayer.addChild(this.stars); // Generate stars for (let i = 0; i < 100; i++) { const star = new PIXI.Graphics(); star.beginFill(0xffffff); const size = Math.random() * 2 + 1; star.drawRect(0, 0, size, size); star.endFill(); star.x = Math.random() * window.innerWidth * 3 - window.innerWidth; star.y = (Math.random() * window.innerHeight) / 2; star.alpha = 0; // Stars start invisible during day this.stars.addChild(star); } // Add clouds container this.clouds = new PIXI.Container(); this.backgroundLayer.addChild(this.clouds); // Generate clouds for (let i = 0; i < 10; i++) { this.createCloud( Math.random() * window.innerWidth * 3 - window.innerWidth, Math.random() * 200, 0.1 + Math.random() * 0.2, ); } } // Create a single cloud private createCloud( x: number, y: number, speed: number, ): PIXI.Graphics | null { if (!this.clouds) return null; const cloud = new PIXI.Graphics(); cloud.beginFill(0xffffff, 0.8); // Create fluffy cloud shape const cloudWidth = 30 + Math.random() * 70; const cloudHeight = 20 + Math.random() * 30; // Draw multiple overlapping circles for a cloud shape for (let i = 0; i < 5; i++) { const circleSize = cloudHeight / 2 + (Math.random() * cloudHeight) / 2; const offsetX = (i / 5) * cloudWidth - cloudWidth / 2; const offsetY = Math.random() * 10 - 5; cloud.drawCircle(offsetX, offsetY, circleSize); } cloud.endFill(); cloud.x = x; cloud.y = y; // Add custom properties (cloud as any).speed = speed; (cloud as any).cloudWidth = cloudWidth; this.clouds.addChild(cloud); return cloud; } // Create distant mountains for background private createMountains(): void { const mountains = new PIXI.Graphics(); // Draw several mountain ranges with different colors for (let range = 0; range < 3; range++) { // Use progressively lighter colors for distant ranges const color = range === 0 ? 0x303656 : range === 1 ? 0x424c78 : 0x5a6291; const baseY = 300 - range * 40; // Each range is higher on screen (more distant) const peakHeight = 100 - range * 20; // More distant mountains are shorter mountains.beginFill(color); // Start at the left edge below the terrain line mountains.moveTo(-window.innerWidth, baseY + 50); // Create a series of peaks const numPeaks = 20; const peakWidth = (window.innerWidth * 3) / numPeaks; for (let i = 0; i <= numPeaks; i++) { const x = -window.innerWidth + i * peakWidth; // Randomize peak heights for a natural look const heightVariation = Math.random() * peakHeight; const y = baseY - heightVariation; // For smoother mountains, use quadraticCurveTo instead of lineTo if (i === 0) { mountains.lineTo(x, y); } else { const cpX = x - peakWidth / 2; const cpY = baseY - (Math.random() * peakHeight) / 2; mountains.quadraticCurveTo(cpX, cpY, x, y); } } // Close the shape by extending to the right edge and down mountains.lineTo(window.innerWidth * 2, baseY + 50); mountains.lineTo(-window.innerWidth, baseY + 50); mountains.endFill(); } this.backgroundLayer.addChild(mountains); this.mountains = mountains; } // Create the blockchain trail (path for caravans) private createBlockchainTrail(): void { const trail = new PIXI.Graphics(); // Main dirt path trail.beginFill(0x8b4513, 0.7); // Create a winding path using bezier curves this.pathPoints = []; let x = -window.innerWidth; const baseY = 300; // Generate winding path points for (let i = 0; i < 50; i++) { const segment = { x: x, y: baseY + Math.sin(i * 0.2) * 30, }; this.pathPoints.push(segment); x += 200; } // Draw the path as a series of quadratic curves trail.moveTo(this.pathPoints[0].x, this.pathPoints[0].y); for (let i = 1; i < this.pathPoints.length; i++) { const prev = this.pathPoints[i - 1]; const current = this.pathPoints[i]; // Control point halfway between points const cpX = (prev.x + current.x) / 2; const cpY = prev.y + (Math.random() * 20 - 10); // Slight random variation trail.quadraticCurveTo(cpX, cpY, current.x, current.y); } // Make the path wider by drawing another line slightly offset const pathWidth = 30; for (let i = this.pathPoints.length - 1; i >= 0; i--) { const point = this.pathPoints[i]; trail.lineTo(point.x, point.y + pathWidth); } trail.endFill(); // Add some small stones along the path edges trail.beginFill(0x999999, 0.5); for (let i = 0; i < 200; i++) { const pathIndex = Math.floor(Math.random() * this.pathPoints.length); const point = this.pathPoints[pathIndex]; const offsetY = (Math.random() > 0.5 ? 1 : -1) * (pathWidth / 2 + Math.random() * 5); const stoneSize = 2 + Math.random() * 3; trail.drawCircle( point.x + Math.random() * 100 - 50, point.y + offsetY, stoneSize, ); } trail.endFill(); this.trailLayer.addChild(trail); this.trail = trail; } // Create ground with grass and texture private createGround(): void { const ground = new PIXI.Graphics(); // Main ground ground.beginFill(0x225522); ground.drawRect( -window.innerWidth, 300, window.innerWidth * 3, window.innerHeight, ); ground.endFill(); // Add texture to the ground with small grass tufts ground.beginFill(0x338833); for (let x = -window.innerWidth; x < window.innerWidth * 2; x += 20) { for (let y = 320; y < window.innerHeight + 300; y += 20) { if (Math.random() > 0.8) { ground.drawRect(x, y, 3 + Math.random() * 3, 3 + Math.random() * 3); } } } ground.endFill(); this.backgroundLayer.addChild(ground); this.ground = ground; } // Add environmental elements to the scene private addEnvironmentalElements(): void { // Add outposts (representing blockchain nodes) this.createOutposts(); // Add miners' camps this.createMinerCamps(); // Add decorative elements like trees, rocks, etc. this.addDecorations(); } // Create outposts (blockchain nodes) private createOutposts(): void { if (!this.pathPoints || this.pathPoints.length === 0) { console.warn("Path points not initialized, cannot create outposts"); return; } // Create 3 outposts along the blockchain path for (let i = 0; i < 3; i++) { const pathIndex = Math.floor(this.pathPoints.length / 3) * i + 5; if (pathIndex >= this.pathPoints.length) continue; const point = this.pathPoints[pathIndex]; // Create outpost building const outpost = new PIXI.Graphics(); // Main building outpost.beginFill(0x8b4513); outpost.drawRect(-15, -30, 30, 30); outpost.endFill(); // Roof outpost.beginFill(0x555555); outpost.moveTo(-18, -30); outpost.lineTo(0, -45); outpost.lineTo(18, -30); outpost.lineTo(-18, -30); outpost.endFill(); // Window outpost.beginFill(0xffdd88); outpost.drawRect(-5, -25, 10, 10); outpost.endFill(); // Antenna (for node connectivity) outpost.lineStyle(2, 0xaaaaaa); outpost.moveTo(0, -45); outpost.lineTo(0, -60); outpost.lineStyle(0); // Small dish on top outpost.beginFill(0xcccccc); outpost.drawCircle(0, -60, 5); outpost.endFill(); // Position outpost outpost.x = point.x; outpost.y = point.y - 10; // Slightly above the path // Add a sign const sign = new PIXI.Graphics(); sign.beginFill(0x8b4513); sign.drawRect(-10, -25, 20, 15); sign.endFill(); sign.beginFill(0xffdd88); sign.drawRect(-8, -23, 16, 11); sign.endFill(); sign.x = point.x + 25; sign.y = point.y; // Store outpost data (outpost as any).outpostData = { id: `outpost_${i}`, name: `Node ${i + 1}`, type: i === 0 ? "Full Node" : i === 1 ? "Mining Node" : "Light Client", connections: Math.floor(Math.random() * 8) + 3, }; // Make interactive outpost.interactive = true; outpost.buttonMode = true; outpost.on("pointerover", (event: PIXI.interaction.InteractionEvent) => { this.showTooltip( `${(outpost as any).outpostData.name} (${(outpost as any).outpostData.type})`, event.data.global.x, event.data.global.y, ); }); outpost.on("pointerout", () => { this.hideTooltip(); }); outpost.on("pointerdown", () => { this.showOutpostDetails((outpost as any).outpostData); if (this.state.soundEnabled) this.sounds.click.play(); }); this.outpostLayer.addChild(outpost); this.outpostLayer.addChild(sign); this.state.outposts.push(outpost); // Add connectivity animation this.addConnectivityEffect(outpost); } } // Add pulsing connectivity animation to outposts private addConnectivityEffect(outpost: PIXI.Graphics): void { if (!this.effectsLayer) return; // Create effect container to keep animations properly tracked const effectContainer = new PIXI.Container(); effectContainer.position.set(outpost.x, outpost.y - 60); this.effectsLayer.addChild(effectContainer); const connectivityPulse = new PIXI.Graphics(); connectivityPulse.beginFill(0x44aaff, 0.5); connectivityPulse.drawCircle(0, 0, 5); connectivityPulse.endFill(); effectContainer.addChild(connectivityPulse); // Store a reference to the active animations for possible cleanup this.activeAnimations.add(effectContainer); // Recursive animation function const animate = () => { // Skip if container was removed if (!effectContainer.parent) return; // Reset pulse connectivityPulse.scale.set(1); connectivityPulse.alpha = 1; // Animation timeline with cleanup const tl = gsap.timeline({ onComplete: () => { // Schedule next pulse after a delay if (effectContainer.parent) { gsap.delayedCall(1 + Math.random(), animate); } else { // Container was removed, stop animation gsap.killTweensOf(connectivityPulse); this.activeAnimations.delete(effectContainer); } }, }); tl.to( connectivityPulse.scale, { x: 3, y: 3, duration: 1.5, ease: "sine.out", }, 0, ); tl.to( connectivityPulse, { alpha: 0, duration: 1.5, ease: "sine.out", }, 0, ); }; // Start animation animate(); } // Create miner camps private createMinerCamps(): void { if (!this.pathPoints || this.pathPoints.length === 0) { console.warn("Path points not initialized, cannot create miner camps"); return; } // Create 4 mining camps around the trail for (let i = 0; i < 4; i++) { const pathIndex = Math.floor(this.pathPoints.length / 5) * (i + 1); if (pathIndex >= this.pathPoints.length) continue; const point = this.pathPoints[pathIndex]; // Determine position (alternate sides of the trail) const side = i % 2 === 0 ? -1 : 1; const x = point.x + side * (40 + Math.random() * 30); const y = point.y + (Math.random() * 30 - 15); // Create miner camp const minerCamp = new PIXI.Graphics(); // Main tent minerCamp.beginFill(0x8b4513); minerCamp.drawRect(-10, -5, 20, 15); minerCamp.endFill(); // Tent top minerCamp.beginFill(0xaa5533); minerCamp.moveTo(-15, -5); minerCamp.lineTo(0, -20); minerCamp.lineTo(15, -5); minerCamp.lineTo(-15, -5); minerCamp.endFill(); // Mining equipment minerCamp.beginFill(0x555555); minerCamp.drawRect(-15, 5, 10, 5); minerCamp.endFill(); // Pickaxe minerCamp.lineStyle(2, 0x888888); minerCamp.moveTo(10, 0); minerCamp.lineTo(20, -15); minerCamp.lineStyle(0); minerCamp.beginFill(0x444444); minerCamp.drawRect(17, -19, 6, 4); minerCamp.endFill(); // Position miner camp minerCamp.x = x; minerCamp.y = y; // Campfire const campfire = new PIXI.Graphics(); campfire.beginFill(0x555555); campfire.drawCircle(0, 0, 5); campfire.endFill(); // Logs campfire.beginFill(0x8b4513); campfire.drawRect(-6, -1, 12, 2); campfire.drawRect(-1, -6, 2, 12); campfire.endFill(); campfire.x = x - 20; campfire.y = y + 10; // Miner character const miner = this.createMinerCharacter(x + 15, y + 10); if (!miner) continue; // Store miner data (miner as any).minerData = { id: `miner_${i}`, name: `Miner ${i + 1}`, hashPower: Math.floor(Math.random() * 100) + 10, blocksFound: Math.floor(Math.random() * 5), active: Math.random() > 0.3, // 70% chance of being active }; // Make interactive miner.interactive = true; miner.buttonMode = true; miner.on("pointerover", (event: PIXI.interaction.InteractionEvent) => { this.showTooltip( `${(miner as any).minerData.name} (Hashpower: ${(miner as any).minerData.hashPower})`, event.data.global.x, event.data.global.y, ); }); miner.on("pointerout", () => { this.hideTooltip(); }); miner.on("pointerdown", () => { this.showMinerDetails((miner as any).minerData); if (this.state.soundEnabled) this.sounds.click.play(); }); if (this.minerLayer) { this.minerLayer.addChild(minerCamp); this.minerLayer.addChild(campfire); this.state.miners.push(miner); } // Add fire animation this.addFireAnimation(campfire); // Add mining animation if active if ((miner as any).minerData.active) { this.addMiningAnimation(miner); } } } // Create miner character private createMinerCharacter(x: number, y: number): PIXI.Graphics | null { if (!this.minerLayer) return null; const miner = new PIXI.Graphics(); // Body miner.beginFill(0x8b4513); miner.drawRect(-5, -15, 10, 15); miner.endFill(); // Head miner.beginFill(0xffcc99); miner.drawCircle(0, -20, 5); miner.endFill(); // Mining hat miner.beginFill(0x444444); miner.drawRect(-6, -25, 12, 3); miner.drawCircle(0, -25, 5); miner.endFill(); // Position miner miner.x = x; miner.y = y; if (this.minerLayer) { this.minerLayer.addChild(miner); } return miner; } // Add fire animation to campfire private addFireAnimation(campfire: PIXI.Graphics): void { if (!this.effectsLayer) return; // Create a container for the fire effect (for easier cleanup) const fireContainer = new PIXI.Container(); fireContainer.position.set(campfire.x, campfire.y); this.effectsLayer.addChild(fireContainer); // Create the fire graphics inside the container const fireGraphics = new PIXI.Graphics(); fireContainer.addChild(fireGraphics); // Track for cleanup this.activeAnimations.add(fireContainer); // Animation function using requestAnimationFrame const animate = () => { // Skip if container was removed if (!fireContainer.parent) return; // Redraw fire fireGraphics.clear(); // Orange outer flame fireGraphics.beginFill(0xff6600, 0.8); fireGraphics.moveTo(-2, 0); fireGraphics.quadraticCurveTo( -3 + Math.random() * 6, -5 - Math.random() * 5, 2, -8 - Math.random() * 4, ); fireGraphics.quadraticCurveTo( 3 + Math.random() * 2, -5 - Math.random() * 5, 2, 0, ); fireGraphics.lineTo(-2, 0); fireGraphics.endFill(); // Yellow inner flame fireGraphics.beginFill(0xffcc00, 0.9); fireGraphics.moveTo(-1, 0); fireGraphics.quadraticCurveTo( -1 + Math.random() * 2, -3 - Math.random() * 3, 1, -5 - Math.random() * 2, ); fireGraphics.quadraticCurveTo( 1 + Math.random() * 1, -3 - Math.random() * 3, 1, 0, ); fireGraphics.lineTo(-1, 0); fireGraphics.endFill(); // Schedule next frame if container still exists if (fireContainer.parent) { requestAnimationFrame(animate); } else { // Container removed, clean up this.activeAnimations.delete(fireContainer); } }; // Start animation animate(); } // Add mining animation to miner private addMiningAnimation(miner: PIXI.Graphics): void { if (!this.minerLayer) return; // Create a container for better cleanup and control const miningContainer = new PIXI.Container(); miningContainer.position.set(miner.x, miner.y - 5); this.minerLayer.addChild(miningContainer); // Create pickaxe const pickaxe = new PIXI.Graphics(); pickaxe.lineStyle(2, 0x888888); pickaxe.moveTo(0, 0); pickaxe.lineTo(15, -10); pickaxe.lineStyle(0); pickaxe.beginFill(0x444444); pickaxe.drawRect(12, -14, 6, 4); pickaxe.endFill(); pickaxe.pivot.set(0, 0); miningContainer.addChild(pickaxe); // Track for cleanup this.activeAnimations.add(miningContainer); // Mining animation with safety and cleanup const miningAnimation = gsap.to(pickaxe, { rotation: -0.5, duration: 0.5, repeat: -1, yoyo: true, ease: "power1.inOut", onComplete: () => { if (this.minerLayer && miningContainer.parent) { this.minerLayer.removeChild(miningContainer); this.activeAnimations.delete(miningContainer); } }, }); // Occasionally play mining sound const playMiningSound = () => { if (!miningContainer.parent) return; if (this.state.soundEnabled && Math.random() > 0.7) { this.sounds.mining.play(); } // Schedule next sound only if container still exists if (miningContainer.parent) { gsap.delayedCall(5 + Math.random() * 10, playMiningSound); } }; // Start sound timing gsap.delayedCall(Math.random() * 5, playMiningSound); } // Add decorative elements to the world private addDecorations(): void { try { // Add trees, rocks, cacti, etc. this.addTrees(); this.addRocks(); // Add occasional landmarks this.addBlockchainMonument(); this.addTradingOasis(); } catch (error) { console.error("Error adding decorations:", error); } } // Add trees to the scene private addTrees(): void { if (!this.backgroundLayer || !this.pathPoints) return; // Create different types of trees scattered around for (let i = 0; i < 40; i++) { // Position away from the path const x = Math.random() * window.innerWidth * 2 - window.innerWidth; const y = 300 + Math.random() * 200; // Skip if too close to the path let tooClose = false; for (const point of this.pathPoints) { const distance = Math.sqrt( Math.pow(point.x - x, 2) + Math.pow(point.y - y, 2), ); if (distance < 60) { tooClose = true; break; } } if (tooClose) continue; // Randomly choose tree type const treeType = Math.floor(Math.random() * 3); const tree = new PIXI.Graphics(); switch (treeType) { case 0: // Pine tree tree.beginFill(0x8b4513); tree.drawRect(-3, 0, 6, 15); tree.endFill(); tree.beginFill(0x006400); for (let j = 0; j < 3; j++) { const size = 15 - j * 4; tree.drawPolygon([ -size, -j * 10 - 5, 0, -j * 10 - 15, size, -j * 10 - 5, ]); } tree.endFill(); break; case 1: // Oak tree tree.beginFill(0x8b4513); tree.drawRect(-3, 0, 6, 10); tree.endFill(); tree.beginFill(0x228b22); tree.drawCircle(0, -10, 10); tree.endFill(); break; case 2: // Dead tree tree.beginFill(0x8b4513); tree.drawRect(-2, 0, 4, 15); tree.endFill(); tree.lineStyle(2, 0x8b4513); tree.moveTo(0, -15); tree.lineTo(8, -25); tree.moveTo(0, -10); tree.lineTo(-6, -20); tree.moveTo(0, -5); tree.lineTo(5, -15); tree.lineStyle(0); break; } tree.x = x; tree.y = y; this.backgroundLayer.addChild(tree); } } // Add rocks to the scene private addRocks(): void { if (!this.backgroundLayer || !this.pathPoints) return; // Add scattered rocks for (let i = 0; i < 30; i++) { const x = Math.random() * window.innerWidth * 2 - window.innerWidth; const y = 300 + Math.random() * 200; // Skip if too close to the path let tooClose = false; for (const point of this.pathPoints) { const distance = Math.sqrt( Math.pow(point.x - x, 2) + Math.pow(point.y - y, 2), ); if (distance < 40) { tooClose = true; break; } } if (tooClose) continue; const rock = new PIXI.Graphics(); // Determine rock size const size = 3 + Math.random() * 8; // Draw rock with slight variations rock.beginFill(0x888888); if (Math.random() > 0.5) { // Rounded rock rock.drawCircle(0, 0, size); } else { // Angular rock const points = []; const numPoints = 5 + Math.floor(Math.random() * 3); for (let j = 0; j < numPoints; j++) { const angle = (j / numPoints) * Math.PI * 2; const distance = size * (0.8 + Math.random() * 0.4); points.push(Math.cos(angle) * distance, Math.sin(angle) * distance); } rock.drawPolygon(points); } rock.endFill(); // Add some texture/highlights rock.beginFill(0xaaaaaa, 0.5); rock.drawCircle(size * 0.3, -size * 0.3, size * 0.3); rock.endFill(); rock.x = x; rock.y = y; this.backgroundLayer.addChild(rock); } } // Add a blockchain monument (decorative landmark) private addBlockchainMonument(): void { if (!this.backgroundLayer || !this.effectsLayer || !this.pathPoints) return; // Position at a specific point on the path const pathIndex = Math.floor(this.pathPoints.length * 0.7); if (pathIndex >= this.pathPoints.length) return; const point = this.pathPoints[pathIndex]; const x = point.x + 80; const y = point.y - 20; const monument = new PIXI.Graphics(); // Stone base monument.beginFill(0x888888); monument.drawRect(-20, 0, 40, 10); monument.endFill(); // Monument pillar monument.beginFill(0x777777); monument.drawRect(-10, -40, 20, 40); monument.endFill(); // Decorative elements monument.beginFill(0xffcc00); // Bitcoin-inspired symbol monument.drawRect(-5, -35, 10, 2); monument.drawRect(-5, -30, 10, 2); monument.drawRect(-5, -25, 10, 2); monument.drawRect(-5, -20, 10, 2); // Angle marks monument.drawRect(-8, -35, 3, 2); monument.drawRect(5, -25, 3, 2); monument.endFill(); monument.x = x; monument.y = y; // Create a container for the glow effect const glowContainer = new PIXI.Container(); glowContainer.position.set(x, y - 20); this.effectsLayer.addChild(glowContainer); // Add ambient glow effect const glow = new PIXI.Graphics(); glow.beginFill(0xffcc00, 0.2); glow.drawCircle(0, 0, 30); glow.endFill(); glowContainer.addChild(glow); // Animate glow with safe cleanup gsap.to(glow, { alpha: 0.4, duration: 2, repeat: -1, yoyo: true, ease: "sine.inOut", onComplete: () => { if (this.effectsLayer && glowContainer.parent) { this.effectsLayer.removeChild(glowContainer); this.activeAnimations.delete(glowContainer); } }, }); // Track for cleanup this.activeAnimations.add(glowContainer); this.backgroundLayer.addChild(monument); // Make interactive monument.interactive = true; monument.buttonMode = true; monument.on("pointerover", (event: PIXI.interaction.InteractionEvent) => { this.showTooltip( "Ancient Blockchain Monument", event.data.global.x, event.data.global.y, ); }); monument.on("pointerout", () => { this.hideTooltip(); }); monument.on("pointerdown", () => { this.showNotification( "The monument contains inscriptions from the genesis block.", ); if (this.state.soundEnabled) this.sounds.click.play(); }); } // Add a trading oasis (a gathering place for traders) private addTradingOasis(): void { if (!this.backgroundLayer || !this.pathPoints) return; // Position at a specific point on the path const pathIndex = Math.floor(this.pathPoints.length * 0.3); if (pathIndex >= this.pathPoints.length) return; const point = this.pathPoints[pathIndex]; const x = point.x - 60; const y = point.y + 20; const oasis = new PIXI.Graphics(); // Water pool oasis.beginFill(0x4477aa); oasis.drawCircle(0, 0, 15); oasis.endFill(); // Sand around the water oasis.beginFill(0xddcc88); oasis.drawCircle(0, 0, 20); oasis.endFill(); // Add palm trees for (let i = 0; i < 3; i++) { const angle = (i / 3) * Math.PI * 2; const treeX = Math.cos(angle) * 18; const treeY = Math.sin(angle) * 18; // Tree trunk oasis.beginFill(0x8b4513); oasis.drawRect(treeX - 2, treeY - 15, 4, 15); oasis.endFill(); // Slight curve to trunk oasis.beginFill(0x8b4513); oasis.drawRect(treeX - 1, treeY - 22, 3, 7); oasis.endFill(); // Palm leaves oasis.beginFill(0x00aa00); for (let j = 0; j < 4; j++) { const leafAngle = (j / 4) * Math.PI * 2; oasis.moveTo(treeX, treeY - 22); // Create curved palm leaf const controlX = treeX + Math.cos(leafAngle) * 10; const controlY = treeY - 22 + Math.sin(leafAngle) * 5; const endX = treeX + Math.cos(leafAngle) * 15; const endY = treeY - 22 + Math.sin(leafAngle) * 10; oasis.quadraticCurveTo(controlX, controlY, endX, endY); oasis.quadraticCurveTo( controlX + Math.cos(leafAngle + 0.2) * 3, controlY + Math.sin(leafAngle + 0.2) * 3, treeX, treeY - 22, ); } oasis.endFill(); } // Add a small trading stand oasis.beginFill(0x8b4513); oasis.drawRect(10, -5, 15, 10); oasis.endFill(); // Stand roof oasis.beginFill(0xaa5533); oasis.drawRect(8, -10, 19, 5); oasis.endFill(); oasis.x = x; oasis.y = y; this.backgroundLayer.addChild(oasis); // Add a trader character this.createTrader(x + 20, y + 5, { id: "oasis_trader", name: "Oasis Merchant", speciality: "Rare Tokens", inventory: Math.floor(Math.random() * 10) + 5, }); // Add ambient effects this.addWaterAnimation(x, y); } // Add water ripple animation for the oasis private addWaterAnimation(x: number, y: number): void { if (!this.effectsLayer) return; // Create a container for the water effect const waterContainer = new PIXI.Container(); waterContainer.position.set(x, y); this.effectsLayer.addChild(waterContainer); // Main water circle with animation const waterCircle = new PIXI.Graphics(); waterContainer.addChild(waterCircle); // Track for cleanup this.activeAnimations.add(waterContainer); // Animation function const animate = () => { // Skip if container was removed if (!waterContainer.parent) return; // Redraw water waterCircle.clear(); waterCircle.beginFill(0x4477aa, 0.5); waterCircle.drawCircle(0, 0, 5 + Math.sin(Date.now() / 500) * 2); waterCircle.endFill(); // Randomly create ripples if (Math.random() > 0.98) { this.createWaterRipple(waterContainer); } // Continue animation only if container still exists if (waterContainer.parent) { requestAnimationFrame(animate); } else { // Cleanup this.activeAnimations.delete(waterContainer); } }; // Start animation animate(); } // Create water ripple effect private createWaterRipple(parentContainer: PIXI.Container): void { // Create a ripple graphic const ripple = new PIXI.Graphics(); ripple.position.set(Math.random() * 10 - 5, Math.random() * 10 - 5); ripple.lineStyle(1, 0x4477aa, 0.8); ripple.drawCircle(0, 0, 2); ripple.lineStyle(0); parentContainer.addChild(ripple); // Animate and remove with GSAP const tl = gsap.timeline({ onComplete: () => { if (ripple.parent) { ripple.parent.removeChild(ripple); } }, }); tl.to(ripple.scale, { x: 4, y: 4, duration: 1.5, ease: "sine.out" }, 0); tl.to(ripple, { alpha: 0, duration: 1.5, ease: "sine.out" }, 0); } // Create a trader character private createTrader(x: number, y: number, data: any): PIXI.Graphics | null { if (!this.traderLayer) return null; const trader = new PIXI.Graphics(); // Body trader.beginFill(0xaa5500); trader.drawRect(-5, -15, 10, 15); trader.endFill(); // Head trader.beginFill(0xffcc99); trader.drawCircle(0, -20, 5); trader.endFill(); // Hat trader.beginFill(0xaa0000); trader.drawCircle(0, -23, 6); trader.endFill(); // Position trader trader.x = x; trader.y = y; // Store trader data (trader as any).traderData = data; // Make interactive trader.interactive = true; trader.buttonMode = true; trader.on("pointerover", (event: PIXI.interaction.InteractionEvent) => { this.showTooltip( `${(trader as any).traderData.name} (${(trader as any).traderData.speciality})`, event.data.global.x, event.data.global.y, ); }); trader.on("pointerout", () => { this.hideTooltip(); }); trader.on("pointerdown", () => { this.showTraderDetails((trader as any).traderData); if (this.state.soundEnabled) this.sounds.click.play(); }); this.traderLayer.addChild(trader); return trader; } // Fetch blockchain data from API private async fetchBlockchainData(): Promise<any> { try { console.log("Fetching blockchain data..."); // Add timeout for fetch const fetchPromise = fetch("/api/blockchain"); const timeoutPromise = new Promise<Response>((_, reject) => setTimeout(() => reject(new Error("Fetch timeout")), 10000), ); const response = await Promise.race([fetchPromise, timeoutPromise]); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); console.log("Blockchain data received:", data); // Validate the data structure if (!data || typeof data !== "object") { throw new Error("Invalid data format received"); } // Use default values if properties are missing const safeData = { stats: data.stats || { blockCount: 0, totalTxCount: 0, mempoolSize: 0 }, blocks: Array.isArray(data.blocks) ? data.blocks : [], mempool: data.mempool || { txids: [] }, }; // Update stats display this.updateStats(safeData.stats); // Only process blocks if the world is fully initialized if (this.worldInitialized) { this.processBlocks(safeData.blocks); } else { console.log("Deferring block processing until world is initialized"); } // Process mempool this.processMempool(safeData.mempool); return safeData; } catch (error) { console.error("Error fetching blockchain data:", error); // Create fallback data const fallbackData = { stats: { blockCount: 3, totalTxCount: 10, mempoolSize: 5 }, blocks: [ { height: 2, hash: "000000000000000000000000000000000000000000000000000000000000002", time: Date.now() / 1000, txCount: 3, size: 1500, }, { height: 1, hash: "000000000000000000000000000000000000000000000000000000000000001", time: Date.now() / 1000 - 600, txCount: 2, size: 1200, }, { height: 0, hash: "000000000000000000000000000000000000000000000000000000000000000", time: Date.now() / 1000 - 1200, txCount: 1, size: 1000, }, ], mempool: { txids: [ "tx1111111111111111111111111111111111111111111111111111111111111111", "tx2222222222222222222222222222222222222222222222222222222222222222", "tx3333333333333333333333333333333333333333333333333333333333333333", "tx4444444444444444444444444444444444444444444444444444444444444444", "tx5555555555555555555555555555555555555555555555555555555555555555", ], }, }; // Show notification this.showNotification("Using demo data - couldn't connect to blockchain"); // Use fallback data this.updateStats(fallbackData.stats); // Only process blocks if world is initialized if (this.worldInitialized) { this.processBlocks(fallbackData.blocks); } this.processMempool(fallbackData.mempool); return fallbackData; } } // Update stats display private updateStats(stats: any): void { try { const blockCount = document.getElementById("block-count"); const txCount = document.getElementById("tx-count"); const mempoolSize = document.getElementById("mempool-size"); const gameTime = document.getElementById("game-time"); if (blockCount) blockCount.textContent = stats.blockCount || "0"; if (txCount) txCount.textContent = stats.totalTxCount || "0"; if (mempoolSize) mempoolSize.textContent = stats.mempoolSize || "0"; // Update compass direction based on mining activity this.updateCompassDirection(stats.mempoolSize || 0); } catch (error) { console.error("Error updating stats:", error); } } // Update compass direction private updateCompassDirection(mempoolSize: number): void { try { // Transform mempool size into an angle (more txs = more north/east) const angle = (mempoolSize / 20) * 360; // 1 full rotation per 20 txs // Update compass arrow const compassArrow = document.getElementById("compass-arrow"); if (compassArrow) { compassArrow.style.transform = `rotate(${angle}deg)`; } // Update label based on quadrant const compassLabel = document.getElementById("compass-label"); if (compassLabel) { const direction = Math.floor(((angle + 45) % 360) / 90); switch (direction) { case 0: compassLabel.textContent = "MINING NORTH"; break; case 1: compassLabel.textContent = "MINING EAST"; break; case 2: compassLabel.textContent = "MINING SOUTH"; break; case 3: compassLabel.textContent = "MINING WEST"; break; } } } catch (error) { console.error("Error updating compass:", error); } } // Process blocks into caravans private processBlocks(blocks: any[]): void { // Safety check for blocks data if (!blocks || !Array.isArray(blocks)) { console.warn("Invalid blocks data received:", blocks); return; } // Safety check for required properties if ( !this.caravanLayer || !this.pathPoints || !Array.isArray(this.pathPoints) || !this.worldInitialized ) { console.warn("Cannot process blocks yet, world not fully initialized"); return; } console.log("Processing", blocks.length, "blocks"); // Clear existing caravans if needed if (this.state.caravans.length === 0) { // First time - add all blocks as caravans blocks.forEach((block, index) => { this.addCaravan(block, index); }); } else { // Check for new blocks const knownHashes = this.state.blocks.map((b) => b.hash); blocks.forEach((block, index) => { if (!knownHashes.includes(block.hash)) { // New block found - add at the beginning this.addCaravan(block, 0, true); } }); } // Update state this.state.blocks = [...blocks]; } // Process mempool into traders private processMempool(mempool: any): void { // Safety check if (!mempool || !mempool.txids || !Array.isArray(mempool.txids)) { console.warn("Invalid mempool data:", mempool); return; } console.log( "Processing mempool with", mempool.txids.length, "transactions", ); // Safety check for trader layer if (!this.traderLayer) { console.error("Trader layer not available, cannot process mempool"); retur