commit 35bc54eaac674fcd5a9c1ef1281e35bd2fb2bd1e Author: KingOfDog Date: Tue Jun 11 18:46:14 2019 +0200 initial commit diff --git a/DOM.js b/DOM.js new file mode 100644 index 0000000..507796c --- /dev/null +++ b/DOM.js @@ -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; +}; \ No newline at end of file diff --git a/Game.js b/Game.js new file mode 100644 index 0000000..440d132 --- /dev/null +++ b/Game.js @@ -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(); + } +} \ No newline at end of file diff --git a/GameMap.js b/GameMap.js new file mode 100644 index 0000000..3458e09 --- /dev/null +++ b/GameMap.js @@ -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 + }; + } +} \ No newline at end of file diff --git a/GameObject.js b/GameObject.js new file mode 100644 index 0000000..3762fc6 --- /dev/null +++ b/GameObject.js @@ -0,0 +1,10 @@ +class GameObject { + constructor(game, x, y) { + this.game = game; + this.x = x; + this.y = y; + } + + draw(ctx, conf) {} + update() {} +} \ No newline at end of file diff --git a/Ghost.js b/Ghost.js new file mode 100644 index 0000000..45344b6 --- /dev/null +++ b/Ghost.js @@ -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; + } +} \ No newline at end of file diff --git a/MapLoader.js b/MapLoader.js new file mode 100644 index 0000000..5c6c5ea --- /dev/null +++ b/MapLoader.js @@ -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; + } +} \ No newline at end of file diff --git a/MapTile.js b/MapTile.js new file mode 100644 index 0000000..69566a9 --- /dev/null +++ b/MapTile.js @@ -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(); + } +} \ No newline at end of file diff --git a/MovingObject.js b/MovingObject.js new file mode 100644 index 0000000..b31fce4 --- /dev/null +++ b/MovingObject.js @@ -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, + }; + } +} \ No newline at end of file diff --git a/Player.js b/Player.js new file mode 100644 index 0000000..430a4cf --- /dev/null +++ b/Player.js @@ -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; + } +} \ No newline at end of file diff --git a/helper-methods.js b/helper-methods.js new file mode 100644 index 0000000..1452807 --- /dev/null +++ b/helper-methods.js @@ -0,0 +1,5 @@ +function calculateTileSize(width, height, tilesX, tilesY) { + const tileWidth = width / tilesX, + tileHeight = height / tilesY; + return Math.floor(Math.min(tileWidth, tileHeight)); +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..5c65bfc --- /dev/null +++ b/index.html @@ -0,0 +1,28 @@ + + + + + Pacman + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/levels/level1.pacmap b/levels/level1.pacmap new file mode 100644 index 0000000..2b4cb19 --- /dev/null +++ b/levels/level1.pacmap @@ -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 \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..0155513 --- /dev/null +++ b/main.js @@ -0,0 +1,2 @@ +const game = new Game(16, 8); +game.start(); \ No newline at end of file