544 lines
20 KiB
JavaScript
544 lines
20 KiB
JavaScript
// 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;
|
|
}
|
|
}
|
|
}
|