<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eli's Broughlike</title>
<meta description="A Broughlike, or something like Flatland.">
<style>
body {
background-color: #f0f0f0;
font-size: x-large;
}
canvas {
width: 90vw;
height: 90vw;
max-width: 600px;
max-height: 600px;
border: 2px solid black;
display: block;
margin: 0 auto;
background-color: #f0f0f0;
}
@keyframes shake {
0% { transform: translate(0.5px, 0.5px) rotate(0deg); }
10% { transform: translate(-0.5px, -1px) rotate(-0.5deg); }
20% { transform: translate(-1.5px, 0px) rotate(0.5deg); }
30% { transform: translate(1.5px, 1px) rotate(0deg); }
40% { transform: translate(0.5px, -0.5px) rotate(0.5deg); }
50% { transform: translate(-0.5px, 1px) rotate(-0.5deg); }
60% { transform: translate(-1.5px, 0.5px) rotate(0deg); }
70% { transform: translate(1.5px, 0.5px) rotate(-0.5deg); }
80% { transform: translate(-0.5px, -0.5px) rotate(0.5deg); }
90% { transform: translate(0.5px, 1px) rotate(0deg); }
100% { transform: translate(0.5px, -1px) rotate(-0.5deg); }
}
.shake {
animation: shake 0.5s;
animation-iteration-count: 1;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.5em;
background-color: #f0f0f0;
}
.toggleShake {
padding: 0.75em;
margin: 0.5em;
}
</style>
</head>
<body>
<div class="header">
<p><a href="about.html">About</a></p>
<button class="toggleShake" id="toggleShake" onclick="toggleShake()">Turn Shake Off</button>
</div>
<canvas id="gameCanvas"></canvas>
<script>
const COLORS = {
grid: '#2d2d2d',
walls: '#2d2d2d',
exit: 'teal',
diamond: 'gold',
pentagon: 'blueviolet',
player: 'rgba(0, 237, 209, ',
enemy: 'rgba(255, 155, 28, ',
combatDotPlayer: '#00edd1',
combatDotEnemy: '#ff731c'
};
const GRID_SIZE = 6;
const PLAYER_HEALTH = 12;
const PLAYER_MAX_HEALTH = 16;
const PLAYER_BASE_DAMAGE = 1;
const ENEMY_CHASE_DISTANCE = 4;
const MIN_ENEMIES_ON_LEVEL = 1;
const MAX_ENEMIES_ON_LEVEL = 4;
const MAX_ENEMY_HEALTH = 7;
const MIN_ENEMY_HEALTH = 2;
const WALLS_MIN = 5;
const WALLS_MAX = 20;
const ITEMS_MIN = 1;
const ITEMS_MAX = 3;
const DOTS_PER_HIT = 7;
let highScore = localStorage.getItem('highScore') || 0;
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
let tileSize = canvas.width / GRID_SIZE;
const player = {
x: 0,
y: 0,
health: PLAYER_HEALTH,
score: 0,
damage: PLAYER_BASE_DAMAGE,
totalDamageDone: 0,
totalDamageTaken: 0,
cellsTraveled: 0,
killCount: 0,
itemsCollected: 0
};
const exit = { x: Math.floor(Math.random() * GRID_SIZE), y: Math.floor(Math.random() * GRID_SIZE) };
let walls = [];
let enemies = [];
let items = [];
let combatDots = {};
function isValidMove(newX, newY) {
return (
newX >= 0 && newX < GRID_SIZE &&
newY >= 0 && newY < GRID_SIZE &&
!walls.some(wall => wall.x === newX && wall.y === newY)
);
}
function generateExit() {
let distance = 0;
do {
exit.x = Math.floor(Math.random() * GRID_SIZE);
exit.y = Math.floor(Math.random() * GRID_SIZE);
distance = Math.abs(exit.x - player.x) + Math.abs(exit.y - player.y);
} while (distance < 4);
}
function generateEnemies() {
enemies = [];
// Generate between 0 and MAX_ENEMIES_ON_LEVEL enemies if the player's score is 4 or lower
// Generate between MIN_ENEMIES_ON_LEVEL and MAX_ENEMIES_ON_LEVEL enemies if the player's score is 5 or higher
const numEnemies = player.score > 4
? Math.floor(Math.random() * (MAX_ENEMIES_ON_LEVEL - MIN_ENEMIES_ON_LEVEL + 1)) + MIN_ENEMIES_ON_LEVEL
: Math.floor(Math.random() * (MAX_ENEMIES_ON_LEVEL + 1));
for (let i = 0; i < numEnemies; i++) {
let enemyX, enemyY;
do {
enemyX = Math.floor(Math.random() * GRID_SIZE);
enemyY = Math.floor(Math.random() * GRID_SIZE);
} while (
(enemyX === player.x && enemyY === player.y) ||
(enemyX === exit.x && enemyY === exit.y) ||
walls.some(wall => wall.x === enemyX && wall.y === enemyY)
);
enemies.push({
x: enemyX,
y: enemyY,
health: Math.floor(Math.random() * (MAX_ENEMY_HEALTH - MIN_ENEMY_HEALTH + 1)) + MIN_ENEMY_HEALTH
});
}
}
function generateWalls() {
walls = [];
let numWalls = Math.floor(Math.random() * (WALLS_MAX - WALLS_MIN + 1)) + WALLS_MIN;
while (walls.length < numWalls) {
const wallX = Math.floor(Math.random() * GRID_SIZE);
const wallY = Math.floor(Math.random() * GRID_SIZE);
if (
(wallX === player.x && wallY === player.y) || // Check that a wall is not placed on the starting position
(wallX === exit.x && wallY === exit.y) || // Check that a wall is not placed on the exit
enemies.some(enemy => enemy.x === wallX && enemy.y === wallY) || // Check that a wall is not placed on any enemies
items.some(item => item.x === wallX && item.y === wallY) // Check that a wall is not placed on any items
) continue;
if (!walls.some(wall => wall.x === wallX && wall.y === wallY)) {
walls.push({ x: wallX, y: wallY });
}
}
if (!isPassable()) {
generateWalls();
}
}
function generateItems() {
items = [];
const numItems = Math.floor(Math.random() * (ITEMS_MAX - ITEMS_MIN + 1)) + ITEMS_MIN;
for (let i = 0; i < numItems; i++) {
let itemX, itemY;
do {
itemX = Math.floor(Math.random() * GRID_SIZE);
itemY = Math.floor(Math.random() * GRID_SIZE);
} while (
(itemX === player.x && itemY === player.y) ||
(itemX === exit.x && itemY === exit.y) ||
walls.some(wall => wall.x === itemX && wall.y === itemY) ||
enemies.some(enemy => enemy.x === itemX && enemy.y === itemY) ||
items.some(item => item.x === itemX && item.y === itemY)
);
const itemType = Math.random() < 0.5 ? 'diamond' : 'pentagon'; // 50% chance for each type
items.push({ x: itemX, y: itemY, type: itemType });
}
}
function isPassable() {
const visited = Array(GRID_SIZE).fill().map(() => Array(GRID_SIZE).fill(false));
function dfs(x, y) {
if (x < 0 || x >= GRID_SIZE || y < 0 || y >= GRID_SIZE) return false; // Are the coordinates in bounds?
if (visited[x][y]) return false;
if (walls.some(wall => wall.x === x && wall.y === y)) return false;
visited[x][y] = true;
if (x === exit.x && y === exit.y) return true;
return dfs(x + 1, y) || dfs(x - 1, y) || dfs(x, y + 1) || dfs(x, y - 1);
}
return dfs(player.x, player.y);
}
function drawGrid() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = COLORS.grid;
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
ctx.strokeRect(col * tileSize, row * tileSize, tileSize, tileSize);
}
}
}
function drawExit() {
const x = exit.x * tileSize + tileSize / 2;
const y = exit.y * tileSize + tileSize / 2;
ctx.beginPath();
ctx.moveTo(x, y - tileSize / 3);
ctx.lineTo(x + tileSize / 3, y + tileSize / 3);
ctx.lineTo(x - tileSize / 3, y + tileSize / 3);
ctx.closePath();
ctx.fillStyle = COLORS.exit;
ctx.fill();
}
function drawWalls() {
ctx.fillStyle = COLORS.walls;
walls.forEach(wall => {
ctx.fillRect(wall.x * tileSize, wall.y * tileSize, tileSize, tileSize);
});
}
function drawItems() {
items.forEach(item => {
const x = item.x * tileSize + tileSize / 2;
const y = item.y * tileSize + tileSize / 2;
ctx.fillStyle = item.type === 'diamond' ? COLORS.diamond : COLORS.pentagon;
ctx.beginPath();
if (item.type === 'diamond') {
ctx.moveTo(x, y - tileSize / 4);
ctx.lineTo(x + tileSize / 4, y);
ctx.lineTo(x, y + tileSize / 4);
ctx.lineTo(x - tileSize / 4, y);
} else {
const sides = 5;
const radius = tileSize / 4;
for (let i = 0; i < sides; i++) {
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
const pointX = x + radius * Math.cos(angle);
const pointY = y + radius * Math.sin(angle);
if (i === 0) ctx.moveTo(pointX, pointY);
else ctx.lineTo(pointX, pointY);
}
}
ctx.closePath();
ctx.fill();
});
}
function drawCharacterBorder(x, y, radius, damageTaken) {
const dashLength = 5;
const gapLength = Math.max(1, damageTaken * 2); // More damage, larger gaps
ctx.lineWidth = 2;
ctx.strokeStyle = '#2d2d2d';
ctx.setLineDash([dashLength, gapLength]);
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.stroke();
ctx.setLineDash([]); // Reset to a solid line
}
function drawEnemies() {
enemies.forEach(enemy => {
const x = enemy.x * tileSize + tileSize / 2;
const y = enemy.y * tileSize + tileSize / 2;
const opacity = enemy.health / MAX_ENEMY_HEALTH; // Opacity based on health
const radius = tileSize / 3;
const damageTaken = MAX_ENEMY_HEALTH - enemy.health;
ctx.beginPath();
ctx.arc(x, y, radius, 0, 2 * Math.PI);
ctx.fillStyle = `${COLORS.enemy}${opacity})`;
ctx.fill();
drawCharacterBorder(x, y, radius, damageTaken);
});
}
function drawPlayer() {
const x = player.x * tileSize + tileSize / 2;
const y = player.y * tileSize + tileSize / 2;
const radius = tileSize / 3;
const playerOpacity = player.health / PLAYER_HEALTH; // Opacity based on health
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i;
const hexX = x + radius * Math.cos(angle);
const hexY = y + radius * Math.sin(angle);
if (i === 0) {
ctx.moveTo(hexX, hexY);
} else {
ctx.lineTo(hexX, hexY);
}
}
ctx.closePath();
ctx.fillStyle = `${COLORS.player}${playerOpacity})`;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#2d2d2d';
ctx.stroke();
}
function drawCombatDots() {
for (const key in combatDots) {
const [cellX, cellY] = key.split(',').map(Number);
const dots = combatDots[key];
dots.forEach(dot => {
ctx.beginPath();
ctx.arc(cellX * tileSize + dot.x, cellY * tileSize + dot.y, 2, 0, Math.PI * 2);
ctx.fillStyle = dot.color;
ctx.fill();
ctx.closePath();
});
}
}
function handleItemCollection() {
const collectedItem = items.find(item => item.x === player.x && item.y === player.y);
if (collectedItem) {
player.itemsCollected++;
if (collectedItem.type === 'diamond') {
player.damage += 3;
console.log("Collected diamond! +3 damage on this level.");
} else if (collectedItem.type === 'pentagon') {
const healAmount = Math.floor(Math.random() * 2) + 1;
player.health = Math.min(player.health + healAmount, PLAYER_MAX_HEALTH);
console.log("Collected pentagon! Healed " + healAmount + " health.");
}
items = items.filter(item => item !== collectedItem); // Remove collected item
}
}
function addCombatDots(x, y, color) {
const cellsToFill = [
[x, y], // Center
[x - 1, y], // Left
[x + 1, y], // Right
[x, y - 1], // Up
[x, y + 1], // Down
[x - 1, y - 1], // Top-left
[x + 1, y - 1], // Top-right
[x - 1, y + 1], // Bottom-left
[x + 1, y + 1] // Bottom-right
];
cellsToFill.forEach(([cellX, cellY]) => {
if (cellX >= 0 && cellX < GRID_SIZE && cellY >= 0 && cellY < GRID_SIZE) {
const key = `${cellX},${cellY}`;
if (!combatDots[key]) {
combatDots[key] = [];
}
for (let i = 0; i < DOTS_PER_HIT; i++) {
combatDots[key].push({
x: Math.random() * tileSize,
y: Math.random() * tileSize,
color: color
});
}
}
});
}
function movePlayer(dx, dy) {
const newX = player.x + dx;
const newY = player.y + dy;
if (isValidMove(newX, newY) && !enemies.some(enemy => enemy.x === newX && enemy.y === newY)) {
if (newX !== player.x || newY !== player.y) player.cellsTraveled++;
player.x = newX;
player.y = newY;
handleItemCollection(); // Did the player collect an item?
checkPlayerAtExit(); // Did the player get to the exit after moving?
} else {
// If an enemy is in the target cell, player stays put and does combat
const enemyInTargetCell = enemies.find(enemy => enemy.x === newX && enemy.y === newY);
if (enemyInTargetCell) {
handleDamage(player, enemyInTargetCell);
}
}
moveEnemies();
render();
}
function moveEnemies() {
enemies.forEach(enemy => {
const distanceToPlayer = Math.abs(enemy.x - player.x) + Math.abs(enemy.y - player.y);
const distanceToExit = Math.abs(enemy.x - exit.x) + Math.abs(enemy.y - exit.y);
const target = distanceToPlayer <= ENEMY_CHASE_DISTANCE ? player : exit;
const path = findPath(enemy, target);
if (path.length > 1) {
const nextStep = path[1];
const enemyInNextStep = enemies.find(e => e.x === nextStep.x && e.y === nextStep.y);
// Is the next step occupied by an enemy?
if (!enemyInNextStep && !(nextStep.x === player.x && nextStep.y === player.y)) {
// Move to the next place
enemy.x = nextStep.x;
enemy.y = nextStep.y;
} else if (nextStep.x === player.x && nextStep.y === player.y) {
// If the player is in the next step, stay put and do combat
handleDamage(player, enemy);
}
}
});
}
function findPath(start, goal) {
const queue = [{ x: start.x, y: start.y, path: [] }];
const visited = new Set();
visited.add(`${start.x},${start.y}`);
while (queue.length > 0) {
const { x, y, path } = queue.shift();
const newPath = [...path, { x, y }];
if (x === goal.x && y === goal.y) {
return newPath;
}
const directions = [
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 }
];
directions.forEach(({ dx, dy }) => {
const newX = x + dx;
const newY = y + dy;
const key = `${newX},${newY}`;
// Check if the new position is within the level and if it is passable
if (
newX >= 0 && newX < GRID_SIZE &&
newY >= 0 && newY < GRID_SIZE &&
// Have we already been here?
!visited.has(key) &&
// Is it a wall?
!walls.some(wall => wall.x === newX && wall.y === newY) &&
// Is there an enemy already there?
!enemies.some(enemy => enemy.x === newX && enemy.y === newY)
) {
queue.push({ x: newX, y: newY, path: newPath });
visited.add(key);
}
});
}
return [];
}
let combatAnimationEnabled = localStorage.getItem('combatAnimationEnabled');
if (combatAnimationEnabled === null) {
combatAnimationEnabled = true; // default to on...is that a good idea?
localStorage.setItem('combatAnimationEnabled', combatAnimationEnabled);
} else {
combatAnimationEnabled = combatAnimationEnabled === 'true';
}
document.getElementById('toggleShake').textContent = combatAnimationEnabled ? 'Turn Shake Off' : 'Turn Shake On';
function toggleShake() {
combatAnimationEnabled = !combatAnimationEnabled;
localStorage.setItem('combatAnimationEnabled', combatAnimationEnabled);
document.getElementById('toggleShake').textContent = combatAnimationEnabled ? 'Turn Shake Off' : 'Turn Shake On';
}
function combatAnimation() {
const canvas = document.getElementById('gameCanvas');
canvas.classList.add('shake');
canvas.addEventListener('animationend', () => {
canvas.classList.remove('shake');
}, { once: true });
}
function handleDamage(player, enemy) {
const enemyMisses = Math.random() < 0.5; // 50% chance the enemy misses you
const cellX = player.x;
const cellY = player.y;
if (!enemyMisses) {
player.health--;
player.totalDamageTaken++;
addCombatDots(cellX, cellY, COLORS.combatDotPlayer); // Add dots for player damage
console.log("Enemy hit! Player health: " + player.health);
} else {
console.log("Enemy missed!");
}
enemy.health = enemy.health - player.damage;
player.totalDamageDone++;
addCombatDots(cellX, cellY, COLORS.combatDotEnemy); // Add dots for enemy damage
console.log("Player hit! Enemy health: " + enemy.health);
if (combatAnimationEnabled) {
combatAnimation(); // Trigger the shake animation
}
if (enemy.health <= 0) {
player.killCount++;
enemies = enemies.filter(e => e !== enemy);
}
if (player.health <= 0) {
if (player.score > highScore) {
highScore = player.score;
localStorage.setItem('highScore', highScore);
}
alert(`Score: ${player.score}\nDistance Traveled: ${player.cellsTraveled}\nTotal Damage Dealt: ${player.totalDamageDone}\nTotal Damage Received: ${player.totalDamageTaken}\nCircles Vanquished: ${player.killCount}\n\nHigh Score: ${highScore}`);
resetGame();
}
}
function resetGame() {
player.health = PLAYER_HEALTH;
player.damage = PLAYER_BASE_DAMAGE;
player.bonusDamageTurns = 0;
player.totalDamageDone = 0;
player.totalDamageTaken = 0;
player.cellsTraveled = 0;
player.score = 0;
player.killCount = 0;
player.itemsCollected = 0;
player.x = 0;
player.y = 0;
combatDots = {};
generateExit();
generateEnemies();
generateItems();
generateWalls();
render();
}
function checkPlayerAtExit() {
if (player.x === exit.x && player.y === exit.y) {
player.score += 1;
player.damage = PLAYER_BASE_DAMAGE;
console.group("Level complete! " + player.score);
console.log("Score: " + player.score);
console.log("Current health: " + player.health);
console.log("Distance Traveled: " + player.cellsTraveled);
console.log("Total Damage Dealt: " + player.totalDamageDone);
console.log("Total Damage Received: " + player.totalDamageTaken);
console.log("Circles Vanquished: " + player.killCount);
console.log("Items Collected: " + player.itemsCollected);
console.log("High Score: " + highScore);
console.groupEnd();
combatDots = {};
generateExit();
generateEnemies();
generateItems();
generateWalls();
render();
}
}
function render() {
drawGrid();
drawPlayer();
drawExit();
drawItems();
drawEnemies();
drawWalls();
drawCombatDots();
}
const directionMap = {
ArrowUp: [0, -1],
ArrowDown: [0, 1],
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
w: [0, -1],
s: [0, 1],
a: [-1, 0],
d: [1, 0],
h: [-1, 0],
j: [0, 1],
k: [0, -1],
l: [1, 0]
};
document.addEventListener('keydown', (e) => {
const direction = directionMap[e.key];
if (direction) {
movePlayer(...direction);
checkPlayerAtExit();
render();
}
});
let touchStartX = 0;
let touchStartY = 0;
canvas.addEventListener('touchstart', (e) => {
e.preventDefault(); // Prevent scrolling on touchstart
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
});
canvas.addEventListener('touchend', (e) => {
e.preventDefault(); // Prevent scrolling on touchend
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const dx = touchEndX - touchStartX;
const dy = touchEndY - touchStartY;
if (Math.abs(dx) > Math.abs(dy)) {
// Horizontal swipe
if (dx > 0) {
movePlayer(1, 0); // Swipe right
} else {
movePlayer(-1, 0); // Swipe left
}
} else {
// Vertical swipe
if (dy > 0) {
movePlayer(0, 1); // Swipe down
} else {
movePlayer(0, -1); // Swipe up
}
}
render();
}, { passive: false }); // TIL you can use passive set to false to help make preventDefault actually work? Feels like superstition
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
tileSize = canvas.width / GRID_SIZE; // Update tile size based on canvas dimensions
render();
};
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// Initial level setup
generateExit();
generateEnemies();
generateItems();
generateWalls();
render();
</script>
</body>
</html>