Files
lit.ruv.wtf/website/mining.js

2403 lines
87 KiB
JavaScript

document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('game-canvas');
const depthDisplay = document.getElementById('depth-counter');
const scoreDisplay = document.getElementById('score-counter');
if (!canvas) return;
// --- Configuration ---
let GRID_SIZE = 5; // 5x5 grid (Dynamic)
let VISIBLE_LAYERS = 7; // How many layers deep we render (Dynamic)
const BLOCK_SIZE = 1;
const SELL_PRICES = { coal: 10, iron: 20, gold: 50, diamond: 200, emerald: 500 };
const UPGRADE_COSTS = {
speed: (level) => Math.floor(100 * Math.pow(1.5, level)),
luck: (level) => Math.floor(500 * Math.pow(2, level)),
mining_area: (level) => Math.floor(1000 * Math.pow(3, level)), // Expensive!
vein_miner: (level) => Math.floor(2000 * Math.pow(2, level)),
tnt_frequency: (level) => Math.floor(5000 * Math.pow(1.5, level)),
tnt_power: (level) => Math.floor(10000 * Math.pow(2, level)),
pickup_range: (level) => Math.floor(300 * Math.pow(1.5, level)),
grid_size: (level) => Math.floor(10000 * Math.pow(4, level)) // Very Expensive!
};
// --- State ---
let depth = 0;
let score = 0;
let isAnimating = false;
let cameraOffsetY = 0;
let upgrades = { speed: 0, luck: 0, mining_area: 0, vein_miner: 0, tnt_frequency: 0, tnt_power: 0, pickup_range: 0, grid_size: 0 };
let tntTimer = 0;
const tntProjectiles = [];
// Store blocks: blocks[layerIndex][x][z]
// layerIndex 0 is the top-most visible layer.
let layers = [];
let extraBlocks = []; // Blocks above the ground (trees, etc)
// --- Three.js Setup ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB); // Sky blue background
// Orthographic Camera for Isometric view
const aspect = canvas.clientWidth / canvas.clientHeight;
const d = 5;
const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 1000);
// Isometric angle
camera.position.set(20, 14, 20);
camera.lookAt(scene.position); // Look at 0,0,0
camera.updateProjectionMatrix();
const renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: false, antialias: true });
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
renderer.setPixelRatio(window.devicePixelRatio);
// renderer.outputEncoding = THREE.sRGBEncoding; // We use GammaCorrectionShader instead
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
// --- Post Processing (SSAO) ---
const composer = new THREE.EffectComposer(renderer);
const renderPass = new THREE.RenderPass(scene, camera);
composer.addPass(renderPass);
const ssaoPass = new THREE.SSAOPass(scene, camera, canvas.clientWidth, canvas.clientHeight);
ssaoPass.kernelRadius = 16;
ssaoPass.minDistance = 0.001;
ssaoPass.maxDistance = 0.1;
composer.addPass(ssaoPass);
const gammaCorrectionPass = new THREE.ShaderPass(THREE.GammaCorrectionShader);
composer.addPass(gammaCorrectionPass);
// Lighting
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6);
hemiLight.position.set(0, 50, 0);
scene.add(hemiLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
dirLight.position.set(-30, 50, 30);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
const dLight = 10;
dirLight.shadow.camera.left = -dLight;
dirLight.shadow.camera.right = dLight;
dirLight.shadow.camera.top = dLight;
dirLight.shadow.camera.bottom = -dLight;
scene.add(dirLight);
// --- Textures ---
const textureLoader = new THREE.TextureLoader();
const loadTexture = (name) => {
const tex = textureLoader.load(`blocks/${name}`);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.encoding = THREE.sRGBEncoding;
return tex;
};
const loadItemTexture = (name) => {
const tex = textureLoader.load(`items/${name}`);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.encoding = THREE.sRGBEncoding;
return tex;
};
const textures = {
stone: loadTexture('stone.png'),
dirt: loadTexture('dirt.png'),
cobblestone: loadTexture('cobblestone.png'),
coal: loadTexture('coal_ore.png'),
iron: loadTexture('iron_ore.png'),
gold: loadTexture('gold_ore.png'),
diamond: loadTexture('diamond_ore.png'),
emerald: loadTexture('emerald_ore.png'),
bedrock: loadTexture('bedrock.png'),
grass_top: loadTexture('grass_top.png'),
grass_side: loadTexture('grass_side.png'),
dirt: loadTexture('dirt.png'),
log_side: loadTexture('log_oak.png'),
log_top: loadTexture('log_oak_top.png'),
leaves: loadTexture('leaves_oak.png'),
apple: loadItemTexture('apple.png'),
stick: loadItemTexture('stick.png'),
sapling: loadTexture('sapling_oak.png'),
planks: loadTexture('planks_oak.png'),
wood_pickaxe: loadItemTexture('wood_pickaxe.png'),
stone_pickaxe: loadItemTexture('stone_pickaxe.png'),
iron_pickaxe: loadItemTexture('iron_pickaxe.png'),
gold_pickaxe: loadItemTexture('gold_pickaxe.png'),
diamond_pickaxe: loadItemTexture('diamond_pickaxe.png'),
tnt_side: loadTexture('tnt_side.png'),
tnt_top: loadTexture('tnt_top.png'),
tnt_bottom: loadTexture('tnt_bottom.png'),
particle: (() => {
const t = new THREE.TextureLoader().load('particle/particles.png');
t.magFilter = THREE.NearestFilter;
t.minFilter = THREE.NearestFilter;
return t;
})()
};
// Load destroy stages
const destroyTextures = [];
for (let i = 0; i < 10; i++) {
const tex = loadTexture(`destroy_stage_${i}.png`);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
destroyTextures.push(tex);
}
// Materials cache
const materials = {};
const getMaterial = (type) => {
if (materials[type]) return materials[type];
let map = textures.stone;
if (textures[type]) map = textures[type];
// Special case for grass block to look nice
if (type === 'grass') {
const mat = [
new THREE.MeshLambertMaterial({ map: textures.grass_side }), // px
new THREE.MeshLambertMaterial({ map: textures.grass_side }), // nx
new THREE.MeshLambertMaterial({ map: textures.grass_top, color: 0x79C05A }), // py (top) - Tinted green
new THREE.MeshLambertMaterial({ map: textures.dirt }), // ny (bottom)
new THREE.MeshLambertMaterial({ map: textures.grass_side }), // pz
new THREE.MeshLambertMaterial({ map: textures.grass_side }) // nz
];
materials[type] = mat;
return mat;
}
if (type === 'log') {
const mat = [
new THREE.MeshLambertMaterial({ map: textures.log_side }), // px
new THREE.MeshLambertMaterial({ map: textures.log_side }), // nx
new THREE.MeshLambertMaterial({ map: textures.log_top }), // py (top)
new THREE.MeshLambertMaterial({ map: textures.log_top }), // ny (bottom)
new THREE.MeshLambertMaterial({ map: textures.log_side }), // pz
new THREE.MeshLambertMaterial({ map: textures.log_side }) // nz
];
materials[type] = mat;
return mat;
}
if (type === 'leaves') {
const mat = new THREE.MeshLambertMaterial({ map: textures.leaves, transparent: true, alphaTest: 0.5, color: 0x79C05A });
materials[type] = mat;
return mat;
}
if (type === 'apple') {
const mat = new THREE.MeshLambertMaterial({ map: textures.apple, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
materials[type] = mat;
return mat;
}
if (type === 'stick') {
const mat = new THREE.MeshLambertMaterial({ map: textures.stick, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
materials[type] = mat;
return mat;
}
if (type === 'sapling') {
const mat = new THREE.MeshLambertMaterial({ map: textures.sapling, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
materials[type] = mat;
return mat;
}
const mat = new THREE.MeshLambertMaterial({ map: map });
materials[type] = mat;
return mat;
};
// Damage Overlay
const damageMaterial = new THREE.MeshBasicMaterial({
map: destroyTextures[0],
color: 0x808080,
transparent: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1,
blending: THREE.CustomBlending,
blendEquation: THREE.ReverseSubtractEquation,
blendSrc: THREE.SrcAlphaFactor,
blendDst: THREE.OneFactor
});
const damageGeometry = new THREE.BoxGeometry(BLOCK_SIZE * 1.002, BLOCK_SIZE * 1.002, BLOCK_SIZE * 1.002);
const damageOverlay = new THREE.Mesh(damageGeometry, damageMaterial);
damageOverlay.visible = false;
scene.add(damageOverlay);
// --- Game Logic ---
// Hardness reference: Stone (1.5) takes 4 seconds (4,000 ms)
// Multiplier = 4,000 / 1.5 = 2666.66 ms per hardness unit
const HARDNESS_MULTIPLIER = 2666.66;
const BLOCK_HEALTH = {
grass: 0.3 * HARDNESS_MULTIPLIER,
dirt: 0.2 * HARDNESS_MULTIPLIER,
stone: 1.5 * HARDNESS_MULTIPLIER,
cobblestone: 2 * HARDNESS_MULTIPLIER,
coal: 3 * HARDNESS_MULTIPLIER,
iron: 3 * HARDNESS_MULTIPLIER,
gold: 3 * HARDNESS_MULTIPLIER,
diamond: 3 * HARDNESS_MULTIPLIER,
emerald: 3 * HARDNESS_MULTIPLIER,
log: 2.0 * HARDNESS_MULTIPLIER,
leaves: 0.05 * HARDNESS_MULTIPLIER,
bedrock: Infinity
};
function createBlock(x, y, z, type) {
const geometry = new THREE.BoxGeometry(BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
const material = getMaterial(type);
const cube = new THREE.Mesh(geometry, material);
cube.castShadow = true;
cube.receiveShadow = true;
// Center the grid
// GRID_SIZE is dynamic now
const offset = (GRID_SIZE * BLOCK_SIZE) / 2 - (BLOCK_SIZE / 2);
cube.position.set(
x * BLOCK_SIZE - offset,
y * BLOCK_SIZE, // Y is vertical
z * BLOCK_SIZE - offset
);
cube.userData = { gridX: x, gridY: y, gridZ: z, type: type };
scene.add(cube);
return cube;
}
function getBlockTypeForDepth(d) {
// Helper to reuse generation logic
if (d === 0) return 'grass';
if (d === 1) return 'dirt';
if (d === 2) return 'dirt';
// Bedrock at bottom? No, infinite mining.
const rand = Math.random();
// Ore probabilities based on depth
let coalProb = 0.05;
let ironProb = 0.0;
let goldProb = 0.0;
let diamondProb = 0.0;
let emeraldProb = 0.0;
if (d > 5) ironProb = 0.03;
if (d > 10) goldProb = 0.02;
if (d > 20) diamondProb = 0.01;
if (d > 50) emeraldProb = 0.005;
// Increase probs as we go deeper
if (d > 30) {
coalProb = 0.08;
ironProb = 0.05;
}
if (d > 60) {
goldProb = 0.04;
diamondProb = 0.02;
}
if (rand < emeraldProb) return 'emerald';
if (rand < emeraldProb + diamondProb) return 'diamond';
if (rand < emeraldProb + diamondProb + goldProb) return 'gold';
if (rand < emeraldProb + diamondProb + goldProb + ironProb) return 'iron';
if (rand < emeraldProb + diamondProb + goldProb + ironProb + coalProb) return 'coal';
if (d < 5) return 'dirt';
if (d < 10 && Math.random() < 0.5) return 'dirt';
return Math.random() < 0.8 ? 'stone' : 'cobblestone';
}
function generateLayer(layerIndex, absoluteDepth) {
const layer = [];
for (let x = 0; x < GRID_SIZE; x++) {
layer[x] = [];
for (let z = 0; z < GRID_SIZE; z++) {
// layerIndex corresponds to Y position.
// 0 is top, -1 is below, etc.
const type = getBlockTypeForDepth(absoluteDepth);
const block = createBlock(x, -layerIndex, z, type);
layer[x][z] = block;
}
}
return layer;
}
const STRUCTURES = {
tree: []
};
// Init Tree Structure
(() => {
const s = STRUCTURES.tree;
for(let y=0; y<3; y++) s.push({x:0, y:y, z:0, type:'log'});
for(let x=-2; x<=2; x++) {
for(let y=2; y<=3; y++) {
for(let z=-2; z<=2; z++) {
if(Math.abs(x)===2 && Math.abs(z)===2) continue;
if(x===0 && z===0 && y===2) continue;
s.push({x, y, z, type:'leaves'});
}
}
}
for(let x=-1; x<=1; x++) {
for(let z=-1; z<=1; z++) {
if(x===0 && z===0) continue;
s.push({x, y:4, z, type:'leaves'});
}
}
s.push({x:0, y:5, z:0, type:'leaves'});
})();
function placeStructure(gx, gy, gz, structureName) {
const structure = STRUCTURES[structureName];
if (!structure) return;
structure.forEach(block => {
spawnBlock(gx + block.x, gy + block.y, gz + block.z, block.type);
});
}
function generateTree() {
const cx = Math.floor(GRID_SIZE / 2);
const cz = Math.floor(GRID_SIZE / 2);
placeStructure(cx, 1, cz, 'tree');
}
function saveGame() {
const saveData = {
depth,
score,
inventory,
upgrades,
layers: layers.map(layer => layer.map(row => row.map(block => block ? block.userData.type : null))),
extraBlocks: extraBlocks.map(b => ({
type: b.userData.type,
gridX: b.userData.gridX,
gridZ: b.userData.gridZ,
y: Math.round(b.position.y / BLOCK_SIZE),
isSapling: b.userData.isSapling,
growthTimer: b.userData.growthTimer
})),
drops: drops.map(d => ({
type: d.userData.type,
count: d.userData.count || 1,
x: d.position.x,
y: d.position.y,
z: d.position.z,
vx: d.userData.velocity.x,
vy: d.userData.velocity.y,
vz: d.userData.velocity.z,
life: d.userData.life,
autoCollect: d.userData.autoCollect
}))
};
localStorage.setItem('miningGameSave', JSON.stringify(saveData));
}
function loadGame() {
const dataStr = localStorage.getItem('miningGameSave');
if (!dataStr) return false;
try {
const data = JSON.parse(dataStr);
depth = data.depth;
score = data.score;
inventory = Object.assign({}, data.inventory);
if (data.upgrades) {
upgrades = Object.assign(upgrades, data.upgrades);
// Ensure new upgrades are initialized if loading old save
if (upgrades.mining_area === undefined) upgrades.mining_area = 0;
if (upgrades.vein_miner === undefined) upgrades.vein_miner = 0;
if (upgrades.tnt_frequency === undefined) upgrades.tnt_frequency = 0;
if (upgrades.tnt_power === undefined) upgrades.tnt_power = 0;
if (upgrades.pickup_range === undefined) upgrades.pickup_range = 0;
if (upgrades.grid_size === undefined) upgrades.grid_size = 0;
// Update GRID_SIZE based on loaded upgrade
GRID_SIZE = 5 + (upgrades.grid_size * 2);
VISIBLE_LAYERS = 7 + upgrades.grid_size;
// Adjust camera for larger grids
if (upgrades.grid_size > 0) {
const d = 5 + upgrades.grid_size;
camera.left = -d * aspect;
camera.right = d * aspect;
camera.top = d;
camera.bottom = -d;
camera.updateProjectionMatrix();
}
}
depthDisplay.innerText = depth;
scoreDisplay.innerText = score;
updateInventoryUI();
// Rebuild layers
layers = [];
data.layers.forEach((savedLayer, layerIdx) => {
const layer = [];
// Handle grid size mismatch if save is old
const savedSize = savedLayer.length;
const offsetDiff = (GRID_SIZE - savedSize) / 2;
for(let x=0; x<GRID_SIZE; x++) {
layer[x] = [];
for(let z=0; z<GRID_SIZE; z++) {
// Map new coordinates to old saved coordinates
const oldX = x - offsetDiff;
const oldZ = z - offsetDiff;
let type = null;
if (oldX >= 0 && oldX < savedSize && oldZ >= 0 && oldZ < savedSize) {
if (savedLayer[oldX] && savedLayer[oldX][oldZ]) {
type = savedLayer[oldX][oldZ];
}
} else {
// New outer block for expanded grid
// Only if we are expanding. If shrinking (not possible yet), we just clip.
// But wait, if we load an old save with smaller grid, we should probably fill the new space?
// Or just leave it empty?
// If we leave it empty, checkLayerCleared might trigger.
// So we should probably generate blocks for the new area.
if (GRID_SIZE > savedSize) {
type = getBlockTypeForDepth(depth + layerIdx);
}
}
if (type) {
const block = createBlock(x, -layerIdx, z, type);
layer[x][z] = block;
scene.add(block); // Ensure we add to scene!
} else {
layer[x][z] = null;
}
}
}
layers.push(layer);
});
// Fill missing layers if VISIBLE_LAYERS increased
while (layers.length < VISIBLE_LAYERS) {
const layerIdx = layers.length;
const absDepth = depth + layerIdx;
layers.push(generateLayer(layerIdx, absDepth));
}
// Rebuild extraBlocks
extraBlocks = [];
data.extraBlocks.forEach(bData => {
if (bData.isSapling) {
spawnSapling(bData.gridX, bData.y, bData.gridZ, bData.growthTimer);
} else {
spawnBlock(bData.gridX, bData.y, bData.gridZ, bData.type);
}
});
// Rebuild drops
if (data.drops) {
data.drops.forEach(dData => {
const pos = new THREE.Vector3(dData.x, dData.y, dData.z);
spawnDrop(pos, dData.type, dData.autoCollect);
// The last added drop is at drops[drops.length-1]
const drop = drops[drops.length-1];
if (drop) {
drop.userData.velocity.set(dData.vx, dData.vy, dData.vz);
drop.userData.life = dData.life || 0;
drop.userData.physicsPos.copy(pos);
drop.userData.count = dData.count || 1;
// Apply scale based on count
const count = drop.userData.count;
const newScale = Math.min(2.0, 0.6 + (count * 0.1));
if (drop.userData.isExtruded) {
drop.scale.set(newScale, newScale, newScale);
} else {
const blockScale = Math.min(1.5, 1.0 + (count * 0.1));
drop.scale.set(blockScale, blockScale, blockScale);
}
}
});
}
return true;
} catch (e) {
console.error("Failed to load save", e);
return false;
}
}
function initGame() {
if (loadGame()) {
console.log("Game loaded from save.");
} else {
// Generate initial layers
for (let i = 0; i < VISIBLE_LAYERS; i++) {
layers.push(generateLayer(i, depth + i));
}
generateTree();
}
// Set initial camera position for tree
if (extraBlocks.length > 0) {
cameraOffsetY = 3.5;
camera.position.set(20, 14 + cameraOffsetY, 20);
camera.lookAt(0, cameraOffsetY, 0);
}
// Auto-save
setInterval(saveGame, 5000);
}
function checkLayerCleared() {
// Check the top layer (index 0)
const topLayer = layers[0];
let isEmpty = true;
for (let x = 0; x < GRID_SIZE; x++) {
for (let z = 0; z < GRID_SIZE; z++) {
if (topLayer[x][z] !== null) {
isEmpty = false;
break;
}
}
if (!isEmpty) break;
}
if (isEmpty) {
advanceLevel();
}
}
function advanceLevel() {
if (isAnimating) return;
isAnimating = true;
// Remove empty top layer from array
layers.shift();
// Increment depth
depth++;
depthDisplay.innerText = depth;
// Generate new layer at the bottom
const newLayerDepth = depth + VISIBLE_LAYERS - 1;
// Create it one step lower (VISIBLE_LAYERS instead of VISIBLE_LAYERS - 1)
// so it can animate up into place
const newLayer = generateLayer(VISIBLE_LAYERS, newLayerDepth);
layers.push(newLayer);
// Animate all blocks moving up
const duration = 500; // ms
const start = Date.now();
const startPositions = [];
const allBlocks = [];
layers.forEach(layer => {
layer.forEach(row => {
row.forEach(block => {
if (block) {
allBlocks.push(block);
startPositions.push(block.position.y);
}
});
});
});
extraBlocks.forEach(block => {
allBlocks.push(block);
startPositions.push(block.position.y);
});
// Also animate drops
const dropStartPositions = [];
drops.forEach(drop => {
dropStartPositions.push(drop.userData.physicsPos.y);
});
function animate() {
const now = Date.now();
const progress = Math.min((now - start) / duration, 1);
const ease = 1 - Math.pow(1 - progress, 3); // Cubic ease out
const offset = ease * BLOCK_SIZE;
allBlocks.forEach((b, i) => {
b.position.y = startPositions[i] + offset;
});
drops.forEach((drop, i) => {
drop.userData.physicsPos.y = dropStartPositions[i] + offset;
drop.position.y = drop.userData.physicsPos.y; // Visual update handled in updateDrops but we force it here for smoothness
});
if (progress < 1) {
requestAnimationFrame(animate);
} else {
isAnimating = false;
// Correct positions exactly to avoid float drift
layers.forEach((layer, layerIdx) => {
layer.forEach(row => {
row.forEach(block => {
if (block) {
// The top layer (index 0) should now be at Y=0
block.position.y = -layerIdx * BLOCK_SIZE;
}
});
});
});
// Extra blocks just stay where they ended up (shifted up by 1)
// Finalize drop positions
drops.forEach((drop, i) => {
drop.userData.physicsPos.y = dropStartPositions[i] + BLOCK_SIZE;
});
saveGame();
checkLayerCleared(); // Check if the next layer is also empty
}
}
animate();
}
// --- Inventory ---
let inventory = {};
let selectedItem = null;
const inventoryDisplay = document.getElementById('inventory-display');
function isPlaceable(type) {
const placeables = ['log', 'planks', 'cobblestone', 'dirt', 'stone', 'leaves', 'sapling', 'grass'];
return placeables.includes(type);
}
function addToInventory(type) {
if (!inventory[type]) inventory[type] = 0;
inventory[type]++;
updateInventoryUI();
}
function updateInventoryUI() {
if (!inventoryDisplay) return;
inventoryDisplay.innerHTML = '';
for (const [type, count] of Object.entries(inventory)) {
if (count <= 0) continue; // Skip items with 0 count
const slot = document.createElement('div');
slot.className = 'inventory-slot';
slot.dataset.type = type; // For animation targeting
if (selectedItem === type) {
slot.style.borderColor = '#ffff00';
slot.style.borderWidth = '3px';
slot.style.transform = 'scale(1.1)';
}
slot.onclick = () => {
if (selectedItem === type) {
selectedItem = null;
} else {
if (isPlaceable(type)) {
selectedItem = type;
}
}
updateInventoryUI();
};
const img = document.createElement('img');
let iconName = type;
if (type === 'coal') iconName = 'coal_ore';
if (type === 'iron') iconName = 'iron_ore';
if (type === 'gold') iconName = 'gold_ore';
if (type === 'diamond') iconName = 'diamond_ore';
if (type === 'emerald') iconName = 'emerald_ore';
if (type === 'log') iconName = 'log_oak';
if (type === 'leaves') iconName = 'leaves_oak';
if (type === 'grass') iconName = 'grass_side';
if (type === 'stick') iconName = 'stick';
if (type === 'sapling') iconName = 'sapling_oak';
if (type === 'apple') iconName = 'apple';
if (type === 'planks') iconName = 'planks_oak';
if (type === 'wood_pickaxe') iconName = 'wood_pickaxe';
if (type === 'stone_pickaxe') iconName = 'stone_pickaxe';
if (type === 'iron_pickaxe') iconName = 'iron_pickaxe';
if (type === 'gold_pickaxe') iconName = 'gold_pickaxe';
if (type === 'diamond_pickaxe') iconName = 'diamond_pickaxe';
if (['apple', 'stick', 'wood_pickaxe', 'stone_pickaxe', 'iron_pickaxe', 'gold_pickaxe', 'diamond_pickaxe'].includes(type)) {
img.src = `items/${iconName}.png`;
} else {
img.src = `blocks/${iconName}.png`;
}
// Special handling for non-block items to tint them?
if (type === 'stick') {
img.style.transform = 'scale(0.8)';
}
if (type === 'sapling') {
img.style.transform = 'scale(0.8)';
}
const countDiv = document.createElement('div');
countDiv.className = 'inventory-count';
countDiv.innerText = count;
slot.appendChild(img);
slot.appendChild(countDiv);
inventoryDisplay.appendChild(slot);
}
updateCraftingUI();
}
// --- Crafting ---
const craftingDisplay = document.getElementById('crafting-display');
const RECIPES = [
{
output: 'planks',
count: 4,
inputs: { 'log': 1 }
},
{
output: 'stick',
count: 4,
inputs: { 'planks': 2 }
},
{
output: 'wood_pickaxe',
count: 1,
inputs: { 'planks': 3, 'stick': 2 }
},
{
output: 'stone_pickaxe',
count: 1,
inputs: { 'cobblestone': 3, 'stick': 2 }
},
{
output: 'iron_pickaxe',
count: 1,
inputs: { 'iron': 3, 'stick': 2 }
},
{
output: 'gold_pickaxe',
count: 1,
inputs: { 'gold': 3, 'stick': 2 }
},
{
output: 'diamond_pickaxe',
count: 1,
inputs: { 'diamond': 3, 'stick': 2 }
}
];
function canCraft(recipe) {
for (const [item, count] of Object.entries(recipe.inputs)) {
if (!inventory[item] || inventory[item] < count) return false;
}
return true;
}
function craft(recipe) {
if (!canCraft(recipe)) return;
// Deduct inputs
for (const [item, count] of Object.entries(recipe.inputs)) {
inventory[item] -= count;
if (inventory[item] <= 0) delete inventory[item];
}
// Add output
if (!inventory[recipe.output]) inventory[recipe.output] = 0;
inventory[recipe.output] += recipe.count;
updateInventoryUI();
// updateCraftingUI called by updateInventoryUI
}
function updateCraftingUI() {
if (!craftingDisplay) return;
craftingDisplay.innerHTML = '';
RECIPES.forEach(recipe => {
const slot = document.createElement('div');
slot.className = 'crafting-slot';
if (canCraft(recipe)) {
slot.classList.add('can-craft');
slot.onclick = () => craft(recipe);
} else {
slot.style.opacity = '0.5';
slot.style.cursor = 'default';
}
const img = document.createElement('img');
let iconName = recipe.output;
if (iconName === 'planks') iconName = 'planks_oak';
if (['wood_pickaxe', 'stone_pickaxe', 'iron_pickaxe', 'gold_pickaxe', 'diamond_pickaxe', 'stick'].includes(recipe.output)) {
img.src = `items/${iconName}.png`;
} else {
img.src = `blocks/${iconName}.png`;
}
// Tooltip
const tooltip = document.createElement('div');
tooltip.className = 'crafting-tooltip';
let costText = [];
for (const [item, count] of Object.entries(recipe.inputs)) {
costText.push(`${count} ${item}`);
}
tooltip.innerText = `${recipe.output} (${costText.join(', ')})`;
slot.appendChild(img);
slot.appendChild(tooltip);
craftingDisplay.appendChild(slot);
});
}
// --- Drops ---
const drops = [];
const dropGeometry = new THREE.BoxGeometry(0.25, 0.25, 0.25);
// Shadow Texture
const shadowCanvas = document.createElement('canvas');
shadowCanvas.width = 64;
shadowCanvas.height = 64;
const shadowCtx = shadowCanvas.getContext('2d');
const shadowGradient = shadowCtx.createRadialGradient(32, 32, 0, 32, 32, 32);
shadowGradient.addColorStop(0, 'rgba(0, 0, 0, 0.5)');
shadowGradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
shadowCtx.fillStyle = shadowGradient;
shadowCtx.fillRect(0, 0, 64, 64);
const shadowTexture = new THREE.CanvasTexture(shadowCanvas);
const shadowMaterial = new THREE.MeshBasicMaterial({ map: shadowTexture, transparent: true, depthWrite: false });
const shadowGeometry = new THREE.PlaneGeometry(0.4, 0.4);
function spawnDrop(position, type, autoCollect = false) {
// Check for existing drops to clump with
for (const existingDrop of drops) {
if (existingDrop.userData.type === type && !existingDrop.userData.collecting) {
const dist = existingDrop.userData.physicsPos.distanceTo(position);
if (dist < 1.0) { // Within 1 block radius
existingDrop.userData.count = (existingDrop.userData.count || 1) + 1;
// Visual feedback for clumping (scale up slightly, max 2x)
const newScale = Math.min(2.0, 0.6 + (existingDrop.userData.count * 0.1));
if (existingDrop.userData.isExtruded) {
existingDrop.scale.set(newScale, newScale, newScale);
} else {
// Standard blocks start at 1.0
const blockScale = Math.min(1.5, 1.0 + (existingDrop.userData.count * 0.1));
existingDrop.scale.set(blockScale, blockScale, blockScale);
}
// Reset life to keep it around longer
existingDrop.userData.life = 0;
return; // Merged, don't spawn new
}
}
}
const material = getMaterial(type);
// Adjust geometry based on type
let geometry = dropGeometry;
let isExtruded = false;
if (type === 'apple' || type === 'stick' || type === 'sapling') {
if (geometryCache[type]) {
geometry = geometryCache[type];
isExtruded = true;
} else {
// Try to generate
const tex = textures[type];
if (tex && tex.image && tex.image.complete && tex.image.width > 0) {
geometry = generateExtrudedGeometry(tex.image);
geometryCache[type] = geometry;
isExtruded = true;
} else if (tex && tex.image) {
// Load later
tex.image.onload = () => {
const geo = generateExtrudedGeometry(tex.image);
geometryCache[type] = geo;
// Update existing drops of this type?
// For simplicity, just next drops will be correct.
// Or we can update this drop:
if (drop.parent) { // If still in scene
drop.geometry = geo;
}
};
}
}
}
const drop = new THREE.Mesh(geometry, material);
drop.castShadow = false;
drop.receiveShadow = false;
// Physics state
drop.userData.physicsPos = position.clone();
drop.userData.velocity = new THREE.Vector3(
(Math.random() - 0.5) * 0.025, // Slower spread
0.025 + Math.random() * 0.025, // Lower pop
(Math.random() - 0.5) * 0.025
);
drop.userData.life = 0;
drop.userData.bobOffset = Math.random() * Math.PI * 2; // Random start phase
drop.userData.type = type;
drop.userData.count = 1; // Start with 1 item
drop.userData.autoCollect = autoCollect;
drop.userData.autoCollectTimer = 0;
drop.userData.isExtruded = isExtruded;
if (isExtruded) {
drop.scale.set(0.6, 0.6, 0.6);
drop.userData.radius = 0.15; // 0.25 * 0.6
} else {
drop.userData.radius = 0.125;
}
// Shadow
const shadow = new THREE.Mesh(shadowGeometry, shadowMaterial);
shadow.rotation.x = -Math.PI / 2;
shadow.position.copy(position);
shadow.position.y = -100; // Hide initially
drop.userData.shadow = shadow;
scene.add(drop);
scene.add(shadow);
drops.push(drop);
}
function updateDrops() {
for (let i = drops.length - 1; i >= 0; i--) {
const drop = drops[i];
// Collecting animation
if (drop.userData.collecting) {
// Calculate screen position
const vector = drop.position.clone();
vector.project(camera);
const canvas = renderer.domElement;
// Use relative coordinates for the container
const x = (vector.x + 1) / 2 * canvas.clientWidth;
const y = -(vector.y - 1) / 2 * canvas.clientHeight;
// Create 2D floating item
createFloatingItem(drop.userData.type, x - 16, y - 16, drop.userData.count || 1);
// Remove 3D drop immediately
scene.remove(drop);
scene.remove(drop.userData.shadow);
drops.splice(i, 1);
continue;
}
const vel = drop.userData.velocity;
const pos = drop.userData.physicsPos;
// Gravity (Slower)
vel.y -= 0.002;
// Move Physics Position
pos.add(vel);
// Floor Collision Logic (using pos)
const gx = Math.round(pos.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
const gz = Math.round(pos.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
let floorY = -Infinity;
// Check layers
for (let l = 0; l < layers.length; l++) {
if (gx >= 0 && gx < GRID_SIZE && gz >= 0 && gz < GRID_SIZE) {
const block = layers[l][gx][gz];
if (block) {
const by = block.position.y;
if (by < pos.y && by > floorY) {
floorY = by;
}
}
}
}
// Check extra blocks
extraBlocks.forEach(b => {
if (Math.round(b.position.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5)) === gx &&
Math.round(b.position.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5)) === gz) {
if (b.position.y < pos.y && b.position.y > floorY) {
floorY = b.position.y;
}
}
});
// Collision check
// Top of block is floorY + 0.5
// Bottom of drop is pos.y - radius
const radius = drop.userData.radius || 0.125;
const bobAmplitude = 0.05;
if (floorY !== -Infinity) {
// Ensure we sit high enough so the bob doesn't clip
if (pos.y - radius - bobAmplitude < floorY + 0.5) {
pos.y = floorY + 0.5 + radius + bobAmplitude;
vel.y = 0; // No bounce
vel.x *= 0.7; // Friction
vel.z *= 0.7;
}
} else {
if (pos.y < -10) {
scene.remove(drop);
scene.remove(drop.userData.shadow);
drops.splice(i, 1);
continue;
}
}
// Wall Collision
const limit = (GRID_SIZE * BLOCK_SIZE) / 2 - 0.2;
if (pos.x > limit) { pos.x = limit; vel.x *= -0.5; }
if (pos.x < -limit) { pos.x = -limit; vel.x *= -0.5; }
if (pos.z > limit) { pos.z = limit; vel.z *= -0.5; }
if (pos.z < -limit) { pos.z = -limit; vel.z *= -0.5; }
// Update Visuals
drop.userData.life++;
// Rotation (Yaw)
drop.rotation.y += 0.01; // Slow rotation
// Bobbing
const bob = Math.sin(drop.userData.life * 0.02 + drop.userData.bobOffset) * 0.05;
drop.position.copy(pos);
drop.position.y += bob;
// Shadow Update
const shadow = drop.userData.shadow;
if (floorY !== -Infinity) {
shadow.position.set(pos.x, floorY + 0.5 + 0.01, pos.z);
shadow.visible = true;
} else {
shadow.visible = false;
}
// Despawn logic
// Removed auto-despawn. Items stay until collected.
if (pos.y < -10) {
scene.remove(drop);
scene.remove(shadow);
drops.splice(i, 1);
}
}
}
// --- Interaction ---
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let isMining = false;
let miningBlock = null;
let miningStartTime = 0;
let miningDuration = 0;
function updateMouse(event) {
const rect = canvas.getBoundingClientRect();
const clientX = event.clientX || (event.touches && event.touches[0].clientX);
const clientY = event.clientY || (event.touches && event.touches[0].clientY);
if (clientX === undefined || clientY === undefined) return;
mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
}
function getIntersection() {
raycaster.setFromCamera(mouse, camera);
const meshes = [];
layers.forEach(layer => {
layer.forEach(row => {
row.forEach(block => {
if (block) meshes.push(block);
});
});
});
extraBlocks.forEach(block => meshes.push(block));
const intersects = raycaster.intersectObjects(meshes);
if (intersects.length > 0) {
return intersects[0];
}
return null;
}
function getBlockUnderMouse() {
const intersect = getIntersection();
return intersect ? intersect.object : null;
}
function checkCollection() {
// Base radius in NDC (screen space -1 to 1)
// 0.15 is roughly 7.5% of screen width/height
let pickupRadius = 0.15 + (upgrades.pickup_range * 0.05);
drops.forEach(drop => {
if (drop.userData.collecting) return;
const vector = drop.position.clone();
vector.project(camera); // vector is now in NDC
// Check if in front of camera
if (vector.z > 1) return;
const dx = vector.x - mouse.x;
const dy = vector.y - mouse.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < pickupRadius) {
drop.userData.collecting = true;
if (drop.userData.shadow) drop.userData.shadow.visible = false;
}
});
}
function getBlockAt(x, y, z) {
// Check layers
if (y <= 0) {
const layerIdx = -y;
if (layerIdx < layers.length) {
if (x >= 0 && x < GRID_SIZE && z >= 0 && z < GRID_SIZE) {
return layers[layerIdx][x][z];
}
}
}
// Check extra blocks
return extraBlocks.find(b =>
b.userData.gridX === x &&
Math.round(b.position.y / BLOCK_SIZE) === y &&
b.userData.gridZ === z
);
}
function getMiningSpeedMultiplier() {
let multiplier = 1;
if (inventory['wood_pickaxe']) multiplier = 2;
if (inventory['stone_pickaxe']) multiplier = 4;
if (inventory['iron_pickaxe']) multiplier = 6;
if (inventory['gold_pickaxe']) multiplier = 12;
if (inventory['diamond_pickaxe']) multiplier = 8;
// Apply speed upgrade
multiplier *= (1 + (upgrades.speed * 0.2));
return multiplier;
}
function startMining(block) {
if (miningBlock === block) return; // Already mining this block
cancelMining(); // Stop mining previous block
if (!block) return;
// Check if block is mineable (exposed)
const gx = block.userData.gridX;
const gz = block.userData.gridZ;
const gy = Math.round(block.position.y / BLOCK_SIZE);
// Check if there is a block above
const blockAbove = getBlockAt(gx, gy + 1, gz);
if (blockAbove) return;
miningBlock = block;
miningStartTime = performance.now();
const type = block.userData.type;
let baseDuration = BLOCK_HEALTH[type] || 1000;
miningDuration = baseDuration / getMiningSpeedMultiplier();
damageOverlay.position.copy(block.position);
damageOverlay.visible = true;
damageOverlay.material.map = destroyTextures[0];
}
function cancelMining() {
miningBlock = null;
damageOverlay.visible = false;
}
function processMining() {
if (!isMining || !miningBlock) return;
const elapsed = performance.now() - miningStartTime;
const progress = elapsed / miningDuration;
if (progress >= 1) {
breakBlock(miningBlock);
cancelMining();
// Continuous mining: check if mouse is still down and over a block
if (isMining) {
const nextBlock = getBlockUnderMouse();
if (nextBlock) {
startMining(nextBlock);
}
}
} else {
const stage = Math.floor(progress * 10);
if (stage >= 0 && stage < 10) {
damageOverlay.material.map = destroyTextures[stage];
}
}
}
function breakBlock(block, isChainReaction = false) {
if (!block) return;
const gx = block.userData.gridX;
const gz = block.userData.gridZ;
const type = block.userData.type;
const loot = getLoot(type);
// Handle drops
loot.forEach(itemType => {
spawnDrop(block.position, itemType, false);
});
// Check if it's in extra blocks
const extraIdx = extraBlocks.indexOf(block);
if (extraIdx !== -1) {
scene.remove(block);
extraBlocks.splice(extraIdx, 1);
// Score
let points = 1;
if (type === 'log') points = 5;
if (type === 'leaves') points = 1;
score += points;
scoreDisplay.innerText = score;
return;
}
// Find layer index
let foundLayerIdx = -1;
for(let l=0; l<layers.length; l++) {
if (layers[l][gx][gz] === block) {
foundLayerIdx = l;
break;
}
}
if (foundLayerIdx !== -1) {
scene.remove(block);
layers[foundLayerIdx][gx][gz] = null;
// Update score
let points = 1;
if (type === 'coal') points = 5;
if (type === 'iron') points = 10;
if (type === 'gold') points = 25;
if (type === 'diamond') points = 100;
if (type === 'emerald') points = 200;
score += points;
scoreDisplay.innerText = score;
// --- Special Mining Effects (only for primary break) ---
if (!isChainReaction) {
// Vein Miner
if (upgrades.vein_miner > 0 && ['coal', 'iron', 'gold', 'diamond', 'emerald'].includes(type)) {
const maxVein = 2 + upgrades.vein_miner * 2;
mineVein(gx, foundLayerIdx, gz, type, maxVein);
}
// Area Mining (only for non-ores usually, but let's do it for stone/dirt/grass)
if (upgrades.mining_area > 0 && ['stone', 'dirt', 'grass', 'cobblestone'].includes(type)) {
const radius = upgrades.mining_area; // 1 = 3x3, 2 = 5x5
mineArea(gx, foundLayerIdx, gz, radius);
}
// Check for layer clear AFTER all chain reactions are done
// We check layer 0 specifically because that's the one that triggers advancement
checkLayerCleared();
}
}
}
function mineVein(gx, layerIdx, gz, type, maxBlocks) {
let minedCount = 0;
const queue = [{x: gx, z: gz, l: layerIdx}];
const visited = new Set();
visited.add(`${gx},${layerIdx},${gz}`);
while (queue.length > 0 && minedCount < maxBlocks) {
const current = queue.shift();
// Check neighbors (up, down, left, right, forward, back)
const neighbors = [
{x: current.x+1, z: current.z, l: current.l},
{x: current.x-1, z: current.z, l: current.l},
{x: current.x, z: current.z+1, l: current.l},
{x: current.x, z: current.z-1, l: current.l},
// Same layer only for now to keep it simple, or maybe check adjacent layers?
// Let's stick to same layer for simplicity first, or +/- 1 layer
];
for (const n of neighbors) {
if (n.x < 0 || n.x >= GRID_SIZE || n.z < 0 || n.z >= GRID_SIZE) continue;
const key = `${n.x},${n.l},${n.z}`;
if (visited.has(key)) continue;
const block = layers[n.l][n.x][n.z];
if (block && block.userData.type === type) {
visited.add(key);
queue.push(n);
breakBlock(block, true); // Chain reaction
minedCount++;
if (minedCount >= maxBlocks) break;
}
}
}
}
function mineArea(gx, layerIdx, gz, radius) {
for (let x = gx - radius; x <= gx + radius; x++) {
for (let z = gz - radius; z <= gz + radius; z++) {
if (x === gx && z === gz) continue; // Already mined
if (x < 0 || x >= GRID_SIZE || z < 0 || z >= GRID_SIZE) continue;
const block = layers[layerIdx][x][z];
if (block) {
// Only mine common blocks with area miner
if (['stone', 'dirt', 'grass', 'cobblestone'].includes(block.userData.type)) {
breakBlock(block, true);
}
}
}
}
}
function spawnSapling(gx, gy, gz, growthTimer = 0) {
const type = 'sapling';
const geometry = new THREE.PlaneGeometry(0.7, 0.7);
const mat = new THREE.MeshLambertMaterial({
map: textures.sapling,
transparent: true,
side: THREE.DoubleSide,
alphaTest: 0.5
});
const plane1 = new THREE.Mesh(geometry, mat);
plane1.rotation.y = Math.PI / 4;
const plane2 = new THREE.Mesh(geometry, mat);
plane2.rotation.y = -Math.PI / 4;
const saplingGroup = new THREE.Group();
saplingGroup.add(plane1);
saplingGroup.add(plane2);
saplingGroup.position.set(
(gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE,
gy * BLOCK_SIZE, // Center of block
(gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE
);
// Adjust height. 0.7 height. Center is at 0.
// We want bottom at -0.5 relative to block center.
// So center should be at -0.5 + 0.35 = -0.15
plane1.position.y = -0.15;
plane2.position.y = -0.15;
saplingGroup.userData = {
type: type,
gridX: gx,
gridZ: gz,
isSapling: true,
growthTimer: growthTimer,
maxGrowthTime: 600 // 10 seconds at 60fps
};
// Progress Bar
const barBg = new THREE.Mesh(
new THREE.PlaneGeometry(0.8, 0.1),
new THREE.MeshBasicMaterial({ color: 0x000000 })
);
barBg.position.set(0, 0.5, 0);
// Billboard
barBg.userData.isBillboard = true;
const barFg = new THREE.Mesh(
new THREE.PlaneGeometry(0.78, 0.08),
new THREE.MeshBasicMaterial({ color: 0x00ff00 })
);
barFg.position.z = 0.01;
barFg.scale.x = 0;
barBg.add(barFg);
saplingGroup.add(barBg);
saplingGroup.userData.progressBar = barFg;
saplingGroup.userData.progressBarBg = barBg;
scene.add(saplingGroup);
extraBlocks.push(saplingGroup);
}
function placeBlock(gx, gy, gz, type) {
// Deduct from inventory
inventory[type]--;
if (inventory[type] <= 0) {
delete inventory[type];
selectedItem = null;
}
updateInventoryUI();
// Create Mesh
if (type === 'sapling') {
spawnSapling(gx, gy, gz);
return;
}
let geometry = new THREE.BoxGeometry(BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
let material = getMaterial(type);
const block = new THREE.Mesh(geometry, material);
block.position.set(
(gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE,
gy * BLOCK_SIZE,
(gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE
);
block.castShadow = true;
block.receiveShadow = true;
block.userData = { type: type, gridX: gx, gridZ: gz }; // gridX/Z might be off if not in layer structure
scene.add(block);
extraBlocks.push(block);
}
function onMouseDown(event) {
if (isAnimating) return;
updateMouse(event);
if (selectedItem) {
const intersect = getIntersection();
if (intersect) {
const block = intersect.object;
const face = intersect.face;
const p = intersect.point.clone().add(face.normal.clone().multiplyScalar(BLOCK_SIZE * 0.5));
const nx = Math.round(p.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
const ny = Math.round(p.y / BLOCK_SIZE);
const nz = Math.round(p.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
if (nx >= 0 && nx < GRID_SIZE && nz >= 0 && nz < GRID_SIZE) {
if (!getBlockAt(nx, ny, nz)) {
placeBlock(nx, ny, nz, selectedItem);
return;
}
}
}
}
isMining = true;
const block = getBlockUnderMouse();
if (block) startMining(block);
}
function onMouseUp(event) {
isMining = false;
cancelMining();
}
function onMouseMove(event) {
updateMouse(event);
checkCollection(); // Check if hovering over drops
if (isMining) {
const block = getBlockUnderMouse();
if (block !== miningBlock) {
if (block) {
startMining(block);
} else {
cancelMining();
}
}
}
}
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('mouseleave', onMouseUp);
canvas.addEventListener('mousemove', onMouseMove);
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
if (e.touches.length > 0) {
onMouseDown(e);
}
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
onMouseUp(e);
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (isMining && e.touches.length > 0) {
onMouseMove(e);
}
}, { passive: false });
function spawnBlock(gx, gy, gz, type) {
// Check bounds
if (gx < 0 || gx >= GRID_SIZE || gz < 0 || gz >= GRID_SIZE) return;
// Helper to spawn block without inventory
let geometry = new THREE.BoxGeometry(BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
let material = getMaterial(type);
const block = new THREE.Mesh(geometry, material);
block.position.set(
(gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE,
gy * BLOCK_SIZE,
(gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE
);
block.castShadow = true;
block.receiveShadow = true;
block.userData = { type: type, gridX: gx, gridZ: gz };
scene.add(block);
extraBlocks.push(block);
}
function updateSaplings() {
for (let i = extraBlocks.length - 1; i >= 0; i--) {
const block = extraBlocks[i];
if (block.userData.isSapling) {
block.userData.growthTimer++;
// Update Billboard
if (block.userData.progressBarBg) {
block.userData.progressBarBg.lookAt(camera.position);
}
// Update Progress
const progress = block.userData.growthTimer / block.userData.maxGrowthTime;
if (block.userData.progressBar) {
block.userData.progressBar.scale.x = progress;
// To scale from left, we need to adjust position?
// Default plane geometry is centered.
// If we scale x, it scales from center.
// We can just update scale.
}
if (block.userData.growthTimer >= block.userData.maxGrowthTime) {
// Grow!
const { gridX, gridY, gridZ } = block.userData;
// Wait, we didn't save gridY in userData in placeBlock for sapling?
// We saved gridX, gridZ.
// We can calculate gridY from position.
const gy = Math.round(block.position.y / BLOCK_SIZE);
scene.remove(block);
extraBlocks.splice(i, 1);
placeStructure(block.userData.gridX, gy, block.userData.gridZ, 'tree');
}
}
}
}
// --- Particle System ---
let particles = [];
function spawnExplosionParticles(pos) {
if (!textures.particle) return;
const particleCount = 30;
const material = new THREE.SpriteMaterial({
map: textures.particle,
color: 0xffffff,
transparent: true,
blending: THREE.AdditiveBlending
});
for (let i = 0; i < particleCount; i++) {
const sprite = new THREE.Sprite(material);
sprite.position.copy(pos);
// Add some randomness to start position
sprite.position.x += (Math.random() - 0.5) * 0.5;
sprite.position.y += (Math.random() - 0.5) * 0.5;
sprite.position.z += (Math.random() - 0.5) * 0.5;
// Random velocity
const speed = 0.1 + Math.random() * 0.3;
sprite.userData.velocity = new THREE.Vector3(
(Math.random() - 0.5) * speed,
(Math.random() - 0.5) * speed,
(Math.random() - 0.5) * speed
);
sprite.userData.life = 1.0;
const size = 0.3 + Math.random() * 0.4;
sprite.scale.set(size, size, size);
scene.add(sprite);
particles.push(sprite);
}
}
function updateParticles() {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.position.add(p.userData.velocity);
p.userData.velocity.y -= 0.005; // Slight gravity
p.userData.velocity.multiplyScalar(0.95); // Drag
p.userData.life -= 0.02;
p.material.opacity = Math.max(0, p.userData.life);
if (p.userData.life <= 0) {
scene.remove(p);
particles.splice(i, 1);
}
}
}
// --- Animation Loop ---
let lastTime = 0;
const fpsInterval = 1000 / 60;
function animate(currentTime) {
requestAnimationFrame(animate);
if (!currentTime) currentTime = performance.now();
const elapsed = currentTime - lastTime;
if (elapsed > fpsInterval) {
lastTime = currentTime - (elapsed % fpsInterval);
processMining();
updateDrops();
updateSaplings();
updateTNT();
updateParticles();
checkCollection();
// Camera offset logic
const targetOffset = extraBlocks.length > 0 ? 3.5 : 0;
if (Math.abs(cameraOffsetY - targetOffset) > 0.01) {
cameraOffsetY += (targetOffset - cameraOffsetY) * 0.05;
camera.position.set(20, 14 + cameraOffsetY, 20);
camera.lookAt(0, cameraOffsetY, 0);
} else if (cameraOffsetY !== targetOffset) {
cameraOffsetY = targetOffset;
camera.position.set(20, 14 + cameraOffsetY, 20);
camera.lookAt(0, cameraOffsetY, 0);
}
composer.render();
}
}
// Handle resize
const resizeObserver = new ResizeObserver(() => {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
if (width === 0 || height === 0) return;
const aspect = width / height;
// Dynamic view size based on grid size
let d = 5 + (upgrades.grid_size || 0);
// Adjust for portrait mode to ensure width fits
if (aspect < 1) {
d = d / aspect;
}
camera.left = -d * aspect;
camera.right = d * aspect;
camera.top = d;
camera.bottom = -d;
camera.updateProjectionMatrix();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height, false);
composer.setSize(width, height);
if (ssaoPass) ssaoPass.setSize(width, height);
});
resizeObserver.observe(canvas);
// Start
initGame();
animate();
// --- Loot Table ---
function getLoot(blockType) {
if (blockType === 'leaves') {
const drops = [];
if (Math.random() < 0.05) drops.push('apple');
if (Math.random() < 0.05) drops.push('stick');
if (Math.random() < 0.05) drops.push('sapling');
return drops;
}
if (blockType === 'stone') return ['cobblestone'];
if (blockType === 'grass') return ['dirt'];
if (blockType === 'coal') return ['coal'];
if (blockType === 'iron') return ['iron'];
if (blockType === 'gold') return ['gold'];
if (blockType === 'diamond') return ['diamond'];
if (blockType === 'emerald') return ['emerald'];
if (blockType === 'log') return ['log'];
if (blockType === 'dirt') return ['dirt'];
if (blockType === 'cobblestone') return ['cobblestone'];
return [blockType];
}
// --- Extrusion Logic ---
const geometryCache = {};
const extrusionCanvas = document.createElement('canvas');
const extrusionCtx = extrusionCanvas.getContext('2d');
function generateExtrudedGeometry(image, depth = 0.0625) { // 1/16
const w = image.width;
const h = image.height;
extrusionCanvas.width = w;
extrusionCanvas.height = h;
extrusionCtx.drawImage(image, 0, 0);
const data = extrusionCtx.getImageData(0, 0, w, h).data;
const positions = [];
const uvs = [];
const normals = [];
const indices = [];
let indexOffset = 0;
const addFace = (v1, v2, v3, v4, n, u1, u2, u3, u4) => {
positions.push(...v1, ...v2, ...v3, ...v4);
normals.push(...n, ...n, ...n, ...n);
uvs.push(...u1, ...u2, ...u3, ...u4);
indices.push(indexOffset, indexOffset + 1, indexOffset + 2);
indices.push(indexOffset, indexOffset + 2, indexOffset + 3);
indexOffset += 4;
};
const isOpaque = (x, y) => {
if (x < 0 || x >= w || y < 0 || y >= h) return false;
return data[(y * w + x) * 4 + 3] > 10;
};
const px = 1 / w;
const py = 1 / h;
const d = depth / 2;
// Scale factor to fit in 0.5x0.5 world unit
const scale = 0.5;
const sx = scale / w;
const sy = scale / h;
const ox = -scale / 2;
const oy = scale / 2;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (isOpaque(x, y)) {
const x0 = ox + x * sx;
const x1 = ox + (x + 1) * sx;
const y0 = oy - y * sy;
const y1 = oy - (y + 1) * sy;
const u0 = x * px;
const u1 = (x + 1) * px;
const v0 = 1 - y * py;
const v1 = 1 - (y + 1) * py;
// Front
addFace(
[x0, y0, d], [x0, y1, d], [x1, y1, d], [x1, y0, d],
[0, 0, 1],
[u0, v1], [u0, v0], [u1, v0], [u1, v1]
);
// Back
addFace(
[x1, y0, -d], [x1, y1, -d], [x0, y1, -d], [x0, y0, -d],
[0, 0, -1],
[u1, v1], [u1, v0], [u0, v0], [u0, v1]
);
// Top
if (!isOpaque(x, y - 1)) {
addFace(
[x0, y0, -d], [x0, y0, d], [x1, y0, d], [x1, y0, -d],
[0, 1, 0],
[u0, v1], [u0, v1], [u1, v1], [u1, v1] // Map to pixel top edge
);
}
// Bottom
if (!isOpaque(x, y + 1)) {
addFace(
[x0, y1, d], [x0, y1, -d], [x1, y1, -d], [x1, y1, d],
[0, -1, 0],
[u0, v0], [u0, v0], [u1, v0], [u1, v0] // Map to pixel bottom edge
);
}
// Left
if (!isOpaque(x - 1, y)) {
addFace(
[x0, y0, -d], [x0, y1, -d], [x0, y1, d], [x0, y0, d],
[-1, 0, 0],
[u0, v1], [u0, v0], [u0, v0], [u0, v1] // Map to pixel left edge
);
}
// Right
if (!isOpaque(x + 1, y)) {
addFace(
[x1, y0, d], [x1, y1, d], [x1, y1, -d], [x1, y0, -d],
[1, 0, 0],
[u1, v1], [u1, v0], [u1, v0], [u1, v1] // Map to pixel right edge
);
}
}
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(indices);
return geometry;
}
// Fullscreen
window.toggleFullscreen = function() {
const elem = document.querySelector('.mining-container');
const doc = document;
const isNativeFullscreen = doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement;
const isPseudoFullscreen = elem.classList.contains('pseudo-fullscreen');
if (!isNativeFullscreen && !isPseudoFullscreen) {
// Enter Fullscreen
if (elem.requestFullscreen) {
elem.requestFullscreen().catch(err => {
console.log("Native fullscreen failed, using fallback.");
elem.classList.add('pseudo-fullscreen');
});
} else if (elem.webkitRequestFullscreen) {
elem.webkitRequestFullscreen();
} else if (elem.msRequestFullscreen) {
elem.msRequestFullscreen();
} else {
// No API support (e.g. iOS Safari)
elem.classList.add('pseudo-fullscreen');
}
} else {
// Exit Fullscreen
if (isNativeFullscreen) {
if (doc.exitFullscreen) {
doc.exitFullscreen();
} else if (doc.webkitExitFullscreen) {
doc.webkitExitFullscreen();
} else if (doc.mozCancelFullScreen) {
doc.mozCancelFullScreen();
} else if (doc.msExitFullscreen) {
doc.msExitFullscreen();
}
}
// Always remove pseudo class
elem.classList.remove('pseudo-fullscreen');
}
};
// Shop System
window.toggleShop = function() {
const shop = document.getElementById('shop-modal');
if (shop.style.display === 'none' || !shop.style.display) {
shop.style.display = 'flex';
updateShopUI();
} else {
shop.style.display = 'none';
}
};
function updateShopUI() {
const content = document.getElementById('shop-content');
content.innerHTML = '<h2>Shop</h2>';
// Sell Section
const sellDiv = document.createElement('div');
sellDiv.className = 'shop-section';
sellDiv.innerHTML = '<h3>Sell Ores</h3>';
Object.entries(SELL_PRICES).forEach(([type, price]) => {
const count = inventory[type] || 0;
if (count > 0) {
const btn = document.createElement('button');
btn.className = 'shop-btn';
btn.innerHTML = `Sell ${type} (${count}) - $${price * count}`;
btn.onclick = () => sellOre(type);
sellDiv.appendChild(btn);
}
});
content.appendChild(sellDiv);
// Upgrade Section
const upgradeDiv = document.createElement('div');
upgradeDiv.className = 'shop-section';
upgradeDiv.innerHTML = '<h3>Upgrades</h3>';
Object.entries(UPGRADE_COSTS).forEach(([type, costFunc]) => {
const currentLevel = upgrades[type];
const cost = costFunc(currentLevel);
const btn = document.createElement('button');
btn.className = 'shop-btn';
btn.innerHTML = `Upgrade ${type} (Lvl ${currentLevel}) - $${cost}`;
if (score < cost) btn.disabled = true;
btn.onclick = () => buyUpgrade(type);
upgradeDiv.appendChild(btn);
});
content.appendChild(upgradeDiv);
// Close button
const closeBtn = document.createElement('button');
closeBtn.className = 'shop-btn close-btn';
closeBtn.innerText = 'Close';
closeBtn.style.marginTop = '20px';
closeBtn.onclick = window.toggleShop;
content.appendChild(closeBtn);
}
function sellOre(type) {
const count = inventory[type] || 0;
if (count > 0) {
const price = SELL_PRICES[type];
score += price * count;
inventory[type] = 0;
scoreDisplay.innerText = score;
updateInventoryUI();
updateShopUI();
saveGame();
}
}
function buyUpgrade(type) {
const costFunc = UPGRADE_COSTS[type];
const currentLevel = upgrades[type];
const cost = costFunc(currentLevel);
if (score >= cost) {
score -= cost;
upgrades[type]++;
if (type === 'grid_size') {
expandGrid();
}
scoreDisplay.innerText = score;
updateShopUI();
saveGame();
}
}
function expandGrid() {
const oldSize = GRID_SIZE;
const oldVisibleLayers = VISIBLE_LAYERS;
GRID_SIZE = 5 + (upgrades.grid_size * 2);
VISIBLE_LAYERS = 7 + upgrades.grid_size;
const offsetDiff = (GRID_SIZE - oldSize) / 2;
// Adjust camera
const d = 5 + upgrades.grid_size;
camera.left = -d * aspect;
camera.right = d * aspect;
camera.top = d;
camera.bottom = -d;
camera.updateProjectionMatrix();
// Migrate layers
const newLayers = [];
layers.forEach((oldLayer, layerIdx) => {
const newLayer = [];
for (let x = 0; x < GRID_SIZE; x++) {
newLayer[x] = [];
for (let z = 0; z < GRID_SIZE; z++) {
// Map old coordinates to new centered coordinates
const oldX = x - offsetDiff;
const oldZ = z - offsetDiff;
if (oldX >= 0 && oldX < oldSize && oldZ >= 0 && oldZ < oldSize) {
// Existing block
const block = oldLayer[oldX][oldZ];
newLayer[x][z] = block;
if (block) {
// Update position
const offset = (GRID_SIZE * BLOCK_SIZE) / 2 - (BLOCK_SIZE / 2);
block.position.x = x * BLOCK_SIZE - offset;
block.position.z = z * BLOCK_SIZE - offset;
}
} else {
// New outer block
// Generate block based on depth
// We use the global 'depth' variable + layerIdx
const absDepth = depth + layerIdx;
const type = getBlockTypeForDepth(absDepth);
const block = createBlock(x, -layerIdx, z, type);
newLayer[x][z] = block;
scene.add(block);
}
}
}
newLayers.push(newLayer);
});
layers = newLayers;
// Add new layers at the bottom if VISIBLE_LAYERS increased
while (layers.length < VISIBLE_LAYERS) {
const layerIdx = layers.length;
const absDepth = depth + layerIdx;
layers.push(generateLayer(layerIdx, absDepth));
}
}
// --- TNT System ---
function updateTNT() {
// Spawning
if (upgrades.tnt_frequency > 0) {
// Base interval 60s, decreases by 5s per level, min 5s
const interval = Math.max(5000, 60000 - (upgrades.tnt_frequency * 5000));
// Use performance.now() for smoother timing if needed, but simple counter is fine for now
// Actually, let's use a delta time approach if we had one, but we don't.
// Assuming 60fps, 16.6ms per frame.
tntTimer += 16.6;
if (tntTimer >= interval) {
tntTimer = 0;
spawnTNT();
}
}
// Physics
for (let i = tntProjectiles.length - 1; i >= 0; i--) {
const tnt = tntProjectiles[i];
// Initialize fuse if not present (for existing TNTs)
if (tnt.userData.fuse === undefined) tnt.userData.fuse = 120;
if (tnt.userData.landed === undefined) tnt.userData.landed = false;
// Flashing effect
tnt.userData.flashTimer += 1;
// Flash faster as fuse runs out
const flashInterval = tnt.userData.fuse < 60 ? 5 : 10;
if (tnt.userData.flashTimer % flashInterval === 0) {
tnt.userData.isFlashing = !tnt.userData.isFlashing;
if (tnt.userData.isFlashing) {
if (!tnt.userData.whiteMaterial) {
tnt.userData.whiteMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
}
tnt.material = tnt.userData.whiteMaterial;
} else {
tnt.material = tnt.userData.originalMaterials;
}
}
if (!tnt.userData.landed) {
tnt.position.y -= 0.1; // Fall speed
const gx = Math.round(tnt.position.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
const gz = Math.round(tnt.position.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
const gy = Math.round(tnt.position.y / BLOCK_SIZE);
// Check if we hit a block
const block = getBlockAt(gx, gy, gz);
if (block) {
// Land on top
tnt.userData.landed = true;
tnt.position.y = block.position.y + BLOCK_SIZE;
// Snap to grid
tnt.position.x = (gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE;
tnt.position.z = (gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE;
} else if (gy < -depth - 5) {
// Fell too far
tntProjectiles.splice(i, 1);
scene.remove(tnt);
}
} else {
// Landed logic
// Check if block below still exists
const gx = Math.round(tnt.position.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
const gz = Math.round(tnt.position.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
const gyBelow = Math.round((tnt.position.y - BLOCK_SIZE) / BLOCK_SIZE);
const blockBelow = getBlockAt(gx, gyBelow, gz);
if (!blockBelow && tnt.position.y > -depth - 1) {
tnt.userData.landed = false; // Start falling again
}
tnt.userData.fuse--;
if (tnt.userData.fuse <= 0) {
explodeTNT(tnt);
tntProjectiles.splice(i, 1);
}
}
}
}
function spawnTNT() {
const gx = Math.floor(Math.random() * GRID_SIZE);
const gz = Math.floor(Math.random() * GRID_SIZE);
const geometry = new THREE.BoxGeometry(0.6, 0.6, 0.6);
const materials = [
new THREE.MeshLambertMaterial({ map: textures.tnt_side }), // px
new THREE.MeshLambertMaterial({ map: textures.tnt_side }), // nx
new THREE.MeshLambertMaterial({ map: textures.tnt_top }), // py (top)
new THREE.MeshLambertMaterial({ map: textures.tnt_bottom }), // ny (bottom)
new THREE.MeshLambertMaterial({ map: textures.tnt_side }), // pz
new THREE.MeshLambertMaterial({ map: textures.tnt_side }) // nz
];
const tnt = new THREE.Mesh(geometry, materials);
tnt.position.set(
(gx - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE,
10, // Start high up
(gz - (GRID_SIZE/2 - 0.5)) * BLOCK_SIZE
);
tnt.userData = {
flashTimer: 0,
isFlashing: false,
originalMaterials: materials,
fuse: 120, // 2 seconds at 60fps
landed: false
};
scene.add(tnt);
tntProjectiles.push(tnt);
}
function explodeTNT(tnt) {
spawnExplosionParticles(tnt.position);
scene.remove(tnt);
// Explosion effect (simple)
// Radius based on power: 0 -> 1 (3x3), 1 -> 2 (5x5)
const radius = 1 + upgrades.tnt_power;
const gx = Math.round(tnt.position.x / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
const gz = Math.round(tnt.position.z / BLOCK_SIZE + (GRID_SIZE/2 - 0.5));
// Find the layer we hit. Since TNT falls, it likely hit the top-most block at gx,gz.
// But we might have hit a specific Y.
// Let's just explode around the impact point.
// We need to find the layer index corresponding to the impact Y.
// tnt.position.y is roughly the block Y.
const impactY = Math.round(tnt.position.y / BLOCK_SIZE);
// Find layer index
// layers[0] is at y = -depth
// layers[1] is at y = -depth - 1
// So y = -depth - layerIdx
// layerIdx = -depth - y
// Wait, layers are generated at `depth + i`.
// `createBlock` uses `y = -layerIdx`.
// But `generateLayer` calls `createBlock` with `y`?
// No, `generateLayer` returns a 2D array of types.
// `loadGame` rebuilds layers: `createBlock(x, -layerIdx, z, type)`.
// So visually, layer 0 is at Y=0?
// Ah, `createBlock` sets `block.position.y = y * BLOCK_SIZE`.
// In `loadGame`, `y` is passed as `-layerIdx`.
// So layer 0 is at Y=0. Layer 1 is at Y=-1.
// But `advanceLevel` shifts layers. `layers.shift()`.
// And `animate` moves blocks up.
// `allBlocks.forEach((b, i) => { b.position.y = startPositions[i] + offset; });`
// So the visual Y position changes!
// This makes `getBlockAt` tricky if it relies on `layers` array index matching Y.
// `getBlockAt`:
// `if (y <= 0) { const layerIdx = -y; ... }`
// This assumes layer 0 is at Y=0, layer 1 at Y=-1.
// But if we advanced a level, the blocks moved UP.
// So the block that was at Y=-1 is now at Y=0.
// And it is now in `layers[0]`.
// So `getBlockAt` logic holds: `layers[0]` is always at Y=0 (visually, after animation).
// So `impactY` should correspond to `-layerIdx`.
// `layerIdx = -impactY`.
const centerLayerIdx = -impactY;
// Explode in radius
// We want a sphere or cylinder explosion?
// Let's do a simple cube/cylinder explosion across layers.
for (let l = centerLayerIdx - radius; l <= centerLayerIdx + radius; l++) {
if (l < 0 || l >= layers.length) continue;
for (let x = gx - radius; x <= gx + radius; x++) {
for (let z = gz - radius; z <= gz + radius; z++) {
if (x < 0 || x >= GRID_SIZE || z < 0 || z >= GRID_SIZE) continue;
// Distance check for round explosion
const dx = x - gx;
const dz = z - gz;
const dl = l - centerLayerIdx;
if (dx*dx + dz*dz + dl*dl > radius*radius) continue;
const block = layers[l][x][z];
if (block) {
breakBlock(block, true);
}
}
}
}
// Also check extra blocks (trees)
for (let i = extraBlocks.length - 1; i >= 0; i--) {
const b = extraBlocks[i];
const bx = b.userData.gridX;
const bz = b.userData.gridZ;
const by = Math.round(b.position.y / BLOCK_SIZE);
const dx = bx - gx;
const dz = bz - gz;
const dy = by - impactY;
if (dx*dx + dz*dz + dy*dy <= radius*radius) {
breakBlock(b, true);
}
}
checkLayerCleared();
}
// Floating Item Animation
function createFloatingItem(type, startX, startY, count) {
const img = document.createElement('img');
let iconName = type;
if (type === 'coal') iconName = 'coal_ore';
if (type === 'iron') iconName = 'iron_ore';
if (type === 'gold') iconName = 'gold_ore';
if (type === 'diamond') iconName = 'diamond_ore';
if (type === 'emerald') iconName = 'emerald_ore';
if (type === 'log') iconName = 'log_oak';
if (type === 'leaves') iconName = 'leaves_oak';
if (type === 'grass') iconName = 'grass_side';
if (type === 'stick') iconName = 'stick';
if (type === 'sapling') iconName = 'sapling_oak';
if (type === 'apple') iconName = 'apple';
if (type === 'planks') iconName = 'planks_oak';
if (type === 'wood_pickaxe') iconName = 'wood_pickaxe';
if (type === 'stone_pickaxe') iconName = 'stone_pickaxe';
if (type === 'iron_pickaxe') iconName = 'iron_pickaxe';
if (type === 'gold_pickaxe') iconName = 'gold_pickaxe';
if (type === 'diamond_pickaxe') iconName = 'diamond_pickaxe';
if (['apple', 'stick', 'wood_pickaxe', 'stone_pickaxe', 'iron_pickaxe', 'gold_pickaxe', 'diamond_pickaxe'].includes(type)) {
img.src = `items/${iconName}.png`;
} else {
img.src = `blocks/${iconName}.png`;
}
img.style.position = 'absolute';
img.style.left = `${startX}px`;
img.style.top = `${startY}px`;
img.style.width = '32px';
img.style.height = '32px';
img.style.pointerEvents = 'none';
img.style.zIndex = '1000';
const container = document.querySelector('.mining-container');
container.appendChild(img);
// Find target relative to container
const containerRect = container.getBoundingClientRect();
// Default target (center bottom if slot not found)
let targetX = containerRect.width / 2;
let targetY = containerRect.height - 50;
const slot = document.querySelector(`.inventory-slot[data-type="${type}"]`);
if (slot) {
const rect = slot.getBoundingClientRect();
targetX = rect.left - containerRect.left + rect.width / 2 - 16;
targetY = rect.top - containerRect.top + rect.height / 2 - 16;
}
// Control point for Bezier curve
// "Fly away from inv bar" -> Fly UP first
// We want a point that is higher (lower Y) than both start and end
const cpX = (startX + targetX) / 2 + (Math.random() * 100 - 50);
const cpY = Math.min(startY, targetY) - 150 - (Math.random() * 50);
const startTime = Date.now();
const duration = 600; // ms
function animate() {
const now = Date.now();
const progress = Math.min((now - startTime) / duration, 1);
// Quadratic Bezier
// B(t) = (1-t)^2 * P0 + 2(1-t)t * P1 + t^2 * P2
const t = progress;
const invT = 1 - t;
const x = (invT * invT * startX) + (2 * invT * t * cpX) + (t * t * targetX);
const y = (invT * invT * startY) + (2 * invT * t * cpY) + (t * t * targetY);
img.style.left = `${x}px`;
img.style.top = `${y}px`;
// Scale and opacity effects
if (progress > 0.8) {
img.style.opacity = `${1 - (progress - 0.8) * 5}`; // Fade out in last 20%
img.style.transform = `scale(${1 - (progress - 0.8) * 2.5})`; // Shrink in last 20%
}
if (progress < 1) {
requestAnimationFrame(animate);
} else {
if (img.parentNode) {
img.parentNode.removeChild(img);
}
addToInventory(type, count);
}
}
requestAnimationFrame(animate);
}
});