1
0
Fork 0

Tilemap images, items, pathfinding

This commit is contained in:
Pabloader 2025-06-26 13:49:22 +00:00
parent 53930cbe23
commit baab7c9486
30 changed files with 492 additions and 191 deletions

View File

@ -32,6 +32,20 @@ export const shuffle = <T>(array: T[]): T[] => {
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 clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -3,11 +3,12 @@ import Spinner from "./spinner";
import type Entity from "./entity";
import { getRealPoint } from "@common/dom";
import { nextFrame } from "@common/utils";
import { TileMap } from "./tile";
import bgImg from './assets/bg.jpg';
import TileMap from "./tilemap";
const MAP_SIZE = 12;
const MAP_PIXEL_SIZE = 1000;
const MAP_PADDING = 100;
const MAP_PADDING = 10;
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);
@ -24,9 +25,14 @@ async function update(dt: number) {
}
async function render(ctx: CanvasRenderingContext2D) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'green';
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));
}

119
src/games/zombies/item.ts Normal file
View File

@ -0,0 +1,119 @@
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;
type ItemTypeImage = ItemTypeType[ImageKey];
export default class Item {
constructor(public readonly type: ItemTypeImage) {
}
get isPickable() {
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,
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);
}
toString() {
return Object.entries(ItemType).find(t => t[1] === this.type)?.[0];
}
}

View File

@ -0,0 +1,115 @@
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 weve reached the goal, reconstruct and return the path.
if (current === end) {
return reconstructPath(cameFrom, current);
}
openSet.delete(current);
if (current.items.length > 0) continue;
for (const neighbor of current.connections) {
// tentative gScore is currents 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 === 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;

View File

@ -1,10 +1,18 @@
import Entity from "./entity";
export type SpinnerListener = (angle: number) => void;
export enum SpinnerAction {
RUN = 1,
BITE = 2,
MELEE = 3,
SHOOT = 4,
};
export type SpinnerListener = (action: SpinnerAction) => void;
export default class Spinner extends Entity {
private readonly probabilities = [0.3, 0.3, 0.2, 0.2];
private readonly colors = ['yellow', 'green', 'blue', 'red'];
private readonly symbols = ['🏃‍♂️', '🧟‍♂️', '🔪', '🎯'];
private readonly startAngle = -Math.PI / 2 - this.probabilities[0] * 2 * Math.PI;
private angle = this.startAngle;
@ -38,6 +46,7 @@ export default class Spinner extends Entity {
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(this.symbols[i], 0.5 + Math.cos(center) * 0.18, 0.5 + Math.sin(center) * 0.18);
angle = nextAngle;
}

View File

@ -1,209 +1,73 @@
import { range } from "@common/utils";
import Entity from "./entity";
import type Item from "./item";
export enum TileType {
EMPTY,
NORMAL,
START,
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 weve 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 currents 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 {
public connections: Tile[] = [];
public items: Item[] = [];
public isOpen = false;
constructor(position: [number, number], size: number, public type: TileType = TileType.EMPTY) {
constructor(position: [number, number], size: number, public type: TileType = TileType.NORMAL) {
super([position[0] * size, position[1] * size], [size, size]);
}
protected draw(ctx: CanvasRenderingContext2D) {
if (this.hovered) {
ctx.fillStyle = `rgba(255, 255, 255, 0.1)`;
ctx.fillStyle = `rgba(255, 255, 255, 0.2)`;
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.15, 0.15, 0.7, 0.7);
} else {
ctx.fillStyle = 'white';
ctx.fillRect(0.15, 0.15, 0.7, 0.7);
ctx.fillStyle = 'black';
ctx.font = '0.2px Arial';
ctx.fillText('❓', 0.5, 0.5);
}
}
}
public open(): Item[] {
if (!this.isOpen) {
this.isOpen = true;
const { pickable = [], notPickable = [] } = Object.groupBy(
this.items,
(i) => i.isPickable ? 'pickable' : 'notPickable',
);
this.items = notPickable;
return pickable;
}
ctx.lineWidth = 1 / this.width;
ctx.strokeStyle = `rgba(0, 0, 0, 0.5)`;
ctx.strokeRect(0, 0, 1, 1);
return [];
}
public override onClick(): void {
console.log(...this.open().map(t => t.toString()));
}
public update(dt: number) {

View File

@ -0,0 +1,174 @@
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";
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) {
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;
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));
this.tiles = map.flat();
this.fillItems();
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);
}
}
const fillableTiles = this.tiles.filter(t => t.type === TileType.NORMAL);
for (const [tile, item] of zip(shuffle(fillableTiles), items)) {
tile.items.push(item);
};
}
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 = Pathfinding.findPath(this.startTile, tile);
if (this.pathTiles.length > 0) {
this.startTile = this.pathTiles.at(-1);
tile.handleClick(x - this.left, y - this.top);
}
break;
}
}
}
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';
if (this.pathTiles.length > 0) {
ctx.beginPath();
const [start, ...path] = this.pathTiles;
ctx.moveTo(start.centerX, start.centerY);
path.forEach(t => ctx.lineTo(t.centerX, t.centerY));
ctx.stroke();
}
ctx.beginPath();
ctx.arc(this.startTile.centerX, this.startTile.centerY, this.startTile.width * 0.4, 0, 7);
ctx.stroke();
}
public update(dt: number): void {
this.tiles.forEach(t => t.update(dt));
}
}