Core gameplay
This commit is contained in:
parent
baab7c9486
commit
399e3097a3
|
|
@ -0,0 +1,104 @@
|
|||
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 useItem(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.useItem(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;
|
||||
|
|
@ -46,6 +46,6 @@ export default abstract class Entity {
|
|||
}
|
||||
|
||||
protected abstract draw(ctx: CanvasRenderingContext2D): void;
|
||||
protected onClick() {}
|
||||
public abstract update(dt: number): void;
|
||||
protected onClick() { }
|
||||
public update(_dt: number) { };
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { getRealPoint } from "@common/dom";
|
|||
import { nextFrame } from "@common/utils";
|
||||
import bgImg from './assets/bg.jpg';
|
||||
import TileMap from "./tilemap";
|
||||
import Inventory from "./inventory";
|
||||
|
||||
const MAP_SIZE = 12;
|
||||
const MAP_PIXEL_SIZE = 1000;
|
||||
|
|
@ -13,11 +14,21 @@ const TILE_SIZE = (MAP_PIXEL_SIZE - MAP_PADDING * 2) / MAP_SIZE;
|
|||
const SPINNER_SIZE = 200;
|
||||
const canvas = createCanvas(MAP_PIXEL_SIZE + SPINNER_SIZE, MAP_PIXEL_SIZE);
|
||||
const spinner = new Spinner([MAP_PIXEL_SIZE, 0], [SPINNER_SIZE, SPINNER_SIZE]);
|
||||
const map = new TileMap([MAP_PADDING, MAP_PADDING], MAP_SIZE, TILE_SIZE);
|
||||
const map = new TileMap(
|
||||
[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[] = [
|
||||
spinner,
|
||||
map,
|
||||
inventory,
|
||||
];
|
||||
|
||||
async function update(dt: number) {
|
||||
|
|
@ -28,7 +39,7 @@ async function render(ctx: CanvasRenderingContext2D) {
|
|||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.fillStyle = 'green';
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(bgImg, 0, 0, MAP_PIXEL_SIZE, MAP_PIXEL_SIZE);
|
||||
|
|
@ -53,7 +64,8 @@ export default async function main() {
|
|||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
spinner.addListener(console.log);
|
||||
spinner.addListener((a) => map.handleSpin(a));
|
||||
|
||||
canvas.addEventListener('click', onClick);
|
||||
canvas.addEventListener('mousemove', onMouseMove);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import type Character from "./character";
|
||||
import { Characters } from "./character";
|
||||
import Entity from "./entity";
|
||||
|
||||
export default class Inventory extends Entity {
|
||||
constructor(public readonly characters: Character[], position: [number, number], size: [number, number]) {
|
||||
super(position, size);
|
||||
}
|
||||
|
||||
private drawCharacter(ctx: CanvasRenderingContext2D, character: Character) {
|
||||
ctx.drawImage(character.type, 0.1, 0.1, 0.8, 0.8);
|
||||
ctx.fillText(`♥ ${character.health}`, 0.5, 1.5);
|
||||
}
|
||||
|
||||
protected override draw(ctx: CanvasRenderingContext2D): void {
|
||||
const step = 1 / Object.keys(Characters).length;
|
||||
const columnWidth = this.width * step;
|
||||
|
||||
ctx.scale(step, columnWidth / this.height);
|
||||
ctx.font = `0.5px Arial`;
|
||||
ctx.fillStyle = 'black';
|
||||
|
||||
for (const char of this.characters) {
|
||||
this.drawCharacter(ctx, char);
|
||||
ctx.translate(1, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ export const ItemType = {
|
|||
|
||||
type ItemTypeType = typeof ItemType;
|
||||
type ImageKey = keyof ItemTypeType;
|
||||
type ItemTypeImage = ItemTypeType[ImageKey];
|
||||
export type ItemTypeImage = ItemTypeType[ImageKey];
|
||||
|
||||
export default class Item {
|
||||
constructor(public readonly type: ItemTypeImage) {
|
||||
|
|
@ -62,16 +62,6 @@ export default class Item {
|
|||
return this.isItem || this.isWeapon;
|
||||
}
|
||||
|
||||
get isCharacter() {
|
||||
return [
|
||||
ItemType.CHAR_BIG,
|
||||
ItemType.CHAR_NINJA,
|
||||
ItemType.CHAR_NURSE,
|
||||
ItemType.CHAR_POLICE,
|
||||
ItemType.CHAR_RUNNER,
|
||||
].includes(this.type);
|
||||
}
|
||||
|
||||
get isEnemy() {
|
||||
return [
|
||||
ItemType.ENEMY_BOSS,
|
||||
|
|
@ -112,6 +102,10 @@ export default class Item {
|
|||
ItemType.WEAPON_SHOTGUN,
|
||||
].includes(this.type);
|
||||
}
|
||||
|
||||
get isBoss() {
|
||||
return this.type === ItemType.ENEMY_BOSS;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return Object.entries(ItemType).find(t => t[1] === this.type)?.[0];
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ namespace Pathfinding {
|
|||
|
||||
openSet.delete(current);
|
||||
|
||||
if (current.items.length > 0) continue;
|
||||
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
|
||||
|
|
@ -92,7 +92,7 @@ namespace Pathfinding {
|
|||
const result = new Set<Tile>();
|
||||
for (const tile of findPossibleTiles(start, steps)) {
|
||||
const path = findPath(start, tile);
|
||||
if (path.length === steps + 1) {
|
||||
if (path.length > 1 && path.length <= steps + 1) {
|
||||
result.add(tile);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ export default class Spinner extends Entity {
|
|||
private listeners = new Set<SpinnerListener>();
|
||||
|
||||
protected override draw(ctx: CanvasRenderingContext2D) {
|
||||
ctx.scale(0.9, 0.9);
|
||||
ctx.translate(0.05, 0.05);
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
|
||||
ctx.beginPath();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default class Tile extends Entity {
|
|||
public isOpen = false;
|
||||
|
||||
constructor(position: [number, number], size: number, public type: TileType = TileType.NORMAL) {
|
||||
super([position[0] * size, position[1] * size], [size, size]);
|
||||
super(position, [size, size]);
|
||||
}
|
||||
|
||||
protected draw(ctx: CanvasRenderingContext2D) {
|
||||
|
|
@ -39,10 +39,10 @@ export default class Tile extends Entity {
|
|||
if (this.items.length > 0) {
|
||||
if (this.isOpen) {
|
||||
const item = this.items[0];
|
||||
ctx.drawImage(item.type, 0.15, 0.15, 0.7, 0.7);
|
||||
ctx.drawImage(item.type, 0.1, 0.1, 0.8, 0.8);
|
||||
} else {
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0.15, 0.15, 0.7, 0.7);
|
||||
ctx.fillRect(0.1, 0.1, 0.8, 0.8);
|
||||
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.font = '0.2px Arial';
|
||||
|
|
@ -51,6 +51,10 @@ export default class Tile extends Entity {
|
|||
}
|
||||
}
|
||||
|
||||
get enemy() {
|
||||
return this.items.find(i => i.isEnemy);
|
||||
}
|
||||
|
||||
public open(): Item[] {
|
||||
if (!this.isOpen) {
|
||||
this.isOpen = true;
|
||||
|
|
@ -66,10 +70,21 @@ export default class Tile extends Entity {
|
|||
return [];
|
||||
}
|
||||
|
||||
public override onClick(): void {
|
||||
console.log(...this.open().map(t => t.toString()));
|
||||
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 update(dt: number) {
|
||||
public killEnemy() {
|
||||
this.removeItem(this.enemy);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,28 @@ 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[] = [];
|
||||
private pathTiles: Tile[] = [];
|
||||
public startTile: Tile;
|
||||
|
||||
constructor(position: [number, number], private mapSize: number, private tileSize: number) {
|
||||
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() {
|
||||
|
|
@ -19,11 +32,11 @@ export default class TileMap extends Entity {
|
|||
.map(x =>
|
||||
range(this.mapSize)
|
||||
.map(y =>
|
||||
new Tile([x, y], this.tileSize)
|
||||
new Tile([x * this.tileSize, y * this.tileSize], this.tileSize)
|
||||
)
|
||||
);
|
||||
|
||||
const startTile = new Tile([0, (this.mapSize / 2) - 1], this.tileSize * 2, TileType.START);
|
||||
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];
|
||||
|
|
@ -85,9 +98,16 @@ export default class TileMap extends Entity {
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
@ -123,52 +143,144 @@ export default class TileMap extends Entity {
|
|||
}
|
||||
}
|
||||
|
||||
const fillableTiles = this.tiles.filter(t => t.type === TileType.NORMAL);
|
||||
for (const [tile, item] of zip(shuffle(fillableTiles), items)) {
|
||||
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.tiles.forEach(tile => tile.handleMouseMove(x - this.left, y - this.top));
|
||||
this.availableTiles.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 (this.state !== GameState.NORMAL) {
|
||||
return;
|
||||
}
|
||||
for (const tile of this.availableTiles) {
|
||||
if (tile.isPointInBounds(x - this.left, y - this.top)) {
|
||||
this.pathTiles = Pathfinding.findPath(this.startTile, tile);
|
||||
if (this.pathTiles.length > 0) {
|
||||
this.startTile = this.pathTiles.at(-1);
|
||||
tile.handleClick(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);
|
||||
this.tiles.forEach(t => t.render(ctx));
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = 'yellow';
|
||||
ctx.fillStyle = 'rgba(0, 255, 0, 0.5)';
|
||||
|
||||
if (this.pathTiles.length > 0) {
|
||||
if (this.state === GameState.NORMAL && this.availableTiles.length > 0) {
|
||||
ctx.beginPath();
|
||||
|
||||
const [start, ...path] = this.pathTiles;
|
||||
ctx.moveTo(start.centerX, start.centerY);
|
||||
path.forEach(t => ctx.lineTo(t.centerX, t.centerY));
|
||||
this.availableTiles.forEach(t =>
|
||||
ctx.fillRect(t.centerX - t.width / 2, t.centerY - t.height / 2, t.width, t.height)
|
||||
);
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.startTile.centerX, this.startTile.centerY, this.startTile.width * 0.4, 0, 7);
|
||||
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)
|
||||
);
|
||||
|
||||
public update(dt: number): void {
|
||||
this.tiles.forEach(t => t.update(dt));
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue