diff --git a/src/games/zombies/character.ts b/src/games/zombies/character.ts new file mode 100644 index 0000000..1e1c988 --- /dev/null +++ b/src/games/zombies/character.ts @@ -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; diff --git a/src/games/zombies/entity.ts b/src/games/zombies/entity.ts index 0a8d9c9..129430d 100644 --- a/src/games/zombies/entity.ts +++ b/src/games/zombies/entity.ts @@ -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) { }; } \ No newline at end of file diff --git a/src/games/zombies/index.ts b/src/games/zombies/index.ts index 540ae9f..01a870b 100644 --- a/src/games/zombies/index.ts +++ b/src/games/zombies/index.ts @@ -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); diff --git a/src/games/zombies/inventory.ts b/src/games/zombies/inventory.ts new file mode 100644 index 0000000..b746349 --- /dev/null +++ b/src/games/zombies/inventory.ts @@ -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); + } + } +} \ No newline at end of file diff --git a/src/games/zombies/item.ts b/src/games/zombies/item.ts index ce77c54..f9318fa 100644 --- a/src/games/zombies/item.ts +++ b/src/games/zombies/item.ts @@ -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]; diff --git a/src/games/zombies/pathfinding.ts b/src/games/zombies/pathfinding.ts index b514e3a..9e5b1de 100644 --- a/src/games/zombies/pathfinding.ts +++ b/src/games/zombies/pathfinding.ts @@ -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(); 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); } } diff --git a/src/games/zombies/spinner.ts b/src/games/zombies/spinner.ts index e22222c..5ec658c 100644 --- a/src/games/zombies/spinner.ts +++ b/src/games/zombies/spinner.ts @@ -22,6 +22,9 @@ export default class Spinner extends Entity { private listeners = new Set(); protected override draw(ctx: CanvasRenderingContext2D) { + ctx.scale(0.9, 0.9); + ctx.translate(0.05, 0.05); + ctx.fillStyle = 'white'; ctx.beginPath(); diff --git a/src/games/zombies/tile.ts b/src/games/zombies/tile.ts index f1a25d3..835aaa7 100644 --- a/src/games/zombies/tile.ts +++ b/src/games/zombies/tile.ts @@ -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); } } diff --git a/src/games/zombies/tilemap.ts b/src/games/zombies/tilemap.ts index 275329d..2a58131 100644 --- a/src/games/zombies/tilemap.ts +++ b/src/games/zombies/tilemap.ts @@ -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); } } \ No newline at end of file