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,273 lines • 144 kB
JavaScript
"use strict";
/**
* 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() {
// Path data
this.pathPoints = [];
// World elements
this.sky = null;
this.sun = null;
this.moon = null;
this.stars = null;
this.clouds = null;
this.trail = null;
this.ground = null;
this.mountains = null;
// Weather effects
this.rain = null;
this.rainInterval = null;
// Socket connection
this.socket = null;
// Sounds
this.sounds = {
mining: { play: () => { }, pause: () => { } },
transaction: { play: () => { }, pause: () => { } },
click: { play: () => { }, pause: () => { } },
caravan: { play: () => { }, pause: () => { } },
ambient: { play: () => { }, pause: () => { } },
rain: { play: () => { }, pause: () => { } },
};
// Animation tracking
this.activeAnimations = new Set();
// Debug counters
this.frameCount = 0;
// World initialization flag
this.worldInitialized = false;
// Weather types for fallback
this.weathers = ["clear", "cloudy", "rainy"];
// 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
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: () => { }, pause: () => { }, fade: () => { } },
transaction: { play: () => { }, pause: () => { }, fade: () => { } },
click: { play: () => { }, pause: () => { }, fade: () => { } },
caravan: { play: () => { }, pause: () => { }, fade: () => { } },
ambient: { play: () => { }, pause: () => { } },
rain: { play: () => { }, pause: () => { } },
};
}
// Initialize socket connection
initializeSocket() {
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
async initialize() {
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.message;
}
}
}
// Load game assets
async loadAssets() {
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
createWorld() {
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
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;
// Add custom properties
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
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
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 || 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;
// 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
createMinerCamps() {
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.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) {
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
addFireAnimation(campfire) {
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
addMiningAnimation(miner) {
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
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;
// 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) => {
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)
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
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;
// 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
createWaterRipple(parentContainer) {
// 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
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);
// 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.show