feat: Implement basic level 1 with tilemap, enemies, pathfinding and wave system

- Added level 1 assets (tilemap, terrain, enemy sprites)
- Implemented Level1 scene with tilemap loading and collision detection
- Integrated EasyStar.js for enemy pathfinding between spawn and end points
- Created wave manager system to handle enemy spawning schedules
- Added basic enemy configuration and animation support
- Set up game structure with Phaser 3 framework
- Added web server startup script for local development

This commit establishes the foundational level 1 gameplay including map rendering, collision detection, enemy spawning mechanics, and pathfinding behavior using EasyStar.js for AI movement.
This commit is contained in:
Brian Fertig 2025-08-30 20:38:17 -06:00
commit 9bf0b55f33
16 changed files with 618 additions and 0 deletions

BIN
assets/basic-enemies.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
assets/basic-enemies.psd Normal file

Binary file not shown.

BIN
assets/josh-life.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

111
assets/level1.json Normal file
View File

@ -0,0 +1,111 @@
{ "compressionlevel":-1,
"height":9,
"infinite":true,
"layers":[
{
"chunks":[
{
"data":[2, 1, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0,
2, 1, 2, 1, 1, 1, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0,
2, 1, 1, 1, 2, 1, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0,
2, 2, 2, 2, 2, 2, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":16,
"width":16,
"x":0,
"y":0
}],
"height":16,
"id":1,
"name":"main",
"opacity":1,
"startx":0,
"starty":0,
"type":"tilelayer",
"visible":true,
"width":16,
"x":0,
"y":0
},
{
"chunks":[
{
"data":[0, 0, 16, 0, 16, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 16, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0,
16, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 16, 16, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":16,
"width":16,
"x":0,
"y":0
}],
"height":16,
"id":2,
"name":"platforms",
"opacity":1,
"startx":0,
"starty":0,
"type":"tilelayer",
"visible":true,
"width":16,
"x":0,
"y":0
}],
"nextlayerid":3,
"nextobjectid":1,
"orientation":"orthogonal",
"renderorder":"right-down",
"tiledversion":"1.11.2",
"tileheight":200,
"tilesets":[
{
"columns":5,
"firstgid":1,
"image":"terrain.png",
"imageheight":1000,
"imagewidth":1000,
"margin":0,
"name":"terrain",
"spacing":0,
"tilecount":25,
"tileheight":200,
"tiles":[
{
"id":1,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
}],
"tilewidth":200
}],
"tilewidth":200,
"type":"map",
"version":"1.10",
"width":16
}

BIN
assets/terrain.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

BIN
assets/terrain.psd Normal file

Binary file not shown.

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.11.2" orientation="orthogonal" renderorder="right-down" width="16" height="9" tilewidth="200" tileheight="200" infinite="1" nextlayerid="3" nextobjectid="1">
<editorsettings>
<export target="../level1.json" format="json"/>
</editorsettings>
<tileset firstgid="1" name="terrain" tilewidth="200" tileheight="200" tilecount="25" columns="5">
<image source="../terrain.png" width="1000" height="1000"/>
<tile id="1">
<properties>
<property name="collides" type="bool" value="true"/>
</properties>
</tile>
</tileset>
<layer id="1" name="main" width="16" height="9">
<data encoding="csv">
<chunk x="0" y="0" width="16" height="16">
2,1,2,2,2,2,2,2,0,0,0,0,0,0,0,0,
2,1,2,1,1,1,2,2,0,0,0,0,0,0,0,0,
2,1,1,1,2,1,1,2,0,0,0,0,0,0,0,0,
2,2,2,2,2,2,1,2,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</chunk>
</data>
</layer>
<layer id="2" name="platforms" width="16" height="9">
<data encoding="csv">
<chunk x="0" y="0" width="16" height="16">
0,0,16,0,16,0,16,0,0,0,0,0,0,0,0,0,
0,0,16,0,0,0,16,0,0,0,0,0,0,0,0,0,
16,0,0,0,16,0,0,0,0,0,0,0,0,0,0,0,
0,16,16,0,16,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</chunk>
</data>
</layer>
</map>

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Alien Rush</title>
<style>
body { margin: 0; background-color: black; }
canvas { display: block; }
</style>
</head>
<body>
<div id="game-container"></div>
<script src="https://cdn.jsdelivr.net/npm/phaser@v3.90.0/dist/phaser.min.js"></script>
<script src="./src/EasyStar.js"></script>
<script type="module" src="./src/main.js"></script>
</body>
</html>

25
src/EasyStar.js Normal file

File diff suppressed because one or more lines are too long

60
src/levels/level1.js Normal file
View File

@ -0,0 +1,60 @@
import { WaveManager } from '../support/waveManager.js';
export class Level1 extends Phaser.Scene {
constructor() {
super({ key: 'Level1' });
}
init(data) {
}
preload() {
this.load.tilemapTiledJSON('level1', 'assets/level1.json');
this.load.image('terrain', 'assets/terrain.png');
this.load.spritesheet('basic-enemies', 'assets/basic-enemies.png', {
frameWidth: 50,
frameHeight: 50
});
}
create() {
this.levelMap = this.make.tilemap({ key: 'level1' });
const terrainTiles = this.levelMap.addTilesetImage('terrain', 'terrain');
this.mainLayer = this.levelMap.createLayer('main', terrainTiles)
.setCollisionByProperty({ collides: true });
this.platformsLayer = this.levelMap.createLayer('platforms', terrainTiles);
this.waveManager = new WaveManager(this, 1, 1);
this.enemies = this.physics.add.group();
this.physics.add.collider(this.enemies, this.mainLayer);
this.physics.add.collider(this.enemies, this.platformsLayer);
}
moveJoshAlongPath(path) {
let currentIndex = 0;
const moveNextStep = () => {
if (currentIndex >= path.length - 1) return; // Reached target
const nextPoint = path[++currentIndex];
// Move josh to the next point
this.tweens.add({
targets: this.josh,
x: nextPoint.x * 200 + 100,
y: nextPoint.y * 200 + 100,
duration: 2000,
onComplete: moveNextStep
});
};
moveNextStep();
}
update(time, delta) {
this.waveManager.update(time, delta);
}
}

29
src/main.js Normal file
View File

@ -0,0 +1,29 @@
import { Level1 } from './levels/level1.js';
const GAME_CONFIG = {
type: Phaser.AUTO,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
width: 1600,
height: 900,
parent: 'game-container'
},
parent: 'game-container',
backgroundColor: '#bb7432ff',
scene: [
Level1
],
physics: {
default: 'arcade',
arcade: {
gravity: { y: 0 },
debug: false
}
}
};
// Create the game instance
const game = new Phaser.Game(GAME_CONFIG);
console.log('Alien Rush game initialized successfully!');

63
src/support/enemies.js Normal file
View File

@ -0,0 +1,63 @@
import { ENEMIES_CONFIG } from "./enemiesConfig.js";
export class Enemies {
constructor(scene, type, x, y, path) {
this.scene = scene;
this.type = type;
this.x = x;
this.y = y;
this.path = path;
this.spread = ENEMIES_CONFIG[type].spread;
this.speedLow = ENEMIES_CONFIG[type].speedLow;
this.speedHigh = ENEMIES_CONFIG[type].speedHigh;
this.baseSprite = ENEMIES_CONFIG[type].spriteStart;
this.spawnEnemy();
}
spawnEnemy() {
const randX = Phaser.Math.Between(-this.spread, this.spread);
const randY = Phaser.Math.Between(-this.spread, this.spread);
const randSpeed = Phaser.Math.Between(this.speedLow, this.speedHigh);
const spawnX = (this.x * 200) + 100 + randX;
const spawnY = (this.y * 200) + 100 + randY;
// Create enemy and store reference
const enemy = this.scene.add.sprite(spawnX, spawnY, ENEMIES_CONFIG[this.type].spriteSheet, ENEMIES_CONFIG[this.type].spriteStart);
// Create Animations
this.createAnim('side', ENEMIES_CONFIG[this.type].spriteStart, ENEMIES_CONFIG[this.type].spriteStart+2);
this.createAnim('up', ENEMIES_CONFIG[this.type].spriteStart+6, ENEMIES_CONFIG[this.type].spriteStart+7);
this.createAnim('down', ENEMIES_CONFIG[this.type].spriteStart+3, ENEMIES_CONFIG[this.type].spriteStart+5);
this.createAnim('die', ENEMIES_CONFIG[this.type].spriteStart+8, ENEMIES_CONFIG[this.type].spriteStart+9, 0);
enemy.props = {
'offsetX': randX,
'offsetY': randY,
'path': this.path,
'pathPhase': 0,
'speed': randSpeed,
'health': ENEMIES_CONFIG[this.type].health,
'type': this.type
};
this.scene.enemies.add(enemy);
enemy.play(`${this.type}-side`);
}
createAnim(type, start, end, repeat = -1) {
if (!this.scene.anims.get(`${this.type}-${type}`)) {
this.scene.anims.create({
key: `${this.type}-${type}`,
frames: this.scene.anims.generateFrameNumbers(ENEMIES_CONFIG[this.type].spriteSheet, {
start: start,
end: end,
}),
frameRate: 5,
repeat: repeat
});
}
}
}

View File

@ -0,0 +1,18 @@
export const ENEMIES_CONFIG = {
'basic1': {
'spread': 25,
'health': 100,
'speedLow': 25,
'speedHigh': 35,
'spriteStart': 0,
'spriteSheet': 'basic-enemies'
},
'basic2': {
'spread': 0,
'health': 300,
'speedLow': 45,
'speedHigh': 55,
'spriteStart': 0,
'spriteSheet': 'basic-enemies'
}
}

42
src/support/waveConfig.js Normal file
View File

@ -0,0 +1,42 @@
export const WAVE_CONFIG = {
// Level
1: {
//Spawn Point
spawnX: 1,
spawnY: 0,
endX: 6,
endY: 3,
// Wave
1: {
// Schedule
1: {
begin: 0,
basic1: 5
},
2: {
begin: 15,
basic1: 1
},
3: {
begin: 30,
basic1: 5
}
},
// Wave
2: {
// Schedule
1: {
begin: 0,
basic1: 5
},
2: {
begin: 15,
basic1: 5
},
3: {
begin: 30,
basic1: 5
}
}
}
}

194
src/support/waveManager.js Normal file
View File

@ -0,0 +1,194 @@
import { WAVE_CONFIG } from './waveConfig.js'
import { Enemies } from './enemies.js'
export class WaveManager {
constructor(scene, level, wave) {
this.scene = scene;
this.level = level;
this.wave = wave;
this.schedule = 0;
this.scheduleInfo = null;
this.spawnX = WAVE_CONFIG[this.level].spawnX;
this.spawnY = WAVE_CONFIG[this.level].spawnY;
this.endX = WAVE_CONFIG[this.level].endX;
this.endY = WAVE_CONFIG[this.level].endY;
this.path = null;
this.waveTimer = 0;
this.waveScheduleStartTime();
}
waveScheduleStartTime() {
this.schedule++;
this.scheduleInfo = WAVE_CONFIG[this.level][this.wave][this.schedule]
this.waveStart = this.scheduleInfo.begin * 1000;
}
nextWave() {
this.wave++;
this.waveTimer = 0;
this.schedule = 0;
this.waveScheduleStartTime();
}
gridToLocation(num, offset = 0) {
return num * 200 + 100 + offset;
}
update(time, delta) {
this.waveTimer += delta;
// Handle Enemy Pathing
this.scene.enemies.children.iterate((enemy) => {
const path = enemy.props.path;
const pathPhase = enemy.props.pathPhase;
const speed = enemy.props.speed;
const offsetX = enemy.props.offsetX;
const offsetY = enemy.props.offsetY;
// Only move if we have a valid path and pathPhase
if (path && pathPhase !== undefined) {
// Calculate movement for this frame
const segmentSpeed = speed; // Normalize by delta time
// Get current and next points in the path
const currentPoint = path[pathPhase];
const nextPoint = path[pathPhase + 1];
if (currentPoint && nextPoint) {
// Calculate direction vector from current to next point
const dx = this.gridToLocation(nextPoint.x, offsetX) - this.gridToLocation(currentPoint.x, offsetX);
const dy = this.gridToLocation(nextPoint.y, offsetY) - this.gridToLocation(currentPoint.y, offsetY);
const distance = Math.sqrt(dx * dx + dy * dy);
// Normalize the direction
if (distance > 0) {
const normalizedDx = dx / distance;
const normalizedDy = dy / distance;
const velocX = normalizedDx * segmentSpeed;
const velocY = normalizedDy * segmentSpeed;
// Move towards next point
enemy.body.setVelocity(velocX, velocY);
if (Math.abs(velocY) > Math.abs(velocX)) {
if (velocY > 0) {
enemy.play(`${enemy.props.type}-down`, true);
} else {
enemy.play(`${enemy.props.type}-up`, true);
}
} else {
if (velocX > 0) {
enemy.play(`${enemy.props.type}-side`, true);
enemy.setFlipX(true);
} else {
enemy.play(`${enemy.props.type}-side`, true);
enemy.setFlipX(false);
}
}
// Check if we've reached the next point
const distToNextPoint = Math.sqrt(
Math.pow(enemy.x - this.gridToLocation(nextPoint.x, offsetX), 2) +
Math.pow(enemy.y - this.gridToLocation(nextPoint.y, offsetY), 2)
);
if (distToNextPoint < segmentSpeed * 0.5) { // Threshold for reaching point
enemy.props.pathPhase++; // Move to next path segment
// If we've reached the end of the path, remove the enemy or handle accordingly
if (enemy.props.pathPhase >= path.length - 1) {
// Enemy reached destination - you might want to handle this differently
enemy.body.setVelocity(0, 0);
// Remove enemy from scene or mark for removal
}
}
} else {
// We're at the last point in the path
enemy.body.setVelocity(0, 0);
}
} else if (currentPoint && !nextPoint) {
// Last point in path - stop moving
enemy.body.setVelocity(0, 0);
}
}
});
// Handle Waves and Schedules
if (this.waveTimer >= this.waveStart) {
console.log('Wave',this.wave,'Schedule',this.schedule);
// Make path synchronous
this.makePath().then(() => {
// Spawn enemies after path is ready
this.spawnSchedule();
if (WAVE_CONFIG[this.level][this.wave].hasOwnProperty(this.schedule + 1)) {
this.waveScheduleStartTime();
} else if (WAVE_CONFIG[this.level].hasOwnProperty(this.wave+1)) {
this.nextWave();
} else {
console.log('LEVEL COMPLETE');
}
});
}
}
spawnSchedule() {
if (this.scheduleInfo.hasOwnProperty('basic1')) {
console.log('Spawn',this.scheduleInfo.basic1,'Basic1 enemies');
for (let e = 0; e < this.scheduleInfo.basic1; e++) {
const enemy = new Enemies(this.scene, 'basic1', this.spawnX, this.spawnY, this.path);
}
}
}
makePath() {
return new Promise((resolve) => {
this.easyStar = new EasyStar.js();
// Set up the grid for pathfinding
const width = this.scene.levelMap.width;
const height = this.scene.levelMap.height;
const grid = [];
// Create a grid based on collision data
for (let y = 0; y < height; y++) {
grid[y] = [];
for (let x = 0; x < width; x++) {
// Check if the tile at this position is colliding
const tile = this.scene.mainLayer.getTileAt(x, y);
if (tile && tile.properties.collides) {
grid[y][x] = 1; // Blocked
} else {
grid[y][x] = 0; // Free space
}
}
}
this.easyStar.setGrid(grid);
this.easyStar.setAcceptableTiles([0]); // Only allow movement on tiles with value 0
this.easyStar.findPath(
this.spawnX,
this.spawnY,
this.endX,
this.endY,
(path) => {
if (path === null) {
console.log("No path found");
this.path = null;
} else {
this.path = path;
}
resolve();
}
);
this.easyStar.calculate();
});
}
}

1
start_web.bat Normal file
View File

@ -0,0 +1 @@
python -m http.server 8000