Compare commits
No commits in common. "bdca1ce602073ef17ab6343d01f4cc246e35838b" and "53930cbe23a4db7491e3aa913bfa3162021d4dc3" have entirely different histories.
bdca1ce602
...
53930cbe23
|
|
@ -32,20 +32,6 @@ export const shuffle = <T>(array: T[]): T[] => {
|
||||||
return shuffledArray;
|
return shuffledArray;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function zip<T1, T2, T3>(a1: Iterable<T1>, a2: Iterable<T2>, a3: Iterable<T3>): Generator<[T1, T2, T3]>;
|
|
||||||
export function zip<T1, T2>(a1: Iterable<T1>, a2: Iterable<T2>): Generator<[T1, T2]>;
|
|
||||||
export function zip<T1>(a1: Iterable<T1>): Generator<[T1]>;
|
|
||||||
export function* zip(...args: Iterable<any>[]) {
|
|
||||||
const iterators = args.map(i => i[Symbol.iterator]());
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const nextValues = iterators.map(i => i.next());
|
|
||||||
if (nextValues.some(v => v.done)) return;
|
|
||||||
|
|
||||||
yield nextValues.map(v => v.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const range = (size: number | string) => Object.keys((new Array(+size)).fill(0)).map(k => +k);
|
export const range = (size: number | string) => Object.keys((new Array(+size)).fill(0)).map(k => +k);
|
||||||
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -1,104 +0,0 @@
|
||||||
import Item, { ItemType, type ItemTypeImage } from "./item";
|
|
||||||
import { SpinnerAction } from "./spinner";
|
|
||||||
import Tile from "./tile";
|
|
||||||
|
|
||||||
export default class Character extends Item {
|
|
||||||
public health: number;
|
|
||||||
public inventory: Item[] = [];
|
|
||||||
public tile: Tile = new Tile([0, 0], 1);
|
|
||||||
|
|
||||||
constructor(type: ItemTypeImage) {
|
|
||||||
super(type);
|
|
||||||
this.health = this.maxHealth;
|
|
||||||
const defaultItem = this.defaultItem;
|
|
||||||
if (defaultItem) {
|
|
||||||
this.inventory.push(this.defaultItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get maxHealth() {
|
|
||||||
return this.type === ItemType.CHAR_BIG ? 7 : 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
get defaultItem() {
|
|
||||||
if (this.type === ItemType.CHAR_NINJA) {
|
|
||||||
return new Item(ItemType.WEAPON_KNIFE);
|
|
||||||
}
|
|
||||||
if (this.type === ItemType.CHAR_POLICE) {
|
|
||||||
return new Item(ItemType.WEAPON_PISTOL);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get healingAmount() {
|
|
||||||
return this.type === ItemType.CHAR_NURSE ? 2 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
get moveBonus() {
|
|
||||||
return this.type === ItemType.CHAR_RUNNER ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isDead() {
|
|
||||||
return this.health <= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get meleeWeapon() {
|
|
||||||
return this.inventory.find(i => i.isMeleeWeapon);
|
|
||||||
}
|
|
||||||
|
|
||||||
get gun() {
|
|
||||||
return this.inventory.find(i => i.isShootingWeapon);
|
|
||||||
}
|
|
||||||
|
|
||||||
get grenade() {
|
|
||||||
return this.inventory.find(i => i.type === ItemType.WEAPON_GRENADE);
|
|
||||||
}
|
|
||||||
|
|
||||||
get rocketLauncher() {
|
|
||||||
return this.inventory.find(i => i.type === ItemType.WEAPON_ROCKET_LAUNCHER);
|
|
||||||
}
|
|
||||||
|
|
||||||
public heal(character: Character) {
|
|
||||||
character.health += this.healingAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeItem(item: Item | null | undefined): boolean {
|
|
||||||
if (!item) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const itemIndex = this.inventory.findIndex(i => i === item);
|
|
||||||
|
|
||||||
if (itemIndex >= 0) {
|
|
||||||
this.inventory.splice(itemIndex, 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @returns true, if action was performed */
|
|
||||||
public handleSpin(action: SpinnerAction): boolean {
|
|
||||||
switch (action) {
|
|
||||||
case SpinnerAction.RUN:
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case SpinnerAction.BITE:
|
|
||||||
this.health -= 1;
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case SpinnerAction.MELEE:
|
|
||||||
return this.meleeWeapon != null && !this.tile.enemy?.isBoss;
|
|
||||||
|
|
||||||
case SpinnerAction.SHOOT:
|
|
||||||
return this.removeItem(this.gun) && !this.tile.enemy?.isBoss;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Characters = {
|
|
||||||
BIG: new Character(ItemType.CHAR_BIG),
|
|
||||||
NINJA: new Character(ItemType.CHAR_NINJA),
|
|
||||||
NURSE: new Character(ItemType.CHAR_NURSE),
|
|
||||||
POLICE: new Character(ItemType.CHAR_POLICE),
|
|
||||||
RUNNER: new Character(ItemType.CHAR_RUNNER),
|
|
||||||
} as const;
|
|
||||||
|
|
@ -37,7 +37,7 @@ export default abstract class Entity {
|
||||||
|
|
||||||
public handleClick(x: number, y: number) {
|
public handleClick(x: number, y: number) {
|
||||||
if (this.isPointInBounds(x, y)) {
|
if (this.isPointInBounds(x, y)) {
|
||||||
this.onClick(x - this.left, y - this.right);
|
this.onClick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +46,6 @@ export default abstract class Entity {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract draw(ctx: CanvasRenderingContext2D): void;
|
protected abstract draw(ctx: CanvasRenderingContext2D): void;
|
||||||
protected onClick(_x: number, _y: number) { }
|
protected onClick() {}
|
||||||
public update(_dt: number) { };
|
public abstract update(dt: number): void;
|
||||||
}
|
}
|
||||||
|
|
@ -3,32 +3,20 @@ import Spinner from "./spinner";
|
||||||
import type Entity from "./entity";
|
import type Entity from "./entity";
|
||||||
import { getRealPoint } from "@common/dom";
|
import { getRealPoint } from "@common/dom";
|
||||||
import { nextFrame } from "@common/utils";
|
import { nextFrame } from "@common/utils";
|
||||||
import bgImg from './assets/bg.jpg';
|
import { TileMap } from "./tile";
|
||||||
import TileMap from "./tilemap";
|
|
||||||
import Inventory from "./inventory";
|
|
||||||
|
|
||||||
const MAP_SIZE = 12;
|
const MAP_SIZE = 12;
|
||||||
const MAP_PIXEL_SIZE = 1000;
|
const MAP_PIXEL_SIZE = 1000;
|
||||||
const MAP_PADDING = 10;
|
const MAP_PADDING = 100;
|
||||||
const TILE_SIZE = (MAP_PIXEL_SIZE - MAP_PADDING * 2) / MAP_SIZE;
|
const TILE_SIZE = (MAP_PIXEL_SIZE - MAP_PADDING * 2) / MAP_SIZE;
|
||||||
const SPINNER_SIZE = 200;
|
const SPINNER_SIZE = 200;
|
||||||
const canvas = createCanvas(MAP_PIXEL_SIZE + SPINNER_SIZE, MAP_PIXEL_SIZE);
|
const canvas = createCanvas(MAP_PIXEL_SIZE + SPINNER_SIZE, MAP_PIXEL_SIZE);
|
||||||
const spinner = new Spinner([MAP_PIXEL_SIZE, 0], [SPINNER_SIZE, SPINNER_SIZE]);
|
const spinner = new Spinner([MAP_PIXEL_SIZE, 0], [SPINNER_SIZE, SPINNER_SIZE]);
|
||||||
const map = new TileMap(
|
const map = new TileMap([MAP_PADDING, MAP_PADDING], MAP_SIZE, TILE_SIZE);
|
||||||
[MAP_PADDING, MAP_PADDING],
|
|
||||||
MAP_SIZE,
|
|
||||||
TILE_SIZE,
|
|
||||||
);
|
|
||||||
const inventory = new Inventory(
|
|
||||||
map.characters,
|
|
||||||
[MAP_PIXEL_SIZE, SPINNER_SIZE],
|
|
||||||
[SPINNER_SIZE, MAP_PIXEL_SIZE - SPINNER_SIZE],
|
|
||||||
);
|
|
||||||
|
|
||||||
const entities: Entity[] = [
|
const entities: Entity[] = [
|
||||||
spinner,
|
spinner,
|
||||||
map,
|
map,
|
||||||
inventory,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
async function update(dt: number) {
|
async function update(dt: number) {
|
||||||
|
|
@ -36,14 +24,9 @@ async function update(dt: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function render(ctx: CanvasRenderingContext2D) {
|
async function render(ctx: CanvasRenderingContext2D) {
|
||||||
ctx.textAlign = 'center';
|
ctx.fillStyle = 'green';
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
|
|
||||||
ctx.fillStyle = 'white';
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
ctx.drawImage(bgImg, 0, 0, MAP_PIXEL_SIZE, MAP_PIXEL_SIZE);
|
|
||||||
|
|
||||||
entities.forEach(entity => entity.render(ctx));
|
entities.forEach(entity => entity.render(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,9 +47,7 @@ export default async function main() {
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
spinner.addListener((a) => map.handleSpin(a));
|
spinner.addListener(console.log);
|
||||||
inventory.addListener((c, i) => map.handleItemUse(c, i));
|
|
||||||
|
|
||||||
canvas.addEventListener('click', onClick);
|
canvas.addEventListener('click', onClick);
|
||||||
canvas.addEventListener('mousemove', onMouseMove);
|
canvas.addEventListener('mousemove', onMouseMove);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
import { range } from "@common/utils";
|
|
||||||
import type Character from "./character";
|
|
||||||
import { Characters } from "./character";
|
|
||||||
import Entity from "./entity";
|
|
||||||
import Tile from "./tile";
|
|
||||||
import type Item from "./item";
|
|
||||||
|
|
||||||
export type UseListener = (character: Character, item: Item) => void;
|
|
||||||
|
|
||||||
export default class Inventory extends Entity {
|
|
||||||
private tiles: Tile[][];
|
|
||||||
private listeners = new Set<UseListener>();
|
|
||||||
|
|
||||||
constructor(public readonly characters: Character[], position: [number, number], size: [number, number]) {
|
|
||||||
super(position, size);
|
|
||||||
|
|
||||||
const numCharacters = Object.keys(Characters).length;
|
|
||||||
const tileSize = this.width / numCharacters;
|
|
||||||
const numTiles = Math.floor(this.height / tileSize) - 2;
|
|
||||||
|
|
||||||
this.tiles = range(numCharacters).map((x) =>
|
|
||||||
range(numTiles).map((y) =>
|
|
||||||
new Tile([x * tileSize, (2 + y) * tileSize], tileSize)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private drawCharacter(ctx: CanvasRenderingContext2D, idx: number) {
|
|
||||||
const character = this.characters[idx];
|
|
||||||
ctx.drawImage(character.type, 0.1, 0.1, 0.8, 0.8);
|
|
||||||
ctx.fillText(`💖 ${character.health}`, 0.5, 1.5);
|
|
||||||
|
|
||||||
let y = 2;
|
|
||||||
for (const item of character.inventory) {
|
|
||||||
ctx.drawImage(item.type, 0.1, 0.1 + y, 0.8, 0.8);
|
|
||||||
y += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override handleClick(x: number, y: number): void {
|
|
||||||
for (const { tile, item, character } of this.activeTiles) {
|
|
||||||
if (tile.isPointInBounds(x - this.left, y - this.top)) {
|
|
||||||
this.listeners.forEach(l => l(character, item));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override handleMouseMove(x: number, y: number): void {
|
|
||||||
this.activeTiles.forEach(({ tile }) => tile.handleMouseMove(x - this.left, y - this.top));
|
|
||||||
}
|
|
||||||
|
|
||||||
private get activeTiles() {
|
|
||||||
return this.tiles.slice(0, this.characters.length)
|
|
||||||
.flatMap((column, characterIndex) =>
|
|
||||||
column.slice(0, this.characters[characterIndex].inventory.length)
|
|
||||||
.map((tile, tileIndex) => ({
|
|
||||||
tile,
|
|
||||||
character: this.characters[characterIndex],
|
|
||||||
item: this.characters[characterIndex].inventory[tileIndex],
|
|
||||||
}))
|
|
||||||
.filter(({ item }) => item.isConsumable)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public addListener(listener: UseListener) {
|
|
||||||
this.listeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeListener(listener: UseListener) {
|
|
||||||
this.listeners.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override draw(ctx: CanvasRenderingContext2D): void {
|
|
||||||
const step = 1 / Object.keys(Characters).length;
|
|
||||||
const columnWidth = this.width * step;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.scale(step, columnWidth / this.height);
|
|
||||||
ctx.font = `0.3px Arial`;
|
|
||||||
ctx.fillStyle = 'black';
|
|
||||||
|
|
||||||
for (const i of range(this.characters.length)) {
|
|
||||||
this.drawCharacter(ctx, i);
|
|
||||||
ctx.translate(1, 0);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
ctx.scale(1 / this.width, 1 / this.height);
|
|
||||||
this.activeTiles.forEach(({ tile }) => tile.render(ctx));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import big from './assets/characters/big.jpg';
|
|
||||||
import ninja from './assets/characters/ninja.jpg';
|
|
||||||
import nurse from './assets/characters/nurse.jpg';
|
|
||||||
import police from './assets/characters/police.jpg';
|
|
||||||
import runner from './assets/characters/runner.jpg';
|
|
||||||
|
|
||||||
import boss from './assets/enemies/boss.jpg';
|
|
||||||
import dog from './assets/enemies/dog.jpg';
|
|
||||||
import spider from './assets/enemies/spider.jpg';
|
|
||||||
import zombie from './assets/enemies/zombie.jpg';
|
|
||||||
|
|
||||||
import fuel from './assets/items/fuel.jpg';
|
|
||||||
import heal from './assets/items/heal.jpg';
|
|
||||||
import keys from './assets/items/keys.jpg';
|
|
||||||
import planks from './assets/items/planks.jpg';
|
|
||||||
|
|
||||||
import assaultRifle from './assets/weapons/assault_rifle.jpg';
|
|
||||||
import axe from './assets/weapons/axe.jpg';
|
|
||||||
import crossbow from './assets/weapons/crossbow.jpg';
|
|
||||||
import grenade from './assets/weapons/grenade.jpg';
|
|
||||||
import knife from './assets/weapons/knife.jpg';
|
|
||||||
import pistol from './assets/weapons/pistol.jpg';
|
|
||||||
import rocketLauncher from './assets/weapons/rocket_launcher.jpg';
|
|
||||||
import shotgun from './assets/weapons/shotgun.jpg';
|
|
||||||
|
|
||||||
export const ItemType = {
|
|
||||||
CHAR_BIG: big,
|
|
||||||
CHAR_NINJA: ninja,
|
|
||||||
CHAR_NURSE: nurse,
|
|
||||||
CHAR_POLICE: police,
|
|
||||||
CHAR_RUNNER: runner,
|
|
||||||
|
|
||||||
ENEMY_BOSS: boss,
|
|
||||||
ENEMY_DOG: dog,
|
|
||||||
ENEMY_SPIDER: spider,
|
|
||||||
ENEMY_ZOMBIE: zombie,
|
|
||||||
|
|
||||||
ITEM_FUEL: fuel,
|
|
||||||
ITEM_HEAL: heal,
|
|
||||||
ITEM_KEYS: keys,
|
|
||||||
ITEM_PLANKS: planks,
|
|
||||||
|
|
||||||
WEAPON_ASSAULT_RIFLE: assaultRifle,
|
|
||||||
WEAPON_AXE: axe,
|
|
||||||
WEAPON_CROSSBOW: crossbow,
|
|
||||||
WEAPON_GRENADE: grenade,
|
|
||||||
WEAPON_KNIFE: knife,
|
|
||||||
WEAPON_PISTOL: pistol,
|
|
||||||
WEAPON_ROCKET_LAUNCHER: rocketLauncher,
|
|
||||||
WEAPON_SHOTGUN: shotgun,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type ItemTypeType = typeof ItemType;
|
|
||||||
type ImageKey = keyof ItemTypeType;
|
|
||||||
export type ItemTypeImage = ItemTypeType[ImageKey];
|
|
||||||
|
|
||||||
export default class Item {
|
|
||||||
constructor(public readonly type: ItemTypeImage) {
|
|
||||||
}
|
|
||||||
|
|
||||||
get isPickable() {
|
|
||||||
return this.isItem || this.isWeapon;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isEnemy() {
|
|
||||||
return [
|
|
||||||
ItemType.ENEMY_BOSS,
|
|
||||||
ItemType.ENEMY_DOG,
|
|
||||||
ItemType.ENEMY_SPIDER,
|
|
||||||
ItemType.ENEMY_ZOMBIE,
|
|
||||||
].includes(this.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isItem() {
|
|
||||||
return [
|
|
||||||
ItemType.ITEM_FUEL,
|
|
||||||
ItemType.ITEM_HEAL,
|
|
||||||
ItemType.ITEM_KEYS,
|
|
||||||
ItemType.ITEM_PLANKS,
|
|
||||||
].includes(this.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isWeapon() {
|
|
||||||
return this.isMeleeWeapon || this.isShootingWeapon || [
|
|
||||||
ItemType.WEAPON_GRENADE,
|
|
||||||
ItemType.WEAPON_ROCKET_LAUNCHER,
|
|
||||||
].includes(this.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isMeleeWeapon() {
|
|
||||||
return [
|
|
||||||
ItemType.WEAPON_AXE,
|
|
||||||
ItemType.WEAPON_KNIFE,
|
|
||||||
].includes(this.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isShootingWeapon() {
|
|
||||||
return [
|
|
||||||
ItemType.WEAPON_ASSAULT_RIFLE,
|
|
||||||
ItemType.WEAPON_CROSSBOW,
|
|
||||||
ItemType.WEAPON_PISTOL,
|
|
||||||
ItemType.WEAPON_SHOTGUN,
|
|
||||||
].includes(this.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
get isBoss() {
|
|
||||||
return this.type === ItemType.ENEMY_BOSS;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isConsumable() {
|
|
||||||
return [
|
|
||||||
ItemType.ITEM_HEAL,
|
|
||||||
ItemType.ITEM_PLANKS,
|
|
||||||
ItemType.WEAPON_GRENADE,
|
|
||||||
].includes(this.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
toString() {
|
|
||||||
return Object.entries(ItemType).find(t => t[1] === this.type)?.[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import type Tile from "./tile";
|
|
||||||
|
|
||||||
namespace Pathfinding {
|
|
||||||
/**
|
|
||||||
* A* search from start to end Tile.
|
|
||||||
* @returns array of Tiles from start to end (inclusive), or [] if no path.
|
|
||||||
*/
|
|
||||||
export function findPath(start: Tile, end: Tile): Tile[] {
|
|
||||||
// The set of discovered nodes to be evaluated
|
|
||||||
const openSet: Set<Tile> = new Set([start]);
|
|
||||||
|
|
||||||
// For each node, the cost of getting from start to that node.
|
|
||||||
const gScore = new Map<Tile, number>();
|
|
||||||
gScore.set(start, 0);
|
|
||||||
|
|
||||||
// For each node, the total cost of getting from start to goal
|
|
||||||
// by passing by that node: gScore + heuristic estimate to goal.
|
|
||||||
const fScore = new Map<Tile, number>();
|
|
||||||
fScore.set(start, heuristic(start, end));
|
|
||||||
|
|
||||||
// For path reconstruction: map each node to the node it can most efficiently be reached from.
|
|
||||||
const cameFrom = new Map<Tile, Tile>();
|
|
||||||
|
|
||||||
while (openSet.size > 0) {
|
|
||||||
// Get the node in openSet having the lowest fScore
|
|
||||||
let current: Tile | null = null;
|
|
||||||
let currentF = Infinity;
|
|
||||||
for (const tile of openSet) {
|
|
||||||
const f = fScore.get(tile) ?? Infinity;
|
|
||||||
if (f < currentF) {
|
|
||||||
currentF = f;
|
|
||||||
current = tile;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!current) break;
|
|
||||||
|
|
||||||
// If we’ve reached the goal, reconstruct and return the path.
|
|
||||||
if (current === end) {
|
|
||||||
return reconstructPath(cameFrom, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
openSet.delete(current);
|
|
||||||
|
|
||||||
if (current !== start && current.items.length > 0) continue;
|
|
||||||
|
|
||||||
for (const neighbor of current.connections) {
|
|
||||||
// tentative gScore is current’s gScore plus cost to move to neighbor
|
|
||||||
const tentativeG = (gScore.get(current) ?? Infinity) + distance(current, neighbor);
|
|
||||||
|
|
||||||
if (tentativeG < (gScore.get(neighbor) ?? Infinity)) {
|
|
||||||
// This path to neighbor is better than any previous one. Record it!
|
|
||||||
cameFrom.set(neighbor, current);
|
|
||||||
gScore.set(neighbor, tentativeG);
|
|
||||||
fScore.set(neighbor, tentativeG + heuristic(neighbor, end));
|
|
||||||
if (!openSet.has(neighbor)) {
|
|
||||||
openSet.add(neighbor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open set is empty but goal was never reached
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Heuristic: Manhattan distance between tiles. */
|
|
||||||
function heuristic(a: Tile, b: Tile): number {
|
|
||||||
return Math.abs(a.centerX - b.centerX) + Math.abs(a.centerY - b.centerY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Actual cost between two connected tiles (Euclidean). */
|
|
||||||
function distance(a: Tile, b: Tile): number {
|
|
||||||
const dx = a.centerX - b.centerX;
|
|
||||||
const dy = a.centerY - b.centerY;
|
|
||||||
return Math.hypot(dx, dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reconstructs path from cameFrom map, ending at `current`. */
|
|
||||||
function reconstructPath(
|
|
||||||
cameFrom: Map<Tile, Tile>,
|
|
||||||
current: Tile
|
|
||||||
): Tile[] {
|
|
||||||
const path: Tile[] = [current];
|
|
||||||
while (cameFrom.has(current)) {
|
|
||||||
current = cameFrom.get(current)!;
|
|
||||||
path.push(current);
|
|
||||||
}
|
|
||||||
return path.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findPossibleMoves(start: Tile, steps: number): Tile[] {
|
|
||||||
const result = new Set<Tile>();
|
|
||||||
for (const tile of findPossibleTiles(start, steps)) {
|
|
||||||
const path = findPath(start, tile);
|
|
||||||
if (path.length > 1 && path.length <= steps + 1) {
|
|
||||||
result.add(tile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findPossibleTiles(start: Tile, steps: number): Set<Tile> {
|
|
||||||
const result = new Set<Tile>([start]);
|
|
||||||
if (steps > 0) {
|
|
||||||
for (const tile of start.connections) {
|
|
||||||
findPossibleTiles(tile, steps - 1).forEach(t => result.add(t));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Pathfinding;
|
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
import Entity from "./entity";
|
import Entity from "./entity";
|
||||||
|
|
||||||
export enum SpinnerAction {
|
export type SpinnerListener = (angle: number) => void;
|
||||||
RUN = 1,
|
|
||||||
BITE = 2,
|
|
||||||
MELEE = 3,
|
|
||||||
SHOOT = 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SpinnerListener = (action: SpinnerAction) => void;
|
|
||||||
|
|
||||||
export default class Spinner extends Entity {
|
export default class Spinner extends Entity {
|
||||||
private readonly probabilities = [0.3, 0.3, 0.2, 0.2];
|
private readonly probabilities = [0.3, 0.3, 0.2, 0.2];
|
||||||
private readonly colors = ['yellow', 'green', 'blue', 'red'];
|
private readonly colors = ['yellow', 'green', 'blue', 'red'];
|
||||||
private readonly symbols = ['🏃♂️', '🧟♂️', '🔪', '🎯'];
|
|
||||||
private readonly startAngle = -Math.PI / 2 - this.probabilities[0] * 2 * Math.PI;
|
private readonly startAngle = -Math.PI / 2 - this.probabilities[0] * 2 * Math.PI;
|
||||||
|
|
||||||
private angle = this.startAngle;
|
private angle = this.startAngle;
|
||||||
|
|
@ -22,9 +14,6 @@ export default class Spinner extends Entity {
|
||||||
private listeners = new Set<SpinnerListener>();
|
private listeners = new Set<SpinnerListener>();
|
||||||
|
|
||||||
protected override draw(ctx: CanvasRenderingContext2D) {
|
protected override draw(ctx: CanvasRenderingContext2D) {
|
||||||
ctx.scale(0.9, 0.9);
|
|
||||||
ctx.translate(0.05, 0.05);
|
|
||||||
|
|
||||||
ctx.fillStyle = 'white';
|
ctx.fillStyle = 'white';
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
|
|
@ -49,7 +38,6 @@ export default class Spinner extends Entity {
|
||||||
|
|
||||||
const center = (angle + nextAngle) / 2;
|
const center = (angle + nextAngle) / 2;
|
||||||
ctx.fillText(`${i + 1}`, 0.5 + Math.cos(center) * 0.4, 0.5 + Math.sin(center) * 0.4);
|
ctx.fillText(`${i + 1}`, 0.5 + Math.cos(center) * 0.4, 0.5 + Math.sin(center) * 0.4);
|
||||||
ctx.fillText(this.symbols[i], 0.5 + Math.cos(center) * 0.18, 0.5 + Math.sin(center) * 0.18);
|
|
||||||
|
|
||||||
angle = nextAngle;
|
angle = nextAngle;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,211 @@
|
||||||
|
import { range } from "@common/utils";
|
||||||
import Entity from "./entity";
|
import Entity from "./entity";
|
||||||
import type Item from "./item";
|
|
||||||
|
|
||||||
export enum TileType {
|
export enum TileType {
|
||||||
NORMAL,
|
EMPTY,
|
||||||
START,
|
START,
|
||||||
END,
|
END,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace AStar {
|
||||||
|
/**
|
||||||
|
* A* search from start to end Tile.
|
||||||
|
* @returns array of Tiles from start to end (inclusive), or [] if no path.
|
||||||
|
*/
|
||||||
|
export function findPath(start: Tile, end: Tile): Tile[] {
|
||||||
|
// The set of discovered nodes to be evaluated
|
||||||
|
const openSet: Set<Tile> = new Set([start]);
|
||||||
|
|
||||||
|
// For each node, the cost of getting from start to that node.
|
||||||
|
const gScore = new Map<Tile, number>();
|
||||||
|
gScore.set(start, 0);
|
||||||
|
|
||||||
|
// For each node, the total cost of getting from start to goal
|
||||||
|
// by passing by that node: gScore + heuristic estimate to goal.
|
||||||
|
const fScore = new Map<Tile, number>();
|
||||||
|
fScore.set(start, heuristic(start, end));
|
||||||
|
|
||||||
|
// For path reconstruction: map each node to the node it can most efficiently be reached from.
|
||||||
|
const cameFrom = new Map<Tile, Tile>();
|
||||||
|
|
||||||
|
while (openSet.size > 0) {
|
||||||
|
// Get the node in openSet having the lowest fScore
|
||||||
|
let current: Tile | null = null;
|
||||||
|
let currentF = Infinity;
|
||||||
|
for (const tile of openSet) {
|
||||||
|
const f = fScore.get(tile) ?? Infinity;
|
||||||
|
if (f < currentF) {
|
||||||
|
currentF = f;
|
||||||
|
current = tile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!current) break;
|
||||||
|
|
||||||
|
// If we’ve reached the goal, reconstruct and return the path.
|
||||||
|
if (current === end) {
|
||||||
|
return reconstructPath(cameFrom, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
openSet.delete(current);
|
||||||
|
|
||||||
|
for (const neighbor of current.connections) {
|
||||||
|
// tentative gScore is current’s gScore plus cost to move to neighbor
|
||||||
|
const tentativeG = (gScore.get(current) ?? Infinity) + distance(current, neighbor);
|
||||||
|
|
||||||
|
if (tentativeG < (gScore.get(neighbor) ?? Infinity)) {
|
||||||
|
// This path to neighbor is better than any previous one. Record it!
|
||||||
|
cameFrom.set(neighbor, current);
|
||||||
|
gScore.set(neighbor, tentativeG);
|
||||||
|
fScore.set(neighbor, tentativeG + heuristic(neighbor, end));
|
||||||
|
if (!openSet.has(neighbor)) {
|
||||||
|
openSet.add(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open set is empty but goal was never reached
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heuristic: Manhattan distance between tiles. */
|
||||||
|
function heuristic(a: Tile, b: Tile): number {
|
||||||
|
return Math.abs(a.centerX - b.centerX) + Math.abs(a.centerY - b.centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actual cost between two connected tiles (Euclidean). */
|
||||||
|
function distance(a: Tile, b: Tile): number {
|
||||||
|
const dx = a.centerX - b.centerX;
|
||||||
|
const dy = a.centerY - b.centerY;
|
||||||
|
return Math.hypot(dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconstructs path from cameFrom map, ending at `current`. */
|
||||||
|
function reconstructPath(
|
||||||
|
cameFrom: Map<Tile, Tile>,
|
||||||
|
current: Tile
|
||||||
|
): Tile[] {
|
||||||
|
const path: Tile[] = [current];
|
||||||
|
while (cameFrom.has(current)) {
|
||||||
|
current = cameFrom.get(current)!;
|
||||||
|
path.push(current);
|
||||||
|
}
|
||||||
|
return path.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TileMap extends Entity {
|
||||||
|
private tiles: Tile[] = [];
|
||||||
|
private pathTiles: Tile[] = [];
|
||||||
|
public startTile: Tile;
|
||||||
|
|
||||||
|
constructor(position: [number, number], private mapSize: number, private tileSize: number) {
|
||||||
|
super(position, [mapSize * tileSize, mapSize * tileSize]);
|
||||||
|
this.startTile = this.createMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
public createMap() {
|
||||||
|
const map = range(this.mapSize)
|
||||||
|
.map(x =>
|
||||||
|
range(this.mapSize)
|
||||||
|
.map(y =>
|
||||||
|
new Tile([x, y], this.tileSize)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const startTile = new Tile([0, (this.mapSize / 2) - 1], 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;
|
||||||
|
|
||||||
|
// TODO walls
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
map[x - 1]?.[y],
|
||||||
|
map[x + 1]?.[y],
|
||||||
|
map[x]?.[y - 1],
|
||||||
|
map[x]?.[y + 1],
|
||||||
|
].filter(t => t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
this.tiles = map.flat();
|
||||||
|
|
||||||
|
return startTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override handleMouseMove(x: number, y: number): void {
|
||||||
|
this.tiles.forEach(tile => tile.handleMouseMove(x - this.left, y - this.top));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override handleClick(x: number, y: number): void {
|
||||||
|
for (const tile of this.tiles) {
|
||||||
|
if (tile.isPointInBounds(x - this.left, y - this.top)) {
|
||||||
|
this.pathTiles = AStar.findPath(this.startTile, tile);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected draw(ctx: CanvasRenderingContext2D): void {
|
||||||
|
ctx.scale(1 / this.width, 1 / this.height);
|
||||||
|
this.tiles.forEach(t => t.render(ctx));
|
||||||
|
|
||||||
|
if (this.pathTiles.length > 0) {
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = 'yellow';
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
const [start, ...path] = this.pathTiles;
|
||||||
|
ctx.moveTo(start.centerX, start.centerY);
|
||||||
|
path.forEach(t => ctx.lineTo(t.centerX, t.centerY));
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public update(dt: number): void {
|
||||||
|
this.tiles.forEach(t => t.update(dt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default class Tile extends Entity {
|
export default class Tile extends Entity {
|
||||||
public connections: Tile[] = [];
|
public connections: Tile[] = [];
|
||||||
public items: Item[] = [];
|
|
||||||
public isOpen = false;
|
|
||||||
|
|
||||||
constructor(position: [number, number], size: number, public type: TileType = TileType.NORMAL) {
|
constructor(position: [number, number], size: number, public type: TileType = TileType.EMPTY) {
|
||||||
super(position, [size, size]);
|
super([position[0] * size, position[1] * size], [size, size]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected draw(ctx: CanvasRenderingContext2D) {
|
protected draw(ctx: CanvasRenderingContext2D) {
|
||||||
if (this.hovered) {
|
if (this.hovered) {
|
||||||
ctx.fillStyle = `rgba(255, 255, 255, 0.2)`;
|
ctx.fillStyle = `rgba(255, 255, 255, 0.1)`;
|
||||||
ctx.fillRect(0, 0, 1, 1);
|
ctx.fillRect(0, 0, 1, 1);
|
||||||
|
|
||||||
// ctx.lineWidth = 1 / this.width;
|
|
||||||
// ctx.strokeStyle = 'red';
|
|
||||||
|
|
||||||
// for (const tile of this.connections) {
|
|
||||||
// const center = [
|
|
||||||
// 0.5 + (tile.centerX - this.centerX) / this.width,
|
|
||||||
// 0.5 + (tile.centerY - this.centerY) / this.height,
|
|
||||||
// ];
|
|
||||||
|
|
||||||
// ctx.beginPath();
|
|
||||||
// ctx.moveTo(0.5, 0.5);
|
|
||||||
// ctx.lineTo(center[0], center[1]);
|
|
||||||
// ctx.stroke();
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
if (this.items.length > 0) {
|
|
||||||
if (this.isOpen) {
|
|
||||||
const item = this.items[0];
|
|
||||||
ctx.drawImage(item.type, 0.1, 0.1, 0.8, 0.8);
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle = 'white';
|
|
||||||
ctx.fillRect(0.1, 0.1, 0.8, 0.8);
|
|
||||||
|
|
||||||
ctx.fillStyle = 'black';
|
|
||||||
ctx.font = '0.2px Arial';
|
|
||||||
ctx.fillText('❓', 0.5, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get enemy() {
|
ctx.lineWidth = 1 / this.width;
|
||||||
return this.items.find(i => i.isEnemy);
|
ctx.strokeStyle = `rgba(0, 0, 0, 0.5)`;
|
||||||
|
ctx.strokeRect(0, 0, 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public open(): Item[] {
|
public update(dt: number) {
|
||||||
if (!this.isOpen) {
|
|
||||||
this.isOpen = true;
|
|
||||||
const { pickable = [], notPickable = [] } = Object.groupBy(
|
|
||||||
this.items,
|
|
||||||
(i) => i.isPickable ? 'pickable' : 'notPickable',
|
|
||||||
);
|
|
||||||
|
|
||||||
this.items = notPickable;
|
|
||||||
return pickable;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeItem(item: Item | null | undefined): boolean {
|
|
||||||
if (!item) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const itemIndex = this.items.findIndex(i => i === item);
|
|
||||||
|
|
||||||
if (itemIndex >= 0) {
|
|
||||||
this.items.splice(itemIndex, 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public killEnemy() {
|
|
||||||
this.removeItem(this.enemy);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
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 endTiles = this.tiles.filter(t => t.type === TileType.END);
|
|
||||||
const endTilesNeighbors = new Set(endTiles.flatMap(t => t.connections));
|
|
||||||
const normalTiles = this.tiles.filter(t =>
|
|
||||||
t.type === TileType.NORMAL
|
|
||||||
&& !endTilesNeighbors.has(t)
|
|
||||||
&& !this.startTile.connections.includes(t)
|
|
||||||
);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
if (this.state !== GameState.NORMAL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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.removeItem(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;
|
|
||||||
this.setNormalState();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case SpinnerAction.MELEE:
|
|
||||||
case SpinnerAction.SHOOT:
|
|
||||||
this.killEnemy();
|
|
||||||
break;
|
|
||||||
case SpinnerAction.RUN:
|
|
||||||
this.setNormalState();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public handleItemUse(character: Character, item: Item) {
|
|
||||||
const success = character.removeItem(item);
|
|
||||||
if (success) {
|
|
||||||
if (item.type === ItemType.ITEM_HEAL) {
|
|
||||||
character.heal(character);
|
|
||||||
} else if (item.type === ItemType.WEAPON_GRENADE && this.state === GameState.FIGHT) {
|
|
||||||
this.killEnemy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private findAvailableTiles(moveDistance: number = 1) {
|
|
||||||
const characterTiles = new Set(this.characters.map(c => c.tile));
|
|
||||||
this.availableTiles = Pathfinding.findPossibleMoves(this.character.tile, moveDistance + this.character.moveBonus)
|
|
||||||
.filter(t => !characterTiles.has(t));
|
|
||||||
}
|
|
||||||
|
|
||||||
private setNormalState() {
|
|
||||||
this.state = GameState.NORMAL;
|
|
||||||
this.findAvailableTiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
private nextCharacter() {
|
|
||||||
this.currentCharacterIdx = (this.currentCharacterIdx + 1) % this.characters.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
private killEnemy() {
|
|
||||||
this.character.tile.killEnemy();
|
|
||||||
this.nextCharacter();
|
|
||||||
this.setNormalState();
|
|
||||||
}
|
|
||||||
|
|
||||||
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 => {
|
|
||||||
if (t.items.length > 0 || (this.state === GameState.NORMAL && this.availableTiles.includes(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||