286 lines
9.8 KiB
TypeScript
286 lines
9.8 KiB
TypeScript
import { range, shuffle, zip } from "@common/utils";
|
|
import Entity from "./entity";
|
|
import Tile, { TileType } from "./tile";
|
|
import Pathfinding from "./pathfinding";
|
|
import Item, { ItemType } from "./item";
|
|
import Character, { Characters } from "./character";
|
|
import { SpinnerAction } from "./spinner";
|
|
|
|
enum GameState {
|
|
NORMAL,
|
|
FIGHT,
|
|
}
|
|
|
|
export default class TileMap extends Entity {
|
|
private tiles: Tile[] = [];
|
|
public startTile: Tile;
|
|
|
|
public readonly characters: Character[];
|
|
private currentCharacterIdx = 0;
|
|
private state = GameState.NORMAL;
|
|
private availableTiles: Tile[] = [];
|
|
|
|
constructor(position: [number, number], private mapSize: number, private tileSize: number, numPlayers: number = 2) {
|
|
super(position, [mapSize * tileSize, mapSize * tileSize]);
|
|
this.characters = shuffle(Object.values(Characters)).slice(0, numPlayers);
|
|
this.startTile = this.createMap();
|
|
this.findAvailableTiles();
|
|
}
|
|
|
|
public createMap() {
|
|
const map = range(this.mapSize)
|
|
.map(x =>
|
|
range(this.mapSize)
|
|
.map(y =>
|
|
new Tile([x * this.tileSize, y * this.tileSize], this.tileSize)
|
|
)
|
|
);
|
|
|
|
const startTile = new Tile([0, (this.mapSize - 2) * this.tileSize], this.tileSize * 2, TileType.START);
|
|
|
|
delete map[0][this.mapSize - 2];
|
|
delete map[1][this.mapSize - 2];
|
|
delete map[1][this.mapSize - 1];
|
|
|
|
map[0][this.mapSize - 1] = startTile;
|
|
|
|
const verticalWalls: [number, number][] = [
|
|
[1, 3], [3, 3], [6, 3], [10, 3],
|
|
[3, 4],
|
|
[1, 5], [6, 5], [10, 5],
|
|
[1, 6], [6, 6], [10, 6],
|
|
[1, 8], [6, 8], [10, 8],
|
|
[1, 9], [6, 9], [10, 9],
|
|
];
|
|
const horizontalWalls: [number, number][] = [
|
|
[2, 2], [2, 5], [2, 9],
|
|
[3, 2], [3, 5], [3, 9],
|
|
[4, 2], [5, 5],
|
|
[6, 2], [6, 5], [6, 9],
|
|
[7, 2], [7, 5], [7, 9],
|
|
[8, 2], [8, 5],
|
|
[9, 2], [9, 9],
|
|
[10, 2], [10, 5], [10, 9],
|
|
];
|
|
|
|
for (let x = 0; x < map.length; x++) {
|
|
const column = map[x];
|
|
if (!column) continue;
|
|
for (let y = 0; y < column.length; y++) {
|
|
const tile = column[y];
|
|
if (!tile) continue;
|
|
tile.connections = [
|
|
verticalWalls.find(w => w[0] === x - 1 && w[1] === y)
|
|
? undefined
|
|
: map[x - 1]?.[y],
|
|
|
|
verticalWalls.find(w => w[0] === x && w[1] === y)
|
|
? undefined
|
|
: map[x + 1]?.[y],
|
|
|
|
horizontalWalls.find(w => w[0] === x && w[1] === y - 1)
|
|
? undefined
|
|
: map[x]?.[y - 1],
|
|
|
|
horizontalWalls.find(w => w[0] === x && w[1] === y)
|
|
? undefined
|
|
: map[x]?.[y + 1],
|
|
].filter(t => t != null);
|
|
}
|
|
}
|
|
|
|
startTile.connections = [
|
|
map[0][this.mapSize - 3],
|
|
map[1][this.mapSize - 3],
|
|
map[2][this.mapSize - 2],
|
|
map[2][this.mapSize - 1],
|
|
].filter(t => t);
|
|
|
|
startTile.connections.forEach(t => t.connections.push(startTile));
|
|
|
|
const endTiles = [[8, 1], [9, 1], [10, 1]];
|
|
for (const [x, y] of endTiles) {
|
|
map[x][y].type = TileType.END;
|
|
}
|
|
|
|
this.tiles = map.flat();
|
|
this.startTile = startTile;
|
|
|
|
this.fillItems();
|
|
this.characters.forEach(c => c.tile = startTile);
|
|
|
|
return startTile;
|
|
}
|
|
|
|
public fillItems() {
|
|
const itemsMap = new Map([
|
|
[ItemType.ENEMY_BOSS, 1],
|
|
[ItemType.ENEMY_DOG, 5],
|
|
[ItemType.ENEMY_SPIDER, 5],
|
|
[ItemType.ENEMY_ZOMBIE, 17],
|
|
|
|
[ItemType.ITEM_FUEL, 1],
|
|
[ItemType.ITEM_HEAL, 6],
|
|
[ItemType.ITEM_KEYS, 1],
|
|
[ItemType.ITEM_PLANKS, 8],
|
|
|
|
[ItemType.WEAPON_ASSAULT_RIFLE, 1],
|
|
[ItemType.WEAPON_AXE, 1],
|
|
[ItemType.WEAPON_CROSSBOW, 1],
|
|
[ItemType.WEAPON_GRENADE, 4],
|
|
[ItemType.WEAPON_KNIFE, 1],
|
|
[ItemType.WEAPON_PISTOL, 1],
|
|
[ItemType.WEAPON_ROCKET_LAUNCHER, 1],
|
|
[ItemType.WEAPON_SHOTGUN, 1],
|
|
]);
|
|
|
|
const items: Item[] = [];
|
|
|
|
for (const [type, amount] of itemsMap.entries()) {
|
|
for (const _ in range(amount)) {
|
|
const item = new Item(type);
|
|
items.push(item);
|
|
}
|
|
}
|
|
|
|
for (const char of Object.values(Characters)) {
|
|
if (this.characters.includes(char)) continue;
|
|
items.push(char);
|
|
}
|
|
|
|
const normalTiles = this.tiles.filter(t => t.type === TileType.NORMAL);
|
|
const endTiles = this.tiles.filter(t => t.type === TileType.END);
|
|
const endTilesNeighbors = new Set(endTiles.flatMap(t => t.connections));
|
|
|
|
const fillableTiles = [
|
|
...endTilesNeighbors,
|
|
...this.startTile.connections,
|
|
...shuffle(normalTiles),
|
|
];
|
|
|
|
for (const [tile, item] of zip(fillableTiles, shuffle(items))) {
|
|
tile.items.push(item);
|
|
if (item instanceof Character) {
|
|
item.tile = tile;
|
|
}
|
|
};
|
|
}
|
|
|
|
get character() {
|
|
return this.characters[this.currentCharacterIdx];
|
|
}
|
|
|
|
public override handleMouseMove(x: number, y: number): void {
|
|
this.availableTiles.forEach(tile => tile.handleMouseMove(x - this.left, y - this.top));
|
|
}
|
|
|
|
public override handleClick(x: number, y: number): void {
|
|
if (this.state !== GameState.NORMAL) {
|
|
return;
|
|
}
|
|
for (const tile of this.availableTiles) {
|
|
if (tile.isPointInBounds(x - this.left, y - this.top)) {
|
|
const path = Pathfinding.findPath(this.character.tile, tile);
|
|
if (path.length > 1) {
|
|
this.character.tile = tile;
|
|
const items = tile.open();
|
|
this.character.inventory.push(...items);
|
|
if (tile.items.length > 0) {
|
|
for (const item of tile.items) { // iterate remaining items
|
|
if (item instanceof Character) {
|
|
tile.removeItem(item);
|
|
this.characters.push(item);
|
|
this.nextCharacter();
|
|
} else if (item.isBoss && this.character.useItem(this.character.rocketLauncher)) {
|
|
tile.killEnemy();
|
|
this.nextCharacter();
|
|
} else if (item.isEnemy) {
|
|
this.state = GameState.FIGHT;
|
|
} else {
|
|
alert(`Unknown item found: ${item}`);
|
|
}
|
|
}
|
|
} else {
|
|
this.nextCharacter();
|
|
}
|
|
}
|
|
|
|
this.findAvailableTiles();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
public handleSpin(action: SpinnerAction) {
|
|
switch (this.state) {
|
|
case GameState.NORMAL:
|
|
this.findAvailableTiles(action);
|
|
break;
|
|
case GameState.FIGHT:
|
|
const success = this.character.handleSpin(action);
|
|
if (success) {
|
|
switch (action) {
|
|
case SpinnerAction.BITE:
|
|
if (this.character.isDead) {
|
|
this.character.tile.items.push(...this.character.inventory);
|
|
this.characters.splice(this.currentCharacterIdx, 1);
|
|
this.currentCharacterIdx = this.currentCharacterIdx % this.characters.length;
|
|
}
|
|
break;
|
|
case SpinnerAction.MELEE:
|
|
case SpinnerAction.SHOOT:
|
|
this.character.tile.killEnemy();
|
|
this.nextCharacter();
|
|
break;
|
|
case SpinnerAction.RUN:
|
|
break;
|
|
}
|
|
|
|
if (action !== SpinnerAction.BITE) {
|
|
this.state = GameState.NORMAL;
|
|
this.findAvailableTiles();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
private findAvailableTiles(moveDistance: number = 1) {
|
|
const characterTiles = new Set(this.characters.map(c => c.tile));
|
|
this.availableTiles = Pathfinding.findPossibleMoves(this.character.tile, moveDistance)
|
|
.filter(t => !characterTiles.has(t));
|
|
}
|
|
|
|
private nextCharacter() {
|
|
this.currentCharacterIdx = (this.currentCharacterIdx + 1) % this.characters.length;
|
|
}
|
|
|
|
protected draw(ctx: CanvasRenderingContext2D): void {
|
|
ctx.scale(1 / this.width, 1 / this.height);
|
|
|
|
ctx.lineWidth = 2;
|
|
ctx.fillStyle = 'rgba(0, 255, 0, 0.5)';
|
|
|
|
if (this.state === GameState.NORMAL && this.availableTiles.length > 0) {
|
|
ctx.beginPath();
|
|
|
|
this.availableTiles.forEach(t =>
|
|
ctx.fillRect(t.centerX - t.width / 2, t.centerY - t.height / 2, t.width, t.height)
|
|
);
|
|
|
|
ctx.stroke();
|
|
}
|
|
|
|
const w = this.tileSize * 0.8;
|
|
this.characters.toReversed().forEach(c =>
|
|
ctx.drawImage(c.type, c.tile.centerX - w / 2, c.tile.centerY - w / 2, w, w)
|
|
);
|
|
|
|
this.tiles.forEach(t => t.render(ctx));
|
|
|
|
ctx.lineWidth = 3;
|
|
ctx.strokeStyle = 'yellow';
|
|
|
|
ctx.strokeRect(this.character.tile.centerX - w / 2, this.character.tile.centerY - w / 2, w, w);
|
|
}
|
|
} |