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= 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 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 = '

Shop

'; // Sell Section const sellDiv = document.createElement('div'); sellDiv.className = 'shop-section'; sellDiv.innerHTML = '

Sell Ores

'; 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 = '

Upgrades

'; 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); } });