const lerp = (start, end, t) => {
return start * (1 - t) + end * t;
};
const seededRandom = (x, y) => {
const a = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453123;
return a - Math.floor(a);
};
const worldToGrid = (x, y) => ({
x: Math.floor(x / CONFIG.display.grid.size),
y: Math.floor(y / CONFIG.display.grid.size)
});
// Helper function to create a villager object
const createVillager = (cellX, cellY) => ({
x: (cellX * CONFIG.display.grid.size) + (CONFIG.display.grid.size / 2),
y: (cellY * CONFIG.display.grid.size) + (CONFIG.display.grid.size / 2),
color: CONFIG.world.villagers.colors[Math.floor(Math.random() * CONFIG.world.villagers.colors.length)],
shape: CONFIG.world.villagers.shapes[Math.floor(Math.random() * CONFIG.world.villagers.shapes.length)],
status: 'lost',
cellX,
cellY,
bobSpeed: 0.005 + Math.random() * 0.005,
bobAmplitude: 2 + Math.random() * 2,
lostBobSpeed: 0.005 + Math.random() * 0.005,
lostBobAmplitude: 2 + Math.random() * 2
});
const generateVillagers = () => {
const villagers = [];
const occupiedCells = new Set();
const gridSize = CONFIG.display.grid.size;
const villageSize = CONFIG.world.village.size;
const worldSize = CONFIG.display.grid.worldSize;
// Place one villager near the village
const nearVillageX = villageSize + Math.floor(Math.random() * 2);
const nearVillageY = villageSize + Math.floor(Math.random() * 2);
villagers.push(createVillager(nearVillageX, nearVillageY));
occupiedCells.add(`${nearVillageX},${nearVillageY}`);
while (villagers.length < CONFIG.world.villagers.total) {
const cellX = villageSize + Math.floor(Math.random() * (worldSize - villageSize));
const cellY = villageSize + Math.floor(Math.random() * (worldSize - villageSize));
const cellKey = `${cellX},${cellY}`;
if (occupiedCells.has(cellKey) || state.collisionMap.has(cellKey)) {
continue;
}
villagers.push(createVillager(cellX, cellY));
occupiedCells.add(cellKey);
}
return villagers;
};
// Refactored drawVillagerShape function
const drawVillagerShape = (ctx, x, y, shape, size) => {
ctx.beginPath();
const shapes = {
square: () => ctx.rect(x - size / 2, y - size / 2, size, size),
triangle: () => {
ctx.moveTo(x, y - size / 2);
ctx.lineTo(x + size / 2, y + size / 2);
ctx.lineTo(x - size / 2, y + size / 2);
},
pentagon: () => {
for (let i = 0; i < 5; i++) {
const angle = (i * 2 * Math.PI / 5) - Math.PI / 2;
ctx.lineTo(x + Math.cos(angle) * size / 2, y + Math.sin(angle) * size / 2);
}
},
hexagon: () => {
for (let i = 0; i < 6; i++) {
const angle = (i * 2 * Math.PI / 6);
ctx.lineTo(x + Math.cos(angle) * size / 2, y + Math.sin(angle) * size / 2);
}
}
};
if (shapes[shape]) shapes[shape]();
ctx.closePath();
};
const CONFIG = {
display: {
fps: 60,
grid: {
size: 100,
color: 'rgba(221, 221, 221, 0.5)',
worldSize: 20,
voidColor: '#e6f3ff'
},
camera: {
deadzoneMultiplierX: 0.6,
deadzoneMultiplierY: 0.6,
ease: 0.08
}
},
effects: {
colors: {
primary: '#4169E1',
secondary: '#1E90FF',
tertiary: '#0000CD',
glow: 'rgba(0, 128, 255, 0.5)',
inner: '#0000CD'
}
},
player: {
size: 30,
speed: 5,
sprintMultiplier: 2,
color: '#111',
strafeKey: ' ',
directionIndicator: {
size: 10,
color: 'rgba(32, 178, 170, 1)'
},
dash: {
duration: 3000, // 3 seconds of use
cooldown: 3000, // 3 second cooldown
exhaustedAt: 0
},
idle: {
startDelay: 1500, // Get board after 1.5 seconds
lookSpeed: 0.001,
lookRadius: 0.4
},
equipment: {
swordUnlockCount: 8, // The number of villagers you need to rescue to unlock the sword
unlockAnimation: {
duration: 1500,
glowColor: 'rgba(255, 215, 0, 0.6)',
messageText: 'Sword Unlocked!',
messageColor: '#FFD700'
}
}
},
sword: {
length: 60,
swingSpeed: 0.6,
colors: null
},
bubble: {
size: 20,
speed: 8,
lifetime: 800,
cooldown: 1000,
arcWidth: Math.PI / 3,
colors: null,
particleEmitRate: 0.3,
fadeExponent: 2.5
},
bubbleParticle: {
lifetime: 700,
speedMultiplier: 0.3,
size: 3
},
defense: {
numLayers: 6,
maxRadiusMultiplier: 2,
baseAlpha: 0.15,
particleCount: 12,
orbitRadiusMultiplier: 0.8,
rotationSpeed: 1.5
},
footprints: {
lifetime: 1000,
spacing: 300,
size: 5
},
world: {
village: {
size: 5,
groundColor: '#f2f2f2'
},
villagers: {
total: 25,
colors: [
'#4B0082', // Indigo
'#483D8B', // DarkSlateBlue
'#6A5ACD', // SlateBlue
'#2F4F4F', // DarkSlateGray
'#363636', // DarkGray
'#4682B4' // SteelBlue
],
shapes: ['square', 'triangle', 'pentagon', 'hexagon'],
size: 30,
rescueMessage: 'Congratulations! You rescued all the villagers!',
messageDisplayTime: 5000
},
wilderness: {
groundColor: '#e6ffe6',
vegetation: {
tree: {
frequency: 0.1, // Chance per grid cell
colors: [
'rgba(100, 144, 79, 1)',
'rgba(85, 128, 64, 1)',
'rgba(128, 164, 98, 1)',
'rgba(110, 139, 61, 1)',
'rgba(95, 133, 73, 1)',
'rgba(248, 239, 58, 1)'
],
size: { min: 20, max: 30 }
},
mushroom: {
frequency: 0.03,
colors: [
'rgba(242, 63, 63, 0.25)',
'rgba(245, 131, 148, 0.25)',
'rgba(255, 119, 65, 0.25)',
'rgba(193, 97, 1, 0.5)'
],
pattern: {
size: 3,
spacing: 10,
margin: 10,
variation: 0.5,
offset: 0.5,
singleColor: 0.7 // % chance that all dots in a cell will be the same color
}
},
flower: {
frequency: 0.05,
colors: [
'rgba(255, 105, 180, 0.3)',
'rgba(221, 160, 221, 0.3)',
'rgba(147, 112, 219, 0.3)'
],
pattern: {
size: 12,
spacing: 16,
rotation: Math.PI / 6, // rotation of pattern
margin: 10,
variation: 0.2
}
},
grass: {
frequency: 0.12,
colors: ['rgba(28, 48, 32, 0.25)'],
hatch: {
spacing: 8,
length: 6,
angle: Math.PI / 4,
variation: 0.4, // we can have some randomness, as a treat
margin: 4
},
spreadFactor: 0.6
}
}
}
},
collision: {
enabled: true,
vegetation: {
tree: {
enabled: true,
sizeMultiplier: 1.0
}
}
},
enemies: {
size: {
min: 15,
max: 20
},
colors: {
active: {
min: {
red: 150,
green: 0,
blue: 0
},
max: {
red: 255,
green: 100,
blue: 0
}
},
defeated: 'rgb(100, 100, 100)'
},
patrol: {
radius: {
min: 100,
max: 200
},
speed: {
base: 100 // PPS, pixels per second
}
},
chase: {
range: 2,
speedMultiplier: 1.5
},
return: {
speedMultiplier: 1.25
}
}
};
CONFIG.sword.colors = CONFIG.effects.colors;
CONFIG.bubble.colors = CONFIG.effects.colors;
let GAME_WIDTH = window.innerWidth;
let GAME_HEIGHT = window.innerHeight;
let lastFrameTime = 0;
let animationTime = 0;
const FRAME_TIME = 1000 / CONFIG.display.fps;
const CAMERA_DEADZONE_X = GAME_WIDTH * CONFIG.display.camera.deadzoneMultiplierX;
const CAMERA_DEADZONE_Y = GAME_HEIGHT * CONFIG.display.camera.deadzoneMultiplierY;
const createInitialState = () => ({
player: {
x: CONFIG.player.size,
y: CONFIG.player.size,
isDefending: false,
direction: { x: 0, y: -1 },
swordAngle: 0,
isSwinging: false,
equipment: 'unarmed',
bubbles: [],
bubbleParticles: [],
lastBubbleTime: 0,
dashStartTime: 0,
isDashing: false,
dashExhausted: false,
lastInputTime: 0,
baseDirection: { x: 0, y: -1 },
lastDashEnd: 0,
swordUnlocked: false,
rescuedCount: 0,
hp: 15,
maxHp: 15,
isInvulnerable: false,
invulnerableUntil: 0,
isDead: false,
diamonds: 0,
lastRenderedCircles: 4
},
particles: [],
footprints: [],
lastFootprintTime: 0,
camera: {
x: 0,
y: 0,
targetX: 0,
targetY: 0
},
collisionMap: new Map(),
villagers: [],
gameComplete: false,
gameCompleteMessageShown: false,
enemies: [],
diamonds: []
});
let state = createInitialState();
const keys = new Set();
const handleKeyDown = (e) => {
if (state.player.isDead) {
if (e.code === 'Space') {
window.location.reload();
}
return;
}
keys.add(e.key);
if (e.key === 'z' && !state.player.isDefending) {
Object.assign(state, inputHandlers.handleAttack(state, animationTime));
}
if (e.key === 'e') {
Object.assign(state, inputHandlers.handleEquipmentSwitch(state));
}
if (e.key === 'x') {
Object.assign(state, {
...state,
player: {
...state.player,
isDefending: true
}
});
}
if (e.key === 'c') {
const cellInfo = getCellInfo(state.player.x, state.player.y);
console.group('Current Cell Information:');
console.log(`Position: (${cellInfo.position.cellX}, ${cellInfo.position.cellY})`);
console.log(`Biome: ${cellInfo.biome}`);
console.log('Vegetation:');
const presentVegetation = Object.entries(cellInfo.vegetation)
.filter(([type, present]) => present)
.map(([type]) => type);
if (presentVegetation.length === 0) {
console.log('none');
} else {
presentVegetation.forEach(type => console.log(type));
}
console.groupEnd();
}
state.player.lastInputTime = animationTime;
};
const handleKeyUp = (e) => {
keys.delete(e.key);
if (e.key === 'x') {
Object.assign(state, {
...state,
player: {
...state.player,
isDefending: false
}
});
}
};
const inputHandlers = {
handleAttack: (state, animationTime) => {
if (state.player.isDefending) return state;
if (state.player.equipment === 'sword') {
if (!state.player.swordUnlocked) return state; // can't swing a sword you haven't earned yet!
if (!state.player.isSwinging) {
return {
...state,
player: {
...state.player,
isSwinging: true,
swordAngle: Math.atan2(state.player.direction.y, state.player.direction.x) - Math.PI / 2
}
};
}
} else if (state.player.equipment === 'unarmed') {
return createBubbleAttack(state, animationTime);
}
return state;
},
handleEquipmentSwitch: (state) => {
if (!state.player.swordUnlocked) return state; // can't switch to a sword you haven't earned yet!
const equipment = ['sword', 'unarmed'];
const currentIndex = equipment.indexOf(state.player.equipment);
return {
...state,
player: {
...state.player,
equipment: equipment[(currentIndex + 1) % equipment.length]
}
};
}
};
const calculateMovement = (keys) => {
let dx = 0;
let dy = 0;
if (keys.has('ArrowLeft')) dx -= 1;
if (keys.has('ArrowRight')) dx += 1;
if (keys.has('ArrowUp')) dy -= 1;
if (keys.has('ArrowDown')) dy += 1;
if (dx === 0 && dy === 0) {
return { moving: false };
}
state.player.lastInputTime = animationTime;
const length = Math.sqrt(dx * dx + dy * dy);
const normalizedDx = dx / length;
const normalizedDy = dy / length;
const isStrafing = keys.has(CONFIG.player.strafeKey);
const newDirection = isStrafing ?
{ ...state.player.direction } : // strafe
{ x: normalizedDx, y: normalizedDy }; // normal
if (!isStrafing) {
state.player.baseDirection = { ...newDirection };
}
return {
moving: true,
dx: normalizedDx,
dy: normalizedDy,
direction: newDirection
};
};
const isPositionBlocked = (x, y) => {
const cell = worldToGrid(x, y);
const key = `${cell.x},${cell.y}`;
if (!state.collisionMap.has(key)) return false;
const obstacle = state.collisionMap.get(key);
const obstacleRadius = CONFIG.player.size / 2;
// check distance from the center of a grid cell
const dx = x - obstacle.x;
const dy = y - obstacle.y;
const distanceSquared = dx * dx + dy * dy;
return distanceSquared < obstacleRadius * obstacleRadius;
};
const addToCollisionMap = (cellX, cellY, type) => {
const key = `${cellX},${cellY}`;
state.collisionMap.set(key, {
type,
x: (cellX * CONFIG.display.grid.size) + (CONFIG.display.grid.size / 2),
y: (cellY * CONFIG.display.grid.size) + (CONFIG.display.grid.size / 2)
});
};
const movementSystem = {
updatePosition: (state, keys) => {
if (state.player.isDefending) return state;
const movement = calculateMovement(keys);
if (!movement.moving) {
return {
...state,
player: {
...state.player,
isDashing: false,
dashStartTime: 0
}
};
}
const wantsToDash = keys.has('Shift');
const canDash = !state.player.dashExhausted &&
(animationTime - state.player.lastDashEnd) >= CONFIG.player.dash.cooldown;
let isDashing = false;
let dashExhausted = state.player.dashExhausted;
let dashStartTime = state.player.dashStartTime;
let lastDashEnd = state.player.lastDashEnd;
if (wantsToDash && canDash) {
if (!state.player.isDashing) {
dashStartTime = animationTime;
}
isDashing = true;
// Are you tired of dashing?
if (animationTime - dashStartTime >= CONFIG.player.dash.duration) {
isDashing = false;
dashExhausted = true;
lastDashEnd = animationTime;
}
} else if (state.player.dashExhausted &&
(animationTime - state.player.lastDashEnd >= CONFIG.player.dash.cooldown)) {
dashExhausted = false;
}
const speed = isDashing ?
CONFIG.player.speed * CONFIG.player.sprintMultiplier :
CONFIG.player.speed;
const timeSinceLastFootprint = animationTime - state.lastFootprintTime;
const currentSpacing = isDashing ?
CONFIG.footprints.spacing * CONFIG.player.sprintMultiplier :
CONFIG.footprints.spacing;
let newFootprints = state.footprints;
if (timeSinceLastFootprint > currentSpacing / speed) {
const offset = (Math.random() - 0.5) * 6;
const perpX = -movement.direction.y * offset;
const perpY = movement.direction.x * offset;
newFootprints = [...state.footprints, createFootprint(
state.player.x + perpX,
state.player.y + perpY,
Math.atan2(movement.dy, movement.dx)
)];
}
const worldBounds = {
min: 0,
max: CONFIG.display.grid.size * CONFIG.display.grid.worldSize
};
const newX = state.player.x + movement.dx * speed;
const newY = state.player.y + movement.dy * speed;
const clampedX = Math.max(worldBounds.min, Math.min(worldBounds.max, newX));
const clampedY = Math.max(worldBounds.min, Math.min(worldBounds.max, newY));
const playerRadius = CONFIG.player.size / 2;
// const checkPoints = [
// { x: newX - playerRadius, y: newY - playerRadius }, // Top-left
// { x: newX + playerRadius, y: newY - playerRadius }, // Top-right
// { x: newX - playerRadius, y: newY + playerRadius }, // Bottom-left
// { x: newX + playerRadius, y: newY + playerRadius } // Bottom-right
// ];
const wouldCollide = checkCollision(newX, newY, playerRadius * 0.8);
const finalX = wouldCollide ? state.player.x : clampedX;
const finalY = wouldCollide ? state.player.y : clampedY;
return {
...state,
player: {
...state.player,
x: finalX,
y: finalY,
direction: movement.direction,
isDashing,
dashStartTime,
dashExhausted,
lastDashEnd
},
footprints: newFootprints,
lastFootprintTime: timeSinceLastFootprint > currentSpacing / speed ?
animationTime : state.lastFootprintTime
};
}
};
const updateBubble = (bubble, animationTime) => {
const age = animationTime - bubble.createdAt;
const ageRatio = age / CONFIG.bubble.lifetime;
const speedMultiplier = Math.pow(1 - ageRatio, 0.5);
return {
...bubble,
x: bubble.x + bubble.dx * speedMultiplier,
y: bubble.y + bubble.dy * speedMultiplier
};
};
const generateBubbleParticles = (bubble, animationTime) => {
const age = animationTime - bubble.createdAt;
const ageRatio = age / CONFIG.bubble.lifetime;
if (Math.random() >= CONFIG.bubble.particleEmitRate * (1 - ageRatio)) {
return [];
}
const trailDistance = Math.random() * 20;
const particleX = bubble.x - bubble.dx * (trailDistance / CONFIG.bubble.speed);
const particleY = bubble.y - bubble.dy * (trailDistance / CONFIG.bubble.speed);
const particleAngle = bubble.angle + (Math.random() - 0.5) * CONFIG.bubble.arcWidth * 2;
const spreadSpeed = CONFIG.bubble.speed * 0.2 * (1 - ageRatio);
const spreadAngle = Math.random() * Math.PI * 2;
const speedMultiplier = Math.pow(1 - ageRatio, 0.5);
return [{
x: particleX,
y: particleY,
dx: (Math.cos(particleAngle) * CONFIG.bubble.speed * CONFIG.bubbleParticle.speedMultiplier * speedMultiplier) +
(Math.cos(spreadAngle) * spreadSpeed),
dy: (Math.sin(particleAngle) * CONFIG.bubble.speed * CONFIG.bubbleParticle.speedMultiplier * speedMultiplier) +
(Math.sin(spreadAngle) * spreadSpeed),
size: CONFIG.bubbleParticle.size * (0.5 + Math.random() * 0.5),
createdAt: animationTime
}];
};
const updateBubbleParticles = (particles, animationTime) => {
return particles.filter(particle => {
const age = animationTime - particle.createdAt;
return age < CONFIG.bubbleParticle.lifetime;
}).map(particle => ({
...particle,
x: particle.x + particle.dx,
y: particle.y + particle.dy
}));
};
const createBubbleAttack = (state, animationTime) => {
const timeSinceLastBubble = animationTime - state.player.lastBubbleTime;
if (timeSinceLastBubble < CONFIG.bubble.cooldown) return state;
const angle = Math.atan2(state.player.direction.y, state.player.direction.x);
const bubble = {
x: state.player.x,
y: state.player.y,
dx: state.player.direction.x * CONFIG.bubble.speed,
dy: state.player.direction.y * CONFIG.bubble.speed,
angle: angle,
createdAt: animationTime,
size: CONFIG.bubble.size * (0.8 + Math.random() * 0.4)
};
return {
...state,
player: {
...state.player,
bubbles: [...state.player.bubbles, bubble],
lastBubbleTime: animationTime
}
};
};
const checkEnemyCollision = (enemy, sourceX, sourceY, range, damage = 0) => {
if (enemy.stunned) return false;
const dx = enemy.x - sourceX;
const dy = enemy.y - sourceY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < enemy.size + range) {
const knockbackAngle = Math.atan2(dy, dx);
enemySystem.handleEnemyDamage(enemy, damage, 1, knockbackAngle);
return true;
}
return false;
};
const weaponSystems = {
updateBubbles: (state, animationTime) => {
const updatedBubbles = state.player.bubbles
.filter(bubble => animationTime - bubble.createdAt < CONFIG.bubble.lifetime)
.map(bubble => {
state.enemies.forEach(enemy => {
checkEnemyCollision(enemy, bubble.x, bubble.y, CONFIG.bubble.size, 0);
});
return updateBubble(bubble, animationTime);
});
const newParticles = updatedBubbles
.flatMap(bubble => generateBubbleParticles(bubble, animationTime));
return {
...state,
player: {
...state.player,
bubbles: updatedBubbles,
bubbleParticles: [
...updateBubbleParticles(state.player.bubbleParticles, animationTime),
...newParticles
]
}
};
},
updateSwordSwing: (state) => {
if (!state.player.isSwinging) return state;
const newAngle = state.player.swordAngle + CONFIG.sword.swingSpeed;
const swingComplete = newAngle > Math.atan2(state.player.direction.y, state.player.direction.x) + Math.PI / 2;
const updatedEnemies = state.enemies.map(enemy => {
checkEnemyCollision(enemy, state.player.x, state.player.y, CONFIG.sword.length, 1);
return enemy;
});
return {
...state,
player: {
...state.player,
swordAngle: newAngle,
isSwinging: !swingComplete
},
enemies: updatedEnemies
};
}
};
const createParticle = (x, y, angle) => ({
x,
y,
angle,
createdAt: animationTime,
lifetime: CONFIG.swordParticles.lifetime,
speed: CONFIG.swordParticles.speed * (0.5 + Math.random() * 0.5),
size: CONFIG.swordParticles.size.min + Math.random() * (CONFIG.swordParticles.size.max - CONFIG.swordParticles.size.min)
});
const createFootprint = (x, y, direction) => ({
x,
y,
direction,
createdAt: animationTime,
size: CONFIG.footprints.size * (0.8 + Math.random() * 0.4),
offset: (Math.random() - 0.5) * 5
});
const renderPlayer = () => {
ctx.save();
state.player.bubbleParticles.forEach(particle => {
const age = (animationTime - particle.createdAt) / CONFIG.bubbleParticle.lifetime;
const alpha = (1 - age) * 0.8;
ctx.fillStyle = CONFIG.effects.colors.glow.replace('0.25)', `${alpha * 0.3})`); // Outer glow
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size * 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = CONFIG.effects.colors.secondary.replace('rgb', 'rgba').replace(')', `, ${alpha})`); // Core
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size * 1.5, 0, Math.PI * 2);
ctx.fill();
});
state.player.bubbles.forEach(bubble => {
const age = (animationTime - bubble.createdAt) / CONFIG.bubble.lifetime;
const alpha = Math.pow(1 - age, CONFIG.bubble.fadeExponent);
const expandedSize = bubble.size * (1 + age * 2);
ctx.save();
ctx.translate(bubble.x, bubble.y);
ctx.rotate(bubble.angle);
ctx.beginPath();
ctx.arc(0, 0, expandedSize * 1.5,
-CONFIG.bubble.arcWidth * (1 + age * 0.5),
CONFIG.bubble.arcWidth * (1 + age * 0.5),
false
);
ctx.lineCap = 'round';
ctx.lineWidth = expandedSize * 0.5 * (1 - age * 0.3);
ctx.strokeStyle = CONFIG.bubble.colors.glow.replace(')', `, ${alpha * 0.5})`);
ctx.stroke();
const gradient = ctx.createLinearGradient(
-expandedSize, 0,
expandedSize, 0
);
gradient.addColorStop(0, CONFIG.bubble.colors.primary.replace('rgb', 'rgba').replace(')', `, ${alpha})`));
gradient.addColorStop(0.6, CONFIG.bubble.colors.secondary.replace('rgb', 'rgba').replace(')', `, ${alpha})`));
gradient.addColorStop(1, CONFIG.bubble.colors.tertiary.replace('rgb', 'rgba').replace(')', `, ${alpha})`));
ctx.beginPath();
ctx.arc(0, 0, expandedSize,
-CONFIG.bubble.arcWidth * 0.8 * (1 + age * 0.5),
CONFIG.bubble.arcWidth * 0.8 * (1 + age * 0.5),
false
);
ctx.lineWidth = expandedSize * 0.3 * (1 - age * 0.3);
ctx.strokeStyle = gradient;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, expandedSize * 0.9,
-CONFIG.bubble.arcWidth * 0.6 * (1 + age * 0.5),
CONFIG.bubble.arcWidth * 0.6 * (1 + age * 0.5),
false
);
ctx.lineWidth = expandedSize * 0.1 * (1 - age * 0.3);
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.8})`;
ctx.stroke();
ctx.restore();
});
if (state.player.isSwinging && state.player.equipment === 'sword') {
const blurSteps = 12;
const blurSpread = 0.2;
for (let i = 0; i < blurSteps; i++) {
const alpha = 0.35 - (i * 0.02);
const angleOffset = -blurSpread * i;
ctx.strokeStyle = `rgba(30, 144, 255, ${alpha})`;
ctx.lineWidth = 4 + (blurSteps - i);
ctx.beginPath();
ctx.moveTo(state.player.x, state.player.y);
ctx.lineTo(
state.player.x + Math.cos(state.player.swordAngle + angleOffset) * CONFIG.sword.length,
state.player.y + Math.sin(state.player.swordAngle + angleOffset) * CONFIG.sword.length
);
ctx.stroke();
}
const gradient = ctx.createLinearGradient(
state.player.x,
state.player.y,
state.player.x + Math.cos(state.player.swordAngle) * CONFIG.sword.length,
state.player.y + Math.sin(state.player.swordAngle) * CONFIG.sword.length
);
gradient.addColorStop(0, CONFIG.sword.colors.primary);
gradient.addColorStop(0.6, CONFIG.sword.colors.secondary);
gradient.addColorStop(1, CONFIG.sword.colors.tertiary);
ctx.strokeStyle = CONFIG.sword.colors.glow;
ctx.lineWidth = 10;
ctx.beginPath();
ctx.moveTo(state.player.x, state.player.y);
ctx.lineTo(
state.player.x + Math.cos(state.player.swordAngle) * CONFIG.sword.length,
state.player.y + Math.sin(state.player.swordAngle) * CONFIG.sword.length
);
ctx.stroke();
ctx.strokeStyle = gradient;
ctx.lineWidth = 6;
ctx.stroke();
}
if (state.player.isDefending) {
const numLayers = CONFIG.defense.numLayers;
const maxRadius = CONFIG.player.size * CONFIG.defense.maxRadiusMultiplier;
const baseAlpha = CONFIG.defense.baseAlpha;
for (let i = numLayers - 1; i >= 0; i--) {
const radius = CONFIG.player.size / 2 + (maxRadius - CONFIG.player.size / 2) * (i / numLayers);
const alpha = baseAlpha * (1 - i / numLayers);
const pulseOffset = Math.sin(Date.now() / 500) * 3;
const glowGradient = ctx.createRadialGradient(
state.player.x, state.player.y, radius - 5,
state.player.x, state.player.y, radius + pulseOffset
);
glowGradient.addColorStop(0, `rgba(30, 144, 255, ${alpha})`);
glowGradient.addColorStop(1, 'rgba(30, 144, 255, 0)');
ctx.beginPath();
ctx.arc(state.player.x, state.player.y, radius + pulseOffset, 0, Math.PI * 2);
ctx.fillStyle = glowGradient;
ctx.fill();
}
const mainAuraGradient = ctx.createRadialGradient(
state.player.x, state.player.y, CONFIG.player.size / 2 - 2,
state.player.x, state.player.y, CONFIG.player.size / 2 + 8
);
mainAuraGradient.addColorStop(0, 'rgba(30, 144, 255, 0.3)');
mainAuraGradient.addColorStop(0.5, 'rgba(30, 144, 255, 0.2)');
mainAuraGradient.addColorStop(1, 'rgba(30, 144, 255, 0)');
ctx.beginPath();
ctx.arc(state.player.x, state.player.y, CONFIG.player.size / 2 + 8, 0, Math.PI * 2);
ctx.fillStyle = mainAuraGradient;
ctx.fill();
ctx.beginPath();
ctx.arc(state.player.x, state.player.y, CONFIG.player.size / 2, 0, Math.PI * 2);
ctx.fillStyle = '#111';
ctx.fill();
ctx.beginPath();
ctx.arc(state.player.x, state.player.y, CONFIG.player.size / 2, 0, Math.PI * 2);
ctx.strokeStyle = CONFIG.sword.colors.secondary;
ctx.lineWidth = 3;
ctx.stroke();
ctx.beginPath();
ctx.arc(state.player.x, state.player.y, CONFIG.player.size / 2 - 3, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(30, 144, 255, 0.3)';
ctx.lineWidth = 2;
ctx.stroke();
const numParticles = CONFIG.defense.particleCount;
const baseOrbitRadius = CONFIG.player.size * CONFIG.defense.orbitRadiusMultiplier;
const rotationSpeed = CONFIG.defense.rotationSpeed;
for (let i = 0; i < numParticles; i++) {
const radialOffset = Math.sin(animationTime * 0.002 + i * 0.5) * 4;
const orbitRadius = baseOrbitRadius + radialOffset;
const angle = (i / numParticles) * Math.PI * 2 + animationTime * rotationSpeed * 0.001;
const x = state.player.x + Math.cos(angle) * orbitRadius;
const y = state.player.y + Math.sin(angle) * orbitRadius;
const size = 2 + Math.sin(animationTime * 0.003 + i * 0.8) * 1.5;
const baseAlpha = 0.6 + Math.sin(animationTime * 0.002 + i) * 0.2;
ctx.beginPath();
ctx.arc(x, y, size * 2, 0, Math.PI * 2);
ctx.fillStyle = `rgba(30, 144, 255, ${baseAlpha * 0.3})`;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(135, 206, 250, ${baseAlpha})`;
ctx.fill();
if (i > 0) {
const prevAngle = ((i - 1) / numParticles) * Math.PI * 2 + animationTime * rotationSpeed * 0.001;
const prevX = state.player.x + Math.cos(prevAngle) * orbitRadius;
const prevY = state.player.y + Math.sin(prevAngle) * orbitRadius;
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(x, y);
ctx.strokeStyle = `rgba(30, 144, 255, ${baseAlpha * 0.2})`;
ctx.lineWidth = 1;
ctx.stroke();
}
}
// Draw the eyeball...square
const dotSize = CONFIG.player.directionIndicator.size;
// const timeSinceLastBubble = animationTime - state.player.lastBubbleTime;
// const cooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1);
const dotOpacity = getDotOpacity(state, animationTime);
ctx.fillStyle = CONFIG.player.directionIndicator.color.replace(
'1)',
`${dotOpacity})`
);
ctx.fillRect(
state.player.x - dotSize/2,
state.player.y - dotSize/2,
dotSize,
dotSize
);
} else {
ctx.fillStyle = CONFIG.player.color;
ctx.fillRect(
state.player.x - CONFIG.player.size / 2,
state.player.y - CONFIG.player.size / 2,
CONFIG.player.size,
CONFIG.player.size
);
// direction indicator square with cooldown opacity
const dotSize = CONFIG.player.directionIndicator.size;
const dotDistance = CONFIG.player.size / 3;
const dotX = state.player.x + state.player.direction.x * dotDistance;
const dotY = state.player.y + state.player.direction.y * dotDistance;
const timeSinceLastBubble = animationTime - state.player.lastBubbleTime;
// const cooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1);
const dotOpacity = getDotOpacity(state, animationTime);
ctx.fillStyle = CONFIG.player.directionIndicator.color.replace('1)', `${dotOpacity})`);
ctx.fillRect(
dotX - dotSize/2,
dotY - dotSize/2,
dotSize,
dotSize
);
}
ctx.restore();
};
const renderPlayerHUD = (ctx) => {
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const circleRadius = 15;
const circleSpacing = 40;
const startX = 30;
const startY = 30;
const totalCircles = 5;
const hpPerCircle = state.player.maxHp / totalCircles;
const currentFilledCircles = Math.ceil(state.player.hp / hpPerCircle);
if (currentFilledCircles < state.player.lastRenderedCircles) {
const circleIndex = currentFilledCircles;
const particleX = startX + circleIndex * circleSpacing;
createHealthCircleExplosion(particleX, startY);
}
state.player.lastRenderedCircles = currentFilledCircles;
for (let i = 0; i < totalCircles; i++) {
const circleX = startX + i * circleSpacing;
const circleY = startY;
const circleStartHp = i * hpPerCircle;
const circleEndHp = (i + 1) * hpPerCircle;
let fillAmount = 1;
if (state.player.hp <= circleStartHp) {
fillAmount = 0;
} else if (state.player.hp < circleEndHp) {
fillAmount = (state.player.hp - circleStartHp) / hpPerCircle;
}
ctx.beginPath();
ctx.arc(circleX, circleY, circleRadius, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 2;
ctx.stroke();
if (fillAmount > 0) {
const gradient = ctx.createRadialGradient(
circleX, circleY, 0,
circleX, circleY, circleRadius
);
gradient.addColorStop(0, 'rgba(255, 0, 0, 0.8)');
gradient.addColorStop(0.7, 'rgba(200, 0, 0, 0.6)');
gradient.addColorStop(1, 'rgba(150, 0, 0, 0.4)');
ctx.beginPath();
ctx.arc(circleX, circleY, circleRadius * 0.9, 0, Math.PI * 2 * fillAmount);
ctx.lineTo(circleX, circleY);
ctx.fillStyle = gradient;
ctx.fill();
}
}
ctx.restore();
};
const createHealthCircleExplosion = (screenX, screenY) => {
const numParticles = 20;
const colors = [
'rgba(255, 0, 0, 0.8)',
'rgba(200, 0, 0, 0.6)',
'rgba(150, 0, 0, 0.4)'
];
const worldX = screenX - state.camera.x;
const worldY = screenY - state.camera.y;
for (let i = 0; i < numParticles; i++) {
const angle = (i / numParticles) * Math.PI * 2;
const speed = 2 + Math.random() * 3;
const size = 2 + Math.random() * 3;
const color = colors[Math.floor(Math.random() * colors.length)];
state.particles.push({
x: worldX,
y: worldY,
dx: Math.cos(angle) * speed,
dy: Math.sin(angle) * speed,
size: size,
color: color,
lifetime: 1000,
createdAt: animationTime
});
}
};
const render = () => {
ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
const screenCenterX = -state.camera.x + GAME_WIDTH / 2;
const screenCenterY = -state.camera.y + GAME_HEIGHT / 2;
const distX = state.player.x - screenCenterX;
const distY = state.player.y - screenCenterY;
if (Math.abs(distX) > CAMERA_DEADZONE_X / 2) {
const bufferX = (GAME_WIDTH - CAMERA_DEADZONE_X) / 2;
const targetOffsetX = distX > 0 ? GAME_WIDTH - bufferX : bufferX;
state.camera.targetX = -(state.player.x - targetOffsetX);
}
if (Math.abs(distY) > CAMERA_DEADZONE_Y / 2) {
const bufferY = (GAME_HEIGHT - CAMERA_DEADZONE_Y) / 2;
const targetOffsetY = distY > 0 ? GAME_HEIGHT - bufferY : bufferY;
state.camera.targetY = -(state.player.y - targetOffsetY);
}
state.camera.x = lerp(state.camera.x, state.camera.targetX, CONFIG.display.camera.ease);
state.camera.y = lerp(state.camera.y, state.camera.targetY, CONFIG.display.camera.ease);
ctx.save();
ctx.translate(state.camera.x, state.camera.y);
const gridSize = CONFIG.display.grid.size;
const worldSize = gridSize * CONFIG.display.grid.worldSize;
const villageSize = CONFIG.world.village.size * gridSize;
const startX = Math.floor((-state.camera.x) / gridSize) * gridSize;
const startY = Math.floor((-state.camera.y) / gridSize) * gridSize;
const endX = startX + GAME_WIDTH + gridSize;
const endY = startY + GAME_HEIGHT + gridSize;
ctx.fillStyle = CONFIG.display.grid.voidColor;
ctx.fillRect(
startX, startY,
endX - startX, endY - startY
);
// First draw the wilderness ground for the whole world
ctx.fillStyle = CONFIG.world.wilderness.groundColor;
ctx.fillRect(0, 0, worldSize, worldSize);
// Then draw the village
ctx.fillStyle = CONFIG.world.village.groundColor;
ctx.fillRect(0, 0, villageSize, villageSize);
const shoreWidth = 60;
const shoreColor = 'rgba(179, 220, 255, 0.3)';
// FIXME: There is likely a way to do this all at once, but this was easy
// Top shore
const topShore = ctx.createLinearGradient(0, 0, 0, shoreWidth);
topShore.addColorStop(0, shoreColor);
topShore.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = topShore;
ctx.fillRect(0, 0, worldSize, shoreWidth);
// Bottom shore
const bottomShore = ctx.createLinearGradient(0, worldSize - shoreWidth, 0, worldSize);
bottomShore.addColorStop(0, 'rgba(255, 255, 255, 0)');
bottomShore.addColorStop(1, shoreColor);
ctx.fillStyle = bottomShore;
ctx.fillRect(0, worldSize - shoreWidth, worldSize, shoreWidth);
// Left shore
const leftShore = ctx.createLinearGradient(0, 0, shoreWidth, 0);
leftShore.addColorStop(0, shoreColor);
leftShore.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = leftShore;
ctx.fillRect(0, 0, shoreWidth, worldSize);
// Right shore
const rightShore = ctx.createLinearGradient(worldSize - shoreWidth, 0, worldSize, 0);
rightShore.addColorStop(0, 'rgba(255, 255, 255, 0)');
rightShore.addColorStop(1, shoreColor);
ctx.fillStyle = rightShore;
ctx.fillRect(worldSize - shoreWidth, 0, shoreWidth, worldSize);
ctx.strokeStyle = CONFIG.display.grid.color;
ctx.lineWidth = 1;
for (let x = 0; x < worldSize; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, worldSize);
ctx.stroke();
}
for (let y = 0; y < worldSize; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(worldSize, y);
ctx.stroke();
}
// Now add vegetation
for (let x = startX; x < endX; x += gridSize) {
for (let y = startY; y < endY; y += gridSize) {
if (x >= worldSize || y >= worldSize) continue;
if (x < villageSize && y < villageSize) continue;
const cellX = Math.floor(x / gridSize);
const cellY = Math.floor(y / gridSize);
if (cellX < 0 || cellY < 0 || cellX >= CONFIG.display.grid.worldSize || cellY >= CONFIG.display.grid.worldSize) continue;
const random = seededRandom(cellX, cellY);
// Trees
if (random < CONFIG.world.wilderness.vegetation.tree.frequency) {
const size = CONFIG.world.wilderness.vegetation.tree.size;
const treeSize = size.min + seededRandom(cellX * 2, cellY * 2) * (size.max - size.min);
// Add tree to collision map
if (CONFIG.collision.enabled && CONFIG.collision.vegetation.tree.enabled) {
addToCollisionMap(cellX, cellY, 'tree');
}
const sides = Math.floor(10 + seededRandom(cellX * 3, cellY * 3) * 13); // 10 to 22 sides
const colorIndex = Math.floor(seededRandom(cellX * 4, cellY * 4) * CONFIG.world.wilderness.vegetation.tree.colors.length);
ctx.fillStyle = CONFIG.world.wilderness.vegetation.tree.colors[colorIndex];
ctx.beginPath();
for (let i = 0; i < sides; i++) {
const angle = (i / sides) * Math.PI * 2;
const radiusVariation = 0.8 + seededRandom(cellX * i, cellY * i) * 0.4;
const pointRadius = treeSize * radiusVariation;
const px = x + gridSize/2 + Math.cos(angle) * pointRadius;
const py = y + gridSize/2 + Math.sin(angle) * pointRadius;
if (i === 0) {
ctx.moveTo(px, py);
} else {
ctx.lineTo(px, py);
}
}
ctx.closePath();
ctx.fill();
}
// Mushrooms
else if (random < CONFIG.world.wilderness.vegetation.tree.frequency +
CONFIG.world.wilderness.vegetation.mushroom.frequency) {
const config = CONFIG.world.wilderness.vegetation.mushroom;
const useSingleColor = seededRandom(cellX * 31, cellY * 31) < config.pattern.singleColor;
const cellColorIndex = Math.floor(seededRandom(cellX * 13, cellY * 13) * config.colors.length);
for (let i = config.pattern.margin; i < gridSize - config.pattern.margin; i += config.pattern.spacing) {
for (let j = config.pattern.margin; j < gridSize - config.pattern.margin; j += config.pattern.spacing) {
const offsetX = (Math.floor(j / config.pattern.spacing) % 2) *
(config.pattern.spacing * config.pattern.offset);
const px = x + i + offsetX;
const py = y + j;
const variation = {
x: (seededRandom(cellX * i, cellY * j) - 0.5) * config.pattern.variation * config.pattern.spacing,
y: (seededRandom(cellX * j, cellY * i) - 0.5) * config.pattern.variation * config.pattern.spacing
};
const colorIndex = useSingleColor ? cellColorIndex :
Math.floor(seededRandom(cellX * i, cellY * j) * config.colors.length);
ctx.fillStyle = config.colors[colorIndex];
ctx.beginPath();
ctx.arc(
px + variation.x,
py + variation.y,
config.pattern.size,
0, Math.PI * 2
);
ctx.fill();
}
}
}
// Flowers
else if (random < CONFIG.world.wilderness.vegetation.tree.frequency +
CONFIG.world.wilderness.vegetation.mushroom.frequency +
CONFIG.world.wilderness.vegetation.flower.frequency) {
const config = CONFIG.world.wilderness.vegetation.flower;
const colorIndex = Math.floor(seededRandom(cellX * 13, cellY * 13) * config.colors.length);
ctx.fillStyle = config.colors[colorIndex];
const baseRotation = config.pattern.rotation +
(seededRandom(cellX * 14, cellY * 14) - 0.5) * config.pattern.variation;
for (let i = config.pattern.margin; i < gridSize - config.pattern.margin; i += config.pattern.spacing) {
for (let j = config.pattern.margin; j < gridSize - config.pattern.margin; j += config.pattern.spacing) {
const offsetX = (Math.floor(j / config.pattern.spacing) % 2) * (config.pattern.spacing / 2);
const px = x + i + offsetX;
const py = y + j;
const variation = {
x: (seededRandom(cellX * i, cellY * j) - 0.5) * 4,
y: (seededRandom(cellX * j, cellY * i) - 0.5) * 4
};
ctx.beginPath();
ctx.save();
ctx.translate(px + variation.x, py + variation.y);
ctx.rotate(baseRotation + (seededRandom(cellX * i, cellY * j) - 0.5) * 0.5);
const size = config.pattern.size * (0.8 + seededRandom(cellX * i, cellY * j) * 0.4);
ctx.moveTo(-size/2, size/2);
ctx.lineTo(size/2, size/2);
ctx.lineTo(0, -size/2);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
}
// Grass
else if (random < CONFIG.world.wilderness.vegetation.tree.frequency +
CONFIG.world.wilderness.vegetation.mushroom.frequency +
CONFIG.world.wilderness.vegetation.flower.frequency +
CONFIG.world.wilderness.vegetation.grass.frequency ||
(seededRandom(cellX * 50, cellY * 50) <
CONFIG.world.wilderness.vegetation.grass.spreadFactor &&
hasAdjacentGrass(cellX, cellY))) {
const config = CONFIG.world.wilderness.vegetation.grass;
ctx.strokeStyle = config.colors[0];
ctx.lineWidth = 1;
const baseAngle = config.hatch.angle +
(seededRandom(cellX * 20, cellY * 20) - 0.5) * config.hatch.variation;
for (let i = config.hatch.margin; i < gridSize - config.hatch.margin; i += config.hatch.spacing) {
for (let j = config.hatch.margin; j < gridSize - config.hatch.margin; j += config.hatch.spacing) {
const hatchX = x + i;
const hatchY = y + j;
const offsetX = (seededRandom(cellX * i, cellY * j) - 0.5) * 2;
const offsetY = (seededRandom(cellX * j, cellY * i) - 0.5) * 2;
ctx.beginPath();
ctx.moveTo(
hatchX + offsetX,
hatchY + offsetY
);
ctx.lineTo(
hatchX + Math.cos(baseAngle) * config.hatch.length + offsetX,
hatchY + Math.sin(baseAngle) * config.hatch.length + offsetY
);
ctx.stroke();
}
}
}
}
}
// After drawing vegetation but before drawing the player
state.villagers.forEach(villager => {
ctx.save();
ctx.fillStyle = villager.color;
// Bobbing
if (villager.status === 'rescued') {
const bobOffset = Math.sin(animationTime * villager.bobSpeed) * villager.bobAmplitude;
drawVillagerShape(ctx, villager.x, villager.y + bobOffset, villager.shape, CONFIG.world.villagers.size);
} else if (villager.status === 'lost') {
const lostBobOffset = Math.sin(animationTime * villager.lostBobSpeed) * villager.lostBobAmplitude;
drawVillagerShape(ctx, villager.x + lostBobOffset, villager.y, villager.shape, CONFIG.world.villagers.size);
} else {
drawVillagerShape(ctx, villager.x, villager.y, villager.shape, CONFIG.world.villagers.size);
}
ctx.fill();
ctx.restore();
});
enemySystem.renderEnemies(ctx, state.enemies);
renderPlayer();
state.footprints.forEach(footprint => {
const age = (animationTime - footprint.createdAt) / CONFIG.footprints.lifetime;
if (age >= 1) return;
const alpha = Math.max(0, 1 - age * age);
ctx.save();
ctx.translate(footprint.x + footprint.offset, footprint.y + footprint.offset);
const radius = Math.max(0.1, footprint.size * (1 - age * 0.5));
if (radius > 0) {
ctx.beginPath();
ctx.arc(0, 0, radius * 2, 0, Math.PI * 2);
ctx.fillStyle = `rgba(17, 17, 17, ${alpha * 0.1})`;
ctx.fill();
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(17, 17, 17, ${alpha * 0.3})`;
ctx.fill();
}
ctx.restore();
});
state.particles = state.particles.filter(particle => {
const age = animationTime - particle.createdAt;
if (age >= particle.lifetime) return false;
const alpha = 1 - (age / particle.lifetime);
ctx.beginPath();
ctx.arc(
particle.x + particle.dx * age * 0.1,
particle.y + particle.dy * age * 0.1,
particle.size,
0, Math.PI * 2
);
ctx.fillStyle = particle.color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
ctx.fill();
return true;
});
renderPlayerHUD(ctx);
if (state.player.isInvulnerable && animationTime >= state.player.invulnerableUntil) {
state.player.isInvulnerable = false;
}
renderDiamonds();
ctx.restore();
};
const updatePlayer = () => {
Object.assign(state, weaponSystems.updateBubbles(state, animationTime));
Object.assign(state, weaponSystems.updateSwordSwing(state, animationTime));
Object.assign(state, movementSystem.updatePosition(state, keys));
state.footprints = state.footprints.filter(footprint => {
return (animationTime - footprint.createdAt) < CONFIG.footprints.lifetime;
});
if (!keys.size && !state.player.isSwinging && !state.player.isDefending) {
const idleTime = animationTime - state.player.lastInputTime;
if (idleTime > CONFIG.player.idle.startDelay) {
const lookAngle = Math.sin(animationTime * CONFIG.player.idle.lookSpeed) * CONFIG.player.idle.lookRadius;
const baseAngle = Math.atan2(state.player.baseDirection.y, state.player.baseDirection.x);
const newAngle = baseAngle + lookAngle;
state.player.direction = {
x: Math.cos(newAngle),
y: Math.sin(newAngle)
};
} else {
state.player.direction = { ...state.player.baseDirection };
}
} else {
if (state.player.isSwinging || state.player.isDefending) {
state.player.lastInputTime = animationTime;
}
}
state.villagers.forEach(villager => {
if (villager.status === 'rescued') return;
const dx = state.player.x - villager.x;
const dy = state.player.y - villager.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < (CONFIG.player.size + CONFIG.world.villagers.size) / 2) {
villager.status = 'rescued';
state.player.rescuedCount++;
if (state.player.rescuedCount >= CONFIG.player.equipment.swordUnlockCount && !state.player.swordUnlocked) {
state.player.swordUnlocked = true;
state.player.equipment = 'sword'; // Auto-equip sword
showSwordUnlockAnimation();
}
const villageSize = CONFIG.world.village.size * CONFIG.display.grid.size;
const margin = CONFIG.world.villagers.size;
villager.x = margin + Math.random() * (villageSize - margin * 2);
villager.y = margin + Math.random() * (villageSize - margin * 2);
const allRescued = state.villagers.every(v => v.status === 'rescued');
if (allRescued && !state.gameComplete) {
state.gameComplete = true;
const message = document.createElement('div');
message.textContent = CONFIG.world.villagers.rescueMessage;
message.style.position = 'fixed';
message.style.top = '50%';
message.style.left = '50%';
message.style.transform = 'translate(-50%, -50%)';
message.style.padding = '20px';
message.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
message.style.color = 'white';
message.style.borderRadius = '10px';
message.style.fontSize = '24px';
document.body.appendChild(message);
setTimeout(() => {
document.body.removeChild(message);
}, CONFIG.world.villagers.messageDisplayTime);
}
}
});
};
const gameLoop = (currentTime) => {
if (!lastFrameTime) {
lastFrameTime = currentTime;
animationTime = 0;
}
const deltaTime = currentTime - lastFrameTime;
if (deltaTime >= FRAME_TIME) {
animationTime += FRAME_TIME;
if (state.player.hp <= 0 && !state.player.isDead) {
state.player.isDead = true;
showGameOver();
}
updatePlayer();
state.enemies = enemySystem.updateEnemies(state.enemies, deltaTime);
collectDiamonds();
render();
lastFrameTime = currentTime;
}
requestAnimationFrame(gameLoop);
};
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const resizeCanvas = () => {
GAME_WIDTH = window.innerWidth;
GAME_HEIGHT = window.innerHeight;
canvas.width = GAME_WIDTH;
canvas.height = GAME_HEIGHT;
if (!state.player.x) {
state.player.x = GAME_WIDTH / 2;
state.player.y = GAME_HEIGHT / 2;
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// Initialize villagers after collision map is populated
state.villagers = generateVillagers();
state.enemies = enemySystem.generateEnemies(state.villagers, state.collisionMap);
requestAnimationFrame(gameLoop);
const getDotOpacity = (state, animationTime) => {
const timeSinceLastBubble = animationTime - state.player.lastBubbleTime;
const bubbleCooldownProgress = Math.min(timeSinceLastBubble / CONFIG.bubble.cooldown, 1);
const bubbleOpacity = 0.1 + (bubbleCooldownProgress * 0.9);
let dashOpacity = 1;
if (state.player.dashExhausted) {
const timeSinceExhaustion = animationTime - state.player.lastDashEnd;
const dashCooldownProgress = timeSinceExhaustion / CONFIG.player.dash.cooldown;
dashOpacity = 0.1 + (Math.min(dashCooldownProgress, 1) * 0.9);
} else if (state.player.isDashing) {
const dashProgress = (animationTime - state.player.dashStartTime) / CONFIG.player.dash.duration;
dashOpacity = 1 - (dashProgress * 0.7);
}
let blinkOpacity = 1;
if (state.player.isDefending) {
const blinkPhase = Math.sin(animationTime * 0.002);
if (blinkPhase > 0.7) {
blinkOpacity = 0.3 + (blinkPhase - 0.7) * 2;
}
}
return Math.min(bubbleOpacity, dashOpacity, blinkOpacity);
};
const checkCollision = (x, y, size) => {
const halfSize = size / 2;
const corners = [
{ x: x - halfSize, y: y - halfSize }, // Top-left
{ x: x + halfSize, y: y - halfSize }, // Top-right
{ x: x - halfSize, y: y + halfSize }, // Bottom-left
{ x: x + halfSize, y: y + halfSize } // Bottom-right
];
return corners.some(corner => isPositionBlocked(corner.x, corner.y));
};
const createNaturalCluster = (centerX, centerY, config, cellX, cellY, i) => {
const angle = seededRandom(cellX * 8 + i, cellY * 8) * Math.PI * 2;
const distanceFromCenter = seededRandom(cellX * 9 + i, cellY * 9);
const distance = Math.sqrt(distanceFromCenter) * config.size.max;
const variation = {
x: (seededRandom(cellX * 10 + i, cellY * 10) - 0.5) * config.size.max,
y: (seededRandom(cellX * 11 + i, cellY * 11) - 0.5) * config.size.max
};
return {
x: centerX + Math.cos(angle) * distance + variation.x,
y: centerY + Math.sin(angle) * distance + variation.y,
size: config.size.min +
seededRandom(cellX * 3 + i, cellY * 3) *
(config.size.max - config.size.min) *
// This makes stuff on the edge a little smaller
(1 - (distance / config.cluster.spread) * 0.3)
};
};
const renderTree = (ctx, x, y, size, isTop = false) => {
ctx.fillStyle = CONFIG.world.wilderness.vegetation.tree.color;
if (isTop) {
ctx.beginPath();
ctx.arc(
x + CONFIG.display.grid.size/2,
y + CONFIG.display.grid.size/2,
size,
-Math.PI, 0
);
ctx.fill();
} else {
ctx.beginPath();
ctx.arc(
x + CONFIG.display.grid.size/2,
y + CONFIG.display.grid.size/2,
size,
0, Math.PI
);
ctx.fill();
}
};
const hasAdjacentGrass = (cellX, cellY) => {
const adjacentCells = [
[-1, -1], [0, -1], [1, -1],
[-1, 0], [1, 0],
[-1, 1], [0, 1], [1, 1]
];
return adjacentCells.some(([dx, dy]) => {
const adjX = cellX + dx;
const adjY = cellY + dy;
const adjRandom = seededRandom(adjX, adjY);
return adjRandom < CONFIG.world.wilderness.vegetation.grass.frequency;
});
};
const getCellInfo = (x, y) => {
const cellX = Math.floor(x / CONFIG.display.grid.size);
const cellY = Math.floor(y / CONFIG.display.grid.size);
const random = seededRandom(cellX, cellY);
// Determine biome
const isInVillage = x < (CONFIG.world.village.size * CONFIG.display.grid.size) &&
y < (CONFIG.world.village.size * CONFIG.display.grid.size);
// If in village, return early with no vegetation
if (isInVillage) {
return {
position: { cellX, cellY },
biome: 'Village',
vegetation: {
tree: false,
mushrooms: false,
flowers: false,
grass: false
}
};
}
// Check for vegetation only if in wilderness
const hasTree = random < CONFIG.world.wilderness.vegetation.tree.frequency;
const hasMushrooms = random < (CONFIG.world.wilderness.vegetation.tree.frequency +
CONFIG.world.wilderness.vegetation.mushroom.frequency);
const hasFlowers = random < (CONFIG.world.wilderness.vegetation.tree.frequency +
CONFIG.world.wilderness.vegetation.mushroom.frequency +
CONFIG.world.wilderness.vegetation.flower.frequency);
const hasGrass = random < (CONFIG.world.wilderness.vegetation.tree.frequency +
CONFIG.world.wilderness.vegetation.mushroom.frequency +
CONFIG.world.wilderness.vegetation.flower.frequency +
CONFIG.world.wilderness.vegetation.grass.frequency) ||
(seededRandom(cellX * 50, cellY * 50) <
CONFIG.world.wilderness.vegetation.grass.spreadFactor &&
hasAdjacentGrass(cellX, cellY));
return {
position: { cellX, cellY },
biome: 'Wilderness',
vegetation: {
tree: hasTree,
mushrooms: !hasTree && hasMushrooms,
flowers: !hasTree && !hasMushrooms && hasFlowers,
grass: !hasTree && !hasMushrooms && !hasFlowers && hasGrass
}
};
};
const createFloatingMessage = (messageConfig) => {
const {
text,
duration = null, // null for permanent
backgroundColor = 'rgba(0, 0, 0, 0.8)',
glowColor = null,
glowDuration = 2000
} = messageConfig;
const container = document.createElement('div');
container.style.position = 'fixed';
container.style.top = '50%';
container.style.left = '50%';
container.style.transform = 'translate(-50%, -50%)';
container.style.padding = '20px';
container.style.backgroundColor = backgroundColor;
container.style.borderRadius = '10px';
container.style.zIndex = '1000';
const message = document.createElement('div');
message.textContent = text;
message.style.color = 'white';
message.style.fontSize = '24px';
message.style.textAlign = 'center';
container.appendChild(message);
if (glowColor) {
container.style.animation = `glow ${glowDuration}ms ease-in-out`;
const style = document.createElement('style');
style.textContent = `
@keyframes glow {
0% { box-shadow: 0 0 0 0 ${glowColor}; opacity: 0; }
50% { box-shadow: 0 0 30px 10px ${glowColor}; opacity: 1; }
100% { box-shadow: 0 0 0 0 ${glowColor}; opacity: ${duration ? 0 : 1}; }
}
`;
document.head.appendChild(style);
setTimeout(() => {
document.head.removeChild(style);
if (!duration) {
container.style.boxShadow = `0 0 30px 10px ${glowColor}`;
}
}, glowDuration);
}
document.body.appendChild(container);
if (duration) {
setTimeout(() => {
document.body.removeChild(container);
}, duration);
}
return container;
};
const showGameOver = () => {
createFloatingMessage({
text: 'Game Over\nPress Space to restart',
duration: null,
glowColor: 'rgba(255, 0, 0, 0.6)'
});
};
const showSwordUnlockAnimation = () => {
createFloatingMessage({
text: CONFIG.player.equipment.unlockAnimation.messageText,
duration: CONFIG.player.equipment.unlockAnimation.duration,
glowColor: CONFIG.player.equipment.unlockAnimation.glowColor,
glowDuration: 2000
});
};
const collectDiamonds = () => {
state.diamonds.forEach(diamond => {
const dx = state.player.x - diamond.x;
const dy = state.player.y - diamond.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < (CONFIG.player.size / 2 + diamond.size / 2) && !diamond.collected) {
diamond.collected = true;
state.player.hp = Math.min(state.player.hp + 1, state.player.maxHp); // Restore 1 HP, max out at maxHp
}
});
state.diamonds = state.diamonds.filter(diamond => !diamond.collected);
};
const renderDiamonds = () => {
state.diamonds.forEach(diamond => {
ctx.save();
const pulseIntensity = 0.5 + Math.sin(animationTime * 0.005) * 0.3; // Pulsing between 0.2 and 0.8
// Outer glow
const gradient = ctx.createRadialGradient(
diamond.x, diamond.y, diamond.size * 0.2,
diamond.x, diamond.y, diamond.size * 1.5
);
gradient.addColorStop(0, `rgba(255, 215, 0, ${pulseIntensity})`); // Gold center
gradient.addColorStop(0.6, 'rgba(255, 215, 0, 0.2)'); // Fading gold
gradient.addColorStop(1, 'rgba(255, 215, 0, 0)'); // Transparent edge
ctx.beginPath();
ctx.arc(diamond.x, diamond.y, diamond.size * 1.5, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
// Inner diamond
ctx.beginPath();
ctx.arc(diamond.x, diamond.y, diamond.size * 0.4, 0, Math.PI * 2); // Make core smaller
ctx.fillStyle = `rgba(255, 223, 0, ${0.8 + pulseIntensity * 0.2})`; // Brighter gold
ctx.fill();
// Shiny
ctx.beginPath();
ctx.arc(
diamond.x - diamond.size * 0.2,
diamond.y - diamond.size * 0.2,
diamond.size * 0.15,
0, Math.PI * 2
);
ctx.fillStyle = `rgba(255, 255, 255, ${0.6 + pulseIntensity * 0.4})`;
ctx.fill();
ctx.restore();
});
};