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,819 lines (1,512 loc) 118 kB
/** * 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 { constructor() { // Initialize container references as null this.worldContainer = null; this.backgroundLayer = null; this.trailLayer = null; this.caravanLayer = null; this.traderLayer = null; this.minerLayer = null; this.outpostLayer = null; this.effectsLayer = null; this.uiLayer = null; this.count = 0; // Initialize pathPoints as an empty array to prevent null reference errors this.pathPoints = []; this.worldInitialized = false; // Initialize PixiJS this.app = new PIXI.Application({ width: window.innerWidth, height: window.innerHeight, backgroundColor: 0x0a0a1a, resolution: window.devicePixelRatio || 1, antialias: false, }); document.getElementById("game-container").appendChild(this.app.view); // 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 }; // Create all containers immediately this.createContainers(); console.log("containers created !!"); // Rain and effects references this.rain = null; this.rainInterval = null; this.activeAnimations = new Set(); // Track active animations for safe cleanup // Initialize sound effects this.initializeSounds(); console.log("Effects created !!"); // Socket connection this.initializeSocket(); console.log("Scoket created !!"); // Set up event listeners this.setupEventListeners(); console.log("Event created !!"); // Start game loop this.app.ticker.add(this.gameLoop.bind(this)); console.log("Game loop started !!"); // Initialize game this.initialize(); } // Create all containers at initialization createContainers() { // Container for all game objects this.worldContainer = new PIXI.Container(); this.app.stage.addChild(this.worldContainer); // Separate containers for layers 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.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); console.log("All containers created successfully"); } // Initialize sounds initializeSounds() { 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 createDummySounds() { this.sounds = { mining: { play: () => {}, fade: () => {} }, transaction: { play: () => {}, fade: () => {} }, click: { play: () => {}, fade: () => {} }, caravan: { play: () => {}, fade: () => {} }, ambient: { play: () => {}, fade: () => {}, pause: () => {} }, rain: { play: () => {}, fade: () => {}, pause: () => {} }, }; } // Initialize socket connection initializeSocket() { try { 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 async initialize() { try { console.log("Assets loading !!"); // Load assets await this.loadAssets(); console.log("Assets loaded !!"); // Create the world this.createWorld(); // Fetch initial blockchain data await this.fetchBlockchainData(); // Set default camera position to show the blockchain path this.state.camera = { x: 0, y: 0, scale: 0.8, targetScale: 0.8, dragging: false, lastPosition: null, }; // Hide loading screen document.getElementById("loading-screen").style.display = "none"; // Add intro animation this.playIntroAnimation(); // Start ambient sound if (this.state.soundEnabled) { 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); document.getElementById("loading-text").textContent = "Error loading caravan: " + error.message; } } // Load game assets async loadAssets() { return new Promise((resolve, reject) => { // 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 createWorld() { try { // Create sky background with gradient this.createSky(); // Create distant mountains this.createMountains(); // Create the blockchain trail (the path that caravans follow) this.createBlockchainTrail(); // Create ground this.createGround(); // Add environmental elements this.addEnvironmentalElements(); // Mark world as initialized this.worldInitialized = true; console.log("World created successfully"); } catch (error) { console.error("Error creating world:", error); } } // Create the sky with gradient and day/night cycle capability createSky() { // 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 createCloud(x, y, speed) { 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; cloud.speed = speed; cloud.cloudWidth = cloudWidth; this.clouds.addChild(cloud); return cloud; } // Create distant mountains for background createMountains() { 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) createBlockchainTrail() { const trail = new PIXI.Graphics(); // Main dirt path trail.beginFill(0x8b4513, 0.7); // Create a winding path using bezier curves const 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, }; pathPoints.push(segment); x += 200; } // Draw the path as a series of quadratic curves trail.moveTo(pathPoints[0].x, pathPoints[0].y); for (let i = 1; i < pathPoints.length; i++) { const prev = pathPoints[i - 1]; const current = 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 = pathPoints.length - 1; i >= 0; i--) { const point = 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() * pathPoints.length); const point = 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; this.pathPoints = pathPoints; } // Create ground with grass and texture createGround() { 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 addEnvironmentalElements() { // 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) createOutposts() { if ( !this.pathPoints || !Array.isArray(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.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) => { this.showTooltip( `${outpost.outpostData.name} (${outpost.outpostData.type})`, event.data.global.x, event.data.global.y, ); }); outpost.on("pointerout", () => { this.hideTooltip(); }); outpost.on("pointerdown", () => { this.showOutpostDetails(outpost.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 addConnectivityEffect(outpost) { if (!this.effectsLayer) return; const connectivityPulse = new PIXI.Graphics(); connectivityPulse.x = outpost.x; connectivityPulse.y = outpost.y - 60; // At the antenna // Draw initial pulse connectivityPulse.beginFill(0x44aaff, 0.5); connectivityPulse.drawCircle(0, 0, 5); connectivityPulse.endFill(); this.effectsLayer.addChild(connectivityPulse); // Animation function const animate = () => { try { // Verify the connectivityPulse animation still exists in the scene if (!connectivityPulse || !connectivityPulse.parent) { return; } // Reset pulse connectivityPulse.clear(); connectivityPulse.beginFill(0x44aaff, 0.5); connectivityPulse.drawCircle(0, 0, 5); connectivityPulse.endFill(); connectivityPulse.alpha = 1; connectivityPulse.scale.set(1); // Animate size and opacity safely gsap.to(connectivityPulse, { alpha: 0, duration: 1.5, ease: "sine.out", onComplete: () => { if (connectivityPulse && connectivityPulse.parent) { // Schedule next pulse with random delay setTimeout( () => { if (connectivityPulse && connectivityPulse.parent) { // Only animate if still in scene animate(); } }, Math.random() * 2000 + 1000, ); } }, }); gsap.to(connectivityPulse.scale, { x: 3, y: 3, duration: 1.5, ease: "sine.out", }); } catch (error) { console.error("Error in connectivity animation:", error); } }; // Start animation animate(); // Track for cleanup this.activeAnimations.add(connectivityPulse); } // Create miner camps createMinerCamps() { if ( !this.pathPoints || !Array.isArray(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); // Store miner data miner.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) => { this.showTooltip( `${miner.minerData.name} (Hashpower: ${miner.minerData.hashPower})`, event.data.global.x, event.data.global.y, ); }); miner.on("pointerout", () => { this.hideTooltip(); }); miner.on("pointerdown", () => { this.showMinerDetails(miner.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.minerData.active) { this.addMiningAnimation(miner); } } } // Create miner character createMinerCharacter(x, y) { 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 addFireAnimation(campfire) { if (!this.effectsLayer) return; const fireAnimation = new PIXI.Graphics(); fireAnimation.x = campfire.x; fireAnimation.y = campfire.y; this.effectsLayer.addChild(fireAnimation); // Animation function using requestAnimationFrame for smoother animation const animate = () => { try { // Verify the fire animation still exists in the scene if (!fireAnimation || !fireAnimation.parent) { return; } fireAnimation.clear(); // Orange outer flame fireAnimation.beginFill(0xff6600, 0.8); fireAnimation.moveTo(-2, 0); fireAnimation.quadraticCurveTo( -3 + Math.random() * 6, -5 - Math.random() * 5, 2, -8 - Math.random() * 4, ); fireAnimation.quadraticCurveTo( 3 + Math.random() * 2, -5 - Math.random() * 5, 2, 0, ); fireAnimation.lineTo(-2, 0); fireAnimation.endFill(); // Yellow inner flame fireAnimation.beginFill(0xffcc00, 0.9); fireAnimation.moveTo(-1, 0); fireAnimation.quadraticCurveTo( -1 + Math.random() * 2, -3 - Math.random() * 3, 1, -5 - Math.random() * 2, ); fireAnimation.quadraticCurveTo( 1 + Math.random() * 1, -3 - Math.random() * 3, 1, 0, ); fireAnimation.lineTo(-1, 0); fireAnimation.endFill(); // Continue animation only if still valid if (fireAnimation && fireAnimation.parent) { requestAnimationFrame(animate); } } catch (error) { console.error("Error in fire animation:", error); } }; // Start animation animate(); // Track for cleanup this.activeAnimations.add(fireAnimation); } // Add mining animation to miner addMiningAnimation(miner) { if (!this.minerLayer) return null; // 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.x = miner.x; pickaxe.y = miner.y - 5; pickaxe.pivot.set(0, 0); this.minerLayer.addChild(pickaxe); // Mining animation with safety checks const miningAnimation = gsap.to(pickaxe, { rotation: -0.5, duration: 0.5, repeat: -1, yoyo: true, ease: "power1.inOut", onComplete: () => { if (this.minerLayer && pickaxe && pickaxe.parent) { this.minerLayer.removeChild(pickaxe); } }, }); // Occasionally play mining sound const playMiningSound = () => { if (!pickaxe || !pickaxe.parent) return; if (this.state.soundEnabled && Math.random() > 0.7) { this.sounds.mining.play(); } // Schedule next sound only if still valid if (pickaxe && pickaxe.parent) { setTimeout(playMiningSound, Math.random() * 10000 + 5000); } }; setTimeout(playMiningSound, Math.random() * 5000); // Track animation for cleanup this.activeAnimations.add(pickaxe); return pickaxe; } // Add decorative elements to the world addDecorations() { 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 addTrees() { 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 addRocks() { 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) addBlockchainMonument() { 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; // Add ambient glow effect const glow = new PIXI.Graphics(); glow.beginFill(0xffcc00, 0.2); glow.drawCircle(0, 0, 30); glow.endFill(); glow.x = x; glow.y = y - 20; // Animate glow with safety gsap.to(glow, { alpha: 0.4, duration: 2, repeat: -1, yoyo: true, ease: "sine.inOut", onComplete: () => { if (this.effectsLayer && glow && glow.parent) { this.effectsLayer.removeChild(glow); } }, }); this.backgroundLayer.addChild(monument); this.effectsLayer.addChild(glow); // Make interactive monument.interactive = true; monument.buttonMode = true; monument.on("pointerover", (event) => { 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(); }); // Track for cleanup this.activeAnimations.add(glow); } // Add a trading oasis (a gathering place for traders) addTradingOasis() { 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 const trader = 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 addWaterAnimation(x, y) { if (!this.effectsLayer) return; const waterEffect = new PIXI.Graphics(); waterEffect.x = x; waterEffect.y = y; this.effectsLayer.addChild(waterEffect); // Animation function const animate = () => { try { // Verify the waterEffect animation still exists in the scene if (!waterEffect || !waterEffect.parent) { return; } waterEffect.clear(); waterEffect.beginFill(0x4477aa, 0.5); waterEffect.drawCircle(0, 0, 5 + Math.sin(Date.now() / 500) * 2); waterEffect.endFill(); // Ripple effect (periodic) if (Math.random() > 0.98) { this.createWaterRipple(x, y); } // Continue animation only if still valid if (waterEffect && waterEffect.parent) { requestAnimationFrame(animate); } } catch (error) { console.error("Error in water animation:", error); } }; // Start animation animate(); // Track for cleanup this.activeAnimations.add(waterEffect); } // Create water ripple effect createWaterRipple(x, y) { if (!this.effectsLayer) return; const ripple = new PIXI.Graphics(); ripple.x = x + (Math.random() * 10 - 5); ripple.y = y + (Math.random() * 10 - 5); // Draw initial ripple ripple.lineStyle(1, 0x4477aa, 0.8); ripple.drawCircle(0, 0, 2); ripple.lineStyle(0); this.effectsLayer.addChild(ripple); // Animate with safety gsap.to(ripple, { alpha: 0, duration: 1.5, ease: "sine.out", onUpdate: () => { if (ripple && ripple.parent) { ripple.clear(); ripple.lineStyle(1, 0x4477aa, ripple.alpha); ripple.drawCircle(0, 0, 2 + (1 - ripple.alpha) * 8); ripple.lineStyle(0); } }, onComplete: () => { if (this.effectsLayer && ripple && ripple.parent) { this.effectsLayer.removeChild(ripple); } }, }); // Track for cleanup this.activeAnimations.add(ripple); } // Create a trader character createTrader(x, y, data) { 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.traderData = data; // Make interactive trader.interactive = true; trader.buttonMode = true; trader.on("pointerover", (event) => { this.showTooltip( `${trader.traderData.name} (${trader.traderData.speciality})`, event.data.global.x, event.data.global.y, ); }); trader.on("pointerout", () => { this.hideTooltip(); }); trader.on("pointerdown", () => { this.showTraderDetails(trader.traderData); if (this.state.soundEnabled) this.sounds.click.play(); }); this.traderLayer.addChild(trader); return trader; } // Fetch blockchain data from API async fetchBlockchainData() { try { console.log("Fetching blockchain data..."); // Add timeout for fetch const fetchPromise = fetch("/api/blockchain"); const timeoutPromise = new Promise((_, 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); // Process blocks this.processBlocks(safeData.blocks); // 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); this.processBlocks(fallbackData.blocks); this.processMempool(fallbackData.mempool); return fallbackData; } } // Update stats display updateStats(stats) { 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 updateCompassDirection(mempoolSize) { 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 processBlocks(blocks) { // 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 processMempool(mempool) { // 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"); return; } // Clear existing traders this.traderLayer.removeChildren(); this.state.traders = []; // Calculate starting point for traders (near the first caravan) let startX, startY; if (this.state.caravans && this.state.caravans.length > 0) { startX = this.state.caravans[0].x - 300; startY = this.state.caravans[0].y; } else if (this.pathPoints && this.pathPoints.length > 0) { startX = this.pathPoints[0].x - 300; startY = this.pathPoints[0].y; } else { // Fallback position startX = -300; startY = 300; } // Add traders for each mempool transaction (limit to avoid performance issues) mempool.txids.slice(0, 20).forEach((txid, index) => { if (!txid) return; // Random position around the starting area const x = startX + Math.random() * 250 - 120; const y = startY + Math.random() * 40 - 20; // Add a trader for this transaction this.addTrader(txid, x, y, "waiting"); }); } // Add a caravan to represent a blockchain block addCaravan(block, position, isNew = false) { if ( !this.caravanLayer || !this.pathPoints || !Array.isArray(this.pathPoints) ) { console.error("Cannot add caravan, missing required layers or path data"); return null; } // Calculate position along the path const pathPosition = Math.min(this.pathPoints.length - 1, position); const pathPoint = this.pathPoints[pathPosition]; // Create caravan container const caravan = new PIXI.Container(); // Main wagon (size based on block size) const baseWidth = 60; const baseHeight = 40; const sizeMultiplier = 0.5 + (block.txCount / 20) * 0.5; // Size based on tx count const width = baseWidth * sizeMultiplier; const height = baseHeight * sizeMultiplier; // Wagon body const wagon = new PIXI.Graphics(); // Wagon bed wagon.beginFill(0x8b4513); wagon.drawRect(-width / 2, -height / 2, width, height / 2); wagon.endFill(); // Wagon sides wagon.beginFill(0x8b4513); wagon.drawRect(-width / 2, -height, width, height / 2); wagon.endFill(); // Wagon roof/cover (arched) wagon.beginFill(0xffcc00); // Create an arch shape for the cover wagon.moveTo(-width / 2, -height / 2); for (let i = 0; i <= 10; i++) { const progress = i / 10; const x = -width / 2 + width * progress; const y = -height / 2 - Math.sin(progress * Math.PI) * (height / 2); wagon.lineTo(x, y); } wagon.lineTo(width / 2, -height / 2); wagon.lineTo(-width / 2, -height / 2); wagon.endFill(); // Wheels const wheelRadius = height / 4; // Left wheel wagon.beginFill(0x444444); wagon.drawCircle(-width / 3, 0, wheelRadius); wagon.endFill(); wagon.beginFill(0x222222); wagon.drawCircle(-width / 3, 0, wheelRadius / 1.5); wagon.endFill(); // Right wheel wagon.beginFill(0x444444); wagon.drawCircle(width / 3, 0, wheelRadius); wagon.endFill(); wagon.beginFill(0x222222); wagon.drawCircle(width / 3, 0, wheelRadius / 1.5); wagon.endFill(); // Block height number on the wagon const blockText = new PIXI.Text(`#${block.height}`, { fontFamily: "PixelFont, monospace", fontSize: 12, fill: 0x000000, align: "center", }); blockText.anchor.set(0.5); blockText.x = 0; blockText.y = -height / 4; // Add block hash segment as a flag on top const hashFlag = new PIXI.Graphics(); hashFlag.beginFill(0x1e3b70); hashFlag.drawRect(0, 0, 20, 15); hashFlag.endFill(); // First characters of hash in yellow c