// Player entity class class Player extends Entity { constructor(x, y, options = {}) { super(ENTITY_TYPES.PLAYER, x, y, { width: 3, // 50% smaller collision box width height: 6, // 50% smaller collision box height ...options }); // Load player sprite this.sprite = new Image(); this.sprite.src = 'sprites/player.png'; // Movement properties this.moveSpeed = 0.03; this.jumpForce = -0.2; this.gravity = 0.02; this.maxVelocity = 0.5; this.friction = 0.9; // State tracking this.isJumping = false; this.direction = 1; // 1 = right, -1 = left this.lastUpdate = performance.now(); this.lastDirection = 1; // Track last direction to prevent unnecessary flipping this.isClimbing = false; // Track climbing state // Player stats this.maxHealth = 100; this.health = 100; this.breakingPower = 1; this.breakingRange = 10; // Increased from 3 to 10 pixels this.isBreaking = false; this.breakingCooldown = 0; this.breakingCooldownMax = 10; // Inventory this.inventory = { sand: 0, water: 0, dirt: 0, stone: 0, wood: 0, grass: 0, seed: 0 }; // Animation properties this.frameWidth = 32; this.frameHeight = 30; this.frameCount = 4; this.currentFrame = 0; this.animationSpeed = 150; // ms per frame this.lastFrameUpdate = 0; this.isMoving = false; this.animationTimer = 0; // Consistent timer for animation } update() { const now = performance.now(); const deltaTime = Math.min(50, now - this.lastUpdate); this.lastUpdate = now; // Apply gravity this.vy += this.gravity; // Cap velocity if (this.vx > this.maxVelocity) this.vx = this.maxVelocity; if (this.vx < -this.maxVelocity) this.vx = -this.maxVelocity; if (this.vy > this.maxVelocity * 2) this.vy = this.maxVelocity * 2; // Apply friction when not actively moving if (!this.isMoving) { this.vx *= this.friction; } // Calculate new position let newX = this.x + this.vx * deltaTime; let newY = this.y + this.vy * deltaTime; // Check for collisions const collisionResult = this.checkCollisions(newX, newY); if (collisionResult.collision) { if (collisionResult.horizontal) { // Try to climb up if there's a 1-pixel step if (this.tryClimbing(newX, newY)) { // Successfully climbed, continue with adjusted position newY -= 1; // Move up one pixel to climb } else { // Can't climb, stop horizontal movement newX = this.x; this.vx = 0; } } if (collisionResult.vertical) { if (this.vy > 0) { this.isJumping = false; } newY = this.y; this.vy = 0; } } // Update position this.x = newX; this.y = newY; // Update breaking cooldown if (this.breakingCooldown > 0) { this.breakingCooldown--; } // Handle breaking action if (this.isBreaking && this.breakingCooldown <= 0) { this.breakBlock(); this.breakingCooldown = this.breakingCooldownMax; } // Update animation this.updateAnimation(deltaTime); // Center camera on player this.centerCamera(); // Update HUD this.updateHUD(); return true; } updateAnimation(deltaTime) { // Update animation timer consistently this.animationTimer += deltaTime; // Only update direction when it actually changes to prevent flipping if (Math.abs(this.vx) > 0.005) { const newDirection = this.vx > 0 ? 1 : -1; if (newDirection !== this.lastDirection) { this.direction = newDirection; this.lastDirection = newDirection; } } } render(ctx, offsetX, offsetY) { const screenX = (this.x - offsetX) * PIXEL_SIZE; const screenY = (this.y - offsetY) * PIXEL_SIZE; if (this.sprite && this.sprite.complete) { // Set pixelated rendering (nearest neighbor) ctx.imageSmoothingEnabled = false; ctx.save(); ctx.translate(screenX, screenY); // Use 50% smaller dimensions for the sprite const spriteDisplayWidth = 12 * (PIXEL_SIZE / 2); // 50% smaller sprite const spriteDisplayHeight = 12 * (PIXEL_SIZE / 2); // 50% smaller sprite // Flip horizontally based on direction if (this.direction < 0) { ctx.scale(-1, 1); } // Draw the correct sprite frame // Center the sprite on the entity position, with y-offset to align feet with collision box // Stretch the sprite vertically to match the collision box height ctx.drawImage( this.sprite, this.currentFrame * this.frameWidth, 0, this.frameWidth, this.frameHeight, -spriteDisplayWidth / 2, -spriteDisplayHeight / 2, // Remove the negative offset that caused levitation spriteDisplayWidth, spriteDisplayHeight * 1.2 // Stretch sprite vertically by 20% to match collision box ); ctx.restore(); // Reset image smoothing for other rendering ctx.imageSmoothingEnabled = true; // Draw collision box in debug mode if (debugMode) { ctx.strokeStyle = '#00ff00'; ctx.lineWidth = 1; ctx.strokeRect( screenX - this.width * PIXEL_SIZE / 2, screenY - this.height * PIXEL_SIZE / 2, this.width * PIXEL_SIZE, this.height * PIXEL_SIZE ); // Also draw sprite boundary in debug mode ctx.strokeStyle = '#ff00ff'; ctx.lineWidth = 1; ctx.strokeRect( screenX - spriteDisplayWidth / 2, screenY - spriteDisplayHeight / 2, // Match the updated sprite drawing position spriteDisplayWidth, spriteDisplayHeight * 1.2 // Match the stretched sprite height ); // Draw a dot at the entity's exact position ctx.fillStyle = '#ffff00'; ctx.beginPath(); ctx.arc(screenX, screenY, 2, 0, Math.PI * 2); ctx.fill(); } } } moveLeft() { this.vx = -this.moveSpeed; this.direction = -1; this.isMoving = true; } moveRight() { this.vx = this.moveSpeed; this.direction = 1; this.isMoving = true; } moveUp() { this.vy = -this.moveSpeed; this.isMoving = true; } moveDown() { this.vy = this.moveSpeed; this.isMoving = true; } stopMoving() { this.isMoving = false; } jump() { if (!this.isJumping) { this.vy = this.jumpForce; this.isJumping = true; } } startBreaking() { this.isBreaking = true; } stopBreaking() { this.isBreaking = false; } breakBlock() { // Get mouse position in world coordinates const worldX = Math.floor(currentMouseX / PIXEL_SIZE) + worldOffsetX; const worldY = Math.floor(currentMouseY / PIXEL_SIZE) + worldOffsetY; // Calculate distance from player to target block const distance = Math.sqrt( Math.pow(worldX - this.x, 2) + Math.pow(worldY - this.y, 2) ); // Only break blocks within range if (distance <= this.breakingRange) { // Get the block type at that position const blockType = getPixel(worldX, worldY); // Only break non-empty blocks that aren't special entities if (blockType !== EMPTY && blockType !== WATER && blockType !== FIRE && blockType !== SQUARE && blockType !== CIRCLE && blockType !== TRIANGLE) { // Add to inventory based on block type this.addToInventory(blockType); // Replace with empty space setPixel(worldX, worldY, EMPTY); // Create a breaking effect (particles) this.createBreakingEffect(worldX, worldY, blockType); } } } addToInventory(blockType) { // Map block type to inventory item switch(blockType) { case SAND: this.inventory.sand++; break; case DIRT: this.inventory.dirt++; break; case STONE: this.inventory.stone++; break; case GRASS: this.inventory.grass++; break; case WOOD: this.inventory.wood++; break; case SEED: case TREE_SEED: this.inventory.seed++; break; } } createBreakingEffect(x, y, blockType) { // Create a simple particle effect at the breaking location // This could be expanded with a proper particle system const numParticles = 5; // For now, we'll just create a visual feedback by setting nearby pixels // to a different color briefly, then clearing them for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (dx === 0 && dy === 0) continue; // Skip if the pixel is not empty if (getPixel(x + dx, y + dy) !== EMPTY) continue; // Set a temporary pixel setPixel(x + dx, y + dy, EMPTY); // Mark the chunk as dirty for rendering const { chunkX, chunkY } = getChunkCoordinates(x + dx, y + dy); const key = getChunkKey(chunkX, chunkY); dirtyChunks.add(key); } } } updateHUD() { // Get or create the HUD container let hudContainer = document.getElementById('player-hud'); if (!hudContainer) { hudContainer = document.createElement('div'); hudContainer.id = 'player-hud'; hudContainer.style.position = 'fixed'; hudContainer.style.bottom = '10px'; hudContainer.style.left = '10px'; hudContainer.style.width = '300px'; hudContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; hudContainer.style.color = 'white'; hudContainer.style.padding = '10px'; hudContainer.style.borderRadius = '5px'; hudContainer.style.fontFamily = 'Arial, sans-serif'; hudContainer.style.zIndex = '1000'; document.body.appendChild(hudContainer); // Create health bar container const healthBarContainer = document.createElement('div'); healthBarContainer.id = 'health-bar-container'; healthBarContainer.style.width = '100%'; healthBarContainer.style.height = '20px'; healthBarContainer.style.backgroundColor = 'rgba(255, 255, 255, 0.3)'; healthBarContainer.style.marginBottom = '10px'; healthBarContainer.style.borderRadius = '3px'; // Create health bar const healthBar = document.createElement('div'); healthBar.id = 'health-bar'; healthBar.style.width = '100%'; healthBar.style.height = '100%'; healthBar.style.backgroundColor = '#4CAF50'; healthBar.style.borderRadius = '3px'; healthBar.style.transition = 'width 0.3s'; healthBarContainer.appendChild(healthBar); hudContainer.appendChild(healthBarContainer); // Create inventory container const inventoryContainer = document.createElement('div'); inventoryContainer.id = 'inventory-container'; inventoryContainer.style.display = 'grid'; inventoryContainer.style.gridTemplateColumns = 'repeat(7, 1fr)'; inventoryContainer.style.gap = '5px'; // Create inventory slots const inventoryItems = ['sand', 'dirt', 'stone', 'grass', 'wood', 'water', 'seed']; inventoryItems.forEach(item => { const slot = document.createElement('div'); slot.id = `inventory-${item}`; slot.className = 'inventory-slot'; slot.style.width = '30px'; slot.style.height = '30px'; slot.style.backgroundColor = 'rgba(255, 255, 255, 0.2)'; slot.style.borderRadius = '3px'; slot.style.display = 'flex'; slot.style.flexDirection = 'column'; slot.style.alignItems = 'center'; slot.style.justifyContent = 'center'; slot.style.fontSize = '10px'; slot.style.position = 'relative'; // Create item icon const icon = document.createElement('div'); icon.style.width = '20px'; icon.style.height = '20px'; icon.style.borderRadius = '3px'; // Set color based on item type switch(item) { case 'sand': icon.style.backgroundColor = '#e6c588'; break; case 'dirt': icon.style.backgroundColor = '#8B4513'; break; case 'stone': icon.style.backgroundColor = '#A9A9A9'; break; case 'grass': icon.style.backgroundColor = '#7CFC00'; break; case 'wood': icon.style.backgroundColor = '#8B5A2B'; break; case 'water': icon.style.backgroundColor = '#4a80f5'; break; case 'seed': icon.style.backgroundColor = '#654321'; break; } // Create count label const count = document.createElement('div'); count.id = `${item}-count`; count.style.position = 'absolute'; count.style.bottom = '2px'; count.style.right = '2px'; count.style.fontSize = '8px'; count.style.fontWeight = 'bold'; count.textContent = '0'; slot.appendChild(icon); slot.appendChild(count); inventoryContainer.appendChild(slot); }); hudContainer.appendChild(inventoryContainer); // Create controls help text const controlsHelp = document.createElement('div'); controlsHelp.style.marginTop = '10px'; controlsHelp.style.fontSize = '10px'; controlsHelp.style.color = '#aaa'; controlsHelp.innerHTML = 'Controls: A/D - Move, W/Space - Jump, E - Break blocks'; hudContainer.appendChild(controlsHelp); } // Update health bar const healthBar = document.getElementById('health-bar'); if (healthBar) { const healthPercent = (this.health / this.maxHealth) * 100; healthBar.style.width = `${healthPercent}%`; // Change color based on health if (healthPercent > 60) { healthBar.style.backgroundColor = '#4CAF50'; // Green } else if (healthPercent > 30) { healthBar.style.backgroundColor = '#FFC107'; // Yellow } else { healthBar.style.backgroundColor = '#F44336'; // Red } } // Update inventory counts for (const [item, count] of Object.entries(this.inventory)) { const countElement = document.getElementById(`${item}-count`); if (countElement) { countElement.textContent = count; } } } // Try to climb up a small step tryClimbing(newX, newY) { const halfWidth = this.width / 2; // Check if there's a solid pixel in front of the player const frontX = newX + (this.direction * halfWidth); const frontY = newY; // Check if there's a solid pixel at the current level if (this.isPixelSolid(frontX, frontY)) { // Check if there's empty space one pixel above if (!this.isPixelSolid(frontX, frontY - 1) && !this.isPixelSolid(this.x, this.y - 1)) { // Check if there's ground to stand on after climbing if (this.isPixelSolid(frontX, frontY + 1)) { this.isClimbing = true; return true; } } } this.isClimbing = false; return false; } centerCamera() { // Get current camera center in world coordinates const cameraWidth = canvas.width / PIXEL_SIZE; const cameraHeight = canvas.height / PIXEL_SIZE; const cameraCenterX = worldOffsetX + cameraWidth / 2; const cameraCenterY = worldOffsetY + cameraHeight / 2; // Calculate distance from player to camera center const distanceX = Math.abs(this.x - cameraCenterX); const distanceY = Math.abs(this.y - cameraCenterY); // Define thresholds for camera movement (percentage of screen size) const thresholdX = cameraWidth * 0.2; // Move when player is 30% away from center const thresholdY = cameraHeight * 0.2; // Only move camera when player gets close to the edge of current view let needsUpdate = false; if (distanceX > thresholdX) { // Calculate target position with chunk-based snapping const chunkSize = CHUNK_SIZE; const playerChunkX = Math.floor(this.x / chunkSize); const targetX = this.x - (canvas.width / PIXEL_SIZE / 2); // Smooth transition to the target position worldOffsetX += (targetX - worldOffsetX) * 0.2; needsUpdate = true; } if (distanceY > thresholdY) { // Calculate target position with chunk-based snapping const chunkSize = CHUNK_SIZE; const playerChunkY = Math.floor(this.y / chunkSize); const targetY = this.y - (canvas.height / PIXEL_SIZE / 2); // Smooth transition to the target position worldOffsetY += (targetY - worldOffsetY) * 0.1; needsUpdate = true; } // Only mark world as moved if we actually updated the camera if (needsUpdate) { worldMoved = true; } } }