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
JavaScript
/**
* 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