initial commit

This commit is contained in:
KingOfDog
2019-06-11 18:46:14 +02:00
committed by KingOfDog
commit 35bc54eaac
13 changed files with 875 additions and 0 deletions

10
DOM.js Normal file
View File

@@ -0,0 +1,10 @@
Node.prototype.createChild = function(name, className) {
const el = document.createElement(name);
if(typeof className === 'string') {
el.classList.add(className);
}
this.appendChild(el);
return el;
};

114
Game.js Normal file
View File

@@ -0,0 +1,114 @@
class Game {
constructor() {
this.canvas = document.body.createChild('canvas', 'pacman-game');
this.ctx = this.canvas.getContext('2d');
this.loadMap();
this.onResize();
this.gameObjects = [];
this.paused = false;
this.gameOver = false;
this.init();
}
init() {
this.gameObjects.push(new Player(this, this.map.spawn.x + 0.5, this.map.spawn.y + 0.5));
this.gameObjects.push(new Ghost(this, 5.5, 6.5));
// this.gameObjects.push(new Ghost(this, 3.5, 20.5));
// this.gameObjects.push(new Ghost(this, 10.5, 7.5));
// this.gameObjects.push(new Ghost(this, 19.5, 12.5));
this.registerKeyListeners();
this.registerWindowListeners();
}
registerKeyListeners() {
window.addEventListener('keydown', (event) => {
const key = event.key;
switch (key) {
case 'ArrowLeft':
this.getPlayer().changeDir({x: -1, y: 0});
break;
case 'ArrowUp':
this.getPlayer().changeDir({x: 0, y: -1});
break;
case 'ArrowRight':
this.getPlayer().changeDir({x: 1, y: 0});
break;
case 'ArrowDown':
this.getPlayer().changeDir({x: 0, y: 1});
break;
}
});
}
registerWindowListeners() {
window.addEventListener('resize', (event) => {
this.onResize();
});
}
onResize() {
this.calculateConf(window.innerWidth, window.innerHeight);
this.canvas.width = this.conf.tileSize * this.map.width;
this.canvas.height = this.conf.tileSize * this.map.height;
this.canvas.style.width = this.canvas.width + 'px';
this.canvas.style.height = this.canvas.height + 'px';
}
loadMap() {
const mapLoader = new MapLoader("levels/level1.pacmap");
mapLoader.start();
this.map = mapLoader.getMap();
}
calculateConf(width, height) {
this.conf = {
tileSize: calculateTileSize(width, height, this.map.width, this.map.height),
};
}
getGameObjectsByType(type) {
return this.gameObjects.filter(object => object instanceof type);
}
getPlayer() {
return this.getGameObjectsByType(Player)[0];
}
draw() {
this.ctx.clearRect(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
this.map.draw(this.ctx, this.conf);
this.gameObjects.forEach(object => {
object.draw(this.ctx, this.conf);
});
requestAnimationFrame(() => this.draw());
}
update(lastUpdate) {
const updateTime = Date.now();
const deltaTime = updateTime - lastUpdate || 16;
this.gameObjects.forEach(object => {
object.update(deltaTime);
});
if (!this.paused && !this.gameOver)
requestAnimationFrame(() => this.update(updateTime));
}
start() {
this.draw();
this.update();
}
}

47
GameMap.js Normal file
View File

@@ -0,0 +1,47 @@
class GameMap {
constructor(tiles, spawn) {
this.width = tiles.length;
this.height = tiles[0].length;
this.tiles = tiles;
this.spawn = spawn;
this.init();
}
init() {
this.tiles.forEach(row => {
row.forEach(tile => {
if(tile.solid) {
tile.lookAtNeighbours(this.getSurroundingTiles(tile.x, tile.y));
}
});
});
}
draw(ctx, conf) {
this.tiles.forEach(col => {
col.forEach(tile => {
tile.draw(ctx, conf);
});
});
}
getTile(x, y) {
try {
return this.tiles[x][y];
} catch (e) {
return null;
}
}
getSurroundingTiles(x, y) {
const left = this.getTile(x - 1, y);
const right = this.getTile(x + 1, y);
const top = this.getTile(x, y - 1);
const bottom = this.getTile(x, y + 1);
return {
left, top, right, bottom
};
}
}

10
GameObject.js Normal file
View File

@@ -0,0 +1,10 @@
class GameObject {
constructor(game, x, y) {
this.game = game;
this.x = x;
this.y = y;
}
draw(ctx, conf) {}
update() {}
}

278
Ghost.js Normal file
View File

@@ -0,0 +1,278 @@
function dirNameToValue(dirName) {
switch (dirName) {
case 'left':
return {x: -1, y: 0};
case 'right':
return {x: 1, y: 0};
case 'top':
return {x: 0, y: -1};
case 'bottom':
return {x: 0, y: 1};
default:
return null;
}
}
class Ghost extends MovingObject {
constructor(game, x, y) {
super(game, x, y);
this.speed = 2;
this.dir = {x: 0, y: 0};
this.lastDirChange = {x: Infinity, y: Infinity};
}
draw(ctx, conf) {
const size = conf.tileSize;
const cx = this.x * size;
const cy = this.y * size;
const left = -0.5 * size;
const top = -0.5 * size;
const right = 0.5 * size;
const base = 0.5 * size - 3;
const inc = size / 10;
const high = 3;
const low = -3;
ctx.save();
ctx.translate(cx, cy);
ctx.fillStyle = "#0f0";
ctx.beginPath();
// Draw body
ctx.moveTo(left, base);
ctx.quadraticCurveTo(left, top, 0, top);
ctx.quadraticCurveTo(right, top, right, base);
// Wavy things at the bottom
ctx.quadraticCurveTo(right - (inc * 1), base + high, right - (inc * 2), base);
ctx.quadraticCurveTo(right - (inc * 3), base + low, right - (inc * 4), base);
ctx.quadraticCurveTo(right - (inc * 5), base + high, right - (inc * 6), base);
ctx.quadraticCurveTo(right - (inc * 7), base + low, right - (inc * 8), base);
ctx.quadraticCurveTo(right - (inc * 9), base + high, right - (inc * 10), base);
ctx.closePath();
ctx.fill();
// Draw eye background
ctx.fillStyle = "#fff";
ctx.beginPath();
ctx.arc(left + size * .3, top + size * .3, size / 6, 0, 2 * Math.PI);
ctx.arc(right - size * .3, top + size * .3, size / 6, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
// Draw eyes
const f = size / 12;
const offset = {
x: this.dir.x * f,
y: this.dir.y * f,
};
ctx.fillStyle = "#000";
ctx.beginPath();
ctx.arc(left + size * .3 + offset.x, top + size * .3 + offset.y, size / 15, 0, 2 * Math.PI);
ctx.arc(right - size * .3 + offset.x, top + size * .3 + offset.y, size / 15, 0, 2 * Math.PI);
ctx.closePath();
ctx.fill();
ctx.restore();
}
update(deltaTime) {
const nextTile = this.getNextTile(0, 0);
if (nextTile.distanceX <= 0.5 && nextTile.distanceY <= 0.5 && !(nextTile.tile.x === this.lastDirChange.x && nextTile.tile.y === this.lastDirChange.y)) {
const surroundings = this.game.map.getSurroundingTiles(nextTile.tile.x, nextTile.tile.y);
const nonSolid = Object.entries(surroundings).filter(surrounding => !surrounding[1].solid);
if (nonSolid.length === 1) {
this.changeDir({x: -this.dir.x, y: -this.dir.y});
this.lastDirChange = nextTile.tile;
} else {
const possibleDirections = [];
nonSolid.forEach(entry => {
const dir = dirNameToValue(entry[0]);
if (dir.x === -this.dir.x && dir.y === -this.dir.y)
return;
possibleDirections.push(dir);
});
if (possibleDirections.length === 1) {
this.changeDir(possibleDirections[0]);
this.lastDirChange = nextTile.tile;
} else if (possibleDirections.length === 2) {
const newDirection = possibleDirections.find(dir => dir.x !== -this.dir.x || dir.y !== -this.dir.y);
this.changeDir(newDirection);
this.lastDirChange = nextTile.tile;
} else {
let bestScore = -Infinity;
let bestDir;
const player = this.game.getPlayer().getNextTile(0, 0).tile;
possibleDirections.forEach(dir => {
const score = this.getDirectionValue(nextTile.tile, dir, player);
if (score > bestScore) {
bestDir = dir;
bestScore = score;
}
});
if (bestDir) {
this.changeDir(bestDir);
this.lastDirChange = nextTile.tile;
}
}
}
}
super.update(deltaTime);
}
getDirectionValue(tile, dir, goal) {
const newTile = this.game.map.getTile(tile.x + dir.x, tile.y + dir.y);
if (!newTile || newTile.solid) {
return -Infinity;
}
const pathFinder = new Pathfinder(this.game.map.tiles);
const path = pathFinder.findShortestPath(newTile, goal);
// const curDistance = (tile.x - goal.x) ** 2 + (tile.y - goal.y) ** 2;
// const newDistance = (newTile.x - goal.x) ** 2 + (newTile.y - goal.y) ** 2;
//
// let score = curDistance - newDistance;
let score = 1 / path.length * 10;
if (this.dir.x === -dir.x || this.dir.y === -dir.y) {
score -= Math.abs(score) / 2;
}
return score;
}
}
class Pathfinder {
constructor(grid) {
this.grid = [];
this.queue = [];
grid.forEach((col, x) => {
this.grid.push([]);
col.forEach(tile => {
this.grid[x].push(tile.solid ? 2 : 0);
});
});
}
findShortestPath(start, end) {
this.grid[end.x][end.y] = 10;
const location = {
x: start.x,
y: start.y,
path: [],
status: 1,
};
this.queue = [location];
return this.loop();
}
loop() {
while (this.queue.length > 0) {
const current = this.queue.shift();
let newLocation;
// Explore north
newLocation = this.exploreInDirection(current, {x: 0, y: -1});
if (newLocation.status === 10) {
return newLocation.path;
} else if (newLocation.status === 5) {
this.queue.push(newLocation);
}
// Explore east
newLocation = this.exploreInDirection(current, {x: 1, y: 0});
if (newLocation.status === 10) {
return newLocation.path;
} else if (newLocation.status === 5) {
this.queue.push(newLocation);
}
// Explore south
newLocation = this.exploreInDirection(current, {x: 0, y: 1});
if (newLocation.status === 10) {
return newLocation.path;
} else if (newLocation.status === 5) {
this.queue.push(newLocation);
}
// Explore west
newLocation = this.exploreInDirection(current, {x: -1, y: 0});
if (newLocation.status === 10) {
return newLocation.path;
} else if (newLocation.status === 5) {
this.queue.push(newLocation);
}
}
return false;
}
locationStatus(location) {
const width = this.grid.length,
height = this.grid[0].length;
const x = location.x,
y = location.y;
if (x < 0 || x >= width || y < 0 || y >= height) {
return 7; // Invalid
}
if (this.grid[x][y] === 10) {
return 10; // Goal
}
if (this.grid[x][y] !== 0) {
return 6; // Blocked: location is either an obstacle or has already been visited
} else {
return 5; // Valid
}
}
exploreInDirection(location, direction) {
const newPath = location.path.slice();
const x = location.x + direction.x,
y = location.y + direction.y;
newPath.push({
x: x,
y: y,
direction: direction,
});
const newLocation = {
x: x,
y: y,
path: newPath,
status: -1, // unknown status
};
newLocation.status = this.locationStatus(newLocation);
if (newLocation.status === 5) {
this.grid[x][y] = 4; // Set as visited
}
return newLocation;
}
}

87
MapLoader.js Normal file
View File

@@ -0,0 +1,87 @@
class MapLoader {
constructor(fileName) {
this.fileName = fileName;
this.fileContent = null;
}
start() {
this.readFile();
}
readFile() {
const rawFile = new XMLHttpRequest();
rawFile.open("GET", this.fileName, false);
rawFile.addEventListener('readystatechange', () => {
if(rawFile.readyState === 4) {
if(rawFile.status === 200 || rawFile.status === 0) {
const content = rawFile.responseText;
console.log(content);
this.fileContent = content;
this.loadMap();
}
}
});
rawFile.send(null);
}
loadMap() {
const tiles = [];
const mapRaw = this.fileContent;
const rows = mapRaw.split(/\r?\n/g);
if(rows.length === 0) {
throw LoadingException;
}
const width = rows[0].length,
height = rows.length;
let spawn;
for(let x = 0; x < width; x++) {
tiles.push([]);
}
rows.forEach((row, y) => {
if(row.length !== width) {
throw `Row length not consistent. Expected ${width}, got ${row.length} at row ${y}`;
}
for(let x = 0; x < row.length; x++) {
const tileRaw = row[x];
let tile;
switch (tileRaw) {
case 'X':
tile = new MapTile(x, y, true);
break;
case '.':
tile = new MapTile(x, y, false, true);
break;
case '-':
tile = new MapTile(x, y, false);
break;
case 'S':
tile = new MapTile(x, y, false);
spawn = {x, y};
break;
case 'O':
tile = new MapTile(x, y, false);
break;
}
tiles[x][y] = tile;
}
});
this.map = new GameMap(tiles, spawn);
}
getMap() {
while(!this.map) {
}
return this.map;
}
}

79
MapTile.js Normal file
View File

@@ -0,0 +1,79 @@
class MapTile {
constructor(x, y, isSolid, hasCoin) {
this.x = x;
this.y = y;
this.solid = isSolid;
this.coin = hasCoin;
}
lookAtNeighbours(neighbours) {
this.borders = {};
if(neighbours.left && neighbours.left.solid) {
this.borders.left = true;
}
if(neighbours.top && neighbours.top.solid) {
this.borders.top = true;
}
if(neighbours.right && neighbours.right.solid) {
this.borders.right = true;
}
if(neighbours.bottom && neighbours.bottom.solid) {
this.borders.bottom = true;
}
}
draw(ctx, conf) {
if (this.solid) {
this.drawSolid(ctx, conf);
}
if(this.coin) {
ctx.save();
ctx.translate((this.x + 0.5) * conf.tileSize, (this.y + 0.5) * conf.tileSize);
ctx.fillStyle = "#fff";
ctx.beginPath();
ctx.arc(0, 0, 0.15 * conf.tileSize, 0, 2 * Math.PI);
ctx.fill();
ctx.restore();
}
}
drawSolid(ctx, conf) {
ctx.save();
ctx.translate((this.x + 0.5) * conf.tileSize, (this.y + 0.5) * conf.tileSize);
ctx.strokeStyle = "#00f";
ctx.lineWidth = conf.tileSize * .5;
ctx.beginPath();
if (this.borders.left) {
ctx.moveTo(0.25 * conf.tileSize, 0);
ctx.lineTo(-0.5 * conf.tileSize, 0);
}
if (this.borders.top) {
ctx.moveTo(0, 0.25 * conf.tileSize);
ctx.lineTo(0, -0.5 * conf.tileSize);
}
if (this.borders.right) {
ctx.moveTo(-0.25 * conf.tileSize, 0);
ctx.lineTo(0.5 * conf.tileSize, 0);
}
if (this.borders.bottom) {
ctx.moveTo(0, -0.25 * conf.tileSize);
ctx.lineTo(0, 0.5 * conf.tileSize);
}
ctx.stroke();
ctx.restore();
}
}

98
MovingObject.js Normal file
View File

@@ -0,0 +1,98 @@
class MovingObject extends GameObject {
constructor(game, x, y) {
super(game, x, y);
this.dir = {x: 0, y: 0};
this.speed = 1;
this.turnTimeout = null;
}
changeDir(dir) {
if (this.dir.x === dir.x && this.dir.y === dir.y) {
return;
}
if (this.dir.x === dir.x || this.dir.y === dir.y) {
clearTimeout(this.turnTimeout);
this.dir = dir;
return;
}
const isX = this.dir.x !== 0;
let curPos;
let dirPositive;
if (isX) {
curPos = this.x;
dirPositive = this.dir.x > 0;
} else if (this.dir.y !== 0) {
curPos = this.y;
dirPositive = this.dir.y > 0;
}
let targetSwitch;
if (dirPositive) {
targetSwitch = Math.ceil(curPos + 0.5) - 0.5;
} else {
targetSwitch = Math.floor(curPos - 0.5) + 0.5;
}
const distance = Math.abs(curPos - targetSwitch);
const duration = distance / this.speed * 1000;
clearTimeout(this.turnTimeout);
this.turnTimeout = setTimeout(() => {
if (isX) {
this.x = targetSwitch;
} else {
this.y = targetSwitch;
}
this.dir = dir;
}, duration);
}
update(deltaTime) {
const ticks = Math.floor(deltaTime / 16);
const tickLength = deltaTime / ticks;
for(let i = 0; i < ticks; i++) {
this.x += this.dir.x * (this.speed * tickLength / 1000);
this.y += this.dir.y * (this.speed * tickLength / 1000);
const nextTile = this.getNextTile(this.dir.x, this.dir.y);
if (!nextTile.tile) {
if (nextTile.distanceX <= 0.5 || nextTile.distanceY <= 0.5) {
return;
}
}
if (nextTile.tile.solid) {
if (nextTile.distanceX <= 0.5) {
this.x = nextTile.tile.x - this.dir.x + 0.5;
}
if (nextTile.distanceY <= 0.5) {
this.y = nextTile.tile.y - this.dir.y + 0.5;
}
}
}
}
getNextTile(offsetX, offsetY) {
const tileX = Math.round(this.x - 0.5);
const tileY = Math.round(this.y - 0.5);
const nextX = tileX + offsetX;
const nextY = tileY + offsetY;
const distanceX = Math.abs(this.x - (offsetX >= 0 ? nextX : nextX + 1));
const distanceY = Math.abs(this.y - (offsetY >= 0 ? nextY : nextY + 1));
return {
tile: this.game.map.getTile(nextX, nextY),
distanceX: distanceX,
distanceY: distanceY,
};
}
}

96
Player.js Normal file
View File

@@ -0,0 +1,96 @@
class Player extends MovingObject {
constructor(game, x, y) {
super(game, x, y);
this.speed = 3;
this.score = 0;
this.mouthAngle = 90; // = 90°
this.mouthDecreasing = true;
}
draw(ctx, conf) {
const radius = conf.tileSize * 0.5;
const cx = this.x * conf.tileSize;
const cy = this.y * conf.tileSize;
const mouthAngle = this.mouthAngle / 180 * Math.PI;
ctx.fillStyle = '#ff0';
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(this.getRotateDir());
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, radius, mouthAngle / 2, 2 * Math.PI - mouthAngle / 2, false);
ctx.lineTo(0, 0);
ctx.fill();
ctx.restore();
}
update(deltaTime) {
super.update(deltaTime);
this.checkForCollisions();
// Collect coins
const currentTile = this.getNextTile(0, 0);
if(currentTile.tile.coin) {
if(currentTile.distanceX <= 0.5 || currentTile.distanceY <= 0.5) {
currentTile.tile.coin = false;
this.score += 10;
}
}
if(this.mouthDecreasing) {
this.mouthAngle -= this.speed * 4;
if(this.mouthAngle <= 0) {
this.mouthAngle = 0;
this.mouthDecreasing = false;
}
} else {
this.mouthAngle += this.speed * 4;
if(this.mouthAngle >= 90) {
this.mouthAngle = 90;
this.mouthDecreasing = true;
}
}
}
checkForCollisions() {
this.game.gameObjects.forEach(object => {
if(object === this) {
return;
}
if(Math.abs(object.x - this.x) < 1 && Math.abs(object.y - this.y) < 1) {
if(object instanceof Ghost) {
this.gameOver();
}
}
});
}
getRotateDir() {
if(this.dir.x === -1) {
return Math.PI;
}
if(this.dir.y === 1) {
return Math.PI / 2;
}
if(this.dir.y === -1) {
return 3 / 2 * Math.PI;
}
return 0;
}
gameOver() {
this.game.gameOver = true;
}
}

5
helper-methods.js Normal file
View File

@@ -0,0 +1,5 @@
function calculateTileSize(width, height, tilesX, tilesY) {
const tileWidth = width / tilesX,
tileHeight = height / tilesY;
return Math.floor(Math.min(tileWidth, tileHeight));
}

28
index.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Pacman</title>
<style>
body {
margin: 0;
}
.pacman-game {
background: black;
}
</style>
</head>
<body>
<script src="helper-methods.js"></script>
<script src="DOM.js"></script>
<script src="GameObject.js"></script>
<script src="MovingObject.js"></script>
<script src="Ghost.js"></script>
<script src="Player.js"></script>
<script src="MapTile.js"></script>
<script src="GameMap.js"></script>
<script src="MapLoader.js"></script>
<script src="Game.js"></script>
<script src="main.js"></script>
</body>
</html>

21
levels/level1.pacmap Normal file
View File

@@ -0,0 +1,21 @@
XXXXXXXXXXXXXXXXXXXXXXXXXXX
X............X............X
X.XXX.XXXXXX.X.XXXXXX.XXX.X
XOX-X.X-X.........X-X.X-XOX
X.XXX.XXX.XXXXXXX.XXX.XXX.X
X............X............X
X.XXX.XXXXXX-X-XXXXXX.XXX.X
X.......X---------X.......X
XXXXXXX.X-XXXXXXX-X.XXXXXXX
XXXXXXX.X-X-----X-X.XXXXXXX
-------.--X-----X--.-------
XXXXXXX.X-X-----X-X.XXXXXXX
XXXXXXX.X-XXXXXXX-X.XXXXXXX
X............X............X
XOXXX.XXXXXX.X.XXXXXX.XXXOX
X...X........S........X...X
XXX.XXX.X.XXXXXXX.X.XXX.XXX
X.......X....X....X.......X
X.XXXXXXXXXX.X.XXXXXXXXXX.X
X.........................X
XXXXXXXXXXXXXXXXXXXXXXXXXXX

2
main.js Normal file
View File

@@ -0,0 +1,2 @@
const game = new Game(16, 8);
game.start();