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
450 lines (371 loc) • 13 kB
JavaScript
/**
* CharacterSystem - Manages NPCs and transaction characters
*/
class CharacterSystem {
constructor(voxelEngine) {
this.engine = voxelEngine;
this.characters = [];
this.playerInteractions = {};
// Set character interaction handler on the voxel engine
this.engine.onCharacterInteraction = this.interact.bind(this);
// Initialize UI elements
this.initializeUI();
}
initializeUI() {
// Create conversation UI
this.conversationUI = document.createElement("div");
this.conversationUI.className = "conversation-ui";
this.conversationUI.style.display = "none";
document.body.appendChild(this.conversationUI);
// Create interaction prompt
this.interactionPrompt = document.createElement("div");
this.interactionPrompt.className = "interaction-prompt";
this.interactionPrompt.textContent = "Press E to interact";
this.interactionPrompt.style.display = "none";
document.body.appendChild(this.interactionPrompt);
}
createCharacter(type, position, data = {}) {
// Create character in the 3D world
const character = this.engine.createCharacter(
type,
position.x,
position.y,
position.z,
);
// Add additional data
character.userData = {
...character.userData,
...data,
interactable: true,
};
// Add dialogues based on type
character.dialogues = this.generateDialogues(type, data);
// Create nametag
const nametag = document.createElement("div");
nametag.className = "character-nametag";
nametag.textContent = this.getCharacterName(type, data);
nametag.style.display = "none";
document.body.appendChild(nametag);
character.nametag = nametag;
// Add to characters array
this.characters.push(character);
return character;
}
createTransactionCharacter(txData) {
// Determine position
const centerX = 0;
const centerZ = 0;
const radius = 20;
const angle = Math.random() * Math.PI * 2;
const position = new THREE.Vector3(
centerX + Math.cos(angle) * radius,
1,
centerZ + Math.sin(angle) * radius,
);
// Create character
const character = this.createCharacter("transaction", position, txData);
// Set additional properties
character.txData = txData;
character.state = "waiting";
// Set random animation offset
character.animationOffset = Math.random() * Math.PI * 2;
return character;
}
getCharacterName(type, data) {
switch (type) {
case "transaction":
return `TX: ${data.txid ? data.txid.substring(0, 8) + "..." : "Unknown"}`;
case "miner":
return "Block Miner";
case "trader":
return "Token Trader";
case "validator":
return "Validator";
default:
return type.charAt(0).toUpperCase() + type.slice(1);
}
}
generateDialogues(type, data) {
switch (type) {
case "transaction":
return this.generateTransactionDialogues(data);
case "miner":
return [
"I'm a block miner! I secure the network by solving cryptographic puzzles.",
"Each block I mine contains multiple transactions.",
"Mining difficulty adjusts to keep block time consistent.",
"Want to try mining? Find a block and click on it with your pickaxe.",
];
case "trader":
return [
"I trade different cryptocurrencies across the blockchain.",
"Each token has different properties and uses.",
"Smart contracts ensure our trades are fair and automatic.",
"Want to trade some tokens? I can help you exchange them.",
];
case "validator":
return [
"I validate transactions to ensure they follow consensus rules.",
"Invalid transactions get rejected from entering blocks.",
"Validation is crucial for maintaining blockchain integrity.",
"The rules of the blockchain are enforced by nodes like me.",
];
default:
return ["Hello there!"];
}
}
generateTransactionDialogues(txData) {
if (!txData) return ["I'm a transaction waiting to be processed."];
const dialogues = [
`I'm transaction ${txData.txid ? txData.txid.substring(0, 8) + "..." : "Unknown"}.`,
];
// Add information about inputs and outputs
if (txData.vin && txData.vout) {
dialogues.push(
`I have ${txData.vin.length} input(s) and ${txData.vout.length} output(s).`,
);
}
// Add information about size
if (txData.size) {
dialogues.push(`My size is ${txData.size} bytes.`);
}
// Add information about confirmations
if (txData.confirmations !== undefined) {
if (txData.confirmations > 0) {
dialogues.push(`I have ${txData.confirmations} confirmation(s).`);
} else {
dialogues.push(
"I'm still waiting in the mempool to be included in a block.",
);
}
}
// Add a random fact
const facts = [
"Transactions like me get grouped together in blocks.",
"I pay a fee to incentivize miners to include me in a block.",
"My data is secured by cryptographic signatures.",
"Once confirmed, I become a permanent part of the blockchain.",
];
dialogues.push(facts[Math.floor(Math.random() * facts.length)]);
return dialogues;
}
update(delta) {
// Update all characters
this.characters.forEach((character) => {
// Skip if character is removed
if (!character.parent) return;
// Update animation based on state
if (character.state === "waiting") {
// Floating animation for waiting transactions
character.position.y =
1 + Math.sin(Date.now() / 1000 + character.animationOffset) * 0.1;
} else if (character.state === "moving" && character.targetPosition) {
// Moving animation
this.updateMovingCharacter(character, delta);
}
// Update nametag position if it exists
if (character.nametag) {
this.updateNametag(character);
}
// Check for player proximity
this.checkPlayerProximity(character);
});
}
updateMovingCharacter(character, delta) {
// Calculate direction to target
const direction = new THREE.Vector3().subVectors(
character.targetPosition,
character.position,
);
// Check if we've reached the target
if (direction.length() < 0.1) {
character.position.copy(character.targetPosition);
character.state = "arrived";
// Handle arrival callback if set
if (character.onArrival) {
character.onArrival();
}
return;
}
// Move toward target
direction.normalize();
const moveSpeed = character.moveSpeed || 0.05;
character.position.add(direction.multiplyScalar(moveSpeed));
// Rotate character to face movement direction
character.rotation.y = Math.atan2(direction.x, direction.z);
// Add bob animation while moving
character.position.y += Math.sin(Date.now() / 150) * 0.01;
}
updateNametag(character) {
// Project 3D position to screen
const vector = new THREE.Vector3();
vector.setFromMatrixPosition(character.matrixWorld);
vector.project(this.engine.camera);
// Convert to screen coordinates
const x = ((vector.x + 1) / 2) * window.innerWidth;
const y = (-(vector.y - 1) / 2) * window.innerHeight;
// Position nametag
character.nametag.style.left = `${x}px`;
character.nametag.style.top = `${y - 40}px`;
// Check if character is in front of camera
const cameraPosition = this.engine.camera.position;
const characterPosition = character.position;
const cameraDirection = new THREE.Vector3();
this.engine.camera.getWorldDirection(cameraDirection);
const toCharacter = new THREE.Vector3().subVectors(
characterPosition,
cameraPosition,
);
// Hide nametag if character is behind camera
if (toCharacter.dot(cameraDirection) <= 0) {
character.nametag.style.display = "none";
}
}
checkPlayerProximity(character) {
// Get player position
const playerPosition = this.engine.controls.getObject().position;
const characterPosition = character.position;
// Calculate distance
const distance = characterPosition.distanceTo(playerPosition);
// Show nametag if close enough
if (distance < 15) {
character.nametag.style.display = "block";
// Show interaction prompt if very close
if (distance < 3 && character.userData.interactable) {
this.showInteractionPrompt(character);
this.interactionTarget = character;
} else if (this.interactionTarget === character) {
this.hideInteractionPrompt();
this.interactionTarget = null;
}
} else {
character.nametag.style.display = "none";
if (this.interactionTarget === character) {
this.hideInteractionPrompt();
this.interactionTarget = null;
}
}
}
showInteractionPrompt() {
// Position the prompt in the center of the screen
this.interactionPrompt.style.display = "block";
}
hideInteractionPrompt() {
this.interactionPrompt.style.display = "none";
}
interact(character) {
// Called when player interacts with a character
if (!character || !character.dialogues || character.dialogues.length === 0)
return;
console.log("Interacting with character:", character.characterType);
// Start conversation
this.startConversation(character);
}
startConversation(character) {
// Pause the game
if (this.engine.controls.isLocked) {
this.engine.controls.unlock();
}
// Show conversation UI
this.conversationUI.style.display = "block";
// Create conversation content
this.conversationUI.innerHTML = `
<div class="conversation-header">
<h3>${character.nametag ? character.nametag.textContent : "Character"}</h3>
<button class="close-btn">×</button>
</div>
<div class="conversation-content">
<p>${character.dialogues[0]}</p>
</div>
<div class="conversation-options">
<button class="next-btn">Next</button>
</div>
`;
// Set current dialogue state
this.currentDialogueIndex = 0;
this.currentCharacter = character;
// Add event listeners
this.conversationUI
.querySelector(".close-btn")
.addEventListener("click", () => {
this.endConversation();
});
this.conversationUI
.querySelector(".next-btn")
.addEventListener("click", () => {
this.showNextDialogue();
});
}
showNextDialogue() {
if (!this.currentCharacter || !this.currentCharacter.dialogues) return;
this.currentDialogueIndex++;
if (this.currentDialogueIndex >= this.currentCharacter.dialogues.length) {
// End of dialogue
this.endConversation();
return;
}
// Update dialogue text
const content = this.conversationUI.querySelector(".conversation-content");
content.innerHTML = `<p>${this.currentCharacter.dialogues[this.currentDialogueIndex]}</p>`;
}
endConversation() {
// Hide conversation UI
this.conversationUI.style.display = "none";
// Reset conversation state
this.currentCharacter = null;
this.currentDialogueIndex = 0;
// Resume game
if (!this.engine.controls.isLocked) {
this.engine.controls.lock();
}
}
moveCharacterTo(character, targetPosition, onArrival) {
if (!character) return;
// Set target position
character.targetPosition = targetPosition.clone();
character.state = "moving";
character.moveSpeed = 0.05 + Math.random() * 0.03; // Random speed variation
// Set optional arrival callback
if (onArrival) {
character.onArrival = onArrival;
}
}
removeCharacter(character) {
if (!character) return;
// Remove from scene
this.engine.scene.remove(character);
// Remove nametag
if (character.nametag && character.nametag.parentNode) {
character.nametag.parentNode.removeChild(character.nametag);
}
// Remove from array
const index = this.characters.indexOf(character);
if (index !== -1) {
this.characters.splice(index, 1);
}
}
fadeOutCharacter(character, duration = 1) {
if (!character) return;
// Animate opacity
let opacity = 1;
const fadeInterval = setInterval(() => {
opacity -= 0.05;
if (opacity <= 0) {
clearInterval(fadeInterval);
this.removeCharacter(character);
return;
}
// Update material opacity
if (character.material) {
if (!character.material.transparent) {
character.material.transparent = true;
}
character.material.opacity = opacity;
}
// Update nametag opacity
if (character.nametag) {
character.nametag.style.opacity = opacity.toString();
}
}, duration * 50);
}
}