1
0
Fork 0

Core gameplay

This commit is contained in:
Pabloader 2025-06-26 16:41:57 +00:00
parent baab7c9486
commit 399e3097a3
9 changed files with 316 additions and 48 deletions

View File

@ -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;

View File

@ -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) { };
}

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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];

View File

@ -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 currents 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);
}
}

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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);
}
}